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

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:
2026-10-03 16:56:42 +02:00
parent 5188dbdb44
commit f417cdede1
28 changed files with 2338 additions and 49 deletions
+177
View File
@@ -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
}
+156
View File
@@ -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
}
+106
View File
@@ -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)
}
}
+45
View File
@@ -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
}