Skip to main content

varve_core/
attest.rs

1//! Attestations carried with a layer (REQ-ATTEST-001).
2//!
3//! A layer's bytes are authentic and current — varve's signature and counter
4//! say so. They say nothing about whether the code was *reviewed*, how it was
5//! *built*, or what it *contains*. Other people produce those claims:
6//! cargo-vet and cargo-crev audits, SLSA in-toto build provenance, CycloneDX
7//! SBOMs, VEX documents, and vendor attestations (AdaCore ships DSSE-signed
8//! in-toto provenance of its own).
9//!
10//! varve does NOT re-attest any of that. Re-signing someone else's claim under
11//! our root would launder their judgement into ours, and an assessor reading
12//! the chain could no longer tell who asserted what. What varve does instead is
13//! narrow and honest: it **transports the bytes verbatim** and **binds them to
14//! a layer**, by signing a short statement that says *this digest, of this kind,
15//! accompanies this layer*. The consumer then verifies two separate things —
16//! that varve vouches for the association (offline, against the pinned root),
17//! and, using the producer's own key, whatever the producer actually claimed.
18//!
19//! That split is the point. It is what lets a disconnected site check an
20//! AdaCore or Sigstore attestation whose issuer it cannot reach.
21
22use serde::{Deserialize, Serialize};
23
24/// What an attached attestation is. Unknown kinds are refused rather than
25/// guessed — the same fail-closed rule payload kinds follow.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum AttestationKind {
29    /// A software bill of materials (CycloneDX or SPDX).
30    Sbom,
31    /// Build provenance — SLSA / in-toto.
32    Provenance,
33    /// A human review record — cargo-vet, cargo-crev.
34    Audit,
35    /// Vulnerability exploitability exchange.
36    Vex,
37    /// A qualification or certification artifact (kit, report, certificate).
38    Qualification,
39}
40
41impl std::fmt::Display for AttestationKind {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let s = match self {
44            Self::Sbom => "sbom",
45            Self::Provenance => "provenance",
46            Self::Audit => "audit",
47            Self::Vex => "vex",
48            Self::Qualification => "qualification",
49        };
50        f.write_str(s)
51    }
52}
53
54impl std::str::FromStr for AttestationKind {
55    type Err = AttestError;
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        match s {
58            "sbom" => Ok(Self::Sbom),
59            "provenance" => Ok(Self::Provenance),
60            "audit" => Ok(Self::Audit),
61            "vex" => Ok(Self::Vex),
62            "qualification" => Ok(Self::Qualification),
63            other => Err(AttestError::UnknownKind(other.to_string())),
64        }
65    }
66}
67
68/// varve's statement ABOUT an attestation — never a restatement OF it.
69///
70/// Signed under the trust root, so a consumer can check offline that these
71/// bytes belong with this layer. `producer` records who made the underlying
72/// claim, as a string varve copies rather than interprets: varve is asserting
73/// association and integrity, not endorsing the producer's conclusion.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct AttestationStatement {
76    /// The layer these bytes accompany, e.g. `2026.08.0`.
77    pub layer: String,
78    /// `sha256:<hex>` of the DSSE-signed layer manifest — the join key.
79    pub layer_manifest_digest: String,
80    /// What the attached document is.
81    pub kind: AttestationKind,
82    /// `sha256:<hex>` of the attached bytes, exactly as carried.
83    pub digest: String,
84    /// Who produced the underlying claim, verbatim and uninterpreted
85    /// (e.g. `adacore`, `cargo-vet`, `varve`).
86    pub producer: String,
87}
88
89#[derive(Debug, thiserror::Error)]
90pub enum AttestError {
91    #[error(
92        "unknown attestation kind '{0}' (expected: sbom, provenance, audit, vex, qualification)"
93    )]
94    UnknownKind(String),
95    #[error("attestation statement is not valid JSON: {0}")]
96    Payload(String),
97    #[error(
98        "attestation bytes do not match the signed statement: statement names {expected}, \
99         the carried bytes hash to {got} — refusing"
100    )]
101    DigestMismatch { expected: String, got: String },
102    #[error(
103        "this attestation belongs to layer manifest {expected}, but it was presented for \
104         {got} — refusing to associate it"
105    )]
106    LayerMismatch { expected: String, got: String },
107    #[error(
108        "attestation statement names layer '{named}' but the digest it carries is that of \
109         '{found}' — refusing a statement whose own two identities disagree"
110    )]
111    NameDigestMismatch { named: String, found: String },
112    #[error("signature: {0}")]
113    Signature(String),
114}
115
116/// Build the statement varve will sign for a set of attestation bytes.
117pub fn statement(
118    layer: &str,
119    layer_manifest_digest: &str,
120    kind: AttestationKind,
121    bytes: &[u8],
122    producer: &str,
123) -> AttestationStatement {
124    AttestationStatement {
125        layer: layer.to_string(),
126        layer_manifest_digest: layer_manifest_digest.to_string(),
127        kind,
128        digest: crate::store::manifest_digest(bytes),
129        producer: producer.to_string(),
130    }
131}
132
133/// Check a statement against the bytes it describes and the layer it is
134/// presented for. Both must hold: bytes that do not hash to the signed digest
135/// are not the attested bytes, and a statement for another layer must never be
136/// silently accepted here (the confused-deputy case).
137pub fn check(
138    st: &AttestationStatement,
139    bytes: &[u8],
140    layer_manifest_digest: &str,
141    layer_name: &str,
142) -> Result<(), AttestError> {
143    let got = crate::store::manifest_digest(bytes);
144    if got != st.digest {
145        return Err(AttestError::DigestMismatch {
146            expected: st.digest.clone(),
147            got,
148        });
149    }
150    if st.layer_manifest_digest != layer_manifest_digest {
151        return Err(AttestError::LayerMismatch {
152            expected: st.layer_manifest_digest.clone(),
153            got: layer_manifest_digest.to_string(),
154        });
155    }
156    // The statement carries TWO identities for the same layer — a name and a
157    // digest — and callers print the name. `resolve` already refuses a pin
158    // whose name and digest disagree (NameDigestMismatch); a statement must be
159    // held to the same rule, or a mis-issued one makes varve print a confident,
160    // signed-looking, wrong layer identity.
161    if st.layer != layer_name {
162        return Err(AttestError::NameDigestMismatch {
163            named: st.layer.clone(),
164            found: layer_name.to_string(),
165        });
166    }
167    Ok(())
168}
169
170/// Sign a statement into a DSSE envelope under the root key.
171pub fn sign(
172    st: &AttestationStatement,
173    secret_key: &[u8],
174    key_id: &str,
175) -> Result<String, AttestError> {
176    let payload = serde_json::to_vec(st).map_err(|e| AttestError::Payload(e.to_string()))?;
177    crate::verify::dsse_sign_typed(&payload, PAYLOAD_TYPE, secret_key, key_id)
178        .map_err(|e| AttestError::Signature(e.to_string()))
179}
180
181/// Verify an envelope against the trust root and return the statement inside.
182/// The payload TYPE is checked, so a signed line-status or layer manifest can
183/// never be accepted here as an attestation statement.
184pub fn verify_statement(
185    envelope: &[u8],
186    root_pk: &[u8],
187) -> Result<AttestationStatement, AttestError> {
188    let payload = crate::verify::dsse_verify_typed(envelope, PAYLOAD_TYPE, root_pk)
189        .map_err(|e| AttestError::Signature(e.to_string()))?;
190    serde_json::from_slice(&payload).map_err(|e| AttestError::Payload(e.to_string()))
191}
192
193/// The DSSE payload type for an attestation statement — distinct from every
194/// other document varve signs, so the types cannot be confused.
195pub const PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.attestation-statement.v1+json";
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    const BYTES: &[u8] = b"{\"bomFormat\":\"CycloneDX\"}";
202    const LAYER_DIGEST: &str = "sha256:1111";
203
204    fn st() -> AttestationStatement {
205        statement(
206            "2026.08.0",
207            LAYER_DIGEST,
208            AttestationKind::Sbom,
209            BYTES,
210            "varve",
211        )
212    }
213
214    // rivet: verifies REQ-ATTEST-001
215    #[test]
216    fn a_statement_names_the_bytes_and_the_layer() {
217        let s = st();
218        assert_eq!(s.digest, crate::store::manifest_digest(BYTES));
219        assert_eq!(s.layer_manifest_digest, LAYER_DIGEST);
220        assert_eq!(s.kind, AttestationKind::Sbom);
221        assert!(check(&s, BYTES, LAYER_DIGEST, "2026.08.0").is_ok());
222    }
223
224    // rivet: verifies REQ-ATTEST-001
225    #[test]
226    fn swapped_bytes_are_refused() {
227        // The whole value of the statement is that it pins the bytes.
228        let s = st();
229        match check(
230            &s,
231            b"different attestation entirely",
232            LAYER_DIGEST,
233            "2026.08.0",
234        ) {
235            Err(AttestError::DigestMismatch { .. }) => {}
236            other => panic!("expected DigestMismatch, got {other:?}"),
237        }
238    }
239
240    // rivet: verifies REQ-ATTEST-001
241    #[test]
242    fn an_attestation_for_another_layer_is_refused() {
243        // A validly-signed statement for layer A must not be accepted as
244        // evidence about layer B — the confused-deputy case.
245        let s = st();
246        match check(&s, BYTES, "sha256:2222", "2026.08.0") {
247            Err(AttestError::LayerMismatch { .. }) => {}
248            other => panic!("expected LayerMismatch, got {other:?}"),
249        }
250    }
251
252    // rivet: verifies REQ-ATTEST-001
253    #[test]
254    fn a_statement_whose_own_two_identities_disagree_is_refused() {
255        // The statement names a layer AND carries its digest; callers print the
256        // name. If they disagree, varve would print a signed-looking but wrong
257        // identity. `resolve` refuses this for pins; so must this.
258        let s = st();
259        match check(&s, BYTES, LAYER_DIGEST, "2026.01.0") {
260            Err(AttestError::NameDigestMismatch { .. }) => {}
261            other => panic!("expected NameDigestMismatch, got {other:?}"),
262        }
263    }
264
265    // rivet: verifies REQ-ATTEST-001
266    #[test]
267    fn an_unknown_attestation_kind_is_refused_not_guessed() {
268        assert!("sbom".parse::<AttestationKind>().is_ok());
269        assert!("qualification".parse::<AttestationKind>().is_ok());
270        assert!("vibes".parse::<AttestationKind>().is_err());
271        assert!("".parse::<AttestationKind>().is_err());
272    }
273
274    // rivet: verifies REQ-ATTEST-001
275    #[test]
276    fn a_statement_round_trips_through_a_signature() {
277        let (sk, pk) = crate::generate_root_keypair();
278        let s = st();
279        let env = sign(&s, &sk, "test-root").unwrap();
280        let back = verify_statement(env.as_bytes(), &pk).unwrap();
281        assert_eq!(back, s);
282    }
283
284    // rivet: verifies REQ-ATTEST-001
285    #[test]
286    fn the_wrong_root_cannot_vouch_for_an_association() {
287        let (sk, _) = crate::generate_root_keypair();
288        let (_, other_pk) = crate::generate_root_keypair();
289        let env = sign(&st(), &sk, "test-root").unwrap();
290        assert!(verify_statement(env.as_bytes(), &other_pk).is_err());
291    }
292}