Files
felhom.eu/hub/internal/store/r975_config_token_seal.go
T
admin 5277d859d5
gates / gates (push) Successful in 12m26s
hub 0.168.0: seal the last plain tokens in config_json (R-975, R-1241's Felhom half); decide row R-1243 for the hub's plain Longhorn copies (register 188 -> 189)
The Cloudflare tunnel token, Cloudflare API token and git token inside customer_configs.config_json now hold the
05 §16.2 seal (enc:v1:, OFFSITE_SECRET_KEY). Old rows sealed once at start-up without a config_version bump, then
VACUUM + WAL checkpoint (an UPDATE leaves the old plaintext in a freed block). -unseal-box-secrets opens them too.
Eight tests, each red-proved. Built, not deployed. Audit: documentation/audits/hub-token-seal-2026-10-23/.
2026-10-23 19:48:17 +02:00

250 lines
8.4 KiB
Go

package store
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ── R-975 / R-1241: the tokens inside customer_configs.config_json are sealed at rest too ──────────────
//
// R-879 sealed every secret COLUMN of the hub database but left three secrets that live inside the
// customer's config_json overrides: the Cloudflare tunnel token, the Cloudflare API token and the git
// token. A copy of hub.db (a nightly snapshot, a Longhorn backup of the hub volume — R-1241 measured
// that any pod on DooPlex could read those) therefore still held every household's tunnel and DNS
// credentials in the clear.
//
// Now each of those JSON leaves holds the SAME seal as every other hub secret (`enc:v1:`, AES-256-GCM,
// OFFSITE_SECRET_KEY, `05` §16.2) — no second mechanism, no second key. The rest of config_json stays
// readable (it is not secret, and the operator pages show it). Writers seal in SaveCustomerConfig;
// every reader opens through openCustomerSecrets, so the rest of the hub sees plaintext exactly as
// before. A sealed token that does not open is BLANKED and sets SecretsUnreadable, the R-879 rule:
// never served, never compared, and the record cannot be saved back.
//
// Pinned by: TestR975_FreshWriteIsStoredSealed, TestR975_MigrationSealsOnceThenNoOp,
// TestR975_ReaderGetsTheSameValue, TestR975_DBFileWithoutKeyRevealsNoToken,
// TestR975_WrongKeyBlanksAndRefusesSave, TestR975_UnsealRestoresPlaintextForAnOlderHub.
// configTokenPaths are the secret leaves of config_json, as written by buildConfigJSON (web/configs.go).
// A new secret key there must be added here — TestR975_EveryFormSecretIsSealed reads the form's keys.
var configTokenPaths = [][]string{
{"infrastructure", "cf_tunnel_token"},
{"infrastructure", "cf_api_token"},
{"git", "token"},
}
// decodeConfigJSON parses a config_json object, keeping numbers as written. ok=false for "" or a value
// that is not a JSON object (such a value holds no token leaf this file knows, and is left as it is).
func decodeConfigJSON(js string) (map[string]any, bool) {
if strings.TrimSpace(js) == "" {
return nil, false
}
dec := json.NewDecoder(strings.NewReader(js))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil || m == nil {
return nil, false
}
return m, true
}
func encodeConfigJSON(m map[string]any) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(m); err != nil {
return "", err
}
return strings.TrimRight(buf.String(), "\n"), nil
}
// mapConfigTokens calls fn on every non-empty string token leaf; fn returns the new value and whether
// it changed. The JSON is re-encoded only when something changed, so a config with no token keeps its
// exact bytes.
func mapConfigTokens(js string, fn func(path, v string) (string, bool, error)) (string, int, error) {
m, ok := decodeConfigJSON(js)
if !ok {
return js, 0, nil
}
n := 0
for _, p := range configTokenPaths {
parent, ok := m[p[0]].(map[string]any)
if !ok {
continue
}
v, ok := parent[p[1]].(string)
if !ok || v == "" {
continue
}
nv, changed, err := fn(p[0]+"."+p[1], v)
if err != nil {
return js, n, err
}
if changed {
parent[p[1]] = nv
n++
}
}
if n == 0 {
return js, 0, nil
}
out, err := encodeConfigJSON(m)
if err != nil {
return js, 0, err
}
return out, n, nil
}
// sealConfigJSONTokens seals every plaintext token leaf. No key and a token to seal → ErrNoSealKey
// (fail-closed, §16.2); a config with no token needs no key.
func (s *Store) sealConfigJSONTokens(js string) (string, int, error) {
return mapConfigTokens(js, func(_, v string) (string, bool, error) {
if strings.HasPrefix(v, sealPrefix) {
return v, false, nil
}
sealed, err := s.sealSecret(v)
if err != nil {
return "", false, err
}
return sealed, true, nil
})
}
// openConfigJSONTokens opens every sealed token leaf. A leaf that does not open is blanked and ok=false;
// a legacy plaintext leaf passes through (the start-up sealing may not have run yet).
func (s *Store) openConfigJSONTokens(customerID, js string) (string, bool) {
if !strings.Contains(js, sealPrefix) {
return js, true
}
ok := true
out, _, err := mapConfigTokens(js, func(path, v string) (string, bool, error) {
if !strings.HasPrefix(v, sealPrefix) {
return v, false, nil
}
pt, good := s.openAtRest("customer_configs.config_json."+path, customerID, v)
if !good {
ok = false
return "", true, nil
}
return pt, true, nil
})
if err != nil {
if s.logger != nil {
s.logger.Printf("[ERROR] store: config_json tokens for %s: %v", customerID, err)
}
return js, false
}
return out, ok
}
// SealLegacyConfigTokens seals, in place, every config_json token leaf still in plaintext. Idempotent;
// returns how many token values it sealed. It does NOT bump config_version or updated_at: the config a
// box receives is byte-for-byte the same, so no box should re-pull and restart for it. Each row is
// written guarded by the value it read (a row saved meanwhile is left for the next start). Values are
// never logged. Called at start-up beside SealLegacyBoxSecrets (cmd/hub/main.go).
func (s *Store) SealLegacyConfigTokens() (int, error) {
if s.sealer == nil {
return 0, ErrNoSealKey
}
rows, err := s.db.Query(`SELECT customer_id, config_json FROM customer_configs`)
if err != nil {
return 0, err
}
type row struct{ id, js string }
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.js); err != nil {
rows.Close()
return 0, err
}
all = append(all, r)
}
rows.Close()
n := 0
for _, r := range all {
sealed, k, err := s.sealConfigJSONTokens(r.js)
if err != nil {
return n, fmt.Errorf("customer_configs.config_json for %s: %w", r.id, err)
}
if k == 0 {
continue
}
res, err := s.db.Exec(`UPDATE customer_configs SET config_json = ? WHERE customer_id = ? AND config_json = ?`, sealed, r.id, r.js)
if err != nil {
return n, fmt.Errorf("customer_configs.config_json for %s: %w", r.id, err)
}
if a, _ := res.RowsAffected(); a > 0 {
n += k
}
}
if n > 0 {
// An UPDATE leaves the old row — the plaintext tokens — in a freed page of hub.db and in the -wal
// until something overwrites it, so a copy of the FILE (a Longhorn backup of the volume) would
// still hold them. VACUUM rewrites the file without free pages; the checkpoint empties the -wal.
// Once: a second start seals nothing and does not vacuum. Measured on a copy of the live
// database (534 MB): 2.8 s. TestR975_DBFileWithoutKeyRevealsNoToken pins it.
if _, err := s.db.Exec(`VACUUM`); err != nil {
return n, fmt.Errorf("vacuum after sealing config tokens: %w", err)
}
s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
return n, nil
}
// configTokenUnseal is one customer_configs row whose sealed token leaves were opened.
type configTokenUnseal struct {
id, sealed, plain string
tokens int
}
// unsealConfigTokenRows is UnsealBoxSecrets' half for config_json: for every row holding a sealed token
// it returns the row with every token opened — or an error if any one does not open (all or nothing;
// nothing is written here).
func (s *Store) unsealConfigTokenRows() ([]configTokenUnseal, error) {
rows, err := s.db.Query(`SELECT customer_id, config_json FROM customer_configs WHERE config_json LIKE '%enc:v1:%'`)
if err != nil {
return nil, err
}
var all []configTokenUnseal
for rows.Next() {
var r configTokenUnseal
if err := rows.Scan(&r.id, &r.sealed); err != nil {
rows.Close()
return nil, err
}
all = append(all, r)
}
rows.Close()
var out []configTokenUnseal
for _, r := range all {
plain, k, err := mapConfigTokens(r.sealed, func(path, v string) (string, bool, error) {
if !strings.HasPrefix(v, sealPrefix) {
return v, false, nil
}
pt, err := s.openSecret(v)
if err != nil {
return "", false, fmt.Errorf("customer_configs.config_json.%s for %s does not open — nothing was changed: %w", path, r.id, err)
}
return pt, true, nil
})
if err != nil {
return nil, err
}
if k > 0 {
r.plain, r.tokens = plain, k
out = append(out, r)
}
}
return out, nil
}
// RawConfigJSONForTest returns customer_configs.config_json exactly as stored (still sealed), for the
// web package's TestR975_EveryFormSecretIsSealed. Not used by the hub.
func (s *Store) RawConfigJSONForTest(customerID string) (string, error) {
var v string
err := s.db.QueryRow(`SELECT config_json FROM customer_configs WHERE customer_id = ?`, customerID).Scan(&v)
return v, err
}