Skip to main content

clawft_kernel/workload_pkg/
cognitum.rs

1//! Optional Cognitum ADR-154/155 release-record verifier (ADR-100 section
2//! 6.2). Off unless [`super::VerifyPolicy::accept_cognitum_release`] is set.
3//!
4//! The record's detached signature is Ed25519 (PureEdDSA) over the canonical
5//! statement `{"release":<record minus seededAt and
6//! provenance.detachedSignature>,"schema":"cognitum.cog.release-provenance.v1"}`
7//! with `payloadDigest = sha256:<hex of that statement>`, matching upstream
8//! `scripts/cog_release_provenance_lib.py::canonical_payload`.
9//!
10//! Scope: the operator pins the Cognitum release key directly by key id.
11//! The upstream trust-registry v3 quorum, key status windows and builder /
12//! workflow allow-lists are not re-evaluated here. A record binds the cog
13//! id, version, source commit and one binary digest; it does not cover
14//! `cog.toml`. A package trusted through a record alone is therefore
15//! accepted only when (a) the record's `sourceCommit` matches the package's
16//! recorded source commit and (b) the package's `cog.toml` hash is one the
17//! operator pinned ([`super::VerifyPolicy::record_cog_toml_pins`]). Packages
18//! an operator signed are not subject to (a) and (b), but an attached record
19//! is still verified.
20
21use serde_json::Value;
22use sha2::{Digest, Sha256};
23
24use super::canonical::canonical_json;
25use super::codec::{base64_decode, hex_encode, is_lower_hex};
26use super::manifest::CogPackageBody;
27use super::trust::{PinnedKey, TrustAnchors};
28use super::verify::{AcceptedSigner, FileSource, VerifyError, check_file};
29
30/// Attestation kind carried in a cog package for a Cognitum record.
31pub const COGNITUM_RECORD_KIND: &str = "cognitum.cog.release-record.v1";
32/// Upstream statement / envelope schema.
33pub const PROVENANCE_SCHEMA: &str = "cognitum.cog.release-provenance.v1";
34/// Upstream canonical statement size limit.
35const MAX_STATEMENT_BYTES: usize = 64 * 1024;
36
37/// Facts a verified record binds.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RecordFacts {
40    /// `cogId`.
41    pub cog_id: String,
42    /// `version`.
43    pub version: String,
44    /// `artifactRef.binaryDigest` (`sha256:<hex>`).
45    pub binary_digest: String,
46    /// Signing key id.
47    pub key_id: String,
48    /// `sourceCommit` (empty when the record does not carry one).
49    pub source_commit: String,
50}
51
52/// Canonical upstream statement bytes for `record`.
53pub fn canonical_statement(record: &Value) -> Result<Vec<u8>, String> {
54    let mut unsigned = record
55        .as_object()
56        .ok_or("record must be an object")?
57        .clone();
58    unsigned.remove("seededAt");
59    let prov = unsigned
60        .get_mut("provenance")
61        .and_then(Value::as_object_mut)
62        .ok_or("record.provenance must be an object")?;
63    prov.remove("detachedSignature");
64    let statement =
65        serde_json::json!({ "schema": PROVENANCE_SCHEMA, "release": Value::Object(unsigned) });
66    let bytes = canonical_json(&statement).map_err(|e| e.to_string())?;
67    if bytes.len() > MAX_STATEMENT_BYTES {
68        return Err("canonical release statement is too large".into());
69    }
70    Ok(bytes)
71}
72
73/// Verify a release record against pinned Cognitum keys.
74pub fn verify_release_record(record: &Value, keys: &[PinnedKey]) -> Result<RecordFacts, String> {
75    require_printable_ascii(record, 0)?;
76    let env = record
77        .pointer("/provenance/detachedSignature")
78        .and_then(Value::as_object)
79        .ok_or("provenance.detachedSignature missing")?;
80    let mut fields: Vec<&str> = env.keys().map(String::as_str).collect();
81    fields.sort_unstable();
82    if fields != ["algorithm", "keyId", "payloadDigest", "schema", "signature"] {
83        return Err("detachedSignature has unexpected fields".into());
84    }
85    let field = |k: &str| {
86        env.get(k)
87            .and_then(Value::as_str)
88            .ok_or(format!("detachedSignature.{k} must be a string"))
89    };
90    if field("schema")? != PROVENANCE_SCHEMA || field("algorithm")? != "ed25519" {
91        return Err("detachedSignature schema/algorithm mismatch".into());
92    }
93    let key_id = field("keyId")?;
94    let key = keys
95        .iter()
96        .find(|k| k.key_id == key_id)
97        .ok_or_else(|| format!("release key {key_id} is not pinned"))?;
98    let sig_text = field("signature")?;
99    if sig_text.len() != 86 {
100        return Err("signature must be 86 base64url chars".into());
101    }
102    let sig: [u8; 64] = base64_decode(sig_text)
103        .and_then(|v| v.try_into().ok())
104        .ok_or("signature is not 64-byte base64url")?;
105
106    let statement = canonical_statement(record)?;
107    let digest = format!("sha256:{}", hex_encode(&Sha256::digest(&statement)));
108    if field("payloadDigest")? != digest {
109        return Err("payloadDigest does not match the canonical statement".into());
110    }
111    let vk = ed25519_dalek::VerifyingKey::from_bytes(&key.public_key).map_err(|e| e.to_string())?;
112    vk.verify_strict(&statement, &ed25519_dalek::Signature::from_bytes(&sig))
113        .map_err(|_| format!("signature from {key_id} does not verify"))?;
114
115    let text = |p: &str| {
116        record
117            .pointer(p)
118            .and_then(Value::as_str)
119            .map(str::to_string)
120            .ok_or(format!("record{p} missing"))
121    };
122    let binary_digest = text("/artifactRef/binaryDigest")?;
123    if !(binary_digest.starts_with("sha256:") && is_lower_hex(&binary_digest[7..], 64)) {
124        return Err("artifactRef.binaryDigest must be sha256:<64 hex>".into());
125    }
126    Ok(RecordFacts {
127        cog_id: text("/cogId")?,
128        version: text("/version")?,
129        binary_digest,
130        key_id: key_id.to_string(),
131        source_commit: record
132            .pointer("/sourceCommit")
133            .and_then(Value::as_str)
134            .unwrap_or("")
135            .to_string(),
136    })
137}
138
139fn require_printable_ascii(v: &Value, depth: usize) -> Result<(), String> {
140    if depth > 16 {
141        return Err("record nesting too deep".into());
142    }
143    let ok = |s: &str| s.bytes().all(|b| (0x20..=0x7e).contains(&b));
144    match v {
145        Value::String(s) if !ok(s) => Err("record strings must be printable ASCII".into()),
146        Value::Array(a) => a
147            .iter()
148            .try_for_each(|x| require_printable_ascii(x, depth + 1)),
149        Value::Object(m) => m.iter().try_for_each(|(k, x)| {
150            if ok(k) {
151                require_printable_ascii(x, depth + 1)
152            } else {
153                Err("record keys must be printable ASCII".into())
154            }
155        }),
156        _ => Ok(()),
157    }
158}
159
160/// A verified attached record: what it binds and who vouches for it.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct CheckedRecord {
163    /// Facts the record binds.
164    pub facts: RecordFacts,
165    /// The signer to report if the record alone carries trust.
166    pub signer: AcceptedSigner,
167}
168
169/// If the package carries a Cognitum record, verify it and its binding to
170/// the package (cog id, version, and every binary's SHA-256 equal to
171/// `binaryDigest`). `Ok(None)` when no record is attached.
172pub fn check_attached(
173    body: &CogPackageBody,
174    source: &dyn FileSource,
175    anchors: &TrustAnchors,
176) -> Result<Option<CheckedRecord>, VerifyError> {
177    let Some(att) = body
178        .attestations
179        .iter()
180        .find(|a| a.kind == COGNITUM_RECORD_KIND)
181    else {
182        return Ok(None);
183    };
184    let reject = VerifyError::CognitumRecord;
185    if anchors.cognitum.is_empty() {
186        return Err(reject("no Cognitum release key is pinned".into()));
187    }
188    if att.file.size > 256 * 1024 {
189        return Err(reject("release record larger than 256 KiB".into()));
190    }
191    let bytes = source.read(&att.file)?;
192    check_file(&att.file, &bytes)?;
193    let record: Value =
194        serde_json::from_slice(&bytes).map_err(|e| reject(format!("parse: {e}")))?;
195    let facts = verify_release_record(&record, &anchors.cognitum).map_err(reject)?;
196    if facts.cog_id != body.id || facts.version != body.version {
197        return Err(reject(format!(
198            "record is for {}@{}, package is {}@{}",
199            facts.cog_id, facts.version, body.id, body.version
200        )));
201    }
202    // The record binds exactly one binary and nothing else in the package,
203    // so record-only trust requires every binary to be that binary: an
204    // extra, unbound arch would otherwise ride along unsigned.
205    for bin in body.binaries.values() {
206        let content = source.read(bin)?;
207        check_file(bin, &content)?;
208        if format!("sha256:{}", hex_encode(&Sha256::digest(&content))) != facts.binary_digest {
209            return Err(reject(format!(
210                "record binaryDigest does not cover {}; record-only trust needs every binary bound",
211                bin.path
212            )));
213        }
214    }
215    let signer = AcceptedSigner {
216        key_id: facts.key_id.clone(),
217        origin: super::trust::KeyOrigin::CognitumRelease,
218    };
219    Ok(Some(CheckedRecord { facts, signer }))
220}
221
222/// Extra conditions for trusting a package through a Cognitum record
223/// alone: the record's `sourceCommit` must match the package's source
224/// commit, and the package's `cog.toml` hash must be operator-pinned.
225pub fn require_record_only_binding(
226    body: &CogPackageBody,
227    facts: &RecordFacts,
228    policy: &super::VerifyPolicy,
229) -> Result<(), VerifyError> {
230    let reject = |m: String| VerifyError::CognitumRecord(m);
231    let Some(commit) = body.source.commit.as_deref() else {
232        return Err(reject(
233            "record-only trust needs the package source commit to match the record's sourceCommit"
234                .into(),
235        ));
236    };
237    let is_hex = |c: &str| c.len() >= 7 && c.bytes().all(|b| b.is_ascii_hexdigit());
238    let (pkg, rec) = (commit.to_ascii_lowercase(), facts.source_commit.to_ascii_lowercase());
239    // Both sides need at least 7 hex characters, so an empty or tiny prefix cannot match anything.
240    let ok_commit = is_hex(&pkg) && is_hex(&rec) && (rec.starts_with(&pkg) || pkg.starts_with(&rec));
241    if !ok_commit {
242        return Err(reject(format!(
243            "record sourceCommit {:?} does not match package source commit {commit}",
244            facts.source_commit
245        )));
246    }
247    let want = body.cog_toml.blake3.to_ascii_lowercase();
248    if !policy
249        .record_cog_toml_pins
250        .iter()
251        .any(|p| p.trim().to_ascii_lowercase() == want)
252    {
253        return Err(reject(format!(
254            "cog.toml {} is not bound: a record does not cover cog.toml, so record-only trust needs an operator-pinned cog.toml hash (or an operator signature)",
255            body.cog_toml.blake3
256        )));
257    }
258    Ok(())
259}