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 }