carlos / platform Public

release: signed dev proofs, their bucket keys and the pending note (console defences D3)

Proof record with a canonical-JSON ed25519 signature, create-only bucket keys under the app prefix, and the advisory pending note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f2427ca6652f189b71d0a313a796066e2fe14442 parent a98c66f
2 files changed, +458 −0
  • internal/release/proof.go +255 −0
  • internal/release/proof_test.go +203 −0
diff --git a/internal/release/proof.go b/internal/release/proof.go
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
+}
diff --git a/internal/release/proof_test.go b/internal/release/proof_test.go
new file mode 100644
index 000000000..407646ca7
--- /dev/null
+++ b/internal/release/proof_test.go
@@ -0,0 +1,203 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func proverKey(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey, string) {
+ t.Helper()
+ seed, pubB64, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, pub, pubB64
+}
+
+var testSHA = strings.Repeat("ab", 32)
+
+func sampleProof() Proof {
+ return Proof{App: "console", Platform: "linux-arm64", SHA256: testSHA, 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)}
+}
+
+func TestProofSignVerifyRoundTrip(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := ParseProof(b)
+ if err != nil {
+ t.Fatalf("ParseProof: %v", err)
+ }
+ if got.Schema != 1 {
+ t.Fatalf("schema = %d", got.Schema)
+ }
+ if err := got.Check(pub, "console", "linux-arm64", testSHA, "dev"); err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ // The signature is over the canonical form, not the file's bytes, so a
+ // proof re-encoded in transit still verifies.
+ compact, err := json.Marshal(got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, err := ParseProof(compact)
+ if err != nil || again.Verify(pub) != nil {
+ t.Fatalf("a compact re-encoding does not verify: %v", err)
+ }
+}
+
+func TestProofEditedFieldDoesNotVerify(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, edit := range map[string]func(*Proof){
+ "sha256": func(p *Proof) { p.SHA256 = strings.Repeat("cd", 32) },
+ "app": func(p *Proof) { p.App = "other" },
+ "platform": func(p *Proof) { p.Platform = "linux-amd64" },
+ "version": func(p *Proof) { p.Version = "v9" },
+ "prover": func(p *Proof) { p.Prover = "staging" },
+ "proven_at": func(p *Proof) { p.ProvenAt = p.ProvenAt.Add(time.Second) },
+ } {
+ q := *p
+ edit(&q)
+ if err := q.Verify(pub); err == nil {
+ t.Fatalf("%s edited and the proof still verifies", name)
+ }
+ }
+ _, other, _ := proverKey(t)
+ if err := p.Verify(other); err == nil {
+ t.Fatal("the proof verifies against another prover's key")
+ }
+}
+
+func TestProofCheckNamesTheMismatch(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct{ app, plat, sha, prover, want string }{
+ {"other", "linux-arm64", testSHA, "dev", "app"},
+ {"console", "linux-amd64", testSHA, "dev", "platform"},
+ {"console", "linux-arm64", strings.Repeat("cd", 32), "dev", "sha256"},
+ {"console", "linux-arm64", testSHA, "staging", "prover"},
+ } {
+ err := p.Check(pub, c.app, c.plat, c.sha, c.prover)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("Check(%s,%s,%s,%s) = %v, want an error naming %s", c.app, c.plat, c.sha, c.prover, err, c.want)
+ }
+ }
+}
+
+func TestParseProofRejectsBadShape(t *testing.T) {
+ priv, _, _ := proverKey(t)
+ good, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, edit := range map[string]func(map[string]any){
+ "schema": func(m map[string]any) { m["schema"] = 2 },
+ "sha256": func(m map[string]any) { m["sha256"] = "../../etc" },
+ "prover": func(m map[string]any) { m["prover"] = "Dev Box" },
+ "app": func(m map[string]any) { m["app"] = "" },
+ "sig": func(m map[string]any) { m["sig"] = "not base64!" },
+ "proven_at": func(m map[string]any) { delete(m, "proven_at") },
+ } {
+ b, _ := json.Marshal(good)
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ edit(m)
+ b, _ = json.Marshal(m)
+ if _, err := ParseProof(b); err == nil {
+ t.Fatalf("%s: a malformed proof parsed", name)
+ }
+ }
+ if _, err := ParseProof([]byte("{")); err == nil {
+ t.Fatal("torn JSON parsed")
+ }
+}
+
+func TestProofStoreRoundTrip(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := ProofKey("bab", "console", testSHA, "dev"), "apps/bab/console/proofs/"+testSHA+"/dev.json"; got != want {
+ t.Fatalf("ProofKey = %s, want %s", got, want)
+ }
+ if _, err := GetProof(ctx, s, "bab", "console", testSHA, "dev"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absent proof = %v, want os.ErrNotExist", err)
+ }
+ if err := PutProof(ctx, s, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ if err := PutProof(ctx, s, "bab", p); err != nil {
+ t.Fatalf("carrying the same proof twice: %v", err)
+ }
+ got, err := GetProof(ctx, s, "bab", "console", testSHA, "dev")
+ if err != nil || got.Verify(pub) != nil {
+ t.Fatalf("GetProof = %+v, %v", got, err)
+ }
+ if _, err := GetProof(ctx, s, "bab", "console", "../x", "dev"); err == nil {
+ t.Fatal("a path in the sha256 reached the store")
+ }
+}
+
+func TestProofPendingIsCreateOnly(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ first := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC)
+ rec := ProofPending{App: "console", Platform: "linux-arm64", SHA256: testSHA, Version: "v2", Prover: "dev", LiveSince: first, After: "15m"}
+ if err := PutProofPending(ctx, s, "bab", rec); err != nil {
+ t.Fatal(err)
+ }
+ rec.LiveSince = first.Add(time.Hour)
+ if err := PutProofPending(ctx, s, "bab", rec); err != nil {
+ t.Fatalf("a second sighting: %v", err)
+ }
+ got, err := GetProofPending(ctx, s, "bab", "console", testSHA, "dev")
+ if err != nil || !got.LiveSince.Equal(first) {
+ t.Fatalf("pending = %+v, %v; want the first sighting kept", got, err)
+ }
+}
+
+func TestFileSHA256(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f")
+ if err := os.WriteFile(p, []byte("bad build"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := FileSHA256(p)
+ if err != nil || got != etagOf([]byte("bad build")) {
+ t.Fatalf("FileSHA256 = %q, %v", got, err)
+ }
+}