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) } }