// Package settings is a tiny key/value store backed by the SQLite settings table. // // Sensitive keys (passwords) are encrypted transparently when a passphrase // cipher is installed: Set encrypts them, Get/GetMany/All decrypt them. When a // passphrase is configured but not yet unlocked (cipher nil) an encrypted value // reads back as "" rather than leaking ciphertext — callers treat that as // "not configured" and degrade gracefully. package settings import ( "context" "database/sql" "fmt" "sync" "hamlog/internal/secret" ) // Cipher is the subset of *secret.Cipher the store needs. type Cipher interface { Encrypt(plain string) string Decrypt(stored string) (string, error) } type Store struct { db *sql.DB mu sync.RWMutex cipher Cipher // non-nil when secrets are unlocked sensitive func(key string) bool // which keys are encrypted at rest } func NewStore(db *sql.DB) *Store { return &Store{db: db} } // SetSensitivePredicate registers which keys hold secrets. Set once at startup. func (s *Store) SetSensitivePredicate(fn func(key string) bool) { s.mu.Lock() s.sensitive = fn s.mu.Unlock() } // Unlock installs the cipher derived from the user's passphrase so sensitive // values encrypt on write and decrypt on read. func (s *Store) Unlock(c Cipher) { s.mu.Lock() s.cipher = c s.mu.Unlock() } // Lock drops the cipher (secrets become unreadable until the next Unlock). func (s *Store) Lock() { s.mu.Lock() s.cipher = nil s.mu.Unlock() } // Unlocked reports whether a cipher is currently installed. func (s *Store) Unlocked() bool { s.mu.RLock() defer s.mu.RUnlock() return s.cipher != nil } func (s *Store) isSensitive(key string) bool { if s.sensitive == nil { return false } return s.sensitive(key) } // decodeRead decrypts a sensitive value for reading (plaintext passes through; // locked → ""). func (s *Store) decodeRead(key, val string) string { if val == "" || !s.isSensitive(key) || !secret.IsEncrypted(val) { return val } s.mu.RLock() c := s.cipher s.mu.RUnlock() if c == nil { return "" // locked: don't leak ciphertext to callers } if pt, err := c.Decrypt(val); err == nil { return pt } return "" } // encodeWrite encrypts a sensitive value for storage when unlocked. func (s *Store) encodeWrite(key, val string) string { if val == "" || !s.isSensitive(key) { return val } s.mu.RLock() c := s.cipher s.mu.RUnlock() if c == nil { return val // no passphrase / locked → store as-is (legacy behaviour) } return c.Encrypt(val) } // Get returns the value for key, or "" if not set. func (s *Store) Get(ctx context.Context, key string) (string, error) { var v string err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) if err == sql.ErrNoRows { return "", nil } if err != nil { return "", err } return s.decodeRead(key, v), nil } // GetRaw returns the stored value WITHOUT decryption — used by the passphrase // migration which must read/re-write the raw ciphertext or plaintext. func (s *Store) GetRaw(ctx context.Context, key string) (string, error) { var v string err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) if err == sql.ErrNoRows { return "", nil } return v, err } // SetRaw stores a value verbatim (no encryption) — used by the migration. func (s *Store) SetRaw(ctx context.Context, key, value string) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, key, value) if err != nil { return fmt.Errorf("set %s: %w", key, err) } return nil } // Set upserts a key/value pair (encrypting sensitive keys when unlocked). func (s *Store) Set(ctx context.Context, key, value string) error { return s.SetRaw(ctx, key, s.encodeWrite(key, value)) } // All returns every stored setting (sensitive values decrypted when unlocked). func (s *Store) All(ctx context.Context) (map[string]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`) if err != nil { return nil, err } defer rows.Close() out := map[string]string{} for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, err } out[k] = s.decodeRead(k, v) } return out, rows.Err() } // GetMany fetches several keys in a single round-trip. func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) { out := make(map[string]string, len(keys)) for _, k := range keys { v, err := s.Get(ctx, k) if err != nil { return nil, err } out[k] = v } return out, nil }