plecto_host/trust.rs
1//! Provenance verification (ADR 000006): the set of keys a `Host` trusts to sign filters, and
2//! the signed artifact material [`Host::load`] verifies before ever touching component bytes.
3
4use anyhow::Result;
5use sigstore::crypto::{CosignVerificationKey, Signature};
6
7use crate::errors::{LoadError, sbom_binds_component};
8
9/// The set of public keys the operator trusts to sign filters (ADR 000006 provenance). A
10/// filter loads only if a trusted key verifies BOTH its component signature and its SBOM
11/// signature (keyed cosign, offline — no Fulcio / Rekor / network). An **empty** policy
12/// trusts no one, so nothing loads: deny-by-default / fail-closed, with no "allow unsigned"
13/// escape hatch in the production API. The keys live on the `Host`, not on each `load` call,
14/// so the operator manages one trust root.
15///
16/// This gates *whether a filter may load at all*. It deliberately does NOT pick the filter's
17/// `Isolation` (trusted/untrusted lifecycle) — a valid signature from a third party's key is
18/// still untrusted code. Mapping signer identity to isolation is left to the declarative
19/// manifest (ADR 000007); here, isolation stays the caller's explicit `LoadOptions` choice.
20pub struct TrustPolicy {
21 keys: Vec<CosignVerificationKey>,
22}
23
24impl TrustPolicy {
25 /// Trust the given public keys (SPKI PEM). The key type is auto-detected — cosign's
26 /// default is ECDSA P-256; P-256 / Ed25519 / RSA cosign keys are all accepted.
27 pub fn from_pem_keys<I, B>(pems: I) -> Result<Self>
28 where
29 I: IntoIterator<Item = B>,
30 B: AsRef<[u8]>,
31 {
32 let keys = pems
33 .into_iter()
34 .map(|pem| {
35 CosignVerificationKey::try_from_pem(pem.as_ref())
36 .map_err(|e| anyhow::anyhow!("invalid trusted public key (PEM): {e}"))
37 })
38 .collect::<Result<Vec<_>>>()?;
39 Ok(Self { keys })
40 }
41
42 /// An explicitly empty policy — trusts no one, so every load fails closed. Useful to
43 /// assert the fail-closed default.
44 pub fn empty() -> Self {
45 Self { keys: Vec::new() }
46 }
47
48 /// Does ANY trusted key verify this raw (DER) signature over `msg`? cosign ECDSA
49 /// signatures are ASN.1 DER; verification hashes `msg` internally (do not pre-hash).
50 pub(crate) fn verifies(&self, signature_der: &[u8], msg: &[u8]) -> bool {
51 self.keys.iter().any(|k| {
52 k.verify_signature(Signature::Raw(signature_der), msg)
53 .is_ok()
54 })
55 }
56
57 /// The whole ADR 000006 provenance gate over a resolved artifact — SBOM present, both
58 /// signatures trusted, SBOM subject bound to THIS component — with no wasmtime involved.
59 /// [`Host::load`] runs exactly this before ever compiling the bytes; `plecto validate
60 /// --resolve` runs it standalone so CI can prove a manifest + layout pair would pass the
61 /// load gate without starting a server (field report §3.5).
62 ///
63 /// [`Host::load`]: crate::Host::load
64 pub fn verify_artifact(&self, artifact: &SignedArtifact<'_>) -> Result<(), LoadError> {
65 if artifact.sbom.is_empty() {
66 return Err(LoadError::MissingSbom);
67 }
68 if !self.verifies(artifact.component_signature, artifact.component_bytes) {
69 return Err(LoadError::UnverifiedComponentSignature);
70 }
71 if !self.verifies(artifact.sbom_signature, artifact.sbom) {
72 return Err(LoadError::UnverifiedSbomSignature);
73 }
74 sbom_binds_component(artifact.sbom, artifact.component_bytes)
75 }
76}
77
78/// The material the host verifies before instantiating a filter (ADR 000006). The component
79/// bytes plus a keyed cosign signature over them, and a **mandatory** SBOM with its own
80/// signature. Signatures are RAW DER ECDSA bytes: decoding cosign's base64 `.sig` and
81/// fetching the artifact from an OCI registry is the ADR 000007 / `wkg` boundary, kept out
82/// of the host so ADR 000006 (verify) and ADR 000007 (distribute) stay decoupled.
83pub struct SignedArtifact<'a> {
84 /// The WASM component bytes.
85 pub component_bytes: &'a [u8],
86 /// Raw DER signature over `component_bytes` (cosign `sign-blob`).
87 pub component_signature: &'a [u8],
88 /// The SBOM as an in-toto-style statement whose `subject[].digest.sha256` binds it to
89 /// `component_bytes` (verified at load, review f000003 #1). The predicate (the SBOM body)
90 /// stays opaque in v0.1 — content policy (CVE / license scanning) is deferred.
91 pub sbom: &'a [u8],
92 /// Raw DER signature over `sbom`.
93 pub sbom_signature: &'a [u8],
94}