Skip to main content

confium_signatif/
cose.rs

1//! COSE encoding of trusted artifacts (SIGNATIF §8, the
2//! `/conf/format-cose` profile's co-signature encoding obligation).
3//!
4//! A [`TrustedArtifact`] serializes into a chain of
5//! [`confium_composite::cose::CoseSign1`] structures — one per
6//! co-signature block — followed by one carrying the artifact body
7//! (id, payload, canonical hash) as its payload. Each co-signature
8//! COSE structure signs over the artifact's canonical payload hash,
9//! and the chain round-trips losslessly through
10//! [`encode_artifact_cose`]/[`decode_artifact_cose`]. The
11//! CBOR deterministic-encoding characteristics of the profile
12//! (definite lengths, minimum-length integers) come from the composite
13//! crate's encoder.
14
15use confium_composite::cose::CoseSign1;
16use confium_composite::cose::alg as AlgorithmIds;
17
18use crate::artifact::{CoSignatureBlock, TrustedArtifact};
19use crate::error::{SignatifError, SignatifResult};
20use crate::jcs;
21use crate::registry::DimensionTag;
22
23/// The protected-header key carrying the SIGNATIF trust dimension.
24const DIMENSION_HEADER_KEY: &str = "signatif:dimension";
25/// The protected-header key carrying the signer certificate reference.
26const CERT_REF_HEADER_KEY: &str = "signatif:cert-ref";
27/// The protected-header key carrying the chain (root) reference.
28const CHAIN_REF_HEADER_KEY: &str = "signatif:chain-ref";
29
30fn algorithm_id_for(name: &str) -> SignatifResult<i32> {
31    match name {
32        "Ed25519" => Ok(AlgorithmIds::ED25519),
33        "ECDSA-P256" => Ok(AlgorithmIds::ES256),
34        other => Err(SignatifError::Encoding(format!(
35            "no COSE algorithm id registered for {other}"
36        ))),
37    }
38}
39
40fn cose_algorithm_name(id: i32) -> SignatifResult<String> {
41    match id {
42        AlgorithmIds::ED25519 => Ok("Ed25519".into()),
43        AlgorithmIds::ES256 => Ok("ECDSA-P256".into()),
44        other => Err(SignatifError::Encoding(format!(
45            "unknown COSE algorithm id {other}"
46        ))),
47    }
48}
49
50/// The body payload carried by the trailing COSE structure: the JCS of
51/// the artifact's self-description minus the signatures themselves.
52fn artifact_body(artifact: &TrustedArtifact) -> SignatifResult<Vec<u8>> {
53    let v = serde_json::json!({
54        "artifact_id": artifact.artifact_id,
55        "canonical_payload_hash": hex::encode(artifact.canonical_payload_hash),
56        "payload": artifact.payload,
57        "payload_schema": artifact.payload_schema,
58        "version": {
59            "major": artifact.version.major,
60            "minor": artifact.version.minor,
61        },
62    });
63    Ok(jcs::canonicalize(&v)?.into_bytes())
64}
65
66fn reconstruct_artifact(
67    body: &serde_json::Value,
68    blocks: Vec<CoSignatureBlock>,
69) -> SignatifResult<TrustedArtifact> {
70    let artifact_id = body
71        .get("artifact_id")
72        .and_then(|v| v.as_str())
73        .ok_or_else(|| SignatifError::Encoding("artifact body lacks artifact_id".into()))?
74        .to_string();
75    let hash_hex = body
76        .get("canonical_payload_hash")
77        .and_then(|v| v.as_str())
78        .ok_or_else(|| SignatifError::Encoding("artifact body lacks hash".into()))?;
79    let canonical_payload_hash: [u8; 32] = hex::decode(hash_hex)
80        .map_err(|e| SignatifError::Encoding(format!("hash decode: {e}")))?
81        .try_into()
82        .map_err(|_| SignatifError::Encoding("canonical hash must be 32 bytes".into()))?;
83    let version = body
84        .get("version")
85        .ok_or_else(|| SignatifError::Encoding("artifact body lacks version".into()))?;
86    Ok(TrustedArtifact {
87        version: crate::artifact::ArtifactVersion {
88            major: version.get("major").and_then(|v| v.as_i64()).unwrap_or(1) as u32,
89            minor: version.get("minor").and_then(|v| v.as_i64()).unwrap_or(0) as u32,
90        },
91        artifact_id,
92        payload: body
93            .get("payload")
94            .cloned()
95            .ok_or_else(|| SignatifError::Encoding("artifact body lacks payload".into()))?,
96        payload_schema: body
97            .get("payload_schema")
98            .and_then(|v| v.as_str())
99            .map(|s| s.to_string()),
100        canonical_payload_hash,
101        co_signatures: blocks,
102    })
103}
104
105/// Encode a trusted artifact as a COSE chain: one COSE_Sign1 per
106/// co-signature (signing over the canonical payload hash), then a
107/// body structure carrying the self-description.
108///
109/// # Errors
110///
111/// Encoding errors for unregistered algorithms or CBOR failures.
112pub fn encode_artifact_cose(artifact: &TrustedArtifact) -> SignatifResult<Vec<Vec<u8>>> {
113    let mut out = Vec::new();
114    for block in &artifact.co_signatures {
115        let mut cose = CoseSign1::new(
116            algorithm_id_for(&block.algorithm)?,
117            &artifact.canonical_payload_hash,
118            &block.signature,
119        )
120        .map_err(|e| SignatifError::Encoding(e.to_string()))?;
121        cose.unprotected_bytes = serde_json::to_vec(&serde_json::json!({
122            DIMENSION_HEADER_KEY: block.dimension.as_str(),
123            CERT_REF_HEADER_KEY: block.signer_cert_ref,
124            CHAIN_REF_HEADER_KEY: block.chain_ref,
125        }))
126        .map_err(|e| SignatifError::Encoding(e.to_string()))?;
127        out.push(
128            cose.encode()
129                .map_err(|e| SignatifError::Encoding(e.to_string()))?,
130        );
131    }
132    let body = CoseSign1::new(0, &artifact_body(artifact)?, &[])
133        .map_err(|e| SignatifError::Encoding(e.to_string()))?;
134    out.push(
135        body.encode()
136            .map_err(|e| SignatifError::Encoding(e.to_string()))?,
137    );
138    Ok(out)
139}
140
141/// Decode a COSE chain back into a trusted artifact. The final
142/// structure is the body; every preceding structure is a co-signature
143/// block. The canonical payload hash is cross-checked against the
144/// payload (self-description integrity).
145///
146/// # Errors
147///
148/// Decoding and integrity errors.
149pub fn decode_artifact_cose(chain: &[Vec<u8>]) -> SignatifResult<TrustedArtifact> {
150    if chain.is_empty() {
151        return Err(SignatifError::Encoding("empty COSE chain".into()));
152    }
153    let body_cose = CoseSign1::decode(&chain[chain.len() - 1])
154        .map_err(|e| SignatifError::Encoding(e.to_string()))?;
155    let body: serde_json::Value = serde_json::from_slice(&body_cose.payload)
156        .map_err(|e| SignatifError::Encoding(format!("artifact body: {e}")))?;
157
158    let mut blocks = Vec::new();
159    for cose_bytes in &chain[..chain.len() - 1] {
160        let cose = CoseSign1::decode(cose_bytes.as_slice())
161            .map_err(|e| SignatifError::Encoding(e.to_string()))?;
162        let algorithm = cose_algorithm_name(
163            cose.algorithm()
164                .map_err(|e| SignatifError::Encoding(e.to_string()))?,
165        )?;
166        let headers: serde_json::Value = serde_json::from_slice(&cose.unprotected_bytes)
167            .map_err(|e| SignatifError::Encoding(format!("co-signature headers: {e}")))?;
168        let dimension = headers
169            .get(DIMENSION_HEADER_KEY)
170            .and_then(|v| v.as_str())
171            .ok_or_else(|| SignatifError::Encoding("co-signature lacks dimension".into()))?;
172        blocks.push(CoSignatureBlock {
173            dimension: DimensionTag::custom(dimension),
174            algorithm,
175            signer_cert_ref: headers
176                .get(CERT_REF_HEADER_KEY)
177                .and_then(|v| v.as_str())
178                .unwrap_or_default()
179                .to_string(),
180            signer_pubkey: Vec::new(),
181            chain_ref: headers
182                .get(CHAIN_REF_HEADER_KEY)
183                .and_then(|v| v.as_str())
184                .unwrap_or_default()
185                .to_string(),
186            signature: cose.signature,
187            timestamp: chrono::DateTime::parse_from_rfc3339(
188                headers
189                    .get("signatif:timestamp")
190                    .and_then(|v| v.as_str())
191                    .unwrap_or("1970-01-01T00:00:00Z"),
192            )
193            .map(|t| t.with_timezone(&chrono::Utc))
194            .unwrap_or_default(),
195        });
196    }
197
198    let artifact = reconstruct_artifact(&body, blocks)?;
199    // Self-description integrity: recorded hash == hash of payload.
200    if jcs::canonical_hash(&artifact.payload)? != artifact.canonical_payload_hash {
201        return Err(SignatifError::ArtifactFormat(
202            "COSE artifact body hash does not match its payload".into(),
203        ));
204    }
205    Ok(artifact)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::artifact::ArtifactVersion;
212    use crate::registry::Registry;
213    use chrono::Utc;
214    use ed25519_dalek::Signer;
215    use rand_core::RngCore;
216
217    fn generate_key() -> ed25519_dalek::SigningKey {
218        let mut seed = [0u8; 32];
219        rand_core::OsRng.fill_bytes(&mut seed);
220        ed25519_dalek::SigningKey::from_bytes(&seed)
221    }
222
223    #[test]
224    fn round_trip_preserves_the_artifact() {
225        let registry = Registry::with_initial_values();
226        let sk = generate_key();
227        let mut artifact = TrustedArtifact::new(
228            ArtifactVersion { major: 1, minor: 0 },
229            "cose-art-1",
230            serde_json::json!({"vial": "V-9", "mass_g": 12.342}),
231            None,
232        )
233        .unwrap();
234        artifact
235            .sign(
236                DimensionTag::data(),
237                "Ed25519",
238                "end-1",
239                sk.verifying_key().as_bytes().to_vec(),
240                "root-1",
241                &|m| sk.sign(m).to_bytes().to_vec(),
242                &registry,
243            )
244            .unwrap();
245
246        let chain = encode_artifact_cose(&artifact).unwrap();
247        assert_eq!(chain.len(), 2, "one co-signature + one body");
248        let back = decode_artifact_cose(&chain).unwrap();
249        assert_eq!(back.artifact_id, "cose-art-1");
250        assert_eq!(back.canonical_payload_hash, artifact.canonical_payload_hash);
251        assert_eq!(back.co_signatures.len(), 1);
252        assert_eq!(back.co_signatures[0].algorithm, "Ed25519");
253        assert_eq!(back.co_signatures[0].dimension.as_str(), "data");
254        assert_eq!(back.co_signatures[0].signer_cert_ref, "end-1");
255        assert_eq!(back.co_signatures[0].chain_ref, "root-1");
256        assert_eq!(
257            back.co_signatures[0].signature,
258            artifact.co_signatures[0].signature
259        );
260    }
261
262    #[test]
263    fn tampered_body_hash_detected() {
264        let registry = Registry::with_initial_values();
265        let sk = generate_key();
266        let mut artifact = TrustedArtifact::new(
267            ArtifactVersion { major: 1, minor: 0 },
268            "cose-art-2",
269            serde_json::json!({"a": 1}),
270            None,
271        )
272        .unwrap();
273        artifact
274            .sign(
275                DimensionTag::data(),
276                "Ed25519",
277                "end",
278                sk.verifying_key().as_bytes().to_vec(),
279                "root",
280                &|m| sk.sign(m).to_bytes().to_vec(),
281                &registry,
282            )
283            .unwrap();
284        let mut chain = encode_artifact_cose(&artifact).unwrap();
285        // Tamper the body structure's payload.
286        let mut body = CoseSign1::decode(chain[1].as_slice()).unwrap();
287        body.payload = br#"{"artifact_id":"cose-art-2","canonical_payload_hash":"00","payload":{"a":2},"version":{"major":1,"minor":0}}"#.to_vec();
288        chain[1] = body.encode().unwrap();
289        assert!(decode_artifact_cose(&chain).is_err());
290    }
291
292    #[test]
293    fn empty_chain_rejected() {
294        assert!(decode_artifact_cose(&[]).is_err());
295        let _ = Utc::now();
296    }
297}