| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "reflect" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // The grant-signed wire shape, pinned beside the root-signed one. Captured |
| 15 | // with deterministic seeds (root = bytes 1..32, scoped = bytes 101..132) and |
| 16 | // frozen timestamps: a grant minted 2026-08-03T09:00Z for canary+edge, |
| 17 | // 90 days, over an edge pointer promoted half an hour later. |
| 18 | // |
| 19 | // The root public key is goldenRootPub — the SAME anchor as the root-signed |
| 20 | // fixture, which is the point of the whole scheme: one CARLOS_RELEASE_PUBKEY |
| 21 | // verifies both shapes. |
| 22 | const goldenGrantSignedWrapper = `{ |
| 23 | "payload": "ewogICJzY2hlbWEiOiAxLAogICJhcHAiOiAia2FzcyIsCiAgImNoYW5uZWwiOiAiZWRnZSIsCiAgInZlcnNpb24iOiAiOWYzYzFhZCIsCiAgInByb21vdGVkX2F0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIiwKICAiYXVkaXQiOiBbCiAgICB7CiAgICAgICJ2ZXJzaW9uIjogIjlmM2MxYWQiLAogICAgICAidG8iOiAiZWRnZSIsCiAgICAgICJhY3RvciI6ICJidWlsZGVyQGNpIiwKICAgICAgImF0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIgogICAgfQogIF0KfQ==", |
| 24 | "sig": "oXZdFZDT5ul4RlKG3/VgSmhJjKBshdontRLT5ytm6IsB8MbAnoDicG4T2cVIN0pML2DsJUNhYEIcjg9eZvW2Dw==", |
| 25 | "grant": { |
| 26 | "payload": "ewogICJzY2hlbWEiOiAxLAogICJwdWIiOiAiMmlucFd3TGdEL29WWkZkMSt4MHJvaUtobERPVjdxQnJsT0xBVjdlK2FkQT0iLAogICJydW5ncyI6IFsKICAgICJjYW5hcnkiLAogICAgImVkZ2UiCiAgXSwKICAibmFtZSI6ICJlZGdlLWJ1aWxkZXIiLAogICJtaW50ZWRfYXQiOiAiMjAyNi0wOC0wM1QwOTowMDowMFoiLAogICJleHBpcmVzX2F0IjogIjIwMjYtMTEtMDFUMDk6MDA6MDBaIgp9", |
| 27 | "sig": "MlawPu9BYwUcYr2VhAfPqgj2SRWzfDSIeSGxbW+3noo+xB36gSiPo3Aqa4ZNZ5TZvOx+Vjv+mlg/+Rsb1wYZAw==" |
| 28 | } |
| 29 | }` |
| 30 | |
| 31 | // rootPubKey is the fixture's root anchor, parsed. |
| 32 | func rootPubKey(t *testing.T) ed25519.PublicKey { |
| 33 | t.Helper() |
| 34 | pub, err := ParsePublicKey(goldenRootPub, "golden") |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | return pub |
| 39 | } |
| 40 | |
| 41 | // scopedFixture returns a fresh root key, its public half, a scoped key |
| 42 | // covering rungs, and a Signer built from them — the setup nearly every |
| 43 | // scoped test needs. |
| 44 | func scopedFixture(t *testing.T, rungs []string, ttl time.Duration) (root ed25519.PrivateKey, rootPub ed25519.PublicKey, sg Signer) { |
| 45 | t.Helper() |
| 46 | root, rootPub = testKeys(t) |
| 47 | key, blob := mintGrant(t, root, "edge-builder", rungs, ttl) |
| 48 | sg, err := ScopedSigner(key, blob, rootPub) |
| 49 | if err != nil { |
| 50 | t.Fatalf("ScopedSigner: %v", err) |
| 51 | } |
| 52 | return root, rootPub, sg |
| 53 | } |
| 54 | |
| 55 | // signAs wraps a pointer with a given signer, without a store. The pointer's |
| 56 | // promoted_at is signPointer's own stamp, so these are pointers as they would |
| 57 | // really be written. |
| 58 | func signAs(t *testing.T, sg Signer, app, channel, version string) []byte { |
| 59 | t.Helper() |
| 60 | w, err := signPointer(sg, &ChannelPointer{Account: "bab", App: app, Channel: channel, Version: version}) |
| 61 | if err != nil { |
| 62 | t.Fatalf("signPointer(%s): %v", channel, err) |
| 63 | } |
| 64 | return w |
| 65 | } |
| 66 | |
| 67 | // The grant-signed shape, pinned: three top-level keys, the grant a two-key |
| 68 | // sub-object, and the whole thing verifying against the SAME root public key |
| 69 | // the fleet already has configured. |
| 70 | // |
| 71 | // Mutation-mindedness: rename or restructure any wrapper/grant field and this |
| 72 | // fails on the key list before the signature check even runs — which is what |
| 73 | // stops a "harmless" JSON tag edit from orphaning pointers already in |
| 74 | // production buckets. |
| 75 | func TestGoldenGrantSignedWrapperShapeAndVerification(t *testing.T) { |
| 76 | var top map[string]json.RawMessage |
| 77 | if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &top); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | got := make([]string, 0, len(top)) |
| 81 | for k := range top { |
| 82 | got = append(got, k) |
| 83 | } |
| 84 | sort.Strings(got) |
| 85 | if want := []string{"grant", "payload", "sig"}; !reflect.DeepEqual(got, want) { |
| 86 | t.Fatalf("grant-signed wrapper keys = %v, want %v", got, want) |
| 87 | } |
| 88 | var sub map[string]json.RawMessage |
| 89 | if err := json.Unmarshal(top["grant"], &sub); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | got = got[:0] |
| 93 | for k := range sub { |
| 94 | got = append(got, k) |
| 95 | } |
| 96 | sort.Strings(got) |
| 97 | if want := []string{"payload", "sig"}; !reflect.DeepEqual(got, want) { |
| 98 | t.Fatalf("grant sub-object keys = %v, want %v", got, want) |
| 99 | } |
| 100 | |
| 101 | p, err := verifyPointer(rootPubKey(t), []byte(goldenGrantSignedWrapper), "bab", "kass", "edge") |
| 102 | if err != nil { |
| 103 | t.Fatalf("golden grant-signed pointer does not verify: %v", err) |
| 104 | } |
| 105 | if p.Version != "9f3c1ad" || p.Channel != "edge" { |
| 106 | t.Fatalf("pointer = %+v", p) |
| 107 | } |
| 108 | |
| 109 | // The grant payload's own field names, pinned: they are wire, and a |
| 110 | // verifier that silently read zero values out of a renamed field would |
| 111 | // treat an expiring grant as one that never expires. |
| 112 | var sgw signedWrapper |
| 113 | if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &sgw); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | raw, err := base64.StdEncoding.DecodeString(sgw.Grant.Payload) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | var gk map[string]json.RawMessage |
| 121 | if err := json.Unmarshal(raw, &gk); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | got = got[:0] |
| 125 | for k := range gk { |
| 126 | got = append(got, k) |
| 127 | } |
| 128 | sort.Strings(got) |
| 129 | if want := []string{"expires_at", "minted_at", "name", "pub", "rungs", "schema"}; !reflect.DeepEqual(got, want) { |
| 130 | t.Fatalf("grant payload keys = %v, want %v", got, want) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // In scope verifies; out of scope does not — at the verify end, where it has |
| 135 | // to hold even if the promoting binary was compromised, patched, or simply |
| 136 | // older than this one. |
| 137 | // |
| 138 | // Mutation-mindedness: delete the scope.allows call in verifyPointer and the |
| 139 | // beta case passes, which is a builder key placing builds on beta. |
| 140 | func TestVerifyEnforcesGrantScope(t *testing.T) { |
| 141 | _, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 142 | |
| 143 | for _, ch := range []string{"canary/keymail", "edge"} { |
| 144 | w := signAs(t, sg, "kass", ch, "9f3c1ad") |
| 145 | if _, err := verifyPointer(rootPub, w, "bab", "kass", ch); err != nil { |
| 146 | t.Errorf("in-scope channel %q did not verify: %v", ch, err) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // Out of scope: sign it anyway, bypassing signPointer's own refusal, and |
| 151 | // prove the VERIFIER refuses it. The promote-side guard and this one are |
| 152 | // independent — an attacker holding the scoped key does not run our |
| 153 | // promote path. |
| 154 | for _, ch := range []string{"beta", "stable"} { |
| 155 | w := forgePointerAs(t, sg, "kass", ch, "9f3c1ad", time.Now().UTC().Truncate(time.Second)) |
| 156 | _, err := verifyPointer(rootPub, w, "bab", "kass", ch) |
| 157 | if err == nil { |
| 158 | t.Fatalf("out-of-scope channel %q verified", ch) |
| 159 | } |
| 160 | if !strings.Contains(err.Error(), "key grant") { |
| 161 | t.Errorf("refusal for %q = %q, want it to name the key grant", ch, err) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // forgePointerAs signs a pointer with the signer's key and grant while |
| 167 | // skipping signPointer's scope check, and stamps an arbitrary promoted_at. |
| 168 | // This is the hostile promoter: it holds the scoped key and will not ask |
| 169 | // permission before signing. |
| 170 | func forgePointerAs(t *testing.T, sg Signer, app, channel, version string, at time.Time) []byte { |
| 171 | t.Helper() |
| 172 | p := &ChannelPointer{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: at} |
| 173 | payload, err := json.MarshalIndent(p, "", " ") |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 178 | w, err := json.MarshalIndent(signedWrapper{ |
| 179 | Payload: b64, |
| 180 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), |
| 181 | Grant: sg.grant, |
| 182 | }, "", " ") |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | return w |
| 187 | } |
| 188 | |
| 189 | // Expiry is a promotion-time gate and nothing else. A pointer promoted while |
| 190 | // the grant was valid verifies forever — adoption re-verifies every pass, and |
| 191 | // a pointer that stopped verifying at midnight would take a running route's |
| 192 | // channel resolution dark for a reason that has nothing to do with the build. |
| 193 | // A pointer promoted AFTER expiry never verifies at all. |
| 194 | // |
| 195 | // Mutation-mindedness: replace p.PromotedAt with time.Now() in verifyPointer's |
| 196 | // scope.allows call and the first case fails. The grant used here expired in |
| 197 | // 2025, long before this test can ever run, and its pointer must still |
| 198 | // resolve — that is only true if expiry is read from the pointer. |
| 199 | func TestExpiryGatesPromotionNotAdoption(t *testing.T) { |
| 200 | root, rootPub := testKeys(t) |
| 201 | key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| 202 | pub, _ := key.Public().(ed25519.PublicKey) |
| 203 | |
| 204 | // A grant that lived for the first quarter of 2025 and is long dead now. |
| 205 | g := Grant{ |
| 206 | Schema: 1, |
| 207 | Pub: base64.StdEncoding.EncodeToString(pub), |
| 208 | Rungs: []string{RungEdge}, |
| 209 | Name: "edge-builder", |
| 210 | MintedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| 211 | ExpiresAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), |
| 212 | } |
| 213 | sg := Signer{key: key, grant: forgeGrantSub(t, root, g), scope: &g, root: rootPub} |
| 214 | |
| 215 | // Promoted while the grant was valid, read today: still resolves. This is |
| 216 | // the property adoption depends on — it re-verifies every pass, and a |
| 217 | // pointer that went dark on the grant's expiry date would take a running |
| 218 | // route's channel resolution with it. |
| 219 | w := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)) |
| 220 | if _, err := verifyPointer(rootPub, w, "bab", "kass", "edge"); err != nil { |
| 221 | t.Fatalf("a pointer promoted while the grant was valid stopped verifying: %v", err) |
| 222 | } |
| 223 | |
| 224 | // Promoted one second after expiry: dead, and it says why. |
| 225 | late := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", g.ExpiresAt.Add(time.Second)) |
| 226 | _, err := verifyPointer(rootPub, late, "bab", "kass", "edge") |
| 227 | if err == nil { |
| 228 | t.Fatal("a pointer promoted after the grant expired verified") |
| 229 | } |
| 230 | if !strings.Contains(err.Error(), "expired") { |
| 231 | t.Errorf("refusal %q does not say expired", err) |
| 232 | } |
| 233 | |
| 234 | // And the promote side refuses to sign with it at all, now. |
| 235 | if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "edge", Version: "9f3c1ad"}); err == nil { |
| 236 | t.Fatal("an expired grant signed a new pointer") |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // The three ways a grant-bearing wrapper can be malformed, each failing |
| 241 | // closed, and each naming the link of the chain that broke: a grant the root |
| 242 | // did not sign, a grant whose payload was swapped after signing, and a grant |
| 243 | // that is perfectly valid but authorises a DIFFERENT key than the one that |
| 244 | // signed the pointer. |
| 245 | // |
| 246 | // The first two share a message on purpose — Ed25519 cannot distinguish a |
| 247 | // tampered payload from a wrong signer, and pretending otherwise would be a |
| 248 | // lie in an error string. The third is a genuinely different failure and says |
| 249 | // so: the grant is fine, the pointer is not. |
| 250 | // |
| 251 | // Mutation-mindedness: drop the grant signature check and the first two pass; |
| 252 | // verify the pointer against `pub` instead of the granted key and the third |
| 253 | // passes, which would let any grant launder any signature. |
| 254 | func TestGrantChainFailuresAreDistinct(t *testing.T) { |
| 255 | root, rootPub := testKeys(t) |
| 256 | notRoot, _ := testKeys(t) |
| 257 | key, blob := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| 258 | sg, err := ScopedSigner(key, blob, rootPub) |
| 259 | if err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | good := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| 263 | |
| 264 | // (1) grant signed by a key that is not the root. |
| 265 | forged := withGrant(t, good, forgeGrantSub(t, notRoot, *sg.scope)) |
| 266 | if _, err := verifyPointer(rootPub, forged, "bab", "kass", "edge"); err == nil { |
| 267 | t.Error("a grant signed by a non-root key was honoured") |
| 268 | } else if !strings.Contains(err.Error(), "key grant signature") { |
| 269 | t.Errorf("non-root grant refusal = %q", err) |
| 270 | } |
| 271 | |
| 272 | // (2) grant payload swapped for a wider scope after the root signed it. |
| 273 | wider := *sg.scope |
| 274 | wider.Rungs = []string{RungCanary, RungEdge, RungBeta} |
| 275 | sub := *forgeGrantSub(t, root, *sg.scope) |
| 276 | widerPayload, err := json.MarshalIndent(wider, "", " ") |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | sub.Payload = base64.StdEncoding.EncodeToString(widerPayload) |
| 281 | tampered := withGrant(t, good, &sub) |
| 282 | if _, err := verifyPointer(rootPub, tampered, "bab", "kass", "edge"); err == nil { |
| 283 | t.Error("a tampered grant payload was honoured") |
| 284 | } else if !strings.Contains(err.Error(), "key grant signature") { |
| 285 | t.Errorf("tampered grant refusal = %q", err) |
| 286 | } |
| 287 | |
| 288 | // (3) a valid grant for someone ELSE's key, attached to a pointer this |
| 289 | // key signed. The grant verifies; the pointer signature must not. |
| 290 | otherKey, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| 291 | _ = otherKey |
| 292 | otherSub, err := parseGrantBlob(otherBlob, "test") |
| 293 | if err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | mismatched := withGrant(t, good, otherSub) |
| 297 | if _, err := verifyPointer(rootPub, mismatched, "bab", "kass", "edge"); err == nil { |
| 298 | t.Error("a grant for a different key was honoured") |
| 299 | } else if !strings.Contains(err.Error(), "channel pointer signature") { |
| 300 | t.Errorf("mismatched-key refusal = %q, want it to name the POINTER signature", err) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // forgeGrantSub signs an arbitrary grant with signer and returns the wrapper |
| 305 | // sub-object. |
| 306 | func forgeGrantSub(t *testing.T, signer ed25519.PrivateKey, g Grant) *signedGrant { |
| 307 | t.Helper() |
| 308 | payload, err := json.MarshalIndent(g, "", " ") |
| 309 | if err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 313 | return &signedGrant{ |
| 314 | Payload: b64, |
| 315 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(signer, []byte(b64))), |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | // withGrant swaps the grant sub-object of a wrapper, leaving payload and sig |
| 320 | // untouched. |
| 321 | func withGrant(t *testing.T, wrapper []byte, sub *signedGrant) []byte { |
| 322 | t.Helper() |
| 323 | var sw signedWrapper |
| 324 | if err := json.Unmarshal(wrapper, &sw); err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | sw.Grant = sub |
| 328 | return mustJSON(t, sw) |
| 329 | } |
| 330 | |
| 331 | func mustJSON(t *testing.T, v any) []byte { |
| 332 | t.Helper() |
| 333 | b, err := json.MarshalIndent(v, "", " ") |
| 334 | if err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | return b |
| 338 | } |
| 339 | |
| 340 | // A pointer signed by a scoped key but stripped of its grant is refused: the |
| 341 | // signature is not the root's. This is also what an OLD binary does with a |
| 342 | // new grant-bearing wrapper — it ignores the field it does not know and |
| 343 | // checks the sig against the root key — so the fail-closed deploy order |
| 344 | // (binaries first, scoped keys second) is a property, not a hope. |
| 345 | // |
| 346 | // Mutation-mindedness: default `signer` to anything but the root pubkey when |
| 347 | // there is no grant and this passes, which would accept unsigned-by-root |
| 348 | // pointers on the entire live fleet. |
| 349 | func TestScopedPointerWithoutItsGrantIsRefused(t *testing.T) { |
| 350 | _, rootPub, sg := scopedFixture(t, []string{RungEdge}, 30*24*time.Hour) |
| 351 | w := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| 352 | |
| 353 | var sw signedWrapper |
| 354 | if err := json.Unmarshal(w, &sw); err != nil { |
| 355 | t.Fatal(err) |
| 356 | } |
| 357 | sw.Grant = nil |
| 358 | if _, err := verifyPointer(rootPub, mustJSON(t, sw), "bab", "kass", "edge"); err == nil { |
| 359 | t.Fatal("a scoped signature was accepted as a root signature") |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | // A scoped signer refuses to SIGN out of scope, independently of anything the |
| 364 | // promote verbs check. signPointer is the one place a pointer becomes signed |
| 365 | // bytes, so this is the floor under every caller present and future. |
| 366 | // |
| 367 | // Mutation-mindedness: remove the allows() call from signPointer and this |
| 368 | // fails; it is what makes "a caller forgot to check" not a vulnerability. |
| 369 | func TestSignPointerRefusesOutOfScope(t *testing.T) { |
| 370 | _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 30*24*time.Hour) |
| 371 | if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "stable", Version: "9f3c1ad"}); err == nil { |
| 372 | t.Fatal("a canary/edge signer signed stable") |
| 373 | } |
| 374 | if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "beta", Version: "9f3c1ad"}); err == nil { |
| 375 | t.Fatal("a canary/edge signer signed beta") |
| 376 | } |
| 377 | if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "edge", Version: "9f3c1ad"}); err != nil { |
| 378 | t.Fatalf("a canary/edge signer refused edge: %v", err) |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // ScopedSigner refuses a grant that does not authorise the key it is handed — |
| 383 | // the operator pasted one builder's grant beside another builder's seed. It |
| 384 | // fails when the Signer is BUILT, not at the first promote, because a signer |
| 385 | // that cannot prove its own authority should not exist. |
| 386 | // |
| 387 | // Mutation-mindedness: drop the g.Pub comparison and this passes; the signer |
| 388 | // would then produce pointers that every verifier rejects, turning a clear |
| 389 | // local error into a mystery in production. |
| 390 | func TestScopedSignerRefusesAGrantForAnotherKey(t *testing.T) { |
| 391 | root, rootPub := testKeys(t) |
| 392 | key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| 393 | _, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| 394 | |
| 395 | if _, err := ScopedSigner(key, otherBlob, rootPub); err == nil { |
| 396 | t.Fatal("ScopedSigner accepted a grant for a different key") |
| 397 | } |
| 398 | // And it refuses a grant that does not chain to THIS deployment's root. |
| 399 | elsewhere, _ := testKeys(t) |
| 400 | otherKey, otherRootBlob := mintGrant(t, elsewhere, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| 401 | if _, err := ScopedSigner(otherKey, otherRootBlob, rootPub); err == nil { |
| 402 | t.Fatal("ScopedSigner accepted a grant signed by another deployment's root key") |
| 403 | } |
| 404 | } |
| 405 | |