| new file mode 100644 |
| index 000000000..90510874a |
| --- /dev/null |
| +++ b/internal/release/scopedverify_test.go |
| @@ -0,0 +1,404 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The grant-signed wire shape, pinned beside the root-signed one. Captured |
| +// with deterministic seeds (root = bytes 1..32, scoped = bytes 101..132) and |
| +// frozen timestamps: a grant minted 2026-08-03T09:00Z for canary+edge, |
| +// 90 days, over an edge pointer promoted half an hour later. |
| +// |
| +// The root public key is goldenRootPub — the SAME anchor as the root-signed |
| +// fixture, which is the point of the whole scheme: one CARLOS_RELEASE_PUBKEY |
| +// verifies both shapes. |
| +const goldenGrantSignedWrapper = `{ |
| + "payload": "ewogICJzY2hlbWEiOiAxLAogICJhcHAiOiAia2FzcyIsCiAgImNoYW5uZWwiOiAiZWRnZSIsCiAgInZlcnNpb24iOiAiOWYzYzFhZCIsCiAgInByb21vdGVkX2F0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIiwKICAiYXVkaXQiOiBbCiAgICB7CiAgICAgICJ2ZXJzaW9uIjogIjlmM2MxYWQiLAogICAgICAidG8iOiAiZWRnZSIsCiAgICAgICJhY3RvciI6ICJidWlsZGVyQGNpIiwKICAgICAgImF0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIgogICAgfQogIF0KfQ==", |
| + "sig": "oXZdFZDT5ul4RlKG3/VgSmhJjKBshdontRLT5ytm6IsB8MbAnoDicG4T2cVIN0pML2DsJUNhYEIcjg9eZvW2Dw==", |
| + "grant": { |
| + "payload": "ewogICJzY2hlbWEiOiAxLAogICJwdWIiOiAiMmlucFd3TGdEL29WWkZkMSt4MHJvaUtobERPVjdxQnJsT0xBVjdlK2FkQT0iLAogICJydW5ncyI6IFsKICAgICJjYW5hcnkiLAogICAgImVkZ2UiCiAgXSwKICAibmFtZSI6ICJlZGdlLWJ1aWxkZXIiLAogICJtaW50ZWRfYXQiOiAiMjAyNi0wOC0wM1QwOTowMDowMFoiLAogICJleHBpcmVzX2F0IjogIjIwMjYtMTEtMDFUMDk6MDA6MDBaIgp9", |
| + "sig": "MlawPu9BYwUcYr2VhAfPqgj2SRWzfDSIeSGxbW+3noo+xB36gSiPo3Aqa4ZNZ5TZvOx+Vjv+mlg/+Rsb1wYZAw==" |
| + } |
| +}` |
| + |
| +// rootPubKey is the fixture's root anchor, parsed. |
| +func rootPubKey(t *testing.T) ed25519.PublicKey { |
| + t.Helper() |
| + pub, err := ParsePublicKey(goldenRootPub, "golden") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return pub |
| +} |
| + |
| +// scopedFixture returns a fresh root key, its public half, a scoped key |
| +// covering rungs, and a Signer built from them — the setup nearly every |
| +// scoped test needs. |
| +func scopedFixture(t *testing.T, rungs []string, ttl time.Duration) (root ed25519.PrivateKey, rootPub ed25519.PublicKey, sg Signer) { |
| + t.Helper() |
| + root, rootPub = testKeys(t) |
| + key, blob := mintGrant(t, root, "edge-builder", rungs, ttl) |
| + sg, err := ScopedSigner(key, blob, rootPub) |
| + if err != nil { |
| + t.Fatalf("ScopedSigner: %v", err) |
| + } |
| + return root, rootPub, sg |
| +} |
| + |
| +// signAs wraps a pointer with a given signer, without a store. The pointer's |
| +// promoted_at is signPointer's own stamp, so these are pointers as they would |
| +// really be written. |
| +func signAs(t *testing.T, sg Signer, app, channel, version string) []byte { |
| + t.Helper() |
| + w, err := signPointer(sg, &ChannelPointer{App: app, Channel: channel, Version: version}) |
| + if err != nil { |
| + t.Fatalf("signPointer(%s): %v", channel, err) |
| + } |
| + return w |
| +} |
| + |
| +// The grant-signed shape, pinned: three top-level keys, the grant a two-key |
| +// sub-object, and the whole thing verifying against the SAME root public key |
| +// the fleet already has configured. |
| +// |
| +// Mutation-mindedness: rename or restructure any wrapper/grant field and this |
| +// fails on the key list before the signature check even runs — which is what |
| +// stops a "harmless" JSON tag edit from orphaning pointers already in |
| +// production buckets. |
| +func TestGoldenGrantSignedWrapperShapeAndVerification(t *testing.T) { |
| + var top map[string]json.RawMessage |
| + if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &top); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := make([]string, 0, len(top)) |
| + for k := range top { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"grant", "payload", "sig"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant-signed wrapper keys = %v, want %v", got, want) |
| + } |
| + var sub map[string]json.RawMessage |
| + if err := json.Unmarshal(top["grant"], &sub); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = got[:0] |
| + for k := range sub { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"payload", "sig"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant sub-object keys = %v, want %v", got, want) |
| + } |
| + |
| + p, err := verifyPointer(rootPubKey(t), []byte(goldenGrantSignedWrapper), "kass", "edge") |
| + if err != nil { |
| + t.Fatalf("golden grant-signed pointer does not verify: %v", err) |
| + } |
| + if p.Version != "9f3c1ad" || p.Channel != "edge" { |
| + t.Fatalf("pointer = %+v", p) |
| + } |
| + |
| + // The grant payload's own field names, pinned: they are wire, and a |
| + // verifier that silently read zero values out of a renamed field would |
| + // treat an expiring grant as one that never expires. |
| + var sgw signedWrapper |
| + if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &sgw); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(sgw.Grant.Payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var gk map[string]json.RawMessage |
| + if err := json.Unmarshal(raw, &gk); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = got[:0] |
| + for k := range gk { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"expires_at", "minted_at", "name", "pub", "rungs", "schema"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant payload keys = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// In scope verifies; out of scope does not — at the verify end, where it has |
| +// to hold even if the promoting binary was compromised, patched, or simply |
| +// older than this one. |
| +// |
| +// Mutation-mindedness: delete the scope.allows call in verifyPointer and the |
| +// beta case passes, which is a builder key placing builds on beta. |
| +func TestVerifyEnforcesGrantScope(t *testing.T) { |
| + _, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| + |
| + for _, ch := range []string{"canary/keymail", "edge"} { |
| + w := signAs(t, sg, "kass", ch, "9f3c1ad") |
| + if _, err := verifyPointer(rootPub, w, "kass", ch); err != nil { |
| + t.Errorf("in-scope channel %q did not verify: %v", ch, err) |
| + } |
| + } |
| + |
| + // Out of scope: sign it anyway, bypassing signPointer's own refusal, and |
| + // prove the VERIFIER refuses it. The promote-side guard and this one are |
| + // independent — an attacker holding the scoped key does not run our |
| + // promote path. |
| + for _, ch := range []string{"beta", "stable"} { |
| + w := forgePointerAs(t, sg, "kass", ch, "9f3c1ad", time.Now().UTC().Truncate(time.Second)) |
| + _, err := verifyPointer(rootPub, w, "kass", ch) |
| + if err == nil { |
| + t.Fatalf("out-of-scope channel %q verified", ch) |
| + } |
| + if !strings.Contains(err.Error(), "key grant") { |
| + t.Errorf("refusal for %q = %q, want it to name the key grant", ch, err) |
| + } |
| + } |
| +} |
| + |
| +// forgePointerAs signs a pointer with the signer's key and grant while |
| +// skipping signPointer's scope check, and stamps an arbitrary promoted_at. |
| +// This is the hostile promoter: it holds the scoped key and will not ask |
| +// permission before signing. |
| +func forgePointerAs(t *testing.T, sg Signer, app, channel, version string, at time.Time) []byte { |
| + t.Helper() |
| + p := &ChannelPointer{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: at} |
| + payload, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + w, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), |
| + Grant: sg.grant, |
| + }, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| +} |
| + |
| +// Expiry is a promotion-time gate and nothing else. A pointer promoted while |
| +// the grant was valid verifies forever — adoption re-verifies every pass, and |
| +// a pointer that stopped verifying at midnight would take a running route's |
| +// channel resolution dark for a reason that has nothing to do with the build. |
| +// A pointer promoted AFTER expiry never verifies at all. |
| +// |
| +// Mutation-mindedness: replace p.PromotedAt with time.Now() in verifyPointer's |
| +// scope.allows call and the first case fails. The grant used here expired in |
| +// 2025, long before this test can ever run, and its pointer must still |
| +// resolve — that is only true if expiry is read from the pointer. |
| +func TestExpiryGatesPromotionNotAdoption(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + pub, _ := key.Public().(ed25519.PublicKey) |
| + |
| + // A grant that lived for the first quarter of 2025 and is long dead now. |
| + g := Grant{ |
| + Schema: 1, |
| + Pub: base64.StdEncoding.EncodeToString(pub), |
| + Rungs: []string{RungEdge}, |
| + Name: "edge-builder", |
| + MintedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| + ExpiresAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), |
| + } |
| + sg := Signer{key: key, grant: forgeGrantSub(t, root, g), scope: &g, root: rootPub} |
| + |
| + // Promoted while the grant was valid, read today: still resolves. This is |
| + // the property adoption depends on — it re-verifies every pass, and a |
| + // pointer that went dark on the grant's expiry date would take a running |
| + // route's channel resolution with it. |
| + w := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)) |
| + if _, err := verifyPointer(rootPub, w, "kass", "edge"); err != nil { |
| + t.Fatalf("a pointer promoted while the grant was valid stopped verifying: %v", err) |
| + } |
| + |
| + // Promoted one second after expiry: dead, and it says why. |
| + late := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", g.ExpiresAt.Add(time.Second)) |
| + _, err := verifyPointer(rootPub, late, "kass", "edge") |
| + if err == nil { |
| + t.Fatal("a pointer promoted after the grant expired verified") |
| + } |
| + if !strings.Contains(err.Error(), "expired") { |
| + t.Errorf("refusal %q does not say expired", err) |
| + } |
| + |
| + // And the promote side refuses to sign with it at all, now. |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("an expired grant signed a new pointer") |
| + } |
| +} |
| + |
| +// The three ways a grant-bearing wrapper can be malformed, each failing |
| +// closed, and each naming the link of the chain that broke: a grant the root |
| +// did not sign, a grant whose payload was swapped after signing, and a grant |
| +// that is perfectly valid but authorises a DIFFERENT key than the one that |
| +// signed the pointer. |
| +// |
| +// The first two share a message on purpose — Ed25519 cannot distinguish a |
| +// tampered payload from a wrong signer, and pretending otherwise would be a |
| +// lie in an error string. The third is a genuinely different failure and says |
| +// so: the grant is fine, the pointer is not. |
| +// |
| +// Mutation-mindedness: drop the grant signature check and the first two pass; |
| +// verify the pointer against `pub` instead of the granted key and the third |
| +// passes, which would let any grant launder any signature. |
| +func TestGrantChainFailuresAreDistinct(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + notRoot, _ := testKeys(t) |
| + key, blob := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + sg, err := ScopedSigner(key, blob, rootPub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + good := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| + |
| + // (1) grant signed by a key that is not the root. |
| + forged := withGrant(t, good, forgeGrantSub(t, notRoot, *sg.scope)) |
| + if _, err := verifyPointer(rootPub, forged, "kass", "edge"); err == nil { |
| + t.Error("a grant signed by a non-root key was honoured") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("non-root grant refusal = %q", err) |
| + } |
| + |
| + // (2) grant payload swapped for a wider scope after the root signed it. |
| + wider := *sg.scope |
| + wider.Rungs = []string{RungCanary, RungEdge, RungBeta} |
| + sub := *forgeGrantSub(t, root, *sg.scope) |
| + widerPayload, err := json.MarshalIndent(wider, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sub.Payload = base64.StdEncoding.EncodeToString(widerPayload) |
| + tampered := withGrant(t, good, &sub) |
| + if _, err := verifyPointer(rootPub, tampered, "kass", "edge"); err == nil { |
| + t.Error("a tampered grant payload was honoured") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("tampered grant refusal = %q", err) |
| + } |
| + |
| + // (3) a valid grant for someone ELSE's key, attached to a pointer this |
| + // key signed. The grant verifies; the pointer signature must not. |
| + otherKey, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| + _ = otherKey |
| + otherSub, err := parseGrantBlob(otherBlob, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mismatched := withGrant(t, good, otherSub) |
| + if _, err := verifyPointer(rootPub, mismatched, "kass", "edge"); err == nil { |
| + t.Error("a grant for a different key was honoured") |
| + } else if !strings.Contains(err.Error(), "channel pointer signature") { |
| + t.Errorf("mismatched-key refusal = %q, want it to name the POINTER signature", err) |
| + } |
| +} |
| + |
| +// forgeGrantSub signs an arbitrary grant with signer and returns the wrapper |
| +// sub-object. |
| +func forgeGrantSub(t *testing.T, signer ed25519.PrivateKey, g Grant) *signedGrant { |
| + t.Helper() |
| + payload, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + return &signedGrant{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(signer, []byte(b64))), |
| + } |
| +} |
| + |
| +// withGrant swaps the grant sub-object of a wrapper, leaving payload and sig |
| +// untouched. |
| +func withGrant(t *testing.T, wrapper []byte, sub *signedGrant) []byte { |
| + t.Helper() |
| + var sw signedWrapper |
| + if err := json.Unmarshal(wrapper, &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.Grant = sub |
| + return mustJSON(t, sw) |
| +} |
| + |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.MarshalIndent(v, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// A pointer signed by a scoped key but stripped of its grant is refused: the |
| +// signature is not the root's. This is also what an OLD binary does with a |
| +// new grant-bearing wrapper — it ignores the field it does not know and |
| +// checks the sig against the root key — so the fail-closed deploy order |
| +// (binaries first, scoped keys second) is a property, not a hope. |
| +// |
| +// Mutation-mindedness: default `signer` to anything but the root pubkey when |
| +// there is no grant and this passes, which would accept unsigned-by-root |
| +// pointers on the entire live fleet. |
| +func TestScopedPointerWithoutItsGrantIsRefused(t *testing.T) { |
| + _, rootPub, sg := scopedFixture(t, []string{RungEdge}, 30*24*time.Hour) |
| + w := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| + |
| + var sw signedWrapper |
| + if err := json.Unmarshal(w, &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.Grant = nil |
| + if _, err := verifyPointer(rootPub, mustJSON(t, sw), "kass", "edge"); err == nil { |
| + t.Fatal("a scoped signature was accepted as a root signature") |
| + } |
| +} |
| + |
| +// A scoped signer refuses to SIGN out of scope, independently of anything the |
| +// promote verbs check. signPointer is the one place a pointer becomes signed |
| +// bytes, so this is the floor under every caller present and future. |
| +// |
| +// Mutation-mindedness: remove the allows() call from signPointer and this |
| +// fails; it is what makes "a caller forgot to check" not a vulnerability. |
| +func TestSignPointerRefusesOutOfScope(t *testing.T) { |
| + _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 30*24*time.Hour) |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "stable", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("a canary/edge signer signed stable") |
| + } |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "beta", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("a canary/edge signer signed beta") |
| + } |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err != nil { |
| + t.Fatalf("a canary/edge signer refused edge: %v", err) |
| + } |
| +} |
| + |
| +// ScopedSigner refuses a grant that does not authorise the key it is handed — |
| +// the operator pasted one builder's grant beside another builder's seed. It |
| +// fails when the Signer is BUILT, not at the first promote, because a signer |
| +// that cannot prove its own authority should not exist. |
| +// |
| +// Mutation-mindedness: drop the g.Pub comparison and this passes; the signer |
| +// would then produce pointers that every verifier rejects, turning a clear |
| +// local error into a mystery in production. |
| +func TestScopedSignerRefusesAGrantForAnotherKey(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + _, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| + |
| + if _, err := ScopedSigner(key, otherBlob, rootPub); err == nil { |
| + t.Fatal("ScopedSigner accepted a grant for a different key") |
| + } |
| + // And it refuses a grant that does not chain to THIS deployment's root. |
| + elsewhere, _ := testKeys(t) |
| + otherKey, otherRootBlob := mintGrant(t, elsewhere, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + if _, err := ScopedSigner(otherKey, otherRootBlob, rootPub); err == nil { |
| + t.Fatal("ScopedSigner accepted a grant signed by another deployment's root key") |
| + } |
| +} |