Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions internal/store/secret_crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package store

import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)

const (
encryptedSecretPrefix = "octobus-secret-v1:"
secretKeyEnv = "OCTOBUS_SECRET_ENCRYPTION_KEY"
secretKeyBytes = 32
)

func loadSecretKey(dbPath string) ([]byte, error) {
if encoded := os.Getenv(secretKeyEnv); encoded != "" {
key, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(key) != secretKeyBytes {
return nil, fmt.Errorf("%s must be base64-encoded %d-byte key", secretKeyEnv, secretKeyBytes)
}
return key, nil
}
if dbPath == ":memory:" {
return randomSecretKey()
}

keyPath := dbPath + ".secret-key"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

默认密钥与数据库同目录明文存放,削弱静态加密保护效果

默认(未设置 OCTOBUS_SECRET_ENCRYPTION_KEY)时,AES-256-GCM 密钥以明文写入与数据库同目录的 .secret-key 文件(仅 0600)。能读取数据库文件的一方(备份导出、目录拷贝、同一 OS 用户下的其他进程/恶意软件)通常也能读取该密钥,导致“静态加密”退化为仅防随意查看的混淆,无法满足常见的威胁模型(数据库文件脱离密钥环境泄露时仍不可读)。

Problem code:

Changed code at internal/store/secret_crypto.go:33

Recommendation:
在生产环境中将环境变量/密钥管理服务作为加载密钥的强制路径,并明确密钥文件自动生成仅用于开发或本机使用;若保留密钥文件方案,建议与数据库分目录存放并收紧目录权限,同时在文档中明确其威胁模型与局限。

key, err := os.ReadFile(keyPath)
if err == nil {
if len(key) != secretKeyBytes {
return nil, fmt.Errorf("secret key file %q has invalid length", keyPath)
}
return key, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
key, err = randomSecretKey()
Comment thread
monkeyscan[bot] marked this conversation as resolved.
if err != nil {
return nil, err
}
file, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
if _, writeErr := file.Write(key); writeErr != nil {
_ = file.Close()
_ = os.Remove(keyPath)
return nil, writeErr
}
if closeErr := file.Close(); closeErr != nil {
return nil, closeErr
}
return key, nil
Comment thread
monkeyscan[bot] marked this conversation as resolved.
}
if !errors.Is(err, os.ErrExist) {
return nil, err
}
key, err = os.ReadFile(keyPath)
if err != nil {
return nil, err
}
if len(key) != secretKeyBytes {
return nil, fmt.Errorf("secret key file %q has invalid length", keyPath)
}
return key, nil
}

func randomSecretKey() ([]byte, error) {
key := make([]byte, secretKeyBytes)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
return key, nil
}

func encryptSecret(key, plaintext []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return encryptedSecretPrefix + base64.RawStdEncoding.EncodeToString(ciphertext), nil
}

func decryptSecret(key []byte, encoded string) ([]byte, error) {
if len(encoded) < len(encryptedSecretPrefix) || encoded[:len(encryptedSecretPrefix)] != encryptedSecretPrefix {
return []byte(encoded), nil
}
payload, err := base64.RawStdEncoding.DecodeString(encoded[len(encryptedSecretPrefix):])
if err != nil {
return nil, fmt.Errorf("decode encrypted instance secret: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(payload) < gcm.NonceSize() {
return nil, errors.New("encrypted instance secret is truncated")
}
return gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
}

func (s *Store) encryptLegacySecrets(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT id, secret_json FROM instances WHERE secret_json <> '' AND substr(secret_json, 1, ?) <> ?`, len(encryptedSecretPrefix), encryptedSecretPrefix)
if err != nil {
return err
}
type legacySecret struct {
id string
data string
}
var legacy []legacySecret
for rows.Next() {
var item legacySecret
if err := rows.Scan(&item.id, &item.data); err != nil {
_ = rows.Close()
return err
}
legacy = append(legacy, item)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if len(legacy) == 0 {
return nil
}

tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, item := range legacy {
encrypted, err := encryptSecret(s.secretKey, []byte(item.data))
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE instances SET secret_json = ? WHERE id = ?`, encrypted, item.id); err != nil {
return err
}
}
return tx.Commit()
}
89 changes: 89 additions & 0 deletions internal/store/secret_crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package store

