package store import ( "crypto/sha256" "encoding/hex" "fmt" "strings" ) // ── R-879: the box-facing secrets are sealed at rest too ───────────────────────────────────────────── // // Until this change a copy of hub.db held, readable without any key: every box's hub API key // (`hosts.api_key`), every household's owner passphrase and controller API key // (`customer_configs.retrieval_password`, `customer_configs.api_key`) and the PBS-DR token values // (`host_pbs_secrets.value`, kept after use). A stolen copy let an attacker report as any box. // // Now all four columns hold the SAME seal as R-821 / R-133 (`enc:v1:`, AES-256-GCM, OFFSITE_SECRET_KEY, // `05` §16.2). Every one of them is SERVED again in plaintext somewhere (the box key at a re-enroll, the // customer key inside the generated controller.yaml, the passphrase on the operator page and to the // appliance, the PBS token on a re-stage), so a one-way hash cannot replace the value — it is sealed. // // The two API keys are also LOOKED UP: a box authenticates with its key on every report. Opening every // row to find a match would be slow and would make authentication depend on the sealing key. So each key // column has a twin `api_key_hash` = SHA-256 of the key (the keys are 256-bit random, a hash of one is not // guessable), and the lookup is an indexed equality on the hash. Two properties follow, both pinned: // // - **authentication never needs the sealing key** — a hub whose key is missing, wrong, or whose // start-up sealing failed half-way still lets every box in (TestR879_BoxAuthSurvivesFailedSealing); // - **a row with no hash yet is matched on its plaintext column** — only while it is still plaintext, // never on a sealed value (a sealed string presented as a key must not authenticate: // TestR879_SealedValueIsNotAKey). // // The hash is compared by SQLite, not in constant time; what an observer could learn from the timing is // a prefix of SHA-256(guess), which says nothing about the real key. // // Writing a new value without a key is REFUSED (ErrNoSealKey), as §16.2 states for every sealed column — // a hub that cannot seal must not fall back to plaintext. Reading a legacy plaintext value still works // (the start-up sealing may not have run); a sealed value that does not open leaves the field empty and // sets SecretsUnreadable, and every serve/compare path answers 500 on that flag rather than serving or // comparing an empty string. // apiKeyHash is the lookup twin of a box/controller API key. Domain-separated so it can never equal a // hash computed for another purpose. func apiKeyHash(key string) string { sum := sha256.Sum256([]byte("felhom-hub-api-key-v1:" + key)) return hex.EncodeToString(sum[:]) } // openAtRest returns the plaintext of a stored box secret. A legacy plaintext value (not yet sealed) is // returned as it is; a sealed value is opened with the key. ok=false when a sealed value cannot be opened // (no key / wrong key / corrupt) — the caller marks the record unreadable and never serves "". func (s *Store) openAtRest(table, id, stored string) (plain string, ok bool) { if !strings.HasPrefix(stored, sealPrefix) { return stored, true } pt, err := s.openSecret(stored) if err != nil { if s.logger != nil { s.logger.Printf("[ERROR] store: sealed %s secret for %s does not open (%v)", table, id, err) } return "", false } return pt, true } // sealAtRest seals a box secret for writing. An empty value stays empty (nothing to protect, and some // legacy rows carry ""). No key → ErrNoSealKey. func (s *Store) sealAtRest(plain string) (string, error) { if plain == "" { return "", nil } return s.sealSecret(plain) } // backfillAPIKeyHashes fills `api_key_hash` for every row that has none and still holds a plaintext // key. Needs NO sealing key, so it runs inside migrate() on every start — the hash index is complete // before any box reports, whatever happens to the sealing step later. Idempotent. func (s *Store) backfillAPIKeyHashes() error { for _, t := range []struct{ table, idCol string }{{"hosts", "host_id"}, {"customer_configs", "customer_id"}} { rows, err := s.db.Query(`SELECT ` + t.idCol + `, api_key FROM ` + t.table + ` WHERE api_key_hash = '' AND api_key <> '' AND api_key NOT LIKE 'enc:v1:%'`) if err != nil { return fmt.Errorf("%s: %w", t.table, err) } type row struct{ id, k string } var todo []row for rows.Next() { var r row if err := rows.Scan(&r.id, &r.k); err != nil { rows.Close() return err } todo = append(todo, r) } rows.Close() for _, r := range todo { if _, err := s.db.Exec(`UPDATE `+t.table+` SET api_key_hash = ? WHERE `+t.idCol+` = ? AND api_key = ?`, apiKeyHash(r.k), r.id, r.k); err != nil { return fmt.Errorf("%s: %w", t.table, err) } } } return nil } // SealLegacyBoxSecrets seals, in place, every R-879 column still holding plaintext: hosts.api_key, // customer_configs.api_key, customer_configs.retrieval_password and host_pbs_secrets.value. An API key is // sealed in the SAME statement that (re)writes its hash, so a sealed key never lacks its lookup twin. // Idempotent; returns how many values it sealed. Values are never logged. Called at start-up right after // the key is installed (cmd/hub/main.go), beside SealLegacyOffsiteSecrets / SealLegacyRecoverySecrets. // A failure part-way leaves the remaining rows plaintext and STILL authenticating (their hash, or their // plaintext, matches) — TestR879_BoxAuthSurvivesFailedSealing. func (s *Store) SealLegacyBoxSecrets() (int, error) { if s.sealer == nil { return 0, ErrNoSealKey } n := 0 for _, c := range r879Columns { rows, err := s.db.Query(`SELECT ` + c.idCol + `, ` + c.col + ` FROM ` + c.table + ` WHERE ` + c.col + ` <> '' AND ` + c.col + ` NOT LIKE 'enc:v1:%'`) if err != nil { return n, fmt.Errorf("%s.%s: %w", c.table, c.col, 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 n, err } todo = append(todo, r) } rows.Close() for _, r := range todo { sealed, err := s.sealSecret(r.v) if err != nil { return n, err } if c.withHash { _, err = s.db.Exec(`UPDATE `+c.table+` SET `+c.col+` = ?, api_key_hash = ? WHERE `+c.idCol+` = ? AND `+c.col+` = ?`, sealed, apiKeyHash(r.v), r.id, r.v) } else { _, err = s.db.Exec(`UPDATE `+c.table+` SET `+c.col+` = ? WHERE `+c.idCol+` = ? AND `+c.col+` = ?`, sealed, r.id, r.v) } if err != nil { return n, fmt.Errorf("%s.%s: %w", c.table, c.col, err) } n++ } } return n, nil } // sealAPIKey returns the sealed key and its lookup twin, written together by every API-key writer. func (s *Store) sealAPIKey(key string) (sealed, hash string, err error) { sealed, err = s.sealAtRest(key) if err != nil { return "", "", err } if key != "" { hash = apiKeyHash(key) } return sealed, hash, nil } // r879Columns are the four sealed box-facing columns, in one place for the seal and the reverse. var r879Columns = []struct { table, idCol, col string withHash bool }{ {"hosts", "host_id", "api_key", true}, {"customer_configs", "customer_id", "api_key", true}, {"customer_configs", "customer_id", "retrieval_password", false}, {"host_pbs_secrets", "host_id", "value", false}, } // UnsealBoxSecrets is the ROLL-BACK primitive for R-879: it opens every sealed value in the four // columns back to plaintext, so a hub older than R-879 (which looks keys up with `WHERE api_key = ?` // and serves the passphrase column as it is) works on this database again. api_key_hash is kept // (an older hub ignores it; a newer one re-seals at its next start). // // All or nothing: every sealed value is opened FIRST; if any one does not open (missing/wrong key, // corrupt) it returns an error and changes NOTHING. The writes then go in one transaction, each guarded // by the sealed value it read (a row rewritten meanwhile is left alone and not counted). Idempotent: a // second run finds nothing sealed and returns 0. Values are never logged. // Run by `felhom-hub -unseal-box-secrets` (cmd/hub). Pinned by TestR879_UnsealRestoresPreR879Lookup. func (s *Store) UnsealBoxSecrets() (int, error) { if s.sealer == nil { return 0, ErrNoSealKey } type todo struct { table, idCol, col, id, sealed, plain string } var all []todo for _, c := range r879Columns { rows, err := s.db.Query(`SELECT ` + c.idCol + `, ` + c.col + ` FROM ` + c.table + ` WHERE ` + c.col + ` LIKE 'enc:v1:%'`) if err != nil { return 0, fmt.Errorf("%s.%s: %w", c.table, c.col, err) } for rows.Next() { t := todo{table: c.table, idCol: c.idCol, col: c.col} if err := rows.Scan(&t.id, &t.sealed); err != nil { rows.Close() return 0, err } all = append(all, t) } rows.Close() } for i := range all { pt, err := s.openSecret(all[i].sealed) if err != nil { return 0, fmt.Errorf("%s.%s for %s does not open — nothing was changed: %w", all[i].table, all[i].col, all[i].id, err) } all[i].plain = pt } tx, err := s.db.Begin() if err != nil { return 0, err } defer tx.Rollback() n := 0 for _, t := range all { res, err := tx.Exec(`UPDATE `+t.table+` SET `+t.col+` = ? WHERE `+t.idCol+` = ? AND `+t.col+` = ?`, t.plain, t.id, t.sealed) if err != nil { return 0, fmt.Errorf("%s.%s: %w", t.table, t.col, err) } if k, _ := res.RowsAffected(); k > 0 { n++ } } if err := tx.Commit(); err != nil { return 0, err } return n, nil }