gates / gates (push) Successful in 12m26s
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/.
250 lines
8.4 KiB
Go
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
|
|
}
|