Skip to main content

clawft_kernel/workload_pkg/
verify.rs

1//! Package verification: manifest frame, signatures against pinned anchors,
2//! then every listed file by size and BLAKE3.
3//!
4//! Signatures are checked before any file is read, so an unsigned or
5//! mis-signed package never causes file I/O beyond the manifest itself.
6//! Each failure class has its own [`VerifyError`] variant and stable
7//! [`VerifyError::code`].
8
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use ed25519_dalek::{Signature, VerifyingKey};
13
14use crate::workload_kind::{KindRegistry, validate_envelope};
15
16use super::codec::hex_decode_exact;
17use super::cognitum;
18use super::manifest::{
19    CogPackageBody, FileRef, KIND_COG, MANIFEST_FILE, MAX_MANIFEST_BYTES, ManifestEnvelope,
20    ManifestError,
21};
22use super::trust::{KeyOrigin, TrustAnchors};
23
24/// Largest single package file read during verification.
25pub const MAX_FILE_BYTES: u64 = 512 * 1024 * 1024;
26
27/// Why a package was rejected.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum VerifyError {
30    /// Manifest unreadable or structurally invalid.
31    #[error("manifest invalid: {0}")]
32    Manifest(String),
33    /// No signature entries and no accepted external attestation.
34    #[error("package has no signature")]
35    MissingSignature,
36    /// Signatures exist but none is from a pinned signer.
37    #[error("no signature from a pinned signer (saw: {key_ids})")]
38    UntrustedSigner {
39        /// Key ids seen on the envelope.
40        key_ids: String,
41    },
42    /// A pinned signer's signature does not verify (manifest tampered or
43    /// key id / key mismatch).
44    #[error("signature from {key_id} does not verify")]
45    BadSignature {
46        /// The offending key id.
47        key_id: String,
48    },
49    /// A listed file is absent.
50    #[error("package file missing: {path}")]
51    FileMissing {
52        /// Package-relative path.
53        path: String,
54    },
55    /// A listed file's content does not match its pinned hash or size.
56    #[error("package file tampered: {path} (expected {expected}, got {actual})")]
57    HashMismatch {
58        /// Package-relative path.
59        path: String,
60        /// Pinned value.
61        expected: String,
62        /// Observed value.
63        actual: String,
64    },
65    /// The Cognitum release record was supplied and rejected.
66    #[error("cognitum release record rejected: {0}")]
67    CognitumRecord(String),
68    /// I/O failure other than a missing file.
69    #[error("io error on {path}: {msg}")]
70    Io {
71        /// Path involved.
72        path: String,
73        /// Error text.
74        msg: String,
75    },
76}
77
78impl VerifyError {
79    /// Stable machine-readable code for CLI output and audit payloads.
80    pub fn code(&self) -> &'static str {
81        match self {
82            Self::Manifest(_) => "manifest-invalid",
83            Self::MissingSignature => "missing-signature",
84            Self::UntrustedSigner { .. } => "untrusted-signer",
85            Self::BadSignature { .. } => "bad-signature",
86            Self::FileMissing { .. } => "file-missing",
87            Self::HashMismatch { .. } => "file-tampered",
88            Self::CognitumRecord(_) => "cognitum-record-rejected",
89            Self::Io { .. } => "io-error",
90        }
91    }
92}
93
94impl From<ManifestError> for VerifyError {
95    fn from(e: ManifestError) -> Self {
96        Self::Manifest(e.to_string())
97    }
98}
99
100/// Verification options.
101#[derive(Debug, Clone, Default)]
102pub struct VerifyPolicy {
103    /// Opt in to the Cognitum ADR-154/155 release-record verifier. Off by
104    /// default; when on, a valid record from a pinned Cognitum key that
105    /// binds one of the package binaries counts as a signature.
106    pub accept_cognitum_release: bool,
107    /// BLAKE3 hashes of `cog.toml` files an operator has reviewed and pins
108    /// for **record-only** trust. A Cognitum release record binds the cog id,
109    /// version, source commit and binary digest but not the raw `cog.toml`,
110    /// so a package trusted through a record alone is accepted only when its
111    /// `cog.toml` hash is in this list. Operator-signed packages need no pin
112    /// (the envelope signature covers `cog.toml`).
113    pub record_cog_toml_pins: Vec<String>,
114}
115
116/// A signer whose signature was accepted.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct AcceptedSigner {
119    /// Key id.
120    pub key_id: String,
121    /// Anchor set it came from.
122    pub origin: KeyOrigin,
123}
124
125/// A package that passed verification.
126#[derive(Debug, Clone)]
127pub struct VerifiedPackage {
128    /// BLAKE3 of the signed statement.
129    pub package_id: String,
130    /// The envelope as read.
131    pub envelope: ManifestEnvelope,
132    /// Typed cog body.
133    pub body: CogPackageBody,
134    /// Accepted signers (at least one).
135    pub signers: Vec<AcceptedSigner>,
136}
137
138/// Where package files are read from.
139pub trait FileSource {
140    /// Read `file` (the source may use its path or its hash).
141    fn read(&self, file: &FileRef) -> Result<Vec<u8>, VerifyError>;
142}
143
144/// Reads files from an unpacked package directory.
145pub struct DirSource {
146    root: PathBuf,
147}
148
149impl DirSource {
150    /// Source rooted at `root`.
151    pub fn new(root: impl Into<PathBuf>) -> Self {
152        Self { root: root.into() }
153    }
154}
155
156impl FileSource for DirSource {
157    fn read(&self, file: &FileRef) -> Result<Vec<u8>, VerifyError> {
158        // Paths were validated as relative and traversal-free; also refuse a
159        // symlinked directory that would resolve outside the package.
160        let path = self.root.join(&file.path);
161        if let (Ok(root), Ok(real)) = (self.root.canonicalize(), path.canonicalize())
162            && !real.starts_with(&root)
163        {
164            return Err(VerifyError::Io {
165                path: file.path.clone(),
166                msg: "resolves outside the package".into(),
167            });
168        }
169        read_bounded(&path, &file.path, MAX_FILE_BYTES)
170    }
171}
172
173pub(crate) fn read_bounded(path: &Path, label: &str, max: u64) -> Result<Vec<u8>, VerifyError> {
174    let meta = std::fs::symlink_metadata(path).map_err(|e| io_or_missing(e, label))?;
175    if !meta.is_file() {
176        return Err(VerifyError::Io {
177            path: label.into(),
178            msg: "not a regular file".into(),
179        });
180    }
181    if meta.len() > max {
182        return Err(VerifyError::Io {
183            path: label.into(),
184            msg: format!("larger than {max} bytes"),
185        });
186    }
187    // Bounded read: the file may grow between the metadata check and here.
188    let mut out = Vec::new();
189    std::fs::File::open(path)
190        .map_err(|e| io_or_missing(e, label))?
191        .take(max + 1)
192        .read_to_end(&mut out)
193        .map_err(|e| io_or_missing(e, label))?;
194    if out.len() as u64 > max {
195        return Err(VerifyError::Io {
196            path: label.into(),
197            msg: format!("larger than {max} bytes"),
198        });
199    }
200    Ok(out)
201}
202
203fn io_or_missing(e: std::io::Error, label: &str) -> VerifyError {
204    if e.kind() == std::io::ErrorKind::NotFound {
205        VerifyError::FileMissing { path: label.into() }
206    } else {
207        VerifyError::Io {
208            path: label.into(),
209            msg: e.to_string(),
210        }
211    }
212}
213
214/// Verify the package in directory `dir` (manifest at `dir/cogpkg.json`).
215pub fn verify_dir(
216    dir: &Path,
217    anchors: &TrustAnchors,
218    policy: &VerifyPolicy,
219) -> Result<VerifiedPackage, VerifyError> {
220    verify_dir_in(dir, anchors, policy, &KindRegistry::builtin())
221}
222
223/// [`verify_dir`] against a caller-supplied kind registry.
224pub fn verify_dir_in(
225    dir: &Path,
226    anchors: &TrustAnchors,
227    policy: &VerifyPolicy,
228    kinds: &KindRegistry,
229) -> Result<VerifiedPackage, VerifyError> {
230    let bytes = read_bounded(
231        &dir.join(MANIFEST_FILE),
232        MANIFEST_FILE,
233        MAX_MANIFEST_BYTES as u64,
234    )?;
235    verify_with_source_in(&bytes, &DirSource::new(dir), anchors, policy, kinds)
236}
237
238/// Verify manifest bytes, reading listed files through `source`.
239pub fn verify_with_source(
240    manifest: &[u8],
241    source: &dyn FileSource,
242    anchors: &TrustAnchors,
243    policy: &VerifyPolicy,
244) -> Result<VerifiedPackage, VerifyError> {
245    verify_with_source_in(manifest, source, anchors, policy, &KindRegistry::builtin())
246}
247
248/// [`verify_with_source`] against a caller-supplied kind registry.
249pub fn verify_with_source_in(
250    manifest: &[u8],
251    source: &dyn FileSource,
252    anchors: &TrustAnchors,
253    policy: &VerifyPolicy,
254    kinds: &KindRegistry,
255) -> Result<VerifiedPackage, VerifyError> {
256    let (envelope, body, mut signers) = parse_and_check(manifest, anchors, kinds)?;
257    // The Cognitum release-record path trusts a registry attestation of a
258    // cog binary, so it is defined for the cog kind only. Refuse rather than
259    // ignore it: a caller that turned it on for another kind wants a check
260    // that does not exist there.
261    if policy.accept_cognitum_release && envelope.kind != KIND_COG {
262        return Err(VerifyError::Manifest(
263            "the Cognitum release verifier applies only to the cog kind".into(),
264        ));
265    }
266
267    // An attached Cognitum record is checked whenever the verifier is on, also
268    // on packages an operator already signed: a forged or mismatched record
269    // must not ride along unexamined next to a valid signature.
270    let record = if policy.accept_cognitum_release {
271        cognitum::check_attached(&body, source, anchors)?
272    } else {
273        None
274    };
275    if signers.is_empty()
276        && let Some(rec) = record
277    {
278        // Record-only trust: the record has to bind what it cannot sign.
279        cognitum::require_record_only_binding(&body, &rec.facts, policy)?;
280        signers.push(rec.signer);
281    }
282    if signers.is_empty() {
283        return Err(no_signer_error(&envelope));
284    }
285
286    for file in body.files() {
287        check_file(file, &source.read(file)?)?;
288    }
289    Ok(VerifiedPackage {
290        package_id: envelope.package_id()?,
291        envelope,
292        body,
293        signers,
294    })
295}
296
297/// Verify the manifest envelope and its signatures without reading any
298/// listed file (mesh-placement-11).
299///
300/// Used where file content is proven some other way: the artifact
301/// exchange checks every file's size and BLAKE3 while streaming it, so a
302/// multi-GB payload never has to sit in memory. Only pinned Ed25519
303/// signers count here; the Cognitum release-record path needs a binary
304/// and is only available through [`verify_with_source`].
305pub fn verify_manifest_signatures(
306    manifest: &[u8],
307    anchors: &TrustAnchors,
308) -> Result<VerifiedPackage, VerifyError> {
309    verify_manifest_signatures_in(manifest, anchors, &KindRegistry::builtin())
310}
311
312/// [`verify_manifest_signatures`] against a caller-supplied kind registry.
313pub fn verify_manifest_signatures_in(
314    manifest: &[u8],
315    anchors: &TrustAnchors,
316    kinds: &KindRegistry,
317) -> Result<VerifiedPackage, VerifyError> {
318    let (envelope, body, signers) = parse_and_check(manifest, anchors, kinds)?;
319    if signers.is_empty() {
320        return Err(no_signer_error(&envelope));
321    }
322    Ok(VerifiedPackage {
323        package_id: envelope.package_id()?,
324        envelope,
325        body,
326        signers,
327    })
328}
329
330type Checked = (ManifestEnvelope, CogPackageBody, Vec<AcceptedSigner>);
331
332fn parse_and_check(
333    manifest: &[u8],
334    anchors: &TrustAnchors,
335    kinds: &KindRegistry,
336) -> Result<Checked, VerifyError> {
337    let envelope = ManifestEnvelope::from_bytes(manifest)?;
338    // The kind must be registered and accept the body. The verified package
339    // is still cog-shaped until a kind brings its own verified body, so a
340    // registered kind must also parse as a cog body.
341    validate_envelope(kinds, &envelope)?;
342    let body = envelope.cog_shaped_body()?;
343    let signers = check_signatures(&envelope, anchors)?;
344    Ok((envelope, body, signers))
345}
346
347fn no_signer_error(envelope: &ManifestEnvelope) -> VerifyError {
348    if envelope.signatures.is_empty() {
349        VerifyError::MissingSignature
350    } else {
351        let key_ids: Vec<&str> = envelope
352            .signatures
353            .iter()
354            .map(|s| s.key_id.as_str())
355            .collect();
356        VerifyError::UntrustedSigner {
357            key_ids: key_ids.join(","),
358        }
359    }
360}
361
362/// Check one file's size and hash.
363pub fn check_file(file: &FileRef, content: &[u8]) -> Result<(), VerifyError> {
364    let actual = blake3::hash(content).to_hex().to_string();
365    if actual != file.blake3 {
366        return Err(VerifyError::HashMismatch {
367            path: file.path.clone(),
368            expected: file.blake3.clone(),
369            actual,
370        });
371    }
372    if content.len() as u64 != file.size {
373        return Err(VerifyError::HashMismatch {
374            path: file.path.clone(),
375            expected: format!("{} bytes", file.size),
376            actual: format!("{} bytes", content.len()),
377        });
378    }
379    Ok(())
380}
381
382/// Returns accepted pinned signers. A well-formed signature from a pinned
383/// key that fails to verify is fatal ([`VerifyError::BadSignature`]);
384/// entries from unpinned keys are ignored here and only reported if nothing
385/// else is accepted. An entry whose public key or signature is not valid
386/// hex of the right length is skipped like an unpinned one: it can neither
387/// authenticate the package nor veto a valid signature next to it, so one
388/// garbage entry cannot fail the whole verify.
389pub(crate) fn check_signatures(
390    env: &ManifestEnvelope,
391    anchors: &TrustAnchors,
392) -> Result<Vec<AcceptedSigner>, VerifyError> {
393    let msg = env.signed_statement()?;
394    let mut accepted = Vec::new();
395    for entry in &env.signatures {
396        if entry.algorithm != "ed25519" {
397            return Err(VerifyError::Manifest(format!(
398                "unsupported algorithm {:?}",
399                entry.algorithm
400            )));
401        }
402        let Some(pk) = hex_decode_exact::<32>(&entry.public_key) else {
403            continue;
404        };
405        let Some(pinned) = anchors.signer(&pk) else {
406            continue;
407        };
408        let bad = || VerifyError::BadSignature {
409            key_id: entry.key_id.clone(),
410        };
411        if pinned.key_id != entry.key_id {
412            return Err(bad());
413        }
414        let Some(sig) = hex_decode_exact::<64>(&entry.signature) else {
415            continue;
416        };
417        let vk = VerifyingKey::from_bytes(&pk).map_err(|_| bad())?;
418        vk.verify_strict(&msg, &Signature::from_bytes(&sig))
419            .map_err(|_| bad())?;
420        if !accepted
421            .iter()
422            .any(|a: &AcceptedSigner| a.key_id == pinned.key_id)
423        {
424            accepted.push(AcceptedSigner {
425                key_id: pinned.key_id.clone(),
426                origin: pinned.origin,
427            });
428        }
429    }
430    Ok(accepted)
431}