carlos / platform Public

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>
Paul Campbell 93449d587b82efa4a16617f97ba67ba33627e8d8 parent e96e630
3 files changed, +281 −5
  • cli.go +106 −5
  • main.go +5 −0
  • scopedkeys_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 ""
+}