cli: release-keygen -scope, and promote signs with whatever it is given
Two modes in one verb because they mint the same kind of thing for the same deployment. Without -scope, release-keygen is byte-for-byte what it was: a root pair printed as the two variables a deployment is configured with. With -scope it reads the ROOT key from CARLOS_RELEASE_KEY — same env discipline as promote, read but never printed — and prints a scoped seed and its grant. It deliberately does NOT reprint CARLOS_RELEASE_PUBKEY: a second copy-pasteable pubkey in that output is an invitation to install it on a box, and a deployment with two trust anchors has none. -name or -expires without -scope is refused rather than ignored. An operator who typed them meant to mint a scoped key, and walking away with a ROOT key they believe is scoped is the worst outcome this verb has. promote and rollback gain nothing to type. They sign with what CARLOS_RELEASE_KEY holds, attaching CARLOS_RELEASE_GRANT when it is set. A scoped key with no grant beside it is fatal at the CLI, naming the variable — a scoped seed alone proves nothing, so it would sign pointers the fleet silently refuses, and the operator would learn about it from a box that stopped adopting. That check exists only because a scoped seed is tagged; both kinds of key are otherwise 32 random bytes. The e2e drives the real binary: mint, promote to edge, read it back through `channels` with only the root pubkey, then watch stable be refused with no stable.json in the bucket, -hotfix fail to widen the grant, and a grantless scoped key refuse to promote at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+281
−5
cli.go+106 −5main.go+5 −0scopedkeys_e2e_test.go+170 −0
diff --git a/cli.go b/cli.go| index b5adf5c8f..88872fe60 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -742,6 +742,44 @@ func signingKey() ed25519.PrivateKey { |
| return k |
| } |
| +// releaseSigner is the identity the write verbs sign with: the root key, or a |
| +// scoped key travelling with the grant that authorises it. |
| +// |
| +// The grant is discovered from CARLOS_RELEASE_GRANT, exactly as the key is |
| +// discovered from CARLOS_RELEASE_KEY — secret manager to shell variable to |
| +// process memory, never disk. A scoped key with no grant beside it is fatal |
| +// here rather than three machines away: a scoped seed alone proves nothing, |
| +// so it would sign pointers nothing in the fleet will ever accept, and the |
| +// failure would surface as an unexplained signature error on a box. |
| +// |
| +// That check is only possible because a scoped seed is tagged (release's |
| +// ScopedKeyPrefix). Both kinds of key are otherwise 32 random bytes. |
| +func releaseSigner() release.Signer { |
| + raw := os.Getenv("CARLOS_RELEASE_KEY") |
| + key, err := release.ParseSigningKey(raw, "CARLOS_RELEASE_KEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + blob := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_GRANT")) |
| + if blob == "" { |
| + if release.IsScopedSeed(raw) { |
| + log.Fatal("CARLOS_RELEASE_KEY holds a scoped key but CARLOS_RELEASE_GRANT is not set — a scoped key alone proves nothing; set the grant blob that was printed beside the key by `carlos release-keygen -scope`") |
| + } |
| + return release.RootSigner(key) |
| + } |
| + // A grant is only meaningful against the key the fleet trusts, so scoped |
| + // signing needs the deployment's public key too — the same value every box |
| + // is configured with, not a new secret. |
| + if strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) == "" { |
| + log.Fatal("CARLOS_RELEASE_GRANT is set, so this is a scoped key: signing also needs the deployment's root public key in CARLOS_RELEASE_PUBKEY, which is what the grant is checked against") |
| + } |
| + sg, err := release.ScopedSigner(key, blob, publicKey()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return sg |
| +} |
| + |
| func publicKey() ed25519.PublicKey { |
| k, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| if err != nil { |
| @@ -824,7 +862,7 @@ func runPromote(args []string) { |
| log.Fatal("usage: carlos promote -app <a> [-hotfix] <version> <channel>") |
| } |
| version, channel := fs.Arg(0), fs.Arg(1) |
| - res, err := release.PromoteOnLadder(context.Background(), openStore(), signingKey(), |
| + res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(), |
| acctOf(), *app, channel, version, |
| release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()}) |
| if err != nil { |
| @@ -891,19 +929,82 @@ func runRollback(args []string) { |
| if *app == "" || fs.NArg() != 1 { |
| log.Fatal("usage: carlos rollback -app <a> <channel>") |
| } |
| - if err := release.Rollback(context.Background(), openStore(), signingKey(), acctOf(), *app, fs.Arg(0)); err != nil { |
| + if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0)) |
| } |
| +// defaultGrantTTL is how long a scoped key lives when the operator does not |
| +// say: a quarter. Short enough that a forgotten builder key stops working |
| +// while someone still remembers what it was for, long enough that rotating it |
| +// is a calendar item rather than a chore. |
| +const defaultGrantTTL = 90 * 24 * time.Hour |
| + |
| +// runKeygen has two modes in one verb, because they mint the same kind of |
| +// thing for the same deployment. |
| +// |
| +// Without -scope it is what it has always been: a fresh root pair, printed as |
| +// the two variables a deployment is configured with. Nothing about that |
| +// output or its meaning changes. |
| +// |
| +// With -scope it mints a SCOPED key: a fresh pair plus a grant signed by the |
| +// root key read from CARLOS_RELEASE_KEY (same env discipline as promote — the |
| +// root key is read, never printed, never written). What comes out is the |
| +// scoped seed and the grant blob, which travel together. The deployment's |
| +// CARLOS_RELEASE_PUBKEY does NOT change and is deliberately not reprinted |
| +// here: the fleet's trust anchor stays exactly one key, and a second pubkey |
| +// in this output would invite someone to install it on a box. |
| func runKeygen(args []string) { |
| - seed, pub, err := release.GenerateKey() |
| + fs := flag.NewFlagSet("release-keygen", flag.ExitOnError) |
| + scope := fs.String("scope", "", "mint a SCOPED key for these rungs, comma separated (canary,edge,beta) — stable is never delegable") |
| + name := fs.String("name", "", "label for the scoped key; it appears in every refusal it causes") |
| + expires := fs.Duration("expires", defaultGrantTTL, "how long the scoped key may sign for (max 8760h)") |
| + fs.Parse(args) |
| + |
| + if *scope == "" { |
| + var stray []string |
| + fs.Visit(func(f *flag.Flag) { stray = append(stray, "-"+f.Name) }) |
| + if len(stray) > 0 { |
| + log.Fatalf("%s only apply with -scope; without it this mints the deployment's ROOT key", strings.Join(stray, " and ")) |
| + } |
| + seed, pub, err := release.GenerateKey() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub) |
| + fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config") |
| + return |
| + } |
| + |
| + if *name == "" { |
| + log.Fatal("usage: carlos release-keygen -scope <rungs> -name <label> [-expires <dur>]") |
| + } |
| + if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) { |
| + log.Fatal("CARLOS_RELEASE_KEY holds a scoped key: only the ROOT key can mint a grant, which is what makes a scoped key unable to widen itself") |
| + } |
| + var rungs []string |
| + for _, r := range strings.Split(*scope, ",") { |
| + if r = strings.TrimSpace(r); r != "" { |
| + rungs = append(rungs, r) |
| + } |
| + } |
| + seed, grant, err := release.MintKeyGrant(signingKey(), *name, rungs, *expires) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub) |
| - fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config") |
| + fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_GRANT=%s\n", seed, grant) |
| + fmt.Fprintf(os.Stderr, `scoped key %q for %s, expiring %s |
| + |
| + The KEY is the secret — store it wherever the builder's secrets live. |
| + The GRANT is not secret (public material only) but must travel WITH the |
| + key: a scoped key alone signs pointers nothing will accept. |
| + CARLOS_RELEASE_PUBKEY does not change; the fleet still trusts one key. |
| + |
| + Deploy order: fleet binaries that understand grants FIRST, this key |
| + second. An older binary reads a grant-signed pointer as a bad signature, |
| + which is the right way round to fail but will stop that box adopting. |
| +`, *name, strings.Join(rungs, ", "), time.Now().UTC().Add(*expires).Format(time.RFC3339)) |
| } |
| // statusUsage is shared between "no flag given" and "-h/--help" so an |
diff --git a/main.go b/main.go| index 379e3e791..eee1262a7 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -98,6 +98,11 @@ commands: |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |
| promote climbs the ladder canary -> edge -> beta |
| -> stable; -hotfix is the recorded bypass |
| + release-keygen mint the deployment's root signing key, or with |
| + -scope <rungs> -name <label> [-expires <dur>] a |
| + SCOPED key that can only sign those rungs |
| + (stable is never delegable; needs the root key |
| + in CARLOS_RELEASE_KEY to sign the grant) |
| adopt run one release-adoption pass (the hourly timer's verb) |
| canary ship this worktree to an ephemeral canary channel |
| bootstrap prepare a host (units, dirs, user) |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| new file mode 100644 |
| index 000000000..044b96012 |
| --- /dev/null |
| +++ b/scopedkeys_e2e_test.go |
| @@ -0,0 +1,170 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The scoped signing key as an operator meets it: two modes of one verb, and |
| +// a promote that signs with whatever CARLOS_RELEASE_KEY holds. Driven through |
| +// the real binary, because the whole feature is about which secret is sitting |
| +// in which variable on which machine — a thing the functions underneath |
| +// cannot be wrong about on their own. |
| +func TestScopedKeysCLI(t *testing.T) { |
| + bin := buildCarlos(t) |
| + bucket := t.TempDir() |
| + |
| + run := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = append(os.Environ(), |
| + append([]string{ |
| + "CARLOS_DEPLOYMENT_DIR=" + bucket, |
| + "CARLOS_DATA_DIR=" + t.TempDir(), |
| + "CARLOS_CREDENTIALS=" + filepath.Join(t.TempDir(), "credentials"), |
| + }, env...)...) |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // -- root keygen, unchanged ------------------------------------------ |
| + // |
| + // Mutation-mindedness: any change to the no-flag output breaks every |
| + // deployment's bootstrap instructions, so it is asserted key by key. |
| + out, err := run(nil, "release-keygen") |
| + if err != nil { |
| + t.Fatalf("release-keygen: %v\n%s", err, out) |
| + } |
| + rootKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| + rootPub := envValue(t, out, "CARLOS_RELEASE_PUBKEY") |
| + if release.IsScopedSeed(rootKey) { |
| + t.Fatal("the root key came out tagged as scoped") |
| + } |
| + rootEnv := []string{"CARLOS_RELEASE_KEY=" + rootKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| + |
| + // -name/-expires without -scope are refused rather than silently |
| + // ignored: an operator who typed them meant to mint a scoped key, and |
| + // walking away with a ROOT key they think is scoped is the worst |
| + // possible outcome of this verb. |
| + if out, err := run(rootEnv, "release-keygen", "-name", "edge-builder"); err == nil { |
| + t.Fatalf("release-keygen -name without -scope minted a root key:\n%s", out) |
| + } |
| + |
| + // -- scoped keygen --------------------------------------------------- |
| + out, err = run(rootEnv, "release-keygen", "-scope", "canary,edge", "-name", "edge-builder") |
| + if err != nil { |
| + t.Fatalf("release-keygen -scope: %v\n%s", err, out) |
| + } |
| + scopedKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| + grant := envValue(t, out, "CARLOS_RELEASE_GRANT") |
| + if !release.IsScopedSeed(scopedKey) { |
| + t.Fatalf("scoped keygen printed an untagged key %q", scopedKey) |
| + } |
| + // The fleet's anchor is not reprinted as an assignment. A second |
| + // copy-pasteable CARLOS_RELEASE_PUBKEY= line is an invitation to install |
| + // it on a box, which would give the deployment two trust anchors and |
| + // defeat the entire scheme. (The prose guidance names the variable to say |
| + // it does NOT change; that is the opposite of a footgun.) |
| + for _, line := range strings.Split(out, "\n") { |
| + if strings.HasPrefix(strings.TrimSpace(line), "CARLOS_RELEASE_PUBKEY=") { |
| + t.Fatalf("scoped keygen printed a public key assignment:\n%s", out) |
| + } |
| + } |
| + if !strings.Contains(out, "edge-builder") { |
| + t.Fatalf("scoped keygen does not name the key it minted:\n%s", out) |
| + } |
| + |
| + // stable is refused at mint, by the verb, naming stable. |
| + if out, err := run(rootEnv, "release-keygen", "-scope", "edge,stable", "-name", "bad"); err == nil { |
| + t.Fatalf("release-keygen -scope edge,stable succeeded:\n%s", out) |
| + } else if !strings.Contains(out, "stable") { |
| + t.Fatalf("refusal does not name stable:\n%s", out) |
| + } |
| + |
| + // Minting needs the ROOT key: a scoped key cannot widen itself. |
| + scopedOnly := []string{"CARLOS_RELEASE_KEY=" + scopedKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| + if out, err := run(scopedOnly, "release-keygen", "-scope", "beta", "-name", "wider"); err == nil { |
| + t.Fatalf("a scoped key minted a grant:\n%s", out) |
| + } |
| + |
| + // -- promoting with the scoped key ----------------------------------- |
| + art := filepath.Join(t.TempDir(), "kass") |
| + if err := os.WriteFile(art, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := run(rootEnv, "ship", "-app", "kass", "-version", "9f3c1ad", art); err != nil { |
| + t.Fatalf("ship: %v\n%s", err, out) |
| + } |
| + |
| + scopedEnv := append(append([]string{}, scopedOnly...), "CARLOS_RELEASE_GRANT="+grant) |
| + if out, err := run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "edge"); err != nil { |
| + t.Fatalf("scoped promote to edge: %v\n%s", err, out) |
| + } |
| + // It verifies for a reader holding only the ROOT public key — which is |
| + // every box in the fleet. |
| + out, err = run(rootEnv, "channels", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("channels: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "edge") || !strings.Contains(out, "9f3c1ad") { |
| + t.Fatalf("channels does not show the scope-signed pointer:\n%s", out) |
| + } |
| + |
| + // -- what the scoped key may not do ---------------------------------- |
| + // |
| + // stable is refused locally and nothing is written. The bucket check is |
| + // the load-bearing half: an error message is easy, a channel pointer that |
| + // never appeared is the property. |
| + out, err = run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "stable") |
| + if err == nil { |
| + t.Fatalf("the edge key promoted to stable:\n%s", out) |
| + } |
| + for _, want := range []string{"edge-builder", "stable"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("refusal does not name %q:\n%s", want, out) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(bucket, "apps", release.OpsAccount, "kass", "channels", "stable.json")); !os.IsNotExist(err) { |
| + t.Fatalf("a stable pointer exists after a refused promote (stat err = %v)", err) |
| + } |
| + |
| + // -hotfix bypasses the ladder, never the grant. |
| + if out, err := run(scopedEnv, "promote", "-app", "kass", "-hotfix", "9f3c1ad", "stable"); err == nil { |
| + t.Fatalf("-hotfix let the edge key reach stable:\n%s", out) |
| + } |
| + |
| + // A scoped key with no grant beside it fails HERE, naming the grant, |
| + // rather than signing something the fleet will silently refuse later. |
| + out, err = run(scopedOnly, "promote", "-app", "kass", "9f3c1ad", "edge") |
| + if err == nil { |
| + t.Fatalf("a scoped key promoted with no grant:\n%s", out) |
| + } |
| + if !strings.Contains(out, "CARLOS_RELEASE_GRANT") { |
| + t.Fatalf("refusal does not name the missing grant:\n%s", out) |
| + } |
| + |
| + // -- and the root key is still unrestricted -------------------------- |
| + if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "beta"); err != nil { |
| + t.Fatalf("root promote to beta: %v\n%s", err, out) |
| + } |
| + if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "stable"); err != nil { |
| + t.Fatalf("root promote to stable: %v\n%s", err, out) |
| + } |
| +} |
| + |
| +// envValue pulls VAR=value out of a verb's stdout. |
| +func envValue(t *testing.T, out, key string) string { |
| + t.Helper() |
| + for _, line := range strings.Split(out, "\n") { |
| + if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok { |
| + return v |
| + } |
| + } |
| + t.Fatalf("no %s in output:\n%s", key, out) |
| + return "" |
| +} |