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:`. 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 }