| 1 | // Dev proofs — D3 of the console release defences (spec |
| 2 | // 2026-10-06-console-release-defences-design.md §4.2-§4.4). |
| 3 | // |
| 4 | // A proving deployment (dev) signs a Proof once a build has run healthily on |
| 5 | // its console for a bake period. An operator carries it to a requiring |
| 6 | // deployment's bucket, and a requiring box adopts a listed app's build only |
| 7 | // with a proof for those exact bytes from a prover it trusts (proofgate.go). |
| 8 | // A proof binds app, platform and the artifact's sha256. version is |
| 9 | // informational, because version names get reused after pruning. |
| 10 | // |
| 11 | // The signature covers the canonical JSON of every field except sig: |
| 12 | // json.Marshal of proofBody, whose field order the struct fixes and whose |
| 13 | // times are UTC. Verify re-marshals the parsed fields, so a proof whose bytes |
| 14 | // were reformatted in transit still verifies and one whose fields were edited |
| 15 | // does not. The proving key is its own ed25519 key, never a release key. |
| 16 | package release |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/ed25519" |
| 21 | "crypto/sha256" |
| 22 | "encoding/base64" |
| 23 | "encoding/hex" |
| 24 | "encoding/json" |
| 25 | "errors" |
| 26 | "fmt" |
| 27 | "io" |
| 28 | "os" |
| 29 | "regexp" |
| 30 | "time" |
| 31 | ) |
| 32 | |
| 33 | const proofSchema = 1 |
| 34 | |
| 35 | type Proof struct { |
| 36 | Schema int `json:"schema"` |
| 37 | App string `json:"app"` |
| 38 | Platform string `json:"platform"` |
| 39 | SHA256 string `json:"sha256"` |
| 40 | Version string `json:"version"` |
| 41 | Prover string `json:"prover"` |
| 42 | ActiveSince time.Time `json:"active_since"` |
| 43 | ProvenAt time.Time `json:"proven_at"` |
| 44 | Sig string `json:"sig"` |
| 45 | } |
| 46 | |
| 47 | // proofBody is the signed form: every field of Proof except Sig, in order. |
| 48 | type proofBody struct { |
| 49 | Schema int `json:"schema"` |
| 50 | App string `json:"app"` |
| 51 | Platform string `json:"platform"` |
| 52 | SHA256 string `json:"sha256"` |
| 53 | Version string `json:"version"` |
| 54 | Prover string `json:"prover"` |
| 55 | ActiveSince time.Time `json:"active_since"` |
| 56 | ProvenAt time.Time `json:"proven_at"` |
| 57 | } |
| 58 | |
| 59 | func (p *Proof) canonical() ([]byte, error) { |
| 60 | return json.Marshal(proofBody{Schema: p.Schema, App: p.App, Platform: p.Platform, SHA256: p.SHA256, |
| 61 | Version: p.Version, Prover: p.Prover, ActiveSince: p.ActiveSince.UTC(), ProvenAt: p.ProvenAt.UTC()}) |
| 62 | } |
| 63 | |
| 64 | var ( |
| 65 | proverRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) |
| 66 | sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| 67 | platformRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| 68 | ) |
| 69 | |
| 70 | // ValidProver is a prover name: lower case, digits and dashes, at most 32. |
| 71 | func ValidProver(s string) bool { return proverRe.MatchString(s) } |
| 72 | |
| 73 | // AdoptPlatform is the artifact key this box installs (adoptPlatform), for |
| 74 | // callers outside the package that mint or describe proofs. |
| 75 | func AdoptPlatform() string { return adoptPlatform } |
| 76 | |
| 77 | func (p *Proof) shape() error { |
| 78 | switch { |
| 79 | case p.Schema != proofSchema: |
| 80 | return fmt.Errorf("proof: schema %d, want %d", p.Schema, proofSchema) |
| 81 | case !ValidApp(p.App): |
| 82 | return fmt.Errorf("proof: invalid app %q", p.App) |
| 83 | case !platformRe.MatchString(p.Platform): |
| 84 | return fmt.Errorf("proof: invalid platform %q", p.Platform) |
| 85 | case !sha256Re.MatchString(p.SHA256): |
| 86 | return fmt.Errorf("proof: sha256 %q is not 64 lower-case hex digits", p.SHA256) |
| 87 | case !ValidVersion(p.Version): |
| 88 | return fmt.Errorf("proof: invalid version %q", p.Version) |
| 89 | case !ValidProver(p.Prover): |
| 90 | return fmt.Errorf("proof: invalid prover %q", p.Prover) |
| 91 | case p.ActiveSince.IsZero() || p.ProvenAt.IsZero(): |
| 92 | return fmt.Errorf("proof: active_since and proven_at are both required") |
| 93 | } |
| 94 | return nil |
| 95 | } |
| 96 | |
| 97 | // SignProof stamps the schema and signs p with the proving key. |
| 98 | func SignProof(priv ed25519.PrivateKey, p Proof) (*Proof, error) { |
| 99 | if len(priv) != ed25519.PrivateKeySize { |
| 100 | return nil, fmt.Errorf("proof: no usable proving key") |
| 101 | } |
| 102 | p.Schema, p.Sig = proofSchema, "" |
| 103 | if err := p.shape(); err != nil { |
| 104 | return nil, err |
| 105 | } |
| 106 | body, err := p.canonical() |
| 107 | if err != nil { |
| 108 | return nil, err |
| 109 | } |
| 110 | p.Sig = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, body)) |
| 111 | return &p, nil |
| 112 | } |
| 113 | |
| 114 | // ParseProof checks shape only. Whether it verifies is the requiring box's |
| 115 | // question (Check), because only the box holds the prover's public key. |
| 116 | func ParseProof(b []byte) (*Proof, error) { |
| 117 | var p Proof |
| 118 | if err := json.Unmarshal(b, &p); err != nil { |
| 119 | return nil, fmt.Errorf("proof: %v", err) |
| 120 | } |
| 121 | if err := p.shape(); err != nil { |
| 122 | return nil, err |
| 123 | } |
| 124 | if sig, err := base64.StdEncoding.DecodeString(p.Sig); err != nil || len(sig) != ed25519.SignatureSize { |
| 125 | return nil, fmt.Errorf("proof: sig is not a base64 ed25519 signature") |
| 126 | } |
| 127 | return &p, nil |
| 128 | } |
| 129 | |
| 130 | func (p *Proof) Verify(pub ed25519.PublicKey) error { |
| 131 | if len(pub) != ed25519.PublicKeySize { |
| 132 | return fmt.Errorf("proof: no usable prover public key") |
| 133 | } |
| 134 | sig, err := base64.StdEncoding.DecodeString(p.Sig) |
| 135 | if err != nil { |
| 136 | return fmt.Errorf("proof: sig: %v", err) |
| 137 | } |
| 138 | body, err := p.canonical() |
| 139 | if err != nil { |
| 140 | return err |
| 141 | } |
| 142 | if !ed25519.Verify(pub, body, sig) { |
| 143 | return fmt.Errorf("proof signature does not verify against the configured key") |
| 144 | } |
| 145 | return nil |
| 146 | } |
| 147 | |
| 148 | // Check is Verify plus the binding: this proof is for exactly app, platform |
| 149 | // and sha, from prover. |
| 150 | func (p *Proof) Check(pub ed25519.PublicKey, app, platform, sha, prover string) error { |
| 151 | if err := p.Verify(pub); err != nil { |
| 152 | return err |
| 153 | } |
| 154 | switch { |
| 155 | case p.App != app: |
| 156 | return fmt.Errorf("proof is for app %s, not %s", p.App, app) |
| 157 | case p.Platform != platform: |
| 158 | return fmt.Errorf("proof is for platform %s, not %s", p.Platform, platform) |
| 159 | case p.SHA256 != sha: |
| 160 | return fmt.Errorf("proof is for sha256 %s, not %s", p.SHA256, sha) |
| 161 | case p.Prover != prover: |
| 162 | return fmt.Errorf("proof names prover %s, not %s", p.Prover, prover) |
| 163 | } |
| 164 | return nil |
| 165 | } |
| 166 | |
| 167 | // FileSHA256 hashes the bytes at path (following a symlink), the one way D3 |
| 168 | // learns a sha256 (spec §4.2, codex 3). |
| 169 | func FileSHA256(path string) (string, error) { |
| 170 | f, err := os.Open(path) |
| 171 | if err != nil { |
| 172 | return "", err |
| 173 | } |
| 174 | defer f.Close() |
| 175 | h := sha256.New() |
| 176 | if _, err := io.Copy(h, f); err != nil { |
| 177 | return "", err |
| 178 | } |
| 179 | return hex.EncodeToString(h.Sum(nil)), nil |
| 180 | } |
| 181 | |
| 182 | func ProofsPrefix(acct, app, sha string) string { return appPrefix(acct, app) + "proofs/" + sha + "/" } |
| 183 | func ProofKey(acct, app, sha, prover string) string { |
| 184 | return ProofsPrefix(acct, app, sha) + prover + ".json" |
| 185 | } |
| 186 | func ProofPendingKey(acct, app, sha, prover string) string { |
| 187 | return ProofsPrefix(acct, app, sha) + prover + ".pending.json" |
| 188 | } |
| 189 | |
| 190 | func GetProof(ctx context.Context, s Store, acct, app, sha, prover string) (*Proof, error) { |
| 191 | if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) { |
| 192 | return nil, fmt.Errorf("proof: invalid key %s/%s/%s", app, sha, prover) |
| 193 | } |
| 194 | raw, _, err := s.Get(ctx, ProofKey(acct, app, sha, prover)) |
| 195 | if err != nil { |
| 196 | return nil, err |
| 197 | } |
| 198 | return ParseProof(raw) |
| 199 | } |
| 200 | |
| 201 | func PutProof(ctx context.Context, s Store, acct string, p *Proof) error { |
| 202 | if err := p.shape(); err != nil { |
| 203 | return err |
| 204 | } |
| 205 | b, err := json.MarshalIndent(p, "", " ") |
| 206 | if err != nil { |
| 207 | return err |
| 208 | } |
| 209 | return putImmutable(ctx, s, ProofKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n')) |
| 210 | } |
| 211 | |
| 212 | // ProofPending is the proving box's note that a listed build is live and |
| 213 | // baking, so `carlos proof get` can say how long it has to go. Advisory: no |
| 214 | // box reads it. |
| 215 | type ProofPending struct { |
| 216 | Schema int `json:"schema"` |
| 217 | App string `json:"app"` |
| 218 | Platform string `json:"platform"` |
| 219 | SHA256 string `json:"sha256"` |
| 220 | Version string `json:"version"` |
| 221 | Prover string `json:"prover"` |
| 222 | LiveSince time.Time `json:"live_since"` |
| 223 | After string `json:"after"` |
| 224 | } |
| 225 | |
| 226 | func PutProofPending(ctx context.Context, s Store, acct string, p ProofPending) error { |
| 227 | if !ValidApp(p.App) || !sha256Re.MatchString(p.SHA256) || !ValidProver(p.Prover) { |
| 228 | return fmt.Errorf("proof pending: invalid record %s/%s/%s", p.App, p.SHA256, p.Prover) |
| 229 | } |
| 230 | p.Schema = proofSchema |
| 231 | b, err := json.MarshalIndent(p, "", " ") |
| 232 | if err != nil { |
| 233 | return err |
| 234 | } |
| 235 | _, err = s.Put(ctx, ProofPendingKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n'), "") |
| 236 | if errors.Is(err, ErrCASConflict) { |
| 237 | return nil // the first sighting stands |
| 238 | } |
| 239 | return err |
| 240 | } |
| 241 | |
| 242 | func GetProofPending(ctx context.Context, s Store, acct, app, sha, prover string) (*ProofPending, error) { |
| 243 | if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) { |
| 244 | return nil, fmt.Errorf("proof pending: invalid key %s/%s/%s", app, sha, prover) |
| 245 | } |
| 246 | raw, _, err := s.Get(ctx, ProofPendingKey(acct, app, sha, prover)) |
| 247 | if err != nil { |
| 248 | return nil, err |
| 249 | } |
| 250 | var p ProofPending |
| 251 | if err := json.Unmarshal(raw, &p); err != nil { |
| 252 | return nil, fmt.Errorf("proof pending: %v", err) |
| 253 | } |
| 254 | return &p, nil |
| 255 | } |
| 256 | |