Files
felhom.eu/hub/internal/store/offsite_seal.go
T
admin f417cdede1
gates / gates (push) Successful in 29s
hub v0.127.0: off-site key registrar (box never gets the storage password), password sealed at rest, daily key check, clean-up window (shipped off) — decisions 68-69, R-820/R-821/R-822
Part A evidence (migration spike, sftp-written repo through the pinned rclone key) and the hub
red-proofs under documentation/audits/offsite-lock-build-2026-10-03/. Manifest bump follows after
the image is built and Secret/offsite-secret-key exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 16:57:04 +02:00

157 lines
5.7 KiB
Go

package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// ── R-821 / decision 69: the off-site sub-account password is stored ENCRYPTED, with a key that is not
// in the database ─────────────────────────────────────────────────────────────────────────────────────
//
// Until hub v0.127.0 `one_time_secrets.value` held every customer's Storage Box sub-account password in
// the clear, forever (the value survives a consume on purpose — RestageOneTimeSecret). Measured
// 2026-10-03: the stored value for tester-1 still logged in to its sub-account, and that password can
// rewrite `.ssh/authorized_keys` — i.e. remove the append-only pin from any box's key (R-820). So a copy
// of hub.db alone was enough to erase every household's off-site history.
//
// Now: AES-256-GCM, a fresh nonce per write, stored as `enc:v1:<base64(nonce||ciphertext)>`. The key
// comes from the hub's environment (OFFSITE_SECRET_KEY, from the out-of-band k8s Secret — never the
// database, never git). Without a key the store REFUSES to save (fail-closed): a hub that cannot seal
// must not quietly fall back to plaintext. A row that is still plaintext from before the upgrade is
// sealed in place by SealLegacyOffsiteSecrets at start-up.
//
// Pinned by: TestOffsiteSecret_RawRowHoldsNoPassword, TestOffsiteSecret_WrongKeyCannotOpen,
// TestOffsiteSecret_NoKeyRefusesToSave, TestSealLegacyOffsiteSecrets_SealsPlaintextRows.
const sealPrefix = "enc:v1:"
// ErrNoSealKey is returned when an off-site secret is saved or read on a store with no sealing key.
var ErrNoSealKey = errors.New("store: no off-site secret sealing key configured (OFFSITE_SECRET_KEY)")
// SetOffsiteSecretKey installs the 32-byte AES-256 key used to seal off-site sub-account passwords.
func (s *Store) SetOffsiteSecretKey(key []byte) error {
if len(key) != 32 {
return fmt.Errorf("store: off-site secret key must be 32 bytes, got %d", len(key))
}
blk, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(blk)
if err != nil {
return err
}
s.sealer = gcm
return nil
}
// ParseOffsiteSecretKey decodes OFFSITE_SECRET_KEY: 64 hex characters or standard base64 of 32 bytes.
func ParseOffsiteSecretKey(v string) ([]byte, error) {
v = strings.TrimSpace(v)
if len(v) == 64 {
if b, err := hex.DecodeString(v); err == nil {
return b, nil
}
}
if b, err := base64.StdEncoding.DecodeString(v); err == nil && len(b) == 32 {
return b, nil
}
return nil, errors.New("OFFSITE_SECRET_KEY must be 64 hex characters or base64 of 32 bytes")
}
func (s *Store) sealSecret(plain string) (string, error) {
if s.sealer == nil {
return "", ErrNoSealKey
}
nonce := make([]byte, s.sealer.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := s.sealer.Seal(nil, nonce, []byte(plain), nil)
return sealPrefix + base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil
}
func (s *Store) openSecret(stored string) (string, error) {
if s.sealer == nil {
return "", ErrNoSealKey
}
if !strings.HasPrefix(stored, sealPrefix) {
return "", errors.New("store: off-site secret is not sealed (run SealLegacyOffsiteSecrets)")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, sealPrefix))
if err != nil {
return "", fmt.Errorf("store: sealed secret: %w", err)
}
ns := s.sealer.NonceSize()
if len(raw) < ns {
return "", errors.New("store: sealed secret too short")
}
pt, err := s.sealer.Open(nil, raw[:ns], raw[ns:], nil)
if err != nil {
return "", errors.New("store: sealed secret does not open with this key")
}
return string(pt), nil
}
// OffsitePassword returns the customer's sub-account password, decrypted, for the HUB's own use (the key
// registrar, decision 69). It does NOT mark anything consumed and it is never served to a box.
// sql.ErrNoRows when none is stored.
func (s *Store) OffsitePassword(customerID string) (string, error) {
var v string
if err := s.db.QueryRow(`SELECT value FROM one_time_secrets WHERE customer_id = ?`, customerID).Scan(&v); err != nil {
return "", err
}
return s.openSecret(v)
}
// MarkOffsiteSecretDelivered records that the stored credential has been USED to deliver a key to the
// box (the registrar installed it). It keeps the delivery-state machinery (R-70) meaningful now that no
// box consumes the password: consumed_at = "delivered", exactly as before, without the value leaving.
func (s *Store) MarkOffsiteSecretDelivered(customerID string) error {
_, err := s.db.Exec(`UPDATE one_time_secrets SET consumed_at = datetime('now') WHERE customer_id = ?`, customerID)
return err
}
// SealLegacyOffsiteSecrets seals, in place, every row still holding a plaintext value (written before
// v0.127.0). Idempotent. Returns how many rows it sealed. Values are never logged.
func (s *Store) SealLegacyOffsiteSecrets() (int, error) {
if s.sealer == nil {
return 0, ErrNoSealKey
}
rows, err := s.db.Query(`SELECT customer_id, value FROM one_time_secrets`)
if err != nil {
return 0, err
}
type row struct{ id, v string }
var todo []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.v); err != nil {
rows.Close()
return 0, err
}
if !strings.HasPrefix(r.v, sealPrefix) {
todo = append(todo, r)
}
}
rows.Close()
n := 0
for _, r := range todo {
sealed, err := s.sealSecret(r.v)
if err != nil {
return n, err
}
if _, err := s.db.Exec(`UPDATE one_time_secrets SET value = ? WHERE customer_id = ? AND value = ?`, sealed, r.id, r.v); err != nil {
return n, err
}
n++
}
return n, nil
}