| 1 | package release |
| 2 | |
| 3 | // The production flag (2026-08-04 console bake controls spec §2–§3): a |
| 4 | // root-signed per-app object deciding whether the supply-chain bake applies. |
| 5 | // The properties tested here, in the order they'd hurt if lost: missing is |
| 6 | // the default (nil, nil); a set flag round-trips; a scoped key's wrapper is |
| 7 | // refused even with a valid root grant; a tampered payload is refused; the |
| 8 | // key sits OUTSIDE channels/ so pointer listings never try to parse it. |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "crypto/ed25519" |
| 13 | "crypto/rand" |
| 14 | "encoding/base64" |
| 15 | "encoding/json" |
| 16 | "errors" |
| 17 | "os" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | func TestProductionFlagDefaultIsMissing(t *testing.T) { |
| 24 | s := NewDirStore(t.TempDir()) |
| 25 | _, pub := testKeys(t) |
| 26 | f, err := GetProduction(context.Background(), s, pub, "bab", "kass") |
| 27 | if err != nil || f != nil { |
| 28 | t.Fatalf("GetProduction on empty store = %v, %v; want nil, nil (the default: not production)", f, err) |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func TestProductionFlagRoundTrips(t *testing.T) { |
| 33 | s := NewDirStore(t.TempDir()) |
| 34 | ctx := context.Background() |
| 35 | priv, pub := testKeys(t) |
| 36 | if err := SetProduction(ctx, s, priv, "bab", "kass", true, "paul@keymail.dev"); err != nil { |
| 37 | t.Fatalf("SetProduction: %v", err) |
| 38 | } |
| 39 | f, err := GetProduction(ctx, s, pub, "bab", "kass") |
| 40 | if err != nil { |
| 41 | t.Fatalf("GetProduction: %v", err) |
| 42 | } |
| 43 | if f == nil || !f.Production || f.Actor != "paul@keymail.dev" || f.SetAt.IsZero() { |
| 44 | t.Fatalf("flag = %+v, want production on, actor recorded, set_at stamped", f) |
| 45 | } |
| 46 | // Flip it off over the existing object — the CAS path. |
| 47 | if err := SetProduction(ctx, s, priv, "bab", "kass", false, "paul@keymail.dev"); err != nil { |
| 48 | t.Fatalf("SetProduction off: %v", err) |
| 49 | } |
| 50 | if f, _ = GetProduction(ctx, s, pub, "bab", "kass"); f == nil || f.Production { |
| 51 | t.Fatalf("flag = %+v after flipping off, want production false", f) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // The flag key lives beside channels/, never inside it: ChannelsPrefix |
| 56 | // listings are parsed as pointers by the console and CLI. |
| 57 | func TestProductionKeyIsOutsideChannels(t *testing.T) { |
| 58 | if k := ProductionKey("bab", "kass"); k != "apps/bab/kass/production.json" { |
| 59 | t.Fatalf("ProductionKey = %q", k) |
| 60 | } |
| 61 | if strings.HasPrefix(ProductionKey("bab", "kass"), ChannelsPrefix("bab", "kass")) { |
| 62 | t.Fatal("production flag key sits under channels/ — pointer listings would try to parse it") |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // A scoped key must not be able to mint the flag: a wrapper carrying ANY |
| 67 | // grant is refused before signature checking, and a wrapper signed by a key |
| 68 | // other than the root fails signature checking. Together: root only. |
| 69 | func TestProductionFlagRefusesScopedAndForeignKeys(t *testing.T) { |
| 70 | s := NewDirStore(t.TempDir()) |
| 71 | ctx := context.Background() |
| 72 | priv, pub := testKeys(t) |
| 73 | |
| 74 | // Build a validly-shaped flag payload signed by a DIFFERENT key. |
| 75 | otherPub, otherPriv, err := ed25519.GenerateKey(rand.Reader) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | _ = otherPub |
| 80 | payload, _ := json.Marshal(ProductionFlag{Schema: 1, Account: "bab", App: "kass", |
| 81 | Production: false, SetAt: timeNowTestHelper()}) |
| 82 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 83 | wrapper, _ := json.Marshal(signedWrapper{ |
| 84 | Payload: b64, |
| 85 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(otherPriv, []byte(b64))), |
| 86 | }) |
| 87 | if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, ""); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if _, err := GetProduction(ctx, s, pub, "bab", "kass"); err == nil { |
| 91 | t.Fatal("a flag signed by a non-root key verified") |
| 92 | } |
| 93 | |
| 94 | // A wrapper WITH a grant is refused outright — even a grant the root |
| 95 | // really signed. Mint a real scoped key and have it sign the flag. |
| 96 | seed, grantB64, err := MintKeyGrant(priv, "builder", []string{RungEdge}, maxGrantTTL) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | scopedKey, err := ParseSigningKey(seed, "test") |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | sg, err := parseGrantBlob(grantB64, "test") |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | wrapper, _ = json.Marshal(signedWrapper{ |
| 109 | Payload: b64, |
| 110 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(scopedKey, []byte(b64))), |
| 111 | Grant: sg, |
| 112 | }) |
| 113 | if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, etagOfKey(t, s, "bab", "kass")); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | _, err = GetProduction(ctx, s, pub, "bab", "kass") |
| 117 | if err == nil || !strings.Contains(err.Error(), "root") { |
| 118 | t.Fatalf("a grant-bearing flag wrapper verified (err=%v), want a root-only refusal", err) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // etagOfKey reads the current etag of an app's flag so a test can CAS over it. |
| 123 | func etagOfKey(t *testing.T, s Store, acct, app string) string { |
| 124 | t.Helper() |
| 125 | _, etag, err := s.Get(context.Background(), ProductionKey(acct, app)) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | return etag |
| 130 | } |
| 131 | |
| 132 | func TestProductionFlagRefusesWrongApp(t *testing.T) { |
| 133 | s := NewDirStore(t.TempDir()) |
| 134 | ctx := context.Background() |
| 135 | priv, pub := testKeys(t) |
| 136 | if err := SetProduction(ctx, s, priv, "bab", "kass", true, ""); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | // Copy kass's flag to keymail's key — a cross-app replay must be refused |
| 140 | // because the SIGNED payload names the app. |
| 141 | raw, _, err := s.Get(ctx, ProductionKey("bab", "kass")) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if _, err := s.Put(ctx, ProductionKey("bab", "keymail"), raw, ""); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if _, err := GetProduction(ctx, s, pub, "bab", "keymail"); err == nil { |
| 149 | t.Fatal("another app's flag verified under keymail's key") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // The signature binds the exact base64 payload STRING, not the decoded JSON |
| 154 | // meaning — so a payload that is decoded, edited, and re-encoded (even |
| 155 | // leaving the original Sig in place) must fail verification. A verifier that |
| 156 | // decoded first and checked a signature over re-marshalled bytes would let |
| 157 | // this through if the re-marshalling happened to reproduce the tampered |
| 158 | // bytes; this test pins the "no canonicalisation" wire rule directly against |
| 159 | // the field an attacker would actually want to flip. |
| 160 | func TestProductionFlagRefusesTamperedPayload(t *testing.T) { |
| 161 | s := NewDirStore(t.TempDir()) |
| 162 | ctx := context.Background() |
| 163 | priv, pub := testKeys(t) |
| 164 | if err := SetProduction(ctx, s, priv, "bab", "kass", true, "paul@keymail.dev"); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | raw, etag, err := s.Get(ctx, ProductionKey("bab", "kass")) |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | var sw signedWrapper |
| 172 | if err := json.Unmarshal(raw, &sw); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | var f ProductionFlag |
| 180 | if err := json.Unmarshal(payload, &f); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if !f.Production { |
| 184 | t.Fatal("expected production=true before tampering") |
| 185 | } |
| 186 | f.Production = false // flip the bit an attacker would want to flip |
| 187 | tampered, err := json.Marshal(&f) |
| 188 | if err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | // Keep the ORIGINAL Sig — it covers the ORIGINAL payload string. A |
| 192 | // re-encoded payload, even with identical decoded meaning, must fail |
| 193 | // verification without a fresh signature. |
| 194 | sw.Payload = base64.StdEncoding.EncodeToString(tampered) |
| 195 | wrapper, err := json.Marshal(sw) |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, etag); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | if _, err := GetProduction(ctx, s, pub, "bab", "kass"); err == nil { |
| 203 | t.Fatal("a tampered payload (re-encoded, original sig) verified") |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // A trashed app cannot be flagged production in EITHER direction: restore |
| 208 | // first. This holds even though the flag would refuse to WRITE anything |
| 209 | // destructive itself — the point is that a production flag appearing on a |
| 210 | // trashed app contradicts the flag's own promise (production = never |
| 211 | // deletable), so the door closes before the CAS write, not after. |
| 212 | func TestSetProductionRefusesTrashedApp(t *testing.T) { |
| 213 | s := NewDirStore(t.TempDir()) |
| 214 | ctx := context.Background() |
| 215 | priv, _ := testKeys(t) |
| 216 | |
| 217 | if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if err := SetProduction(ctx, s, priv, "bab", "derp", true, "paul@keymail.dev"); err == nil { |
| 221 | t.Fatal("SetProduction(true) on a trashed app succeeded, want a refusal") |
| 222 | } |
| 223 | // The OFF direction is refused too — there is nothing legitimate to do to |
| 224 | // a trashed app's flag; restore first. |
| 225 | if err := SetProduction(ctx, s, priv, "bab", "derp", false, "paul@keymail.dev"); err == nil { |
| 226 | t.Fatal("SetProduction(false) on a trashed app succeeded, want a refusal") |
| 227 | } |
| 228 | // No flag object should have been written. |
| 229 | if _, _, err := s.Get(ctx, ProductionKey("bab", "derp")); err == nil { |
| 230 | t.Fatal("a refused SetProduction on a trashed app wrote the flag object anyway") |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // An unreadable trash marker refuses too — the usual direction: a caller |
| 235 | // about to declare production must never act as though a marker it cannot |
| 236 | // verify simply isn't there. Uses a store that fails ONLY the tombstone |
| 237 | // key (everything else, including the eventual production.json write, |
| 238 | // would otherwise succeed) so this test actually pins the new trash-marker |
| 239 | // check rather than any generic "the store is broken" refusal. |
| 240 | func TestSetProductionRefusesUnreadableTrashMarker(t *testing.T) { |
| 241 | priv, _ := testKeys(t) |
| 242 | real := NewDirStore(t.TempDir()) |
| 243 | s := &tombstoneReadFailStore{Store: real, key: AppTombstoneKey("bab", "derp"), err: errors.New("bucket on fire")} |
| 244 | if err := SetProduction(context.Background(), s, priv, "bab", "derp", true, "paul@keymail.dev"); err == nil { |
| 245 | t.Fatal("SetProduction with an unreadable trash marker succeeded, want a refusal") |
| 246 | } |
| 247 | // Confirm nothing else about the store is broken — the refusal really is |
| 248 | // the trash-marker check, not a store that happens to fail everything. |
| 249 | if _, _, err := real.Get(context.Background(), ProductionKey("bab", "derp")); !errors.Is(err, os.ErrNotExist) { |
| 250 | t.Fatalf("production flag written despite an unreadable trash marker: err=%v", err) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // tombstoneReadFailStore fails Get for exactly one key and delegates |
| 255 | // everything else to the wrapped Store. |
| 256 | type tombstoneReadFailStore struct { |
| 257 | Store |
| 258 | key string |
| 259 | err error |
| 260 | } |
| 261 | |
| 262 | func (t *tombstoneReadFailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 263 | if key == t.key { |
| 264 | return nil, "", t.err |
| 265 | } |
| 266 | return t.Store.Get(ctx, key) |
| 267 | } |
| 268 | |
| 269 | // timeNowTestHelper is a stamped, truncated now — what signProductionFlag |
| 270 | // itself writes — for tests that hand-build payloads. |
| 271 | func timeNowTestHelper() time.Time { return time.Now().UTC().Truncate(time.Second) } |
| 272 | |
| 273 | // Task-4-review fix 5 (Minor, promoted), tightened by re-review: SetAt is |
| 274 | // truncated to the second, so two flips within the same wall-clock second |
| 275 | // would otherwise share a set_at — and the box-side replay floor (bake.go's |
| 276 | // NewestSetAt) treats an equal set_at as nothing new, silently swallowing |
| 277 | // the second flip on every box that already saw the first. A single |
| 278 | // before/after pair is vacuous if the two calls happen to straddle a |
| 279 | // second boundary (it would pass with or without the bump). Looping several |
| 280 | // rapid flips and requiring EVERY consecutive pair to be strictly |
| 281 | // increasing removes that escape: the bump guarantees strict monotonicity |
| 282 | // regardless of whether any given pair lands in the same truncated second |
| 283 | // or crosses one, so this holds deterministically with the fix in place. |
| 284 | func TestSetProductionMonotonicSetAtOnRapidFlip(t *testing.T) { |
| 285 | s := NewDirStore(t.TempDir()) |
| 286 | ctx := context.Background() |
| 287 | priv, pub := testKeys(t) |
| 288 | |
| 289 | var prev time.Time |
| 290 | for i := 0; i < 3; i++ { |
| 291 | if err := SetProduction(ctx, s, priv, "bab", "kass", i%2 == 0, "paul@keymail.dev"); err != nil { |
| 292 | t.Fatalf("SetProduction flip %d: %v", i, err) |
| 293 | } |
| 294 | f, err := GetProduction(ctx, s, pub, "bab", "kass") |
| 295 | if err != nil || f == nil { |
| 296 | t.Fatalf("GetProduction after flip %d: %v, %v", i, f, err) |
| 297 | } |
| 298 | if i > 0 && !f.SetAt.After(prev) { |
| 299 | t.Fatalf("flip %d's set_at %v not strictly after flip %d's %v (same-second collision not bumped)", |
| 300 | i, f.SetAt, i-1, prev) |
| 301 | } |
| 302 | prev = f.SetAt |
| 303 | } |
| 304 | } |
| 305 | |