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
gates / gates (push) Successful in 29s
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
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OffsiteKey is the registrar's record of the box key the hub installed pinned (decision 69).
|
||||
type OffsiteKey struct {
|
||||
CustomerID string
|
||||
Fingerprint string
|
||||
InstalledAt time.Time
|
||||
ConfirmedAt time.Time // zero = the box has not confirmed it yet
|
||||
}
|
||||
|
||||
// RecordOffsiteKeyInstalled records (last-write-wins) the key the hub just installed; confirmation resets.
|
||||
func (s *Store) RecordOffsiteKeyInstalled(customerID, fp string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO offsite_keys (customer_id, fingerprint, installed_at, confirmed_at) VALUES (?, ?, datetime('now'), NULL)
|
||||
ON CONFLICT(customer_id) DO UPDATE SET fingerprint = excluded.fingerprint, installed_at = datetime('now'), confirmed_at = NULL`,
|
||||
customerID, fp)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordOffsiteKeyConfirmed marks the installed key confirmed by the box; false when fp is not the key on record.
|
||||
func (s *Store) RecordOffsiteKeyConfirmed(customerID, fp string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE offsite_keys SET confirmed_at = datetime('now') WHERE customer_id = ? AND fingerprint = ?`, customerID, fp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GetOffsiteKey returns the record, or (nil, nil) when none exists.
|
||||
func (s *Store) GetOffsiteKey(customerID string) (*OffsiteKey, error) {
|
||||
var k OffsiteKey
|
||||
var inst string
|
||||
var conf sql.NullString
|
||||
err := s.db.QueryRow(`SELECT customer_id, fingerprint, installed_at, confirmed_at FROM offsite_keys WHERE customer_id = ?`, customerID).
|
||||
Scan(&k.CustomerID, &k.Fingerprint, &inst, &conf)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.InstalledAt = parseSQLiteTime(inst)
|
||||
if conf.Valid {
|
||||
k.ConfirmedAt = parseSQLiteTime(conf.String)
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// OffsiteWindowOpen reports whether a clean-up window is open for the customer right now (decision 68):
|
||||
// a window row not closed and not past its closes_by. Errors read as "closed" — the key check then
|
||||
// alarms on a window line, which is the safe side.
|
||||
func (s *Store) OffsiteWindowOpen(customerID string) bool {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM offsite_windows WHERE customer_id = ? AND closed_at IS NULL AND closes_by > datetime('now')`, customerID).Scan(&n)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// OffsiteWindow is one clean-up window (decision 68).
|
||||
type OffsiteWindow struct {
|
||||
ID int64
|
||||
CustomerID string
|
||||
OpenedAt time.Time
|
||||
ClosesBy time.Time
|
||||
ClosedAt time.Time
|
||||
CountBefore int
|
||||
CountAfter int
|
||||
BoxResult string
|
||||
CloseReason string
|
||||
}
|
||||
|
||||
// OpenOffsiteWindowRow records a window the hub just opened.
|
||||
func (s *Store) OpenOffsiteWindowRow(customerID string, closesBy time.Time, countBefore int) (int64, error) {
|
||||
res, err := s.db.Exec(`INSERT INTO offsite_windows (customer_id, opened_at, closes_by, count_before) VALUES (?, datetime('now'), ?, ?)`,
|
||||
customerID, closesBy.UTC().Format("2006-01-02 15:04:05"), countBefore)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CloseOffsiteWindowRow closes a window (idempotent: an already-closed row is not touched).
|
||||
func (s *Store) CloseOffsiteWindowRow(id int64, countAfter int, boxResult, reason string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE offsite_windows SET closed_at = datetime('now'), count_after = ?, box_result = ?, close_reason = ? WHERE id = ? AND closed_at IS NULL`,
|
||||
countAfter, boxResult, reason, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GetOffsiteWindow returns one window row, or (nil, nil).
|
||||
func (s *Store) GetOffsiteWindow(id int64) (*OffsiteWindow, error) {
|
||||
var w OffsiteWindow
|
||||
var opened, closesBy string
|
||||
var closed, boxRes, reason sql.NullString
|
||||
var before, after sql.NullInt64
|
||||
err := s.db.QueryRow(`SELECT id, customer_id, opened_at, closes_by, closed_at, count_before, count_after, box_result, close_reason FROM offsite_windows WHERE id = ?`, id).
|
||||
Scan(&w.ID, &w.CustomerID, &opened, &closesBy, &closed, &before, &after, &boxRes, &reason)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.OpenedAt, w.ClosesBy = parseSQLiteTime(opened), parseSQLiteTime(closesBy)
|
||||
if closed.Valid {
|
||||
w.ClosedAt = parseSQLiteTime(closed.String)
|
||||
}
|
||||
w.CountBefore, w.CountAfter = int(before.Int64), int(after.Int64)
|
||||
w.BoxResult, w.CloseReason = boxRes.String, reason.String
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// LastOffsiteWindowOpened returns when the customer's most recent window was opened (zero = never).
|
||||
func (s *Store) LastOffsiteWindowOpened(customerID string) time.Time {
|
||||
var v sql.NullString
|
||||
if err := s.db.QueryRow(`SELECT MAX(opened_at) FROM offsite_windows WHERE customer_id = ?`, customerID).Scan(&v); err != nil || !v.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return parseSQLiteTime(v.String)
|
||||
}
|
||||
|
||||
// ExpiredOffsiteWindows lists windows still open past their closes_by.
|
||||
func (s *Store) ExpiredOffsiteWindows() ([]OffsiteWindow, error) {
|
||||
rows, err := s.db.Query(`SELECT id, customer_id FROM offsite_windows WHERE closed_at IS NULL AND closes_by <= datetime('now')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OffsiteWindow
|
||||
for rows.Next() {
|
||||
var w OffsiteWindow
|
||||
if err := rows.Scan(&w.ID, &w.CustomerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const offsiteWindowsEnabledKey = "offsite_prune_windows_enabled"
|
||||
|
||||
// OffsiteWindowsEnabled — the operator switch for WEEKLY windows (decision 68). Off by default: the
|
||||
// interim is "nothing prunes" until the operator turns the weekly window on.
|
||||
func (s *Store) OffsiteWindowsEnabled() bool { return s.getSetting(offsiteWindowsEnabledKey) == "on" }
|
||||
|
||||
// SetOffsiteWindowsEnabled flips the weekly switch.
|
||||
func (s *Store) SetOffsiteWindowsEnabled(on bool) error {
|
||||
v := ""
|
||||
if on {
|
||||
v = "on"
|
||||
}
|
||||
return s.setSetting(offsiteWindowsEnabledKey, v)
|
||||
}
|
||||
|
||||
// GrantOffsiteWindowOnce lets the customer's NEXT window request through regardless of the weekly
|
||||
// cadence (operator one-shot).
|
||||
func (s *Store) GrantOffsiteWindowOnce(customerID string) error {
|
||||
return s.setSetting("offsite_window_grant:"+customerID, "1")
|
||||
}
|
||||
|
||||
// TakeOffsiteWindowGrant consumes a one-shot grant; true when one was present.
|
||||
func (s *Store) TakeOffsiteWindowGrant(customerID string) bool {
|
||||
k := "offsite_window_grant:" + customerID
|
||||
if s.getSetting(k) != "1" {
|
||||
return false
|
||||
}
|
||||
_ = s.setSetting(k, "")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sealTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := New(filepath.Join(t.TempDir(), "hub.db"), log.New(os.Stderr, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func rawValue(t *testing.T, st *Store, id string) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := st.db.QueryRow(`SELECT value FROM one_time_secrets WHERE customer_id = ?`, id).Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// R-821: a copy of the database alone does not reveal the password — asserted on the raw column.
|
||||
func TestOffsiteSecret_RawRowHoldsNoPassword(t *testing.T) {
|
||||
st := sealTestStore(t)
|
||||
if err := st.SaveOneTimeSecret("c1", "Sup3rSecretPw%"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := rawValue(t, st, "c1")
|
||||
if strings.Contains(raw, "Sup3rSecretPw") || !strings.HasPrefix(raw, sealPrefix) {
|
||||
t.Fatalf("raw row is not sealed: %q", raw)
|
||||
}
|
||||
if pw, err := st.OffsitePassword("c1"); err != nil || pw != "Sup3rSecretPw%" {
|
||||
t.Fatalf("OffsitePassword = %q, %v", pw, err)
|
||||
}
|
||||
if pw, err := st.ConsumeOneTimeSecret("c1"); err != nil || pw != "Sup3rSecretPw%" {
|
||||
t.Fatalf("Consume = %q, %v", pw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A different key cannot open what was sealed (the key is the secret, not the format).
|
||||
func TestOffsiteSecret_WrongKeyCannotOpen(t *testing.T) {
|
||||
st := sealTestStore(t)
|
||||
if err := st.SaveOneTimeSecret("c1", "Sup3rSecretPw%"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pw, err := st.OffsitePassword("c1"); err == nil || pw != "" {
|
||||
t.Fatalf("wrong key opened the secret: %q", pw)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed: no key → nothing saved, nothing read, never plaintext.
|
||||
func TestOffsiteSecret_NoKeyRefusesToSave(t *testing.T) {
|
||||
st := sealTestStore(t)
|
||||
st.sealer = nil
|
||||
if err := st.SaveOneTimeSecret("c1", "Sup3rSecretPw%"); err != ErrNoSealKey {
|
||||
t.Fatalf("save without key = %v, want ErrNoSealKey", err)
|
||||
}
|
||||
var n int
|
||||
_ = st.db.QueryRow(`SELECT COUNT(*) FROM one_time_secrets`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d row(s) written without a key", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows written by a pre-v0.127.0 hub are sealed in place at start-up; idempotent.
|
||||
func TestSealLegacyOffsiteSecrets_SealsPlaintextRows(t *testing.T) {
|
||||
st := sealTestStore(t)
|
||||
if _, err := st.db.Exec(`INSERT INTO one_time_secrets (customer_id, value) VALUES ('old', 'LegacyPlain1%')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveOneTimeSecret("new", "AlreadySealed1%"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealedNew := rawValue(t, st, "new")
|
||||
n, err := st.SealLegacyOffsiteSecrets()
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("sealed %d, %v; want 1", n, err)
|
||||
}
|
||||
if raw := rawValue(t, st, "old"); strings.Contains(raw, "LegacyPlain") {
|
||||
t.Fatalf("legacy row still plaintext: %q", raw)
|
||||
}
|
||||
if rawValue(t, st, "new") != sealedNew {
|
||||
t.Fatal("an already-sealed row was re-sealed")
|
||||
}
|
||||
if pw, err := st.OffsitePassword("old"); err != nil || pw != "LegacyPlain1%" {
|
||||
t.Fatalf("legacy row does not open: %q %v", pw, err)
|
||||
}
|
||||
if n, _ := st.SealLegacyOffsiteSecrets(); n != 0 {
|
||||
t.Fatalf("second run sealed %d", n)
|
||||
}
|
||||
if _, err := st.OffsitePassword("absent"); err != sql.ErrNoRows {
|
||||
t.Fatalf("absent = %v, want ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
@@ -24,6 +26,10 @@ type Store struct {
|
||||
// defaultMinControllerVersion is the config/env-supplied global FLOOR fallback (Phase 2 managed
|
||||
// updates). Used only when neither a per-customer override nor a hub_settings row is set.
|
||||
defaultMinControllerVersion string
|
||||
|
||||
// sealer encrypts the off-site sub-account password at rest (R-821, decision 69). nil = no key
|
||||
// configured → saving an off-site secret is REFUSED (offsite_seal.go).
|
||||
sealer cipher.AEAD
|
||||
}
|
||||
|
||||
// SetDefaultMinControllerVersion sets the config/env-supplied global floor fallback. Called once at
|
||||
@@ -97,6 +103,12 @@ func New(dbPath string, logger *log.Logger) (*Store, error) {
|
||||
}
|
||||
|
||||
s := &Store{db: db, logger: logger}
|
||||
// Under `go test` ONLY (testing.Testing() is false in the production binary) every store gets a
|
||||
// fixed sealing key, so the ~40 test files that build a store need no ceremony. Production stays
|
||||
// fail-closed until main installs OFFSITE_SECRET_KEY. TestOffsiteSecret_NoKeyRefusesToSave clears it.
|
||||
if testing.Testing() {
|
||||
_ = s.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32"))
|
||||
}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating database: %w", err)
|
||||
@@ -809,6 +821,31 @@ func (s *Store) migrate() error {
|
||||
s.logger.Printf("[INFO] [store] app_stopped_unhealthy added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed)
|
||||
}
|
||||
|
||||
// v0.127.0 (decisions 68–69, R-820): the off-site key registrar's record — which box key the hub
|
||||
// installed pinned append-only, and when the box confirmed it — and the clean-up window ledger.
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS offsite_keys (
|
||||
customer_id TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
installed_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
confirmed_at DATETIME
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS offsite_windows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
opened_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
closes_by DATETIME NOT NULL,
|
||||
closed_at DATETIME,
|
||||
count_before INTEGER,
|
||||
count_after INTEGER,
|
||||
box_result TEXT,
|
||||
close_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_offsite_windows_customer ON offsite_windows(customer_id, opened_at);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("offsite_keys/offsite_windows: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1586,6 +1623,11 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
|
||||
// SaveOneTimeSecret stores (last-write-wins) the one-time transient offsite password for a customer,
|
||||
// resetting the consumed flag (a fresh provision supersedes any prior unconsumed value). Never logged.
|
||||
func (s *Store) SaveOneTimeSecret(customerID, value string) error {
|
||||
sealed, serr := s.sealSecret(value) // R-821: never stored in the clear; no key → refuse
|
||||
if serr != nil {
|
||||
return serr
|
||||
}
|
||||
value = sealed
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO one_time_secrets (customer_id, value, created_at, consumed_at)
|
||||
VALUES (?, ?, datetime('now'), NULL)
|
||||
@@ -1608,6 +1650,9 @@ func (s *Store) ConsumeOneTimeSecret(customerID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err // sql.ErrNoRows when absent OR already consumed
|
||||
}
|
||||
if value, err = s.openSecret(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE one_time_secrets SET consumed_at = datetime('now') WHERE customer_id = ?`, customerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user