carlos / platform Public

release: verifyPointer learns the grant chain — root -> grant -> pointer

The signed wrapper gains one optional field. It is a nil pointer with
omitempty, so a root key writes byte-identical bytes to the ones it wrote
yesterday — golden_test.go's key-list assertion is what proves that, and
it still passes untouched.

verifyPointer stays the single parse site, which is the whole reason this
is a small change: Channel, adoption, VersionRung, the console and the
edge all inherit grant handling by funnelling through it. With no grant,
the payload must be signed by the root key, exactly as before. With one,
the grant must be signed by the root key, the payload by the key the
grant names, and the pointer's channel and promoted_at must fall inside
the grant. Both scope checks read the SIGNED payload — a grant honoured
against the caller's arguments rather than the document would be no scope
at all.

Expiry compares against the pointer's own promoted_at, never the clock
(spec §5). Adoption re-verifies every pass, so a wall-clock comparison
would take a running route's channel resolution dark on the grant's
expiry date, for a reason with nothing to do with the build. The test
that pins this uses a grant that expired in 2025: it can only pass if the
comparison comes from the pointer.

signPointer also refuses out-of-scope, so the floor is under every caller
present and future rather than under the promote verbs only. Signer
carries the root pubkey as the verification anchor, because the pointer a
scoped key replaces was signed by something else.

Mutation-verified, each caught by the named test: dropping the scope
check, swapping promoted_at for time.Now(), verifying the payload against
the root instead of the granted key, and dropping signPointer's refusal.

