91e4ace9b8
Opens the four sealed box-secret columns back to plaintext (all or nothing; keeps api_key_hash; idempotent; counts only in the log) and exits before any start-up sealing, so hub 0.137.0 can run on the database again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
239 lines
9.5 KiB
Go
239 lines
9.5 KiB
Go
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
|
|
}
|