Skip to main content

clawft_kernel/
mesh_artifact_pkg.rs

1//! Signed packages over the artifact exchange (mesh-placement-11).
2//!
3//! Governance for distribution (ADR-099 section 6): a node serves only
4//! content listed by a signed workload manifest (wave-1 `workload_pkg`)
5//! that verified on that node.
6//!
7//! - The origin seeds a package with [`ArtifactExchange::seed_package`]:
8//!   the manifest's signatures are checked first, then every listed file is
9//!   streamed into pieces and must match its pinned size and BLAKE3.
10//! - A fetcher uses [`ArtifactExchange::fetch_package`]: it fetches the
11//!   manifest by content hash, checks its signatures before fetching any
12//!   file, fetches each file by its pinned hash (piece checks plus a
13//!   whole-content check). Once the manifest verifies, this node may serve
14//!   each listed file that it holds verified (pieces assembled to the
15//!   pinned hash here); a peer's descriptor alone never makes content
16//!   servable.
17
18use std::fs::File;
19use std::io::Read;
20use std::path::Path;
21
22use crate::mesh_artifact::{ArtifactExchange, ExchangeError};
23use crate::mesh_artifact_transfer::{FetchError, PeerSet};
24use crate::mesh_artifact_wire::{ArtifactId, ArtifactKey};
25use crate::mesh_swarm_state::GrantOrigin;
26use crate::workload_kind::KindRegistry;
27use crate::workload_pkg::codec::{hex_decode_exact, hex_encode};
28use crate::workload_pkg::manifest::{FileRef, MANIFEST_FILE, MAX_MANIFEST_BYTES};
29use crate::workload_pkg::verify::{
30    VerifiedPackage, VerifyError, read_bounded, verify_manifest_signatures_in,
31};
32use crate::workload_pkg::{TrustAnchors, VerifyPolicy, verify_stored_in};
33
34/// A package this node can serve.
35#[derive(Debug, Clone)]
36pub struct ExchangedPackage {
37    /// BLAKE3 of the signed statement.
38    pub package_id: String,
39    /// BLAKE3 of the manifest bytes (fetch key for other nodes).
40    pub manifest_hash: String,
41    /// `(package path, artifact id)` for every file.
42    pub files: Vec<(String, ArtifactId)>,
43    /// The verified manifest.
44    pub verified: VerifiedPackage,
45}
46
47/// Why a package could not be seeded or fetched.
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum PackageExchangeError {
50    /// Manifest or signature verification failed.
51    #[error(transparent)]
52    Verify(#[from] VerifyError),
53    /// Local exchange failure.
54    #[error(transparent)]
55    Exchange(#[from] ExchangeError),
56    /// Transfer failure.
57    #[error(transparent)]
58    Fetch(#[from] FetchError),
59}
60
61/// Lower-case hex public keys of the manifest's accepted signers, looked up
62/// in `anchors` (a signer revocation names the key, not the key id).
63pub(crate) fn signer_keys(verified: &VerifiedPackage, anchors: &TrustAnchors) -> Vec<String> {
64    verified
65        .signers
66        .iter()
67        .filter_map(|s| {
68            anchors
69                .signers
70                .iter()
71                .chain(anchors.cognitum.iter())
72                .find(|k| k.key_id == s.key_id)
73        })
74        .map(|k| hex_encode(&k.public_key))
75        .collect()
76}
77
78/// Licence standing of a package, from its signed manifest. Fails closed:
79/// `OptIn` only when the signer wrote `redistributable = true`, and never
80/// when the package carries Cognitum provenance, even if the flag is set.
81/// Two signals, either of which marks it Cognitum: an attestation whose kind
82/// starts with `cognitum.` (a release record, or the install-provenance stamp
83/// `weaver workload pack` adds for a `provenance.json` with
84/// `trust = "cognitum-sha256"`), or a release URL containing `cognitum`.
85/// The URL is a substring heuristic and can only add Cognitum standing, never
86/// remove it; it stays as a backstop for packs that carry no attestation.
87/// Cognitum cogs are licence-gated, and nothing here checks a licence or that
88/// a peer belongs to the same operator. An operator re-pack of such a binary
89/// that drops both signals is `NotFlagged`, so it is still not shared
90/// unless its signer explicitly opts in.
91pub(crate) fn grant_origin(verified: &VerifiedPackage) -> GrantOrigin {
92    let cognitum_record = verified
93        .body
94        .attestations
95        .iter()
96        .any(|a| a.kind.to_ascii_lowercase().starts_with("cognitum."));
97    let cognitum_url = verified
98        .body
99        .source
100        .release_url
101        .as_deref()
102        .is_some_and(|u| u.to_ascii_lowercase().contains("cognitum"));
103    if cognitum_record || cognitum_url {
104        GrantOrigin::Cognitum {
105            cog_id: verified.body.id.clone(),
106            version: verified.body.version.clone(),
107        }
108    } else if verified.body.redistributable {
109        GrantOrigin::OptIn
110    } else {
111        GrantOrigin::NotFlagged
112    }
113}
114
115pub(crate) fn pinned(file: &FileRef) -> Result<([u8; 32], u64), VerifyError> {
116    let hash = hex_decode_exact::<32>(&file.blake3)
117        .ok_or_else(|| VerifyError::Manifest(format!("{}: bad blake3", file.path)))?;
118    Ok((hash, file.size))
119}
120
121impl ArtifactExchange {
122    /// Seed a signed package. `open` yields each listed file's bytes; files
123    /// are streamed, so large payloads never sit in memory whole.
124    pub fn seed_package(
125        &self,
126        manifest: &[u8],
127        anchors: &TrustAnchors,
128        open: &mut dyn FnMut(&FileRef) -> std::io::Result<Box<dyn Read>>,
129    ) -> Result<ExchangedPackage, PackageExchangeError> {
130        self.seed_package_in(manifest, anchors, &KindRegistry::builtin(), open)
131    }
132
133    /// [`Self::seed_package`] against a caller-supplied kind registry.
134    pub fn seed_package_in(
135        &self,
136        manifest: &[u8],
137        anchors: &TrustAnchors,
138        kinds: &KindRegistry,
139        open: &mut dyn FnMut(&FileRef) -> std::io::Result<Box<dyn Read>>,
140    ) -> Result<ExchangedPackage, PackageExchangeError> {
141        let verified = verify_manifest_signatures_in(manifest, anchors, kinds)?;
142        self.refuse_if_revoked(&verified, anchors, blake3::hash(manifest).as_bytes())?;
143        let mut files = Vec::new();
144        for file in verified.body.files() {
145            let expect = pinned(file)?;
146            let mut reader = open(file).map_err(|e| VerifyError::Io {
147                path: file.path.clone(),
148                msg: e.to_string(),
149            })?;
150            let d = self
151                .seed_reader(reader.as_mut(), Some(expect))
152                .map_err(|e| match e {
153                    ExchangeError::Mismatch(m) => {
154                        PackageExchangeError::Verify(VerifyError::HashMismatch {
155                            path: file.path.clone(),
156                            expected: file.blake3.clone(),
157                            actual: m,
158                        })
159                    }
160                    other => other.into(),
161                })?;
162            files.push((file.path.clone(), d.id()));
163        }
164        let m = self.seed_bytes(manifest)?;
165        self.materialize(&m.id())?;
166        for (_, id) in &files {
167            self.materialize(id)?;
168        }
169        Ok(self.authorize(verified, m.content_hash, files, anchors))
170    }
171
172    /// [`Self::seed_package`] from an unpacked package directory.
173    pub fn seed_package_dir(
174        &self,
175        dir: &Path,
176        anchors: &TrustAnchors,
177    ) -> Result<ExchangedPackage, PackageExchangeError> {
178        self.seed_package_dir_in(dir, anchors, &KindRegistry::builtin())
179    }
180
181    /// [`Self::seed_package_dir`] against a caller-supplied kind registry.
182    pub fn seed_package_dir_in(
183        &self,
184        dir: &Path,
185        anchors: &TrustAnchors,
186        kinds: &KindRegistry,
187    ) -> Result<ExchangedPackage, PackageExchangeError> {
188        let manifest = read_bounded(
189            &dir.join(MANIFEST_FILE),
190            MANIFEST_FILE,
191            MAX_MANIFEST_BYTES as u64,
192        )?;
193        self.seed_package_dir_manifest(dir, &manifest, anchors, kinds)
194    }
195
196    /// [`Self::seed_package_dir_in`] for a caller that already read the
197    /// manifest bytes from `dir` (so the manifest is read once). `manifest`
198    /// must be the content of `dir`'s `cogpkg.json`; it is verified here.
199    pub fn seed_package_dir_manifest(
200        &self,
201        dir: &Path,
202        manifest: &[u8],
203        anchors: &TrustAnchors,
204        kinds: &KindRegistry,
205    ) -> Result<ExchangedPackage, PackageExchangeError> {
206        let root = dir
207            .canonicalize()
208            .map_err(|e| ExchangeError::Io(e.to_string()))?;
209        self.seed_package_in(manifest, anchors, kinds, &mut |file| {
210            // Paths were validated as relative and traversal-free; also
211            // refuse symlinks that resolve outside the package.
212            let real = root.join(&file.path).canonicalize()?;
213            if !real.starts_with(&root) {
214                return Err(std::io::Error::other("resolves outside the package"));
215            }
216            Ok(Box::new(File::open(real)?) as Box<dyn Read>)
217        })
218    }
219
220    /// Fetch a signed package by its manifest hash. Signatures are checked
221    /// before any file is requested; each file must match its pinned size
222    /// and hash. Once the manifest verifies, this node may serve the listed
223    /// files it holds verified.
224    pub async fn fetch_package(
225        &self,
226        peers: &mut PeerSet,
227        manifest_hash: &str,
228        anchors: &TrustAnchors,
229    ) -> Result<ExchangedPackage, PackageExchangeError> {
230        self.fetch_package_in(peers, manifest_hash, anchors, &KindRegistry::builtin())
231            .await
232    }
233
234    /// [`Self::fetch_package`] against a caller-supplied kind registry. A
235    /// node that places a registered non-cog kind from a peer must use this:
236    /// with the builtin registry the kind is refused, failing closed.
237    pub async fn fetch_package_in(
238        &self,
239        peers: &mut PeerSet,
240        manifest_hash: &str,
241        anchors: &TrustAnchors,
242        kinds: &KindRegistry,
243    ) -> Result<ExchangedPackage, PackageExchangeError> {
244        let mh = hex_decode_exact::<32>(manifest_hash).ok_or_else(|| {
245            VerifyError::Manifest("manifest hash must be 64 lower-case hex".into())
246        })?;
247        let m = self.fetch(peers, ArtifactKey::Content(mh)).await?;
248        if m.descriptor.total_size > MAX_MANIFEST_BYTES as u64 {
249            return Err(VerifyError::Manifest("manifest too large".into()).into());
250        }
251        let manifest = self.read_all(&m.id)?;
252        let verified = verify_manifest_signatures_in(&manifest, anchors, kinds)?;
253        self.refuse_if_revoked(&verified, anchors, &mh)?;
254        // The manifest verified: its pinned content may be served from here
255        // on, once each file is verified on this node.
256        let grant = self.authorize(verified.clone(), mh, Vec::new(), anchors);
257
258        let mut files = Vec::new();
259        let mut all_small = true;
260        for file in verified.body.files() {
261            let (hash, size) = pinned(file)?;
262            let got = self.fetch(peers, ArtifactKey::Content(hash)).await?;
263            if got.descriptor.total_size != size {
264                return Err(VerifyError::HashMismatch {
265                    path: file.path.clone(),
266                    expected: format!("{size} bytes"),
267                    actual: format!("{} bytes", got.descriptor.total_size),
268                }
269                .into());
270            }
271            all_small &= size <= self.config().materialize_limit;
272            files.push((file.path.clone(), got.id));
273        }
274        if all_small {
275            // Everything is held whole: run the full wave-1 verification too.
276            verify_stored_in(
277                self.store(),
278                &hex_encode(&mh),
279                anchors,
280                &VerifyPolicy::default(),
281                kinds,
282            )?;
283        }
284        Ok(ExchangedPackage { files, ..grant })
285    }
286
287    /// Refuse a package whose id, signers or manifest hash is revoked.
288    pub(crate) fn refuse_if_revoked(
289        &self,
290        verified: &VerifiedPackage,
291        anchors: &TrustAnchors,
292        manifest_hash: &[u8; 32],
293    ) -> Result<(), ExchangeError> {
294        let signers = signer_keys(verified, anchors);
295        if self.is_revoked_subject(&verified.package_id, &signers, manifest_hash) {
296            return Err(ExchangeError::Revoked(format!(
297                "package {} (or its signer or manifest) is revoked",
298                verified.package_id
299            )));
300        }
301        Ok(())
302    }
303
304    pub(crate) fn authorize(
305        &self,
306        verified: VerifiedPackage,
307        manifest_hash: [u8; 32],
308        files: Vec<(String, ArtifactId)>,
309        anchors: &TrustAnchors,
310    ) -> ExchangedPackage {
311        let signers = signer_keys(&verified, anchors);
312        let origin = grant_origin(&verified);
313        self.grant_with(manifest_hash, &verified.package_id, signers.clone(), origin.clone());
314        for file in verified.body.files() {
315            if let Ok((hash, _)) = pinned(file) {
316                self.grant_with(hash, &verified.package_id, signers.clone(), origin.clone());
317            }
318        }
319        ExchangedPackage {
320            package_id: verified.package_id.clone(),
321            manifest_hash: hex_encode(&manifest_hash),
322            files,
323            verified,
324        }
325    }
326}