| 1 | // The proof policy file — where D3 is switched on (spec |
| 2 | // 2026-10-06-console-release-defences-design.md §4.5, codex 6). |
| 3 | // |
| 4 | // It is not host.env. host.env is root 0600 and reaches the agent only when |
| 5 | // carlos-edge.service starts, while every root pass starts fresh, so an edit |
| 6 | // there would reach one process and not the other. This file is root-owned |
| 7 | // 0644 and holds no secrets: app names, the prover name, public keys and a |
| 8 | // duration. The agent and root both read it at the start of every pass. |
| 9 | // |
| 10 | // Absent: D3 is off. Absent means nothing at all is at the path (Lstat says |
| 11 | // it does not exist). Anything else that cannot be read or does not parse, |
| 12 | // including a dangling symlink, a directory, an unknown key or data after the |
| 13 | // policy object, is an error, and the gate reads an error as "on and waiting |
| 14 | // for every named-unit route": the box cannot tell which apps were listed, |
| 15 | // and a typo or a broken link must not open the gate. |
| 16 | package release |
| 17 | |
| 18 | import ( |
| 19 | "bytes" |
| 20 | "crypto/ed25519" |
| 21 | "encoding/json" |
| 22 | "errors" |
| 23 | "fmt" |
| 24 | "io" |
| 25 | "os" |
| 26 | "path/filepath" |
| 27 | "time" |
| 28 | |
| 29 | "github.com/carlosframework/platform/internal/safefile" |
| 30 | ) |
| 31 | |
| 32 | const ( |
| 33 | ProofPolicyPath = "/etc/carlos/proof-policy.json" |
| 34 | DefaultProveAfter = 15 * time.Minute |
| 35 | ) |
| 36 | |
| 37 | type ProofPolicy struct { |
| 38 | Schema int `json:"schema"` |
| 39 | Prove *ProveBlock `json:"prove,omitempty"` |
| 40 | Require []RequireEntry `json:"require,omitempty"` |
| 41 | } |
| 42 | |
| 43 | // ProveBlock makes this box a prover: it mints proofs for Apps once a build |
| 44 | // has run healthily for After, signed with the root-only key at KeyFile. |
| 45 | type ProveBlock struct { |
| 46 | Apps []string `json:"apps"` |
| 47 | Prover string `json:"prover"` |
| 48 | After string `json:"after"` |
| 49 | KeyFile string `json:"key_file"` |
| 50 | after time.Duration |
| 51 | } |
| 52 | |
| 53 | // RequireEntry makes App on this box adopt only builds Prover has proven. |
| 54 | type RequireEntry struct { |
| 55 | App string `json:"app"` |
| 56 | Prover string `json:"prover"` |
| 57 | Key string `json:"key"` |
| 58 | pub ed25519.PublicKey |
| 59 | } |
| 60 | |
| 61 | func (e RequireEntry) PublicKey() ed25519.PublicKey { return e.pub } |
| 62 | |
| 63 | func LoadProofPolicy(path string) (*ProofPolicy, error) { |
| 64 | // Lstat, not the read's error, decides absence: a symlink whose target |
| 65 | // has gone reads as ENOENT, and reading that as "D3 off" would let an |
| 66 | // unproven build in because of a broken config. |
| 67 | if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { |
| 68 | return nil, nil |
| 69 | } |
| 70 | b, err := safefile.ReadFile(path) |
| 71 | if err != nil { |
| 72 | return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| 73 | } |
| 74 | p, err := ParseProofPolicy(b) |
| 75 | if err != nil { |
| 76 | return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| 77 | } |
| 78 | return p, nil |
| 79 | } |
| 80 | |
| 81 | func ParseProofPolicy(b []byte) (*ProofPolicy, error) { |
| 82 | dec := json.NewDecoder(bytes.NewReader(b)) |
| 83 | dec.DisallowUnknownFields() |
| 84 | var p ProofPolicy |
| 85 | if err := dec.Decode(&p); err != nil { |
| 86 | return nil, fmt.Errorf("does not parse: %v", err) |
| 87 | } |
| 88 | // More() is false before a stray '}' or ']', so it cannot prove the end |
| 89 | // of input; only a second Decode returning io.EOF does. |
| 90 | if err := dec.Decode(&json.RawMessage{}); err != io.EOF { |
| 91 | return nil, fmt.Errorf("does not parse: data after the policy object") |
| 92 | } |
| 93 | if p.Schema != 1 { |
| 94 | return nil, fmt.Errorf("schema %d, want 1", p.Schema) |
| 95 | } |
| 96 | if pr := p.Prove; pr != nil { |
| 97 | if len(pr.Apps) == 0 { |
| 98 | return nil, fmt.Errorf("prove: apps is empty") |
| 99 | } |
| 100 | for _, a := range pr.Apps { |
| 101 | if !ValidApp(a) { |
| 102 | return nil, fmt.Errorf("prove: invalid app %q", a) |
| 103 | } |
| 104 | } |
| 105 | if !ValidProver(pr.Prover) { |
| 106 | return nil, fmt.Errorf("prove: invalid prover %q", pr.Prover) |
| 107 | } |
| 108 | d, err := time.ParseDuration(pr.After) |
| 109 | if err != nil || d <= 0 { |
| 110 | return nil, fmt.Errorf("prove: after %q is not a positive duration", pr.After) |
| 111 | } |
| 112 | pr.after = d |
| 113 | if !filepath.IsAbs(pr.KeyFile) { |
| 114 | return nil, fmt.Errorf("prove: key_file %q is not an absolute path", pr.KeyFile) |
| 115 | } |
| 116 | } |
| 117 | for i := range p.Require { |
| 118 | e := &p.Require[i] |
| 119 | if !ValidApp(e.App) { |
| 120 | return nil, fmt.Errorf("require[%d]: invalid app %q", i, e.App) |
| 121 | } |
| 122 | if !ValidProver(e.Prover) { |
| 123 | return nil, fmt.Errorf("require[%d]: invalid prover %q", i, e.Prover) |
| 124 | } |
| 125 | k, err := ParsePublicKey(e.Key, fmt.Sprintf("require[%d].key", i)) |
| 126 | if err != nil { |
| 127 | return nil, err |
| 128 | } |
| 129 | e.pub = k |
| 130 | } |
| 131 | return &p, nil |
| 132 | } |
| 133 | |
| 134 | func (p *ProofPolicy) Requires(app string) []RequireEntry { |
| 135 | if p == nil { |
| 136 | return nil |
| 137 | } |
| 138 | var out []RequireEntry |
| 139 | for _, e := range p.Require { |
| 140 | if e.App == app { |
| 141 | out = append(out, e) |
| 142 | } |
| 143 | } |
| 144 | return out |
| 145 | } |
| 146 | |
| 147 | func (p *ProofPolicy) Proves(app string) bool { |
| 148 | if p == nil || p.Prove == nil { |
| 149 | return false |
| 150 | } |
| 151 | for _, a := range p.Prove.Apps { |
| 152 | if a == app { |
| 153 | return true |
| 154 | } |
| 155 | } |
| 156 | return false |
| 157 | } |
| 158 | |
| 159 | // ProveAfter is how long a build must run before it is proven, and before a |
| 160 | // root pass records it as healthy (spec §4.5): the prove block's after, else |
| 161 | // DefaultProveAfter. |
| 162 | func (p *ProofPolicy) ProveAfter() time.Duration { |
| 163 | if p == nil || p.Prove == nil { |
| 164 | return DefaultProveAfter |
| 165 | } |
| 166 | return p.Prove.after |
| 167 | } |
| 168 | |