import (
"context"
"strings"
"testing"

"octobus/internal/domain"
)

func TestInstanceSecretsAreEncryptedAtRestAndReadableAcrossReopen(t *testing.T) {
dbPath := t.TempDir() + "/octobus.db"
ctx := context.Background()
st, err := Open(dbPath)
if err != nil {
t.Fatal(err)
}
if err := st.UpsertService(ctx, domain.Service{ID: "secret-service", Name: "Secret Service"}); err != nil {
t.Fatal(err)
}
plain := []byte(`{"apiToken":"do-not-store-plaintext"}`)
if err := st.UpsertInstance(ctx, domain.Instance{ID: "secret-instance", ServiceID: "secret-service", Name: "Instance", SecretJSON: plain}); err != nil {
t.Fatal(err)
}
var stored string
if err := st.DB().QueryRowContext(ctx, `SELECT secret_json FROM instances WHERE id = ?`, "secret-instance").Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, "do-not-store-plaintext") || !strings.HasPrefix(stored, encryptedSecretPrefix) {
t.Fatalf("secret at rest is not encrypted: %q", stored)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}

st, err = Open(dbPath)
if err != nil {
t.Fatal(err)
}
defer st.Close()
got, err := st.GetInstance(ctx, "secret-instance")
if err != nil {
t.Fatal(err)
}
if string(got.SecretJSON) != string(plain) {
t.Fatalf("decrypted secret = %s", got.SecretJSON)
}
}

func TestLegacyInstanceSecretsAreEncryptedDuringMigration(t *testing.T) {
dbPath := t.TempDir() + "/octobus.db"
ctx := context.Background()
st, err := Open(dbPath)
if err != nil {
t.Fatal(err)
}
if err := st.UpsertService(ctx, domain.Service{ID: "legacy-service", Name: "Legacy Service"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertInstance(ctx, domain.Instance{ID: "legacy-instance", ServiceID: "legacy-service", Name: "Instance", SecretJSON: []byte(`{}`)}); err != nil {
t.Fatal(err)
}
if _, err := st.DB().ExecContext(ctx, `UPDATE instances SET secret_json = ? WHERE id = ?`, `{"legacy":"secret"}`, "legacy-instance"); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}

st, err = Open(dbPath)
if err != nil {
t.Fatal(err)
}
defer st.Close()
var stored string
if err := st.DB().QueryRowContext(ctx, `SELECT secret_json FROM instances WHERE id = ?`, "legacy-instance").Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, "legacy") || !strings.HasPrefix(stored, encryptedSecretPrefix) {
t.Fatalf("legacy secret was not migrated: %q", stored)
}
got, err := st.GetInstance(ctx, "legacy-instance")
if err != nil {
t.Fatal(err)
}
if string(got.SecretJSON) != `{"legacy":"secret"}` {
t.Fatalf("migrated secret = %s", got.SecretJSON)
}
}
44 changes: 36 additions & 8 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import (
)

type Store struct {
db *sql.DB
db *sql.DB
secretKey []byte
}

