| 1 | // carlos proof — D3 of the console release defences (spec |
| 2 | // 2026-10-06-console-release-defences-design.md §4.2-§4.4). |
| 3 | // |
| 4 | // keygen runs as root on the proving console box. get runs against the |
| 5 | // proving deployment's bucket and prints the proof; put runs against the |
| 6 | // requiring deployment's bucket. Direct mode only (CARLOS_DEPLOYMENT_BUCKET or |
| 7 | // _DIR): these are operator verbs, and a proof lives only in a bucket. put |
| 8 | // checks shape, not the signature: only the requiring box holds the prover's |
| 9 | // public key, and it checks. |
| 10 | // |
| 11 | // put is safe to repeat: the same proof again is a no-op success (a re-run |
| 12 | // roll script), a different proof under the same key is refused. |
| 13 | package main |
| 14 | |
| 15 | import ( |
| 16 | "context" |
| 17 | "encoding/json" |
| 18 | "errors" |
| 19 | "flag" |
| 20 | "fmt" |
| 21 | "io" |
| 22 | "log" |
| 23 | "os" |
| 24 | "path" |
| 25 | "strings" |
| 26 | "time" |
| 27 | |
| 28 | "github.com/carlosframework/platform/internal/release" |
| 29 | ) |
| 30 | |
| 31 | const proofUsage = "usage: carlos proof history <status|reconstruct|retire|resume> [flags] | keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>" |
| 32 | |
| 33 | func runProof(args []string) { |
| 34 | if len(args) == 0 { |
| 35 | log.Fatal(proofUsage) |
| 36 | } |
| 37 | ctx := context.Background() |
| 38 | switch args[0] { |
| 39 | case "history": |
| 40 | runHistory(args[1:]) |
| 41 | case "keygen": |
| 42 | fs := flag.NewFlagSet("proof keygen", flag.ExitOnError) |
| 43 | out := fs.String("out", "/etc/carlos/proving.key", "where to write the private key (root only, mode 0600)") |
| 44 | fs.Usage = flagUsage(fs, "") |
| 45 | fs.Parse(args[1:]) |
| 46 | if fs.NArg() != 0 { |
| 47 | log.Fatal(proofUsage) |
| 48 | } |
| 49 | if err := proofKeygen(*out, stdout); err != nil { |
| 50 | log.Fatal(err) |
| 51 | } |
| 52 | case "get": |
| 53 | fs := flag.NewFlagSet("proof get", flag.ExitOnError) |
| 54 | app := appFlag(fs, "") |
| 55 | sha := fs.String("sha256", "", "the artifact's sha256, hex") |
| 56 | prover := fs.String("prover", "", "whose proof (default: the only one)") |
| 57 | _, _, acctOf := accountFlags(fs) |
| 58 | fs.Usage = flagUsage(fs, "") |
| 59 | fs.Parse(args[1:]) |
| 60 | if *app == "" || *sha == "" || fs.NArg() != 0 { |
| 61 | log.Fatal(proofUsage) |
| 62 | } |
| 63 | if err := proofGet(ctx, openStore(), acctOf(), *app, *sha, *prover, stdout); err != nil { |
| 64 | log.Fatal(err) |
| 65 | } |
| 66 | case "put": |
| 67 | fs := flag.NewFlagSet("proof put", flag.ExitOnError) |
| 68 | app := appFlag(fs, "") |
| 69 | _, _, acctOf := accountFlags(fs) |
| 70 | fs.Usage = flagUsage(fs, "") |
| 71 | fs.Parse(args[1:]) |
| 72 | if *app == "" || fs.NArg() != 1 { |
| 73 | log.Fatal(proofUsage) |
| 74 | } |
| 75 | raw, err := os.ReadFile(fs.Arg(0)) |
| 76 | if err != nil { |
| 77 | log.Fatal(err) |
| 78 | } |
| 79 | if err := proofPut(ctx, openStore(), acctOf(), *app, raw, stdout); err != nil { |
| 80 | log.Fatal(err) |
| 81 | } |
| 82 | default: |
| 83 | log.Fatal(proofUsage) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func proofKeygen(file string, w io.Writer) error { |
| 88 | seed, pub, err := release.GenerateKey() |
| 89 | if err != nil { |
| 90 | return err |
| 91 | } |
| 92 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 93 | if errors.Is(err, os.ErrExist) { |
| 94 | 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) |
| 95 | } |
| 96 | if err != nil { |
| 97 | return err |
| 98 | } |
| 99 | if _, err := f.WriteString(seed + "\n"); err != nil { |
| 100 | f.Close() |
| 101 | return err |
| 102 | } |
| 103 | if err := f.Close(); err != nil { |
| 104 | return err |
| 105 | } |
| 106 | fmt.Fprintf(w, "wrote the proving key to %s (root only, mode 0600)\n", file) |
| 107 | fmt.Fprintf(w, "public key: %s\n", pub) |
| 108 | fmt.Fprintf(w, "a requiring box names it in its proof policy: {\"app\":\"<app>\",\"prover\":\"<prover>\",\"key\":%q}\n", pub) |
| 109 | return nil |
| 110 | } |
| 111 | |
| 112 | func proofGet(ctx context.Context, s release.Store, acct, app, sha, prover string, w io.Writer) error { |
| 113 | if prover == "" { |
| 114 | keys, err := s.List(ctx, release.ProofsPrefix(acct, app, sha)) |
| 115 | if err != nil { |
| 116 | return err |
| 117 | } |
| 118 | var names []string |
| 119 | for _, k := range keys { |
| 120 | base := path.Base(k) |
| 121 | if strings.HasSuffix(base, ".pending.json") { |
| 122 | continue |
| 123 | } |
| 124 | if n, ok := strings.CutSuffix(base, ".json"); ok { |
| 125 | names = append(names, n) |
| 126 | } |
| 127 | } |
| 128 | switch len(names) { |
| 129 | case 0: |
| 130 | return proofNotYet(ctx, s, acct, app, sha, keys) |
| 131 | case 1: |
| 132 | prover = names[0] |
| 133 | default: |
| 134 | return fmt.Errorf("%s has proofs from %s; choose one with --prover", sha, strings.Join(names, ", ")) |
| 135 | } |
| 136 | } |
| 137 | p, err := release.GetProof(ctx, s, acct, app, sha, prover) |
| 138 | if errors.Is(err, os.ErrNotExist) { |
| 139 | return proofNotYet(ctx, s, acct, app, sha, []string{release.ProofPendingKey(acct, app, sha, prover)}) |
| 140 | } |
| 141 | if err != nil { |
| 142 | return err |
| 143 | } |
| 144 | b, err := json.MarshalIndent(p, "", " ") |
| 145 | if err != nil { |
| 146 | return err |
| 147 | } |
| 148 | fmt.Fprintf(w, "%s\n", b) |
| 149 | return nil |
| 150 | } |
| 151 | |
| 152 | // proofNotYet explains a missing proof from the proving box's pending note |
| 153 | // when there is one among keys. |
| 154 | func proofNotYet(ctx context.Context, s release.Store, acct, app, sha string, keys []string) error { |
| 155 | for _, k := range keys { |
| 156 | prover, ok := strings.CutSuffix(path.Base(k), ".pending.json") |
| 157 | if !ok { |
| 158 | continue |
| 159 | } |
| 160 | pend, err := release.GetProofPending(ctx, s, acct, app, sha, prover) |
| 161 | if err != nil { |
| 162 | continue |
| 163 | } |
| 164 | return fmt.Errorf("not proven yet: %s has been live on %s since %s, proven after %s", |
| 165 | sha, pend.Prover, pend.LiveSince.UTC().Format(time.RFC3339), pend.After) |
| 166 | } |
| 167 | return fmt.Errorf("not proven yet: %s has not been seen live on the proving deployment", sha) |
| 168 | } |
| 169 | |
| 170 | func proofPut(ctx context.Context, s release.Store, acct, app string, raw []byte, w io.Writer) error { |
| 171 | p, err := release.ParseProof(raw) |
| 172 | if err != nil { |
| 173 | return err |
| 174 | } |
| 175 | if p.App != app { |
| 176 | return fmt.Errorf("this proof is for %s, not %s", p.App, app) |
| 177 | } |
| 178 | if err := release.PutProof(ctx, s, acct, p); err != nil { |
| 179 | return err |
| 180 | } |
| 181 | fmt.Fprintf(w, "put %s's proof of %s %s (sha256 %s) at %s\n", p.Prover, p.App, p.Version, p.SHA256, |
| 182 | release.ProofKey(acct, p.App, p.SHA256, p.Prover)) |
| 183 | return nil |
| 184 | } |
| 185 | |