1use 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
23const DIMENSION_HEADER_KEY: &str = "signatif:dimension";
25const CERT_REF_HEADER_KEY: &str = "signatif:cert-ref";
27const 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
50fn 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
105pub 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
141pub 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 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 ®istry,
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 ®istry,
282 )
283 .unwrap();
284 let mut chain = encode_artifact_cose(&artifact).unwrap();
285 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}