type ServiceInUseError struct {
Expand All @@ -45,6 +46,10 @@ func Open(path string) (*Store, error) {
return nil, err
}
}
secretKey, err := loadSecretKey(path)
if err != nil {
return nil, err
}
dbPath := path
if path != ":memory:" {
dbPath = "file:" + filepath.ToSlash(path)
Expand All @@ -63,7 +68,7 @@ func Open(path string) (*Store, error) {
_ = db.Close()
return nil, err
}
s := &Store{db: db}
s := &Store{db: db, secretKey: secretKey}
if err := s.Migrate(context.Background()); err != nil {
_ = db.Close()
return nil, err
Expand Down Expand Up @@ -109,6 +114,9 @@ func (s *Store) Migrate(ctx context.Context) error {
if err := addColumnIfMissing(ctx, s.db, "services", "service_root", "TEXT NOT NULL DEFAULT '.'"); err != nil {
return err
}
if err := s.encryptLegacySecrets(ctx); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `UPDATE services SET runtime_mode = 'long-running' WHERE runtime_mode = ''`)
return err
}
Expand Down Expand Up @@ -301,11 +309,15 @@ func (s *Store) UpsertInstance(ctx context.Context, inst domain.Instance) error
if inst.SecretSHA256 == "" {
inst.SecretSHA256 = domain.HashBytes(inst.SecretJSON)
}
encryptedSecret, err := encryptSecret(s.secretKey, inst.SecretJSON)
if err != nil {
return err
}
var pid any
if inst.PID != nil {
pid = *inst.PID
}
_, err := s.db.ExecContext(ctx, `
_, err = s.db.ExecContext(ctx, `
INSERT INTO instances (id, service_id, name, enabled, status, pid, listen_addr, node_entry, config_json, config_sha256, secret_json, secret_sha256, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
Expand All @@ -320,17 +332,29 @@ ON CONFLICT(id) DO UPDATE SET
config_sha256=excluded.config_sha256,
secret_json=excluded.secret_json,
secret_sha256=excluded.secret_sha256,
updated_at=excluded.updated_at`, inst.ID, inst.ServiceID, inst.Name, boolInt(inst.Enabled), string(inst.Status), pid, inst.ListenAddr, inst.NodeEntry, string(inst.ConfigJSON), inst.ConfigSHA256, string(inst.SecretJSON), inst.SecretSHA256, formatTime(inst.CreatedAt), formatTime(inst.UpdatedAt))
updated_at=excluded.updated_at`, inst.ID, inst.ServiceID, inst.Name, boolInt(inst.Enabled), string(inst.Status), pid, inst.ListenAddr, inst.NodeEntry, string(inst.ConfigJSON), inst.ConfigSHA256, encryptedSecret, inst.SecretSHA256, formatTime(inst.CreatedAt), formatTime(inst.UpdatedAt))
return err
}

func (s *Store) GetInstance(ctx context.Context, id string) (domain.Instance, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, service_id, name, enabled, status, pid, listen_addr, node_entry, config_json, config_sha256, secret_json, secret_sha256, created_at, updated_at FROM instances WHERE id = ?`, id)
return scanInstance(row)
return s.scanInstance(row)
}

func scanInstance(scanner interface {
Scan(dest ...any) error
}) (domain.Instance, error) {
return scanInstanceWithKey(nil, scanner)
}

func (s *Store) scanInstance(scanner interface {
Scan(dest ...any) error
}) (domain.Instance, error) {
return scanInstanceWithKey(s.secretKey, scanner)
}

func scanInstanceWithKey(secretKey []byte, scanner interface {
Scan(dest ...any) error
}) (domain.Instance, error) {
var inst domain.Instance
var enabled int
Expand All @@ -346,7 +370,11 @@ func scanInstance(scanner interface {
inst.PID = &p
}
inst.ConfigJSON = json.RawMessage(config)
inst.SecretJSON = json.RawMessage(secret)
decryptedSecret, err := decryptSecret(secretKey, secret)
if err != nil {
return domain.Instance{}, err
}
inst.SecretJSON = json.RawMessage(decryptedSecret)
inst.CreatedAt = parseTime(created)
inst.UpdatedAt = parseTime(updated)
return inst, nil
Expand All @@ -360,7 +388,7 @@ func (s *Store) ListInstances(ctx context.Context) ([]domain.Instance, error) {
defer rows.Close()
var out []domain.Instance
for rows.Next() {
inst, err := scanInstance(rows)
inst, err := s.scanInstance(rows)
if err != nil {
return nil, err
}
Expand All @@ -377,7 +405,7 @@ func (s *Store) ListEnabledInstancesByService(ctx context.Context, serviceID str
defer rows.Close()
var out []domain.Instance
for rows.Next() {
inst, err := scanInstance(rows)
inst, err := s.scanInstance(rows)
if err != nil {
return nil, err
}
Expand Down
Loading