f417cdede1
gates / gates (push) Successful in 29s
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
157 lines
5.7 KiB
Go
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
|
|
}
|