A grant-signed golden vector is pinned beside the root-signed one, both
against the SAME root public key — which is the point of the scheme.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 49060e077d95188b81926d950a48fbc6a8ab316f parent 9afb4d5
5 files changed, +560 −9
  • internal/release/qualified_test.go +2 −2
  • internal/release/release.go +68 −6
  • internal/release/release_test.go +1 −1
  • internal/release/scopedverify_test.go +404 −0
  • internal/release/signer.go +85 −0
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go
index ea53feed1..946087ac6 100644
--- a/internal/release/qualified_test.go
+++ b/internal/release/qualified_test.go
@@ -176,7 +176,7 @@ func TestLegacyOnlyAppIsInvisibleToTheRuntime(t *testing.T) {
if _, err := dir.Put(ctx, LegacyManifestKey("old", "v1"), raw, ""); err != nil {
t.Fatal(err)
}
- ptr, err := signPointer(priv, &ChannelPointer{App: "old", Channel: "stable", Version: "v1"})
+ ptr, err := signPointer(RootSigner(priv), &ChannelPointer{App: "old", Channel: "stable", Version: "v1"})
if err != nil {
t.Fatal(err)
}
@@ -523,7 +523,7 @@ func TestPromoteDoesNotInheritALegacyPointer(t *testing.T) {
shipRelease(t, s, "kqmv", "myapp", "v1", "linux-amd64", []byte("v1"))
shipRelease(t, s, "kqmv", "myapp", "v2", "linux-amd64", []byte("v2"))
- legacy, err := signPointer(priv, &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"})
+ legacy, err := signPointer(RootSigner(priv), &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/release/release.go b/internal/release/release.go
index c6ad897a7..4c333b7f4 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -241,25 +241,47 @@ func validateManifest(m *Manifest) error {
type signedWrapper struct {
Payload string `json:"payload"`
Sig string `json:"sig"`
+ // Grant is ABSENT for a root-signed pointer — omitempty on a nil pointer,
+ // so the bytes a root key writes are byte-identical to the bytes it wrote
+ // before scoped keys existed (golden_test.go pins exactly that). When
+ // present, Sig above is by the SCOPED key and this is the root's
+ // signature over the statement authorising it.
+ //
+ // An old binary reading a new grant-bearing wrapper fails signature
+ // verification, because it checks Sig against the root key. That is the
+ // correct fail-closed direction and it sets the deploy order: binaries
+ // first, scoped keys second.
+ Grant *signedGrant `json:"grant,omitempty"`
}
// signPointer stamps PromotedAt (now) and wraps the pointer in the signed
-// wire format.
-func signPointer(priv ed25519.PrivateKey, p *ChannelPointer) ([]byte, error) {
+// wire format, attaching the signer's grant when it has one.
+//
+// The scope check lives here as well as at the top of the promote verbs: this
+// is the single place a pointer becomes signed bytes, so a caller that
+// forgets to ask cannot produce a scope-violating pointer even by accident.
+func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) {
+ if err := sg.usable(); err != nil {
+ return nil, err
+ }
p.Schema = 1
p.PromotedAt = time.Now().UTC().Truncate(time.Second)
if err := validatePointer(p, p.App, p.Channel); err != nil {
return nil, err
}
+ if err := sg.allows(p.Channel, p.PromotedAt); err != nil {
+ return nil, err
+ }
payload, err := json.MarshalIndent(p, "", " ")
if err != nil {
return nil, err
}
b64 := base64.StdEncoding.EncodeToString(payload)
- sig := ed25519.Sign(priv, []byte(b64))
+ sig := ed25519.Sign(sg.key, []byte(b64))
return json.MarshalIndent(signedWrapper{
Payload: b64,
Sig: base64.StdEncoding.EncodeToString(sig),
+ Grant: sg.grant,
}, "", " ")
}
@@ -268,16 +290,47 @@ func signPointer(priv ed25519.PrivateKey, p *ChannelPointer) ([]byte, error) {
// signature. wantApp/wantChannel are matched against the SIGNED payload, so
// a validly-signed pointer copied from another channel's key (a cross-channel
// replay) is rejected.
+//
+// pub is the deployment's ROOT public key, and it is the only key a box is
+// ever configured with. A wrapper with no grant was signed by that key
+// directly — the shape the whole fleet is on. A wrapper WITH a grant was
+// signed by a scoped key, and the grant is the root's signature saying which
+// rungs that key may reach; so the chain is checked root -> grant -> pointer,
+// and then the pointer's own channel and promoted_at are checked against the
+// scope. Both scope checks read the SIGNED payload, never the caller's
+// arguments — a grant that is honoured on a channel the payload does not name
+// would be no scope at all.
+//
+// This is the single parse site for channel pointers, and it must stay that
+// way: every consumer (Channel, adoption, VersionRung, the console, the edge)
+// inherits grant handling by funnelling through here.
func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel string) (*ChannelPointer, error) {
var sw signedWrapper
if err := json.Unmarshal(wrapper, &sw); err != nil {
return nil, fmt.Errorf("pointer wrapper: %v", err)
}
+ // signer is who must have signed the payload: the root itself, or the
+ // scoped key the root delegated to.
+ signer, scope := pub, (*Grant)(nil)
+ if sw.Grant != nil {
+ g, err := verifyGrant(pub, sw.Grant)
+ if err != nil {
+ return nil, err
+ }
+ gpub, err := ParsePublicKey(g.Pub, "key grant pub")
+ if err != nil {
+ return nil, err
+ }
+ signer, scope = gpub, g
+ }
sig, err := base64.StdEncoding.DecodeString(sw.Sig)
if err != nil {
return nil, fmt.Errorf("pointer sig: %v", err)
}
- if !ed25519.Verify(pub, []byte(sw.Payload), sig) {
+ if len(signer) != ed25519.PublicKeySize {
+ return nil, fmt.Errorf("channel pointer: no usable release public key")
+ }
+ if !ed25519.Verify(signer, []byte(sw.Payload), sig) {
return nil, fmt.Errorf("channel pointer signature verification failed")
}
payload, err := base64.StdEncoding.DecodeString(sw.Payload)
@@ -291,6 +344,15 @@ func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel s
if err := validatePointer(&p, wantApp, wantChannel); err != nil {
return nil, err
}
+ if scope != nil {
+ // promoted_at, not the wall clock: adoption re-verifies every pass, so
+ // comparing expiry against now would make an expired grant take a
+ // running route's channel resolution dark long after the promotion it
+ // was meant to gate (spec §5).
+ if err := scope.allows(p.Channel, p.PromotedAt); err != nil {
+ return nil, fmt.Errorf("channel pointer %s/%s: %w", p.App, p.Channel, err)
+ }
+ }
return &p, nil
}
@@ -538,7 +600,7 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, c
}
next.Audit = prependCapped(e, old.Audit, auditMax)
}
- wrapper, err := signPointer(priv, next)
+ wrapper, err := signPointer(RootSigner(priv), next)
if err != nil {
return err
}
@@ -742,7 +804,7 @@ func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app,
// erase how the channel got here either.
Audit: old.Audit,
}
- wrapper, err := signPointer(priv, next)
+ wrapper, err := signPointer(RootSigner(priv), next)
if err != nil {
return err
}
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index 235666e50..a4385b872 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -267,7 +267,7 @@ func TestSignStampsPromotedAt(t *testing.T) {
priv, pub := testKeys(t)
p := &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"}
before := time.Now().Add(-time.Second)
- raw, err := signPointer(priv, p)
+ raw, err := signPointer(RootSigner(priv), p)
if err != nil {
t.Fatalf("signPointer: %v", err)
}
diff --git a/internal/release/scopedverify_test.go b/internal/release/scopedverify_test.go
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")
+ }
+}
diff --git a/internal/release/signer.go b/internal/release/signer.go
new file mode 100644
index 000000000..9c411f711
--- /dev/null
+++ b/internal/release/signer.go
@@ -0,0 +1,85 @@
+package release
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "fmt"
+ "time"
+)
+
+// Signer is a release signing identity: a private key, and — when that key is
+// scoped — the root-signed grant that says which rungs it may reach.
+//
+// It also carries the deployment's ROOT public key, which is the verification
+// anchor for pointers being REPLACED. That used to be the signing key's own
+// public half, and for a root signer it still is; for a scoped signer it
+// cannot be, because the pointer a scoped key is about to replace was almost
+// certainly signed by something else (the root key, or another scoped key).
+// One anchor, both directions.
+type Signer struct {
+ key ed25519.PrivateKey
+ // grant is the sub-object attached to every wrapper this signer writes;
+ // nil for a root signer, which attaches nothing and so writes the exact
+ // bytes it wrote before scoped keys existed.
+ grant *signedGrant
+ // scope is grant's verified contents — nil for a root signer, which may
+ // sign any channel.
+ scope *Grant
+ root ed25519.PublicKey
+}
+
+// RootSigner is today's identity: the deployment's root key, unrestricted.
+func RootSigner(key ed25519.PrivateKey) Signer {
+ pub, _ := key.Public().(ed25519.PublicKey)
+ return Signer{key: key, root: pub}
+}
+
+// ScopedSigner is a delegated identity: a scoped key plus the base64 grant
+// blob that authorises it (CARLOS_RELEASE_GRANT), checked here against the
+// deployment's root public key. The grant is verified at construction — a
+// signer that cannot prove its own authority must not exist long enough to
+// sign anything.
+func ScopedSigner(key ed25519.PrivateKey, grantB64 string, root ed25519.PublicKey) (Signer, error) {
+ if len(key) != ed25519.PrivateKeySize {
+ return Signer{}, fmt.Errorf("scoped signing: CARLOS_RELEASE_KEY is not a signing key")
+ }
+ if len(root) != ed25519.PublicKeySize {
+ return Signer{}, fmt.Errorf("scoped signing needs the deployment's root public key in CARLOS_RELEASE_PUBKEY: a scoped key's grant is only meaningful against the key the fleet trusts")
+ }
+ sg, err := parseGrantBlob(grantB64, "CARLOS_RELEASE_GRANT")
+ if err != nil {
+ return Signer{}, err
+ }
+ g, err := verifyGrant(root, sg)
+ if err != nil {
+ return Signer{}, err
+ }
+ pub, _ := key.Public().(ed25519.PublicKey)
+ if g.Pub != base64.StdEncoding.EncodeToString(pub) {
+ return Signer{}, fmt.Errorf("key grant %q is for a different key than CARLOS_RELEASE_KEY holds", g.Name)
+ }
+ return Signer{key: key, grant: sg, scope: g, root: root}, nil
+}
+
+// verifyPub is the anchor this signer verifies EXISTING pointers against: the
+// root public key, whether this signer is the root or a delegate of it.
+func (s Signer) verifyPub() ed25519.PublicKey { return s.root }
+
+// allows reports whether this signer may sign for a channel at a moment. A
+// root signer may sign anything, which is the property scoped keys exist to
+// take away from everything else.
+func (s Signer) allows(channel string, at time.Time) error {
+ if s.scope == nil {
+ return nil
+ }
+ return s.scope.allows(channel, at)
+}
+
+// usable rejects a zero Signer before it reaches ed25519.Sign, which panics
+// on a wrong-sized key.
+func (s Signer) usable() error {
+ if len(s.key) != ed25519.PrivateKeySize {
+ return fmt.Errorf("no usable release signing key")
+ }
+ return nil
+}