Skip to main content

confium_signatif/
artifact.rs

1//! Trusted artifacts and dimension-tagged co-signatures (SIGNATIF §8).
2//!
3//! A [`TrustedArtifact`] is the convergence point of independent
4//! attestations: every [`CoSignatureBlock`] — regardless of trust
5//! dimension, organization, or trust chain — signs the **same**
6//! canonical payload hash. Partial attestation is not conforming, a
7//! co-signature cannot be stripped without breaking self-description,
8//! and each block binds to the artifact identifier so blocks cannot be
9//! replayed onto a different artifact (the `replay-protection`
10//! requirement).
11//!
12//! Artifacts are *living*: dimension attestations accumulate over time
13//! ([`TrustedArtifact::add_attestation`]) while the original canonical
14//! payload hash stays fixed.
15
16use 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/// Semantic version of the artifact format.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ArtifactVersion {
27    /// Breaking changes — verifiers reject higher majors.
28    pub major: u32,
29    /// Backward/forward compatible additions — unknown fields ignored.
30    pub minor: u32,
31}
32
33impl ArtifactVersion {
34    /// Whether a verifier supporting `(self)` can process `other`:
35    /// same or lower major, any minor.
36    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/// One independent attestation on the artifact.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CoSignatureBlock {
50    /// The trust dimension this block attests.
51    pub dimension: DimensionTag,
52    /// Signing algorithm identifier (from the algorithm registry).
53    pub algorithm: String,
54    /// End-certificate reference: a transparency-log sequence pointer
55    /// or a key fingerprint.
56    pub signer_cert_ref: String,
57    /// The signer's public key (SPKI bytes).
58    pub signer_pubkey: Vec<u8>,
59    /// Reference to the signer's root — may differ per block
60    /// (cross-domain fusion needs no root cross-recognition).
61    pub chain_ref: String,
62    /// The signature over the co-signature signing input.
63    pub signature: Vec<u8>,
64    /// When this attestation was produced.
65    pub timestamp: chrono::DateTime<chrono::Utc>,
66}
67
68/// A trusted artifact: payload + dimension attestations.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TrustedArtifact {
71    /// Artifact format version.
72    pub version: ArtifactVersion,
73    /// Unique artifact identifier — bound into every co-signature to
74    /// prevent replay onto other artifacts.
75    pub artifact_id: String,
76    /// The domain payload (schema identified by `$payload_schema`).
77    pub payload: Value,
78    /// URI identifying the payload schema.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub payload_schema: Option<String>,
81    /// SHA-256 of the JCS canonicalization of the payload.
82    pub canonical_payload_hash: [u8; 32],
83    /// The dimension attestations converging on this artifact.
84    pub co_signatures: Vec<CoSignatureBlock>,
85}
86
87impl TrustedArtifact {
88    /// Create a new artifact, computing the canonical payload hash.
89    ///
90    /// # Errors
91    ///
92    /// Propagates canonicalization errors.
93    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    /// The signing input for a co-signature: artifact identity bound to
111    /// the canonical payload hash, so blocks are artifact-specific and
112    /// cannot be replayed across artifacts.
113    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    /// Produce a co-signature over this artifact (helper for signers).
123    ///
124    /// # Errors
125    ///
126    /// Propagates canonicalization errors.
127    #[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    /// Living artifacts: add a dimension attestation. The block must
167    /// sign the *original* canonical payload hash — enforced because
168    /// the signing input derives from the fixed hash and artifact id.
169    ///
170    /// # Errors
171    ///
172    /// Returns a registry error for unknown dimensions or unusable
173    /// algorithms.
174    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    /// Verify the artifact's self-consistency: the recorded canonical
197    /// payload hash equals the hash of the current payload (detects
198    /// any post-signing payload modification — signature wrapping
199    /// prevention), and every block is verified independently against
200    /// its own public key.
201    ///
202    /// # Errors
203    ///
204    /// [`SignatifError::ArtifactFormat`] on hash mismatch or an
205    /// unknown/retired algorithm; [`SignatifError::BadSignature`] when
206    /// a block fails to verify.
207    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    /// The distinct dimensions attested by currently-valid blocks.
242    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    /// The distinct chain (root) references across blocks — feeds the
251    /// coverage report's independent-root count.
252    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(&registry);
315        assert!(art.verify_self(&registry, &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(&registry);
324        art.payload["quantity"] = serde_json::json!(1);
325        assert!(art.verify_self(&registry, &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(&registry);
332        // A second artifact with different id: the stolen block signs
333        // the first artifact's id+hash, so it fails here.
334        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(&registry, &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(&registry);
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            &registry,
361        )
362        .unwrap();
363        assert_eq!(art.canonical_payload_hash, before_hash);
364        assert!(art.verify_self(&registry, &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(&registry);
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                &registry,
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}