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/.
288 lines
12 KiB
Go
288 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-975: the tunnel, Cloudflare API and git tokens inside customer_configs.config_json are sealed at
|
|
// rest (r975_config_token_seal.go). Canaries only — never a real token.
|
|
|
|
const (
|
|
r975Tunnel = "tunnel-token-canary-eyJhIjoiYWJjIn0-r975"
|
|
r975CFAPI = "cf-api-token-canary-0f1e2d3c4b5a-r975"
|
|
r975Git = "git-token-canary-9a8b7c6d5e4f-r975"
|
|
)
|
|
|
|
var r975Canaries = []string{r975Tunnel, r975CFAPI, r975Git}
|
|
|
|
// r975ConfigJSON is the shape buildConfigJSON (web/configs.go) writes, plus a non-secret field and an
|
|
// offsite descriptor the offsite package merges in later.
|
|
func r975ConfigJSON() string {
|
|
return `{"git":{"token":"` + r975Git + `","username":"felhom-bot"},` +
|
|
`"infrastructure":{"cf_api_token":"` + r975CFAPI + `","cf_tunnel_token":"` + r975Tunnel + `"},` +
|
|
`"logging":{"level":"debug"},"offsite":{"quota_gb":100,"user":"u12345-sub3"}}`
|
|
}
|
|
|
|
func r975Leaves(t *testing.T, js string) (tunnel, cfapi, git string) {
|
|
t.Helper()
|
|
var m struct {
|
|
Git struct{ Token string } `json:"git"`
|
|
Infrastructure struct {
|
|
CFAPIToken string `json:"cf_api_token"`
|
|
CFTunnelToken string `json:"cf_tunnel_token"`
|
|
} `json:"infrastructure"`
|
|
}
|
|
if err := json.Unmarshal([]byte(js), &m); err != nil {
|
|
t.Fatalf("config_json %q: %v", js, err)
|
|
}
|
|
return m.Infrastructure.CFTunnelToken, m.Infrastructure.CFAPIToken, m.Git.Token
|
|
}
|
|
|
|
func r975RawConfig(t *testing.T, st *Store, id string) string {
|
|
t.Helper()
|
|
return r879Raw(t, st, `SELECT config_json FROM customer_configs WHERE customer_id = ?`, id)
|
|
}
|
|
|
|
// A fresh write through SaveCustomerConfig stores each token sealed; the rest of the JSON stays readable.
|
|
func TestR975_FreshWriteIsStoredSealed(t *testing.T) {
|
|
st := sealTestStore(t)
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c1", RetrievalPassword: "p", APIKey: "k", ConfigJSON: r975ConfigJSON()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := r975RawConfig(t, st, "c1")
|
|
for _, c := range r975Canaries {
|
|
if strings.Contains(raw, c) {
|
|
t.Errorf("config_json holds a plaintext token %q", c)
|
|
}
|
|
}
|
|
tun, api, git := r975Leaves(t, raw)
|
|
for name, v := range map[string]string{"cf_tunnel_token": tun, "cf_api_token": api, "git.token": git} {
|
|
if !strings.HasPrefix(v, sealPrefix) {
|
|
t.Errorf("%s is not sealed: %q", name, v)
|
|
}
|
|
}
|
|
for _, plain := range []string{`"level":"debug"`, `"user":"u12345-sub3"`, `"quota_gb":100`, `"username":"felhom-bot"`} {
|
|
if !strings.Contains(raw, plain) {
|
|
t.Errorf("non-secret field %s lost or changed in %q", plain, raw)
|
|
}
|
|
}
|
|
// No key → a config with a token is refused, never written in plaintext (§16.2).
|
|
st.sealer = nil
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c2", ConfigJSON: r975ConfigJSON()}); err != ErrNoSealKey {
|
|
t.Fatalf("SaveCustomerConfig with a token and no key = %v, want ErrNoSealKey", err)
|
|
}
|
|
// ...while a config with no token needs no key for its JSON (empty secrets stay empty).
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c3", ConfigJSON: `{"logging":{"level":"debug"}}`}); err != nil {
|
|
t.Fatalf("SaveCustomerConfig with no token and no key = %v", err)
|
|
}
|
|
if got := r975RawConfig(t, st, "c3"); got != `{"logging":{"level":"debug"}}` {
|
|
t.Fatalf("a token-free config_json was rewritten: %q", got)
|
|
}
|
|
}
|
|
|
|
// Every reader gets back exactly the value that was written.
|
|
func TestR975_ReaderGetsTheSameValue(t *testing.T) {
|
|
st := sealTestStore(t)
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c1", RetrievalPassword: "p", APIKey: "cust-key-r975", ConfigJSON: r975ConfigJSON()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check := func(where string, cfg *CustomerConfig) {
|
|
t.Helper()
|
|
if cfg == nil || cfg.SecretsUnreadable {
|
|
t.Fatalf("%s: %+v", where, cfg)
|
|
}
|
|
tun, api, git := r975Leaves(t, cfg.ConfigJSON)
|
|
if tun != r975Tunnel || api != r975CFAPI || git != r975Git {
|
|
t.Fatalf("%s: tokens read back wrong (tunnel ok=%v api ok=%v git ok=%v)", where, tun == r975Tunnel, api == r975CFAPI, git == r975Git)
|
|
}
|
|
}
|
|
cfg, err := st.GetCustomerConfig("c1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("GetCustomerConfig", cfg)
|
|
byKey, err := st.GetCustomerConfigByAPIKey("cust-key-r975")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check("GetCustomerConfigByAPIKey", byKey)
|
|
list, err := st.ListCustomerConfigs()
|
|
if err != nil || len(list) != 1 {
|
|
t.Fatalf("ListCustomerConfigs = %d, %v", len(list), err)
|
|
}
|
|
check("ListCustomerConfigs", &list[0])
|
|
// Load-modify-save (every operator form does this): the value survives and is sealed again, not
|
|
// double-sealed.
|
|
cfg.CustomerName = "renamed"
|
|
if err := st.SaveCustomerConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, _ := st.GetCustomerConfig("c1")
|
|
check("after a load-modify-save", again)
|
|
if raw := r975RawConfig(t, st, "c1"); strings.Contains(raw, r975Tunnel) {
|
|
t.Fatal("a load-modify-save wrote the tunnel token in plaintext")
|
|
}
|
|
}
|
|
|
|
// The start-up migration seals rows a pre-R-975 hub wrote, once; a second start is a no-op. It does not
|
|
// bump config_version (the box's config is unchanged — no re-pull, no restart).
|
|
func TestR975_MigrationSealsOnceThenNoOp(t *testing.T) {
|
|
st := sealTestStore(t)
|
|
if _, err := st.db.Exec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key, config_json) VALUES ('c1', '', '', ?), ('c2', '', '', '{}')`, r975ConfigJSON()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := r879Raw(t, st, `SELECT config_version || '|' || updated_at FROM customer_configs WHERE customer_id='c1'`)
|
|
n, err := st.SealLegacyConfigTokens()
|
|
if err != nil || n != 3 {
|
|
t.Fatalf("SealLegacyConfigTokens = %d, %v — want the three plaintext tokens", n, err)
|
|
}
|
|
raw := r975RawConfig(t, st, "c1")
|
|
for _, c := range r975Canaries {
|
|
if strings.Contains(raw, c) {
|
|
t.Fatalf("after the migration config_json still holds %q", c)
|
|
}
|
|
}
|
|
if got := r975RawConfig(t, st, "c2"); got != "{}" {
|
|
t.Fatalf("a token-free row was rewritten: %q", got)
|
|
}
|
|
if after := r879Raw(t, st, `SELECT config_version || '|' || updated_at FROM customer_configs WHERE customer_id='c1'`); after != before {
|
|
t.Fatalf("the migration bumped config_version/updated_at: %s -> %s (every box would re-pull and restart)", before, after)
|
|
}
|
|
if n, err := st.SealLegacyConfigTokens(); err != nil || n != 0 {
|
|
t.Fatalf("second SealLegacyConfigTokens = %d, %v — want a no-op", n, err)
|
|
}
|
|
cfg, _ := st.GetCustomerConfig("c1")
|
|
if tun, api, git := r975Leaves(t, cfg.ConfigJSON); tun != r975Tunnel || api != r975CFAPI || git != r975Git {
|
|
t.Fatal("tokens lost in the migration")
|
|
}
|
|
st.sealer = nil
|
|
if _, err := st.SealLegacyConfigTokens(); err != ErrNoSealKey {
|
|
t.Fatalf("SealLegacyConfigTokens without a key = %v, want ErrNoSealKey", err)
|
|
}
|
|
}
|
|
|
|
// The consequence R-1241 cares about: a COPY OF THE DATABASE FILES, without the key, reveals no token.
|
|
// Positive control first: the rows exactly as a pre-R-975 hub wrote them DO show in the file — so the
|
|
// grep is able to see a token. Then the start-up migration runs and the same files hold none (the old
|
|
// plaintext must not survive in a freed page or in the -wal either).
|
|
func TestR975_DBFileWithoutKeyRevealsNoToken(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "hub.db")
|
|
st, err := New(path, log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
// A second customer written AFTER c1, as on the live hub (five customers): c1's old row is then not
|
|
// the last cell on its page, so its sealed replacement does not land on top of it and the plaintext
|
|
// stays in a freed block unless the file is rewritten.
|
|
if _, err := st.db.Exec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key, config_json) VALUES ('c1', '', '', ?), ('c0', '', '', '{"logging":{"level":"debug"}}')`, r975ConfigJSON()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fileHolds := func(c string) bool {
|
|
t.Helper()
|
|
var all []byte
|
|
for _, f := range []string{path, path + "-wal", path + "-shm"} {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
all = append(all, b...)
|
|
}
|
|
return bytes.Contains(all, []byte(c))
|
|
}
|
|
for _, c := range r975Canaries {
|
|
if !fileHolds(c) {
|
|
t.Fatalf("positive control: the pre-R-975 file does not show %q — the grep cannot see a token", c)
|
|
}
|
|
}
|
|
if n, err := st.SealLegacyConfigTokens(); err != nil || n != 3 {
|
|
t.Fatalf("SealLegacyConfigTokens = %d, %v", n, err)
|
|
}
|
|
// A fresh write after the upgrade, too.
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c2", RetrievalPassword: "p", APIKey: "k2", ConfigJSON: strings.ReplaceAll(r975ConfigJSON(), "r975", "r975b")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range append(r975Canaries, strings.ReplaceAll(r975Tunnel, "r975", "r975b"), strings.ReplaceAll(r975Git, "r975", "r975b")) {
|
|
if fileHolds(c) {
|
|
t.Errorf("a copy of the database files still shows %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A wrong or missing key at a restart: the token is blanked, the record is flagged, and it cannot be
|
|
// saved back (which would erase the stored token). The right key brings it back intact.
|
|
func TestR975_WrongKeyBlanksAndRefusesSave(t *testing.T) {
|
|
st := sealTestStore(t)
|
|
// No passphrase and no API key: only the tokens are sealed, so the flag below can come from them alone.
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c1", ConfigJSON: r975ConfigJSON()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := st.GetCustomerConfig("c1")
|
|
if err != nil || cfg == nil || !cfg.SecretsUnreadable {
|
|
t.Fatalf("GetCustomerConfig with a wrong key = %+v, %v — want SecretsUnreadable", cfg, err)
|
|
}
|
|
if strings.Contains(cfg.ConfigJSON, sealPrefix) {
|
|
t.Fatalf("a sealed token that did not open is passed on: %q", cfg.ConfigJSON)
|
|
}
|
|
if tun, _, _ := r975Leaves(t, cfg.ConfigJSON); tun != "" {
|
|
t.Fatal("the tunnel token is not blanked")
|
|
}
|
|
if err := st.SaveCustomerConfig(cfg); err == nil {
|
|
t.Fatal("a config whose tokens did not open was saved back — it would blank them")
|
|
}
|
|
if err := st.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, _ = st.GetCustomerConfig("c1")
|
|
if tun, _, _ := r975Leaves(t, cfg.ConfigJSON); tun != r975Tunnel || cfg.SecretsUnreadable {
|
|
t.Fatal("the stored tunnel token was damaged while the key was wrong")
|
|
}
|
|
}
|
|
|
|
// The roll-back: `felhom-hub -unseal-box-secrets` opens the config tokens too, so a hub older than
|
|
// 0.168.0 (which serves config_json as it is stored) hands the box the real tunnel token again.
|
|
func TestR975_UnsealRestoresPlaintextForAnOlderHub(t *testing.T) {
|
|
st := sealTestStore(t)
|
|
r879Seed(t, st)
|
|
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c9", RetrievalPassword: "p9", APIKey: "k9", ConfigJSON: r975ConfigJSON()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := r975RawConfig(t, st, "c9")
|
|
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, err := st.UnsealBoxSecrets(); err == nil || n != 0 {
|
|
t.Fatalf("UnsealBoxSecrets with a wrong key = %d, %v — want a refusal", n, err)
|
|
}
|
|
if r975RawConfig(t, st, "c9") != before {
|
|
t.Fatal("a refused unseal changed config_json")
|
|
}
|
|
if err := st.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// r879Seed's four values, c9's two sealed columns, and its three tokens.
|
|
if n, err := st.UnsealBoxSecrets(); err != nil || n != 9 {
|
|
t.Fatalf("UnsealBoxSecrets = %d, %v — want 4 + 2 + 3", n, err)
|
|
}
|
|
tun, api, git := r975Leaves(t, r975RawConfig(t, st, "c9"))
|
|
if tun != r975Tunnel || api != r975CFAPI || git != r975Git {
|
|
t.Fatal("after the unseal the raw config_json does not hold the plaintext tokens an older hub serves")
|
|
}
|
|
if n, err := st.UnsealBoxSecrets(); err != nil || n != 0 {
|
|
t.Fatalf("second UnsealBoxSecrets = %d, %v — want a no-op", n, err)
|
|
}
|
|
if n, err := st.SealLegacyConfigTokens(); err != nil || n != 3 {
|
|
t.Fatalf("re-seal after unseal = %d, %v", n, err)
|
|
}
|
|
}
|