1use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum AttestationKind {
29 Sbom,
31 Provenance,
33 Audit,
35 Vex,
37 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct AttestationStatement {
76 pub layer: String,
78 pub layer_manifest_digest: String,
80 pub kind: AttestationKind,
82 pub digest: String,
84 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
116pub 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
133pub 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 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
170pub 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
181pub 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
193pub 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 #[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 #[test]
226 fn swapped_bytes_are_refused() {
227 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 #[test]
242 fn an_attestation_for_another_layer_is_refused() {
243 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 #[test]
254 fn a_statement_whose_own_two_identities_disagree_is_refused() {
255 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 #[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 #[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 #[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}