1use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::error::{SignatifError, SignatifResult};
20use crate::graph::SignatureVerifier;
21use crate::jcs;
22use crate::registry::{DimensionTag, Registry};
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ArtifactVersion {
27 pub major: u32,
29 pub minor: u32,
31}
32
33impl ArtifactVersion {
34 pub fn accepts(&self, other: &ArtifactVersion) -> bool {
37 other.major <= self.major
38 }
39}
40
41impl std::fmt::Display for ArtifactVersion {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "{}.{}", self.major, self.minor)
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CoSignatureBlock {
50 pub dimension: DimensionTag,
52 pub algorithm: String,
54 pub signer_cert_ref: String,
57 pub signer_pubkey: Vec<u8>,
59 pub chain_ref: String,
62 pub signature: Vec<u8>,
64 pub timestamp: chrono::DateTime<chrono::Utc>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TrustedArtifact {
71 pub version: ArtifactVersion,
73 pub artifact_id: String,
76 pub payload: Value,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub payload_schema: Option<String>,
81 pub canonical_payload_hash: [u8; 32],
83 pub co_signatures: Vec<CoSignatureBlock>,
85}
86
87impl TrustedArtifact {
88 pub fn new(
94 version: ArtifactVersion,
95 artifact_id: impl Into<String>,
96 payload: Value,
97 payload_schema: Option<String>,
98 ) -> SignatifResult<Self> {
99 let canonical_payload_hash = jcs::canonical_hash(&payload)?;
100 Ok(Self {
101 version,
102 artifact_id: artifact_id.into(),
103 payload,
104 payload_schema,
105 canonical_payload_hash,
106 co_signatures: Vec::new(),
107 })
108 }
109
110 pub fn cosign_input(&self, dimension: &DimensionTag) -> Vec<u8> {
114 let mut bytes = self.artifact_id.as_bytes().to_vec();
115 bytes.push(0x00);
116 bytes.extend_from_slice(&self.canonical_payload_hash);
117 bytes.push(0x00);
118 bytes.extend_from_slice(dimension.as_str().as_bytes());
119 bytes
120 }
121
122 #[allow(clippy::too_many_arguments)]
128 pub fn sign(
129 &mut self,
130 dimension: DimensionTag,
131 algorithm: impl Into<String>,
132 signer_cert_ref: impl Into<String>,
133 signer_pubkey: Vec<u8>,
134 chain_ref: impl Into<String>,
135 signer: &dyn Fn(&[u8]) -> Vec<u8>,
136 registry: &Registry,
137 ) -> SignatifResult<()> {
138 if !registry.dimensions.contains(dimension.as_str()) {
139 return Err(SignatifError::Registry {
140 registry: "trust-dimension".into(),
141 entry: dimension.as_str().to_string(),
142 });
143 }
144 let algorithm = algorithm.into();
145 registry
146 .algorithms
147 .usable(&algorithm)
148 .ok_or_else(|| SignatifError::Registry {
149 registry: "algorithm".into(),
150 entry: algorithm.clone(),
151 })?;
152 let input = self.cosign_input(&dimension);
153 let signature = signer(&input);
154 self.co_signatures.push(CoSignatureBlock {
155 dimension,
156 algorithm,
157 signer_cert_ref: signer_cert_ref.into(),
158 signer_pubkey,
159 chain_ref: chain_ref.into(),
160 signature,
161 timestamp: chrono::Utc::now(),
162 });
163 Ok(())
164 }
165
166 pub fn add_attestation(
175 &mut self,
176 block: CoSignatureBlock,
177 registry: &Registry,
178 ) -> SignatifResult<()> {
179 if !registry.dimensions.contains(block.dimension.as_str()) {
180 return Err(SignatifError::Registry {
181 registry: "trust-dimension".into(),
182 entry: block.dimension.as_str().to_string(),
183 });
184 }
185 registry
186 .algorithms
187 .usable(&block.algorithm)
188 .ok_or_else(|| SignatifError::Registry {
189 registry: "algorithm".into(),
190 entry: block.algorithm.clone(),
191 })?;
192 self.co_signatures.push(block);
193 Ok(())
194 }
195
196 pub fn verify_self(
208 &self,
209 registry: &Registry,
210 verifier: &dyn SignatureVerifier,
211 ) -> SignatifResult<()> {
212 let recomputed = jcs::canonical_hash(&self.payload)?;
213 if recomputed != self.canonical_payload_hash {
214 return Err(SignatifError::ArtifactFormat(
215 "payload does not match canonical_payload_hash (signed content != processed content)"
216 .into(),
217 ));
218 }
219 for block in &self.co_signatures {
220 registry
221 .algorithms
222 .usable(&block.algorithm)
223 .ok_or_else(|| SignatifError::Registry {
224 registry: "algorithm".into(),
225 entry: block.algorithm.clone(),
226 })?;
227 let input = self.cosign_input(&block.dimension);
228 if !verifier.verify(&block.signer_pubkey, &input, &block.signature) {
229 return Err(SignatifError::BadSignature {
230 context: format!(
231 "co-signature dimension={} signer={}",
232 block.dimension.as_str(),
233 block.signer_cert_ref
234 ),
235 });
236 }
237 }
238 Ok(())
239 }
240
241 pub fn dimensions_verified(&self) -> Vec<DimensionTag> {
243 let mut seen = std::collections::BTreeSet::new();
244 for b in &self.co_signatures {
245 seen.insert(b.dimension.clone());
246 }
247 seen.into_iter().collect()
248 }
249
250 pub fn distinct_roots(&self) -> Vec<String> {
253 let mut seen = std::collections::BTreeSet::new();
254 for b in &self.co_signatures {
255 seen.insert(b.chain_ref.clone());
256 }
257 seen.into_iter().collect()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::registry::Registry;
265 use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
266
267 fn generate_key() -> ed25519_dalek::SigningKey {
268 use rand_core::RngCore;
269 let mut seed = [0u8; 32];
270 rand_core::OsRng.fill_bytes(&mut seed);
271 ed25519_dalek::SigningKey::from_bytes(&seed)
272 }
273
274 struct Ed25519Verifier;
275
276 impl SignatureVerifier for Ed25519Verifier {
277 fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
278 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
279 return false;
280 };
281 let Ok(signature) = Signature::from_slice(sig) else {
282 return false;
283 };
284 vk.verify(msg, &signature).is_ok()
285 }
286 }
287
288 fn sample(registry: &Registry) -> (TrustedArtifact, SigningKey) {
289 let sk = generate_key();
290 let mut art = TrustedArtifact::new(
291 ArtifactVersion { major: 1, minor: 0 },
292 "art-2026-00001",
293 serde_json::json!({"batch_id": "LOT-2026-001", "quantity": 50000}),
294 Some("https://example.cnml/schema/vaccine-batch.json".into()),
295 )
296 .unwrap();
297 let pk = sk.verifying_key().as_bytes().to_vec();
298 art.sign(
299 DimensionTag::data(),
300 "Ed25519",
301 "transparency-log-seq:12345",
302 pk.clone(),
303 "root-cnml",
304 &|m| sk.sign(m).to_bytes().to_vec(),
305 registry,
306 )
307 .unwrap();
308 (art, sk)
309 }
310
311 #[test]
312 fn create_sign_and_verify() {
313 let registry = Registry::with_initial_values();
314 let (art, _) = sample(®istry);
315 assert!(art.verify_self(®istry, &Ed25519Verifier).is_ok());
316 assert_eq!(art.dimensions_verified(), vec![DimensionTag::data()]);
317 assert_eq!(art.distinct_roots(), vec!["root-cnml".to_string()]);
318 }
319
320 #[test]
321 fn payload_tampering_detected() {
322 let registry = Registry::with_initial_values();
323 let (mut art, _) = sample(®istry);
324 art.payload["quantity"] = serde_json::json!(1);
325 assert!(art.verify_self(®istry, &Ed25519Verifier).is_err());
326 }
327
328 #[test]
329 fn replay_across_artifacts_fails() {
330 let registry = Registry::with_initial_values();
331 let (art, sk) = sample(®istry);
332 let mut other = TrustedArtifact::new(
335 ArtifactVersion { major: 1, minor: 0 },
336 "art-2026-00002",
337 serde_json::json!({"batch_id": "LOT-2026-001", "quantity": 50000}),
338 None,
339 )
340 .unwrap();
341 let stolen = art.co_signatures[0].clone();
342 other.co_signatures.push(stolen);
343 assert!(other.verify_self(®istry, &Ed25519Verifier).is_err());
344 let _ = sk;
345 }
346
347 #[test]
348 fn living_artifact_accumulates_dimensions() {
349 let registry = Registry::with_initial_values();
350 let (mut art, _) = sample(®istry);
351 let person = generate_key();
352 let before_hash = art.canonical_payload_hash;
353 art.sign(
354 DimensionTag::person(),
355 "Ed25519",
356 "operator-key-fingerprint",
357 person.verifying_key().as_bytes().to_vec(),
358 "root-cnml",
359 &|m| person.sign(m).to_bytes().to_vec(),
360 ®istry,
361 )
362 .unwrap();
363 assert_eq!(art.canonical_payload_hash, before_hash);
364 assert!(art.verify_self(®istry, &Ed25519Verifier).is_ok());
365 assert_eq!(art.dimensions_verified().len(), 2);
366 }
367
368 #[test]
369 fn unknown_dimension_rejected_via_registry() {
370 let registry = Registry::with_initial_values();
371 let (mut art, _) = sample(®istry);
372 let err = art
373 .sign(
374 DimensionTag::custom("no-such-dimension"),
375 "Ed25519",
376 "x",
377 vec![0u8; 32],
378 "r",
379 &|_| vec![0u8; 64],
380 ®istry,
381 )
382 .unwrap_err();
383 assert!(matches!(err, SignatifError::Registry { .. }));
384 }
385
386 #[test]
387 fn version_compatibility() {
388 let v1 = ArtifactVersion { major: 1, minor: 3 };
389 assert!(v1.accepts(&ArtifactVersion { major: 1, minor: 0 }));
390 assert!(v1.accepts(&ArtifactVersion { major: 1, minor: 9 }));
391 assert!(!v1.accepts(&ArtifactVersion { major: 2, minor: 0 }));
392 }
393}