| new file mode 100644 |
| index 000000000..b4382d023 |
| --- /dev/null |
| +++ b/internal/release/proofpolicy.go |
| @@ -0,0 +1,157 @@ |
| +// The proof policy file — where D3 is switched on (spec |
| +// 2026-10-06-console-release-defences-design.md §4.5, codex 6). |
| +// |
| +// It is not host.env. host.env is root 0600 and reaches the agent only when |
| +// carlos-edge.service starts, while every root pass starts fresh, so an edit |
| +// there would reach one process and not the other. This file is root-owned |
| +// 0644 and holds no secrets: app names, the prover name, public keys and a |
| +// duration. The agent and root both read it at the start of every pass. |
| +// |
| +// Absent: D3 is off. Anything else that does not parse, including an unknown |
| +// key, is an error, and the gate reads an error as "on and waiting for every |
| +// named-unit route": the box cannot tell which apps were listed, and a typo |
| +// must not open the gate. |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "time" |
| +) |
| + |
| +const ( |
| + ProofPolicyPath = "/etc/carlos/proof-policy.json" |
| + DefaultProveAfter = 15 * time.Minute |
| +) |
| + |
| +type ProofPolicy struct { |
| + Schema int `json:"schema"` |
| + Prove *ProveBlock `json:"prove,omitempty"` |
| + Require []RequireEntry `json:"require,omitempty"` |
| +} |
| + |
| +// ProveBlock makes this box a prover: it mints proofs for Apps once a build |
| +// has run healthily for After, signed with the root-only key at KeyFile. |
| +type ProveBlock struct { |
| + Apps []string `json:"apps"` |
| + Prover string `json:"prover"` |
| + After string `json:"after"` |
| + KeyFile string `json:"key_file"` |
| + after time.Duration |
| +} |
| + |
| +// RequireEntry makes App on this box adopt only builds Prover has proven. |
| +type RequireEntry struct { |
| + App string `json:"app"` |
| + Prover string `json:"prover"` |
| + Key string `json:"key"` |
| + pub ed25519.PublicKey |
| +} |
| + |
| +func (e RequireEntry) PublicKey() ed25519.PublicKey { return e.pub } |
| + |
| +func LoadProofPolicy(path string) (*ProofPolicy, error) { |
| + b, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| + } |
| + p, err := ParseProofPolicy(b) |
| + if err != nil { |
| + return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| + } |
| + return p, nil |
| +} |
| + |
| +func ParseProofPolicy(b []byte) (*ProofPolicy, error) { |
| + dec := json.NewDecoder(bytes.NewReader(b)) |
| + dec.DisallowUnknownFields() |
| + var p ProofPolicy |
| + if err := dec.Decode(&p); err != nil { |
| + return nil, fmt.Errorf("does not parse: %v", err) |
| + } |
| + if dec.More() { |
| + return nil, fmt.Errorf("does not parse: data after the policy object") |
| + } |
| + if p.Schema != 1 { |
| + return nil, fmt.Errorf("schema %d, want 1", p.Schema) |
| + } |
| + if pr := p.Prove; pr != nil { |
| + if len(pr.Apps) == 0 { |
| + return nil, fmt.Errorf("prove: apps is empty") |
| + } |
| + for _, a := range pr.Apps { |
| + if !ValidApp(a) { |
| + return nil, fmt.Errorf("prove: invalid app %q", a) |
| + } |
| + } |
| + if !ValidProver(pr.Prover) { |
| + return nil, fmt.Errorf("prove: invalid prover %q", pr.Prover) |
| + } |
| + d, err := time.ParseDuration(pr.After) |
| + if err != nil || d <= 0 { |
| + return nil, fmt.Errorf("prove: after %q is not a positive duration", pr.After) |
| + } |
| + pr.after = d |
| + if !filepath.IsAbs(pr.KeyFile) { |
| + return nil, fmt.Errorf("prove: key_file %q is not an absolute path", pr.KeyFile) |
| + } |
| + } |
| + for i := range p.Require { |
| + e := &p.Require[i] |
| + if !ValidApp(e.App) { |
| + return nil, fmt.Errorf("require[%d]: invalid app %q", i, e.App) |
| + } |
| + if !ValidProver(e.Prover) { |
| + return nil, fmt.Errorf("require[%d]: invalid prover %q", i, e.Prover) |
| + } |
| + k, err := ParsePublicKey(e.Key, fmt.Sprintf("require[%d].key", i)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + e.pub = k |
| + } |
| + return &p, nil |
| +} |
| + |
| +func (p *ProofPolicy) Requires(app string) []RequireEntry { |
| + if p == nil { |
| + return nil |
| + } |
| + var out []RequireEntry |
| + for _, e := range p.Require { |
| + if e.App == app { |
| + out = append(out, e) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func (p *ProofPolicy) Proves(app string) bool { |
| + if p == nil || p.Prove == nil { |
| + return false |
| + } |
| + for _, a := range p.Prove.Apps { |
| + if a == app { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// ProveAfter is how long a build must run before it is proven, and before a |
| +// root pass records it as healthy (spec §4.5): the prove block's after, else |
| +// DefaultProveAfter. |
| +func (p *ProofPolicy) ProveAfter() time.Duration { |
| + if p == nil || p.Prove == nil { |
| + return DefaultProveAfter |
| + } |
| + return p.Prove.after |
| +} |