carlos proof: keygen on the proving box, get and put to carry a proof
put is idempotent for a byte-identical proof and refuses a different one under the same key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+335
−1
main.go+4 −1proof_cli.go+182 −0proof_cli_test.go+149 −0
CI failed — run details
diff --git a/main.go b/main.go| index 16f2ccd5b..9de7b8687 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -132,6 +132,8 @@ func main() { |
| RunSecrets(args) |
| case "release-keygen": |
| runKeygen(args) |
| + case "proof": |
| + runProof(args) |
| case "ops": |
| ops.Run(args) |
| case "system": |
| @@ -263,7 +265,8 @@ Your deployment — stand up and administer boxes, routes and accounts |
| revoke a capability, hibernate it, change its backing |
| instances with no sub-verb: list THIS box's registry routes |
| release-keygen mint the root signing key, or a scoped signing key |
| - economics bill: record the month's AWS bill; backhaul: what region |
| + proof dev proofs a box requires before adopting: keygen | get | put |
| + economics bill: record the month's AWS bill; backhaul: what region |
| edges cost us this period |
| status --live: what every box last reported; --dry-run: leak check |
diff --git a/proof_cli.go b/proof_cli.go| new file mode 100644 |
| index 000000000..5c0108c48 |
| --- /dev/null |
| +++ b/proof_cli.go |
| @@ -0,0 +1,182 @@ |
| +// carlos proof — D3 of the console release defences (spec |
| +// 2026-10-06-console-release-defences-design.md §4.2-§4.4). |
| +// |
| +// keygen runs as root on the proving console box. get runs against the |
| +// proving deployment's bucket and prints the proof; put runs against the |
| +// requiring deployment's bucket. Direct mode only (CARLOS_DEPLOYMENT_BUCKET or |
| +// _DIR): these are operator verbs, and a proof lives only in a bucket. put |
| +// checks shape, not the signature: only the requiring box holds the prover's |
| +// public key, and it checks. |
| +// |
| +// put is safe to repeat: the same proof again is a no-op success (a re-run |
| +// roll script), a different proof under the same key is refused. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "path" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const proofUsage = "usage: carlos proof keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>" |
| + |
| +func runProof(args []string) { |
| + if len(args) == 0 { |
| + log.Fatal(proofUsage) |
| + } |
| + ctx := context.Background() |
| + switch args[0] { |
| + case "keygen": |
| + fs := flag.NewFlagSet("proof keygen", flag.ExitOnError) |
| + out := fs.String("out", "/etc/carlos/proving.key", "where to write the private key (root only, mode 0600)") |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args[1:]) |
| + if fs.NArg() != 0 { |
| + log.Fatal(proofUsage) |
| + } |
| + if err := proofKeygen(*out, stdout); err != nil { |
| + log.Fatal(err) |
| + } |
| + case "get": |
| + fs := flag.NewFlagSet("proof get", flag.ExitOnError) |
| + app := appFlag(fs, "") |
| + sha := fs.String("sha256", "", "the artifact's sha256, hex") |
| + prover := fs.String("prover", "", "whose proof (default: the only one)") |
| + _, _, acctOf := accountFlags(fs) |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args[1:]) |
| + if *app == "" || *sha == "" || fs.NArg() != 0 { |
| + log.Fatal(proofUsage) |
| + } |
| + if err := proofGet(ctx, openStore(), acctOf(), *app, *sha, *prover, stdout); err != nil { |
| + log.Fatal(err) |
| + } |
| + case "put": |
| + fs := flag.NewFlagSet("proof put", flag.ExitOnError) |
| + app := appFlag(fs, "") |
| + _, _, acctOf := accountFlags(fs) |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args[1:]) |
| + if *app == "" || fs.NArg() != 1 { |
| + log.Fatal(proofUsage) |
| + } |
| + raw, err := os.ReadFile(fs.Arg(0)) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if err := proofPut(ctx, openStore(), acctOf(), *app, raw, stdout); err != nil { |
| + log.Fatal(err) |
| + } |
| + default: |
| + log.Fatal(proofUsage) |
| + } |
| +} |
| + |
| +func proofKeygen(file string, w io.Writer) error { |
| + seed, pub, err := release.GenerateKey() |
| + if err != nil { |
| + return err |
| + } |
| + f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| + if errors.Is(err, os.ErrExist) { |
| + return fmt.Errorf("%s already exists. A proving key is never overwritten: delete it first if you mean to replace it, then update every require entry that names its public key", file) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := f.WriteString(seed + "\n"); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(w, "wrote the proving key to %s (root only, mode 0600)\n", file) |
| + fmt.Fprintf(w, "public key: %s\n", pub) |
| + fmt.Fprintf(w, "a requiring box names it in its proof policy: {\"app\":\"<app>\",\"prover\":\"<prover>\",\"key\":%q}\n", pub) |
| + return nil |
| +} |
| + |
| +func proofGet(ctx context.Context, s release.Store, acct, app, sha, prover string, w io.Writer) error { |
| + if prover == "" { |
| + keys, err := s.List(ctx, release.ProofsPrefix(acct, app, sha)) |
| + if err != nil { |
| + return err |
| + } |
| + var names []string |
| + for _, k := range keys { |
| + base := path.Base(k) |
| + if strings.HasSuffix(base, ".pending.json") { |
| + continue |
| + } |
| + if n, ok := strings.CutSuffix(base, ".json"); ok { |
| + names = append(names, n) |
| + } |
| + } |
| + switch len(names) { |
| + case 0: |
| + return proofNotYet(ctx, s, acct, app, sha, keys) |
| + case 1: |
| + prover = names[0] |
| + default: |
| + return fmt.Errorf("%s has proofs from %s; choose one with --prover", sha, strings.Join(names, ", ")) |
| + } |
| + } |
| + p, err := release.GetProof(ctx, s, acct, app, sha, prover) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return proofNotYet(ctx, s, acct, app, sha, []string{release.ProofPendingKey(acct, app, sha, prover)}) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + b, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(w, "%s\n", b) |
| + return nil |
| +} |
| + |
| +// proofNotYet explains a missing proof from the proving box's pending note |
| +// when there is one among keys. |
| +func proofNotYet(ctx context.Context, s release.Store, acct, app, sha string, keys []string) error { |
| + for _, k := range keys { |
| + prover, ok := strings.CutSuffix(path.Base(k), ".pending.json") |
| + if !ok { |
| + continue |
| + } |
| + pend, err := release.GetProofPending(ctx, s, acct, app, sha, prover) |
| + if err != nil { |
| + continue |
| + } |
| + return fmt.Errorf("not proven yet: %s has been live on %s since %s, proven after %s", |
| + sha, pend.Prover, pend.LiveSince.UTC().Format(time.RFC3339), pend.After) |
| + } |
| + return fmt.Errorf("not proven yet: %s has not been seen live on the proving deployment", sha) |
| +} |
| + |
| +func proofPut(ctx context.Context, s release.Store, acct, app string, raw []byte, w io.Writer) error { |
| + p, err := release.ParseProof(raw) |
| + if err != nil { |
| + return err |
| + } |
| + if p.App != app { |
| + return fmt.Errorf("this proof is for %s, not %s", p.App, app) |
| + } |
| + if err := release.PutProof(ctx, s, acct, p); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(w, "put %s's proof of %s %s (sha256 %s) at %s\n", p.Prover, p.App, p.Version, p.SHA256, |
| + release.ProofKey(acct, p.App, p.SHA256, p.Prover)) |
| + return nil |
| +} |
diff --git a/proof_cli_test.go b/proof_cli_test.go| new file mode 100644 |
| index 000000000..c04a3ac30 |
| --- /dev/null |
| +++ b/proof_cli_test.go |
| @@ -0,0 +1,149 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const cliSHA = "e47fcc1e0123456789abcdef0123456789abcdef0123456789abcdef01234567" |
| + |
| +func TestProofKeygenWritesARootOnlyKey(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "proving.key") |
| + var out bytes.Buffer |
| + if err := proofKeygen(path, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(path) |
| + if err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("key file mode = %v, %v; want 0600", fi.Mode().Perm(), err) |
| + } |
| + seed, _ := os.ReadFile(path) |
| + key, err := release.ParseSigningKey(string(seed), path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub := base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)) |
| + if !strings.Contains(out.String(), "public key: "+pub+"\n") { |
| + t.Fatalf("printed %q, want the key file's public half %s", out.String(), pub) |
| + } |
| + if err := proofKeygen(path, &out); err == nil || !strings.Contains(err.Error(), "already exists") { |
| + t.Fatalf("second keygen = %v, want a refusal to overwrite", err) |
| + } |
| +} |
| + |
| +func signedCLIProof(t *testing.T) *release.Proof { |
| + t.Helper() |
| + seed, _, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := release.ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := release.SignProof(key, release.Proof{App: "console", Platform: "linux-arm64", SHA256: cliSHA, |
| + Version: "f4478b15", Prover: "dev", ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC), |
| + ProvenAt: time.Date(2026, 10, 6, 9, 15, 0, 0, time.UTC)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// get against dev, put against flagship, byte for byte. |
| +func TestProofGetThenPutCarriesIt(t *testing.T) { |
| + ctx := context.Background() |
| + dev, flagship := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + p := signedCLIProof(t) |
| + if err := release.PutProof(ctx, dev, "bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + var got bytes.Buffer |
| + if err := proofGet(ctx, dev, "bab", "console", cliSHA, "", &got); err != nil { |
| + t.Fatalf("proof get: %v", err) |
| + } |
| + var out bytes.Buffer |
| + if err := proofPut(ctx, flagship, "bab", "console", got.Bytes(), &out); err != nil { |
| + t.Fatalf("proof put: %v", err) |
| + } |
| + carried, err := release.GetProof(ctx, flagship, "bab", "console", cliSHA, "dev") |
| + if err != nil || carried.Sig != p.Sig { |
| + t.Fatalf("carried = %+v, %v", carried, err) |
| + } |
| +} |
| + |
| +func TestProofGetNotYetNamesTheBake(t *testing.T) { |
| + ctx := context.Background() |
| + dev := release.NewDirStore(t.TempDir()) |
| + if err := release.PutProofPending(ctx, dev, "bab", release.ProofPending{App: "console", Platform: "linux-arm64", |
| + SHA256: cliSHA, Version: "f4478b15", Prover: "dev", |
| + LiveSince: time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC), After: "15m"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + err := proofGet(ctx, dev, "bab", "console", cliSHA, "", &bytes.Buffer{}) |
| + want := "not proven yet: " + cliSHA + " has been live on dev since 2026-10-06T09:30:30Z, proven after 15m" |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err = %v, want %q", err, want) |
| + } |
| + err = proofGet(ctx, release.NewDirStore(t.TempDir()), "bab", "console", cliSHA, "", &bytes.Buffer{}) |
| + if err == nil || !strings.Contains(err.Error(), "has not been seen live") { |
| + t.Fatalf("err = %v, want the never-seen text", err) |
| + } |
| +} |
| + |
| +func TestProofPutRefusesAnotherApp(t *testing.T) { |
| + var b bytes.Buffer |
| + p := signedCLIProof(t) |
| + raw, _ := json.MarshalIndent(p, "", " ") |
| + if err := proofPut(context.Background(), release.NewDirStore(t.TempDir()), "bab", "other", raw, &b); err == nil { |
| + t.Fatal("put a console proof under another app") |
| + } |
| + if err := proofPut(context.Background(), release.NewDirStore(t.TempDir()), "bab", "console", []byte(`{"schema":1}`), &b); err == nil { |
| + t.Fatal("put a malformed proof") |
| + } |
| +} |
| + |
| +// Re-running the roll script puts the same proof again: that must succeed. |
| +func TestProofPutTwiceIsIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := signedCLIProof(t) |
| + raw, _ := json.MarshalIndent(p, "", " ") |
| + for i := 0; i < 2; i++ { |
| + if err := proofPut(ctx, s, "bab", "console", raw, &bytes.Buffer{}); err != nil { |
| + t.Fatalf("put #%d: %v", i+1, err) |
| + } |
| + } |
| +} |
| + |
| +// A different proof under the same key is refused, and the first one stands. |
| +func TestProofPutDifferentProofSameKeyFails(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := signedCLIProof(t) |
| + raw, _ := json.MarshalIndent(p, "", " ") |
| + if err := proofPut(ctx, s, "bab", "console", raw, &bytes.Buffer{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := *p |
| + other.ProvenAt = other.ProvenAt.Add(time.Minute) |
| + raw2, _ := json.MarshalIndent(&other, "", " ") |
| + err := proofPut(ctx, s, "bab", "console", raw2, &bytes.Buffer{}) |
| + if err == nil || !strings.Contains(err.Error(), "already exists with different content") { |
| + t.Fatalf("err = %v, want a refusal naming different content", err) |
| + } |
| + got, gerr := release.GetProof(ctx, s, "bab", "console", cliSHA, "dev") |
| + if gerr != nil || got.Sig != p.Sig { |
| + t.Fatalf("stored proof changed: %+v, %v", got, gerr) |
| + } |
| +} |