clawft_kernel/workload_pkg/
cognitum.rs1use 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
30pub const COGNITUM_RECORD_KIND: &str = "cognitum.cog.release-record.v1";
32pub const PROVENANCE_SCHEMA: &str = "cognitum.cog.release-provenance.v1";
34const MAX_STATEMENT_BYTES: usize = 64 * 1024;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RecordFacts {
40 pub cog_id: String,
42 pub version: String,
44 pub binary_digest: String,
46 pub key_id: String,
48 pub source_commit: String,
50}
51
52pub 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
73pub 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#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct CheckedRecord {
163 pub facts: RecordFacts,
165 pub signer: AcceptedSigner,
167}
168
169pub 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 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
222pub 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 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}