| new file mode 100644 |
| index 000000000..8a17b576f |
| --- /dev/null |
| +++ b/internal/release/proof.go |
| @@ -0,0 +1,255 @@ |
| +// Dev proofs — D3 of the console release defences (spec |
| +// 2026-10-06-console-release-defences-design.md §4.2-§4.4). |
| +// |
| +// A proving deployment (dev) signs a Proof once a build has run healthily on |
| +// its console for a bake period. An operator carries it to a requiring |
| +// deployment's bucket, and a requiring box adopts a listed app's build only |
| +// with a proof for those exact bytes from a prover it trusts (proofgate.go). |
| +// A proof binds app, platform and the artifact's sha256. version is |
| +// informational, because version names get reused after pruning. |
| +// |
| +// The signature covers the canonical JSON of every field except sig: |
| +// json.Marshal of proofBody, whose field order the struct fixes and whose |
| +// times are UTC. Verify re-marshals the parsed fields, so a proof whose bytes |
| +// were reformatted in transit still verifies and one whose fields were edited |
| +// does not. The proving key is its own ed25519 key, never a release key. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "regexp" |
| + "time" |
| +) |
| + |
| +const proofSchema = 1 |
| + |
| +type Proof struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Platform string `json:"platform"` |
| + SHA256 string `json:"sha256"` |
| + Version string `json:"version"` |
| + Prover string `json:"prover"` |
| + ActiveSince time.Time `json:"active_since"` |
| + ProvenAt time.Time `json:"proven_at"` |
| + Sig string `json:"sig"` |
| +} |
| + |
| +// proofBody is the signed form: every field of Proof except Sig, in order. |
| +type proofBody struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Platform string `json:"platform"` |
| + SHA256 string `json:"sha256"` |
| + Version string `json:"version"` |
| + Prover string `json:"prover"` |
| + ActiveSince time.Time `json:"active_since"` |
| + ProvenAt time.Time `json:"proven_at"` |
| +} |
| + |
| +func (p *Proof) canonical() ([]byte, error) { |
| + return json.Marshal(proofBody{Schema: p.Schema, App: p.App, Platform: p.Platform, SHA256: p.SHA256, |
| + Version: p.Version, Prover: p.Prover, ActiveSince: p.ActiveSince.UTC(), ProvenAt: p.ProvenAt.UTC()}) |
| +} |
| + |
| +var ( |
| + proverRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) |
| + sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| + platformRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| +) |
| + |
| +// ValidProver is a prover name: lower case, digits and dashes, at most 32. |
| +func ValidProver(s string) bool { return proverRe.MatchString(s) } |
| + |
| +// AdoptPlatform is the artifact key this box installs (adoptPlatform), for |
| +// callers outside the package that mint or describe proofs. |
| +func AdoptPlatform() string { return adoptPlatform } |
| + |
| +func (p *Proof) shape() error { |
| + switch { |
| + case p.Schema != proofSchema: |
| + return fmt.Errorf("proof: schema %d, want %d", p.Schema, proofSchema) |
| + case !ValidApp(p.App): |
| + return fmt.Errorf("proof: invalid app %q", p.App) |
| + case !platformRe.MatchString(p.Platform): |
| + return fmt.Errorf("proof: invalid platform %q", p.Platform) |
| + case !sha256Re.MatchString(p.SHA256): |
| + return fmt.Errorf("proof: sha256 %q is not 64 lower-case hex digits", p.SHA256) |
| + case !ValidVersion(p.Version): |
| + return fmt.Errorf("proof: invalid version %q", p.Version) |
| + case !ValidProver(p.Prover): |
| + return fmt.Errorf("proof: invalid prover %q", p.Prover) |
| + case p.ActiveSince.IsZero() || p.ProvenAt.IsZero(): |
| + return fmt.Errorf("proof: active_since and proven_at are both required") |
| + } |
| + return nil |
| +} |
| + |
| +// SignProof stamps the schema and signs p with the proving key. |
| +func SignProof(priv ed25519.PrivateKey, p Proof) (*Proof, error) { |
| + if len(priv) != ed25519.PrivateKeySize { |
| + return nil, fmt.Errorf("proof: no usable proving key") |
| + } |
| + p.Schema, p.Sig = proofSchema, "" |
| + if err := p.shape(); err != nil { |
| + return nil, err |
| + } |
| + body, err := p.canonical() |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.Sig = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, body)) |
| + return &p, nil |
| +} |
| + |
| +// ParseProof checks shape only. Whether it verifies is the requiring box's |
| +// question (Check), because only the box holds the prover's public key. |
| +func ParseProof(b []byte) (*Proof, error) { |
| + var p Proof |
| + if err := json.Unmarshal(b, &p); err != nil { |
| + return nil, fmt.Errorf("proof: %v", err) |
| + } |
| + if err := p.shape(); err != nil { |
| + return nil, err |
| + } |
| + if sig, err := base64.StdEncoding.DecodeString(p.Sig); err != nil || len(sig) != ed25519.SignatureSize { |
| + return nil, fmt.Errorf("proof: sig is not a base64 ed25519 signature") |
| + } |
| + return &p, nil |
| +} |
| + |
| +func (p *Proof) Verify(pub ed25519.PublicKey) error { |
| + if len(pub) != ed25519.PublicKeySize { |
| + return fmt.Errorf("proof: no usable prover public key") |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(p.Sig) |
| + if err != nil { |
| + return fmt.Errorf("proof: sig: %v", err) |
| + } |
| + body, err := p.canonical() |
| + if err != nil { |
| + return err |
| + } |
| + if !ed25519.Verify(pub, body, sig) { |
| + return fmt.Errorf("proof signature does not verify against the configured key") |
| + } |
| + return nil |
| +} |
| + |
| +// Check is Verify plus the binding: this proof is for exactly app, platform |
| +// and sha, from prover. |
| +func (p *Proof) Check(pub ed25519.PublicKey, app, platform, sha, prover string) error { |
| + if err := p.Verify(pub); err != nil { |
| + return err |
| + } |
| + switch { |
| + case p.App != app: |
| + return fmt.Errorf("proof is for app %s, not %s", p.App, app) |
| + case p.Platform != platform: |
| + return fmt.Errorf("proof is for platform %s, not %s", p.Platform, platform) |
| + case p.SHA256 != sha: |
| + return fmt.Errorf("proof is for sha256 %s, not %s", p.SHA256, sha) |
| + case p.Prover != prover: |
| + return fmt.Errorf("proof names prover %s, not %s", p.Prover, prover) |
| + } |
| + return nil |
| +} |
| + |
| +// FileSHA256 hashes the bytes at path (following a symlink), the one way D3 |
| +// learns a sha256 (spec §4.2, codex 3). |
| +func FileSHA256(path string) (string, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer f.Close() |
| + h := sha256.New() |
| + if _, err := io.Copy(h, f); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(h.Sum(nil)), nil |
| +} |
| + |
| +func ProofsPrefix(acct, app, sha string) string { return appPrefix(acct, app) + "proofs/" + sha + "/" } |
| +func ProofKey(acct, app, sha, prover string) string { |
| + return ProofsPrefix(acct, app, sha) + prover + ".json" |
| +} |
| +func ProofPendingKey(acct, app, sha, prover string) string { |
| + return ProofsPrefix(acct, app, sha) + prover + ".pending.json" |
| +} |
| + |
| +func GetProof(ctx context.Context, s Store, acct, app, sha, prover string) (*Proof, error) { |
| + if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) { |
| + return nil, fmt.Errorf("proof: invalid key %s/%s/%s", app, sha, prover) |
| + } |
| + raw, _, err := s.Get(ctx, ProofKey(acct, app, sha, prover)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return ParseProof(raw) |
| +} |
| + |
| +func PutProof(ctx context.Context, s Store, acct string, p *Proof) error { |
| + if err := p.shape(); err != nil { |
| + return err |
| + } |
| + b, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return putImmutable(ctx, s, ProofKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n')) |
| +} |
| + |
| +// ProofPending is the proving box's note that a listed build is live and |
| +// baking, so `carlos proof get` can say how long it has to go. Advisory: no |
| +// box reads it. |
| +type ProofPending struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Platform string `json:"platform"` |
| + SHA256 string `json:"sha256"` |
| + Version string `json:"version"` |
| + Prover string `json:"prover"` |
| + LiveSince time.Time `json:"live_since"` |
| + After string `json:"after"` |
| +} |
| + |
| +func PutProofPending(ctx context.Context, s Store, acct string, p ProofPending) error { |
| + if !ValidApp(p.App) || !sha256Re.MatchString(p.SHA256) || !ValidProver(p.Prover) { |
| + return fmt.Errorf("proof pending: invalid record %s/%s/%s", p.App, p.SHA256, p.Prover) |
| + } |
| + p.Schema = proofSchema |
| + b, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, ProofPendingKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n'), "") |
| + if errors.Is(err, ErrCASConflict) { |
| + return nil // the first sighting stands |
| + } |
| + return err |
| +} |
| + |
| +func GetProofPending(ctx context.Context, s Store, acct, app, sha, prover string) (*ProofPending, error) { |
| + if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) { |
| + return nil, fmt.Errorf("proof pending: invalid key %s/%s/%s", app, sha, prover) |
| + } |
| + raw, _, err := s.Get(ctx, ProofPendingKey(acct, app, sha, prover)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var p ProofPending |
| + if err := json.Unmarshal(raw, &p); err != nil { |
| + return nil, fmt.Errorf("proof pending: %v", err) |
| + } |
| + return &p, nil |
| +} |