1use crate::body::Signature;
32use acdp_jcs::try_canonicalize_value;
33use acdp_primitives::error::AcdpError;
34use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RegistryReceipt {
48 pub registry_did: String,
51 pub ctx_id: CtxId,
53 pub lineage_id: LineageId,
55 pub origin_registry: String,
57 #[serde(with = "ms_rfc3339")]
62 pub created_at: DateTime<Utc>,
63 pub content_hash: ContentHash,
65 pub key_fingerprint: String,
68 pub signature: Signature,
70}
71
72mod ms_rfc3339 {
75 use chrono::{DateTime, Utc};
76 use serde::{Deserialize, Deserializer, Serializer};
77
78 pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
79 s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
80 }
81
82 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
83 let raw = String::deserialize(d)?;
84 DateTime::parse_from_rfc3339(&raw)
85 .map(|t| t.with_timezone(&Utc))
86 .map_err(serde::de::Error::custom)
87 }
88}
89
90impl RegistryReceipt {
91 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
94 Self::deserialize(value)
95 .map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
96 }
97
98 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
107 let mut map = value
108 .as_object()
109 .cloned()
110 .ok_or_else(|| AcdpError::InvalidReceipt("receipt must be a JSON object".into()))?;
111 map.remove("signature");
112 let canonical = try_canonicalize_value(&serde_json::Value::Object(map))?;
113 let digest = Sha256::digest(&canonical);
114 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
115 }
116
117 pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
121 let raw = value
122 .get("created_at")
123 .and_then(|v| v.as_str())
124 .ok_or_else(|| {
125 AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
126 })?;
127 let b = raw.as_bytes();
128 let well_formed = b.len() == 24
129 && b[10] == b'T'
130 && b[19] == b'.'
131 && b[23] == b'Z'
132 && b[20..23].iter().all(u8::is_ascii_digit)
133 && chrono::DateTime::parse_from_rfc3339(raw).is_ok();
134 if !well_formed {
135 return Err(AcdpError::InvalidReceipt(format!(
136 "receipt created_at '{raw}' is not canonical millisecond-precision \
137 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
138 )));
139 }
140 Ok(())
141 }
142
143 pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
148 if self.lineage_id != body.lineage_id {
149 return Err(AcdpError::InvalidReceipt(format!(
150 "receipt lineage_id '{}' ≠ body lineage_id '{}'",
151 self.lineage_id, body.lineage_id
152 )));
153 }
154 if self.origin_registry != body.origin_registry {
155 return Err(AcdpError::InvalidReceipt(format!(
156 "receipt origin_registry '{}' ≠ body origin_registry '{}'",
157 self.origin_registry, body.origin_registry
158 )));
159 }
160 if self.created_at != body.created_at {
161 return Err(AcdpError::InvalidReceipt(format!(
162 "receipt created_at '{}' ≠ body created_at '{}'",
163 self.created_at, body.created_at
164 )));
165 }
166 Ok(())
167 }
168
169 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
177 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
178 }
179
180 pub fn verify_signature_with_key(
185 &self,
186 registry_pub_ed25519: Option<&[u8; 32]>,
187 registry_pub_p256_sec1: Option<&[u8]>,
188 ) -> Result<(), AcdpError> {
189 let hash = self.preimage_hash()?;
190 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
191 }
192
193 pub fn verify_signature_against_hash(
197 &self,
198 hash: &ContentHash,
199 registry_pub_ed25519: Option<&[u8; 32]>,
200 registry_pub_p256_sec1: Option<&[u8]>,
201 ) -> Result<(), AcdpError> {
202 match self.signature.algorithm.as_str() {
203 "ed25519" => {
204 let key = registry_pub_ed25519.ok_or_else(|| {
205 AcdpError::InvalidReceipt(
206 "receipt declares ed25519 but no ed25519 registry key was resolved".into(),
207 )
208 })?;
209 acdp_crypto::verify::verify_ed25519(key, &self.signature.value, hash.as_str())
210 .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
211 }
212 "ecdsa-p256" => {
213 let key = registry_pub_p256_sec1.ok_or_else(|| {
214 AcdpError::InvalidReceipt(
215 "receipt declares ecdsa-p256 but no p256 registry key was resolved".into(),
216 )
217 })?;
218 acdp_crypto::verify::verify_ecdsa_p256(key, &self.signature.value, hash.as_str())
219 .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
220 }
221 other => Err(AcdpError::InvalidReceipt(format!(
222 "receipt signature algorithm '{other}' is not supported"
223 ))),
224 }
225 }
226
227 pub fn cross_check(
241 &self,
242 expected_ctx_id: &CtxId,
243 recomputed_body_hash: &ContentHash,
244 producer_key_fingerprint: &str,
245 ) -> Result<(), AcdpError> {
246 if &self.ctx_id != expected_ctx_id {
247 return Err(AcdpError::InvalidReceipt(format!(
248 "receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
249 self.ctx_id
250 )));
251 }
252 if &self.content_hash != recomputed_body_hash {
253 return Err(AcdpError::InvalidReceipt(format!(
254 "receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
255 self.content_hash
256 )));
257 }
258 if self.key_fingerprint != producer_key_fingerprint {
259 return Err(AcdpError::InvalidReceipt(format!(
260 "receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
261 self.key_fingerprint
262 )));
263 }
264 if self.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
265 return Err(AcdpError::InvalidReceipt(
266 "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
267 ));
268 }
269 let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
270 if self.registry_did != expected_did {
271 return Err(AcdpError::InvalidReceipt(format!(
272 "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
273 self.registry_did
274 )));
275 }
276 Ok(())
277 }
278}
279
280pub struct ReceiptSigner {
289 key: acdp_crypto::sign::AcdpSigningKey,
290 key_id: String,
292 registry_did: String,
294}
295
296impl ReceiptSigner {
297 pub fn new(
301 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
302 registry_did: impl Into<String>,
303 key_id: impl Into<String>,
304 ) -> Result<Self, AcdpError> {
305 let registry_did = registry_did.into();
306 let key_id = key_id.into();
307 if !registry_did.starts_with("did:web:") {
308 return Err(AcdpError::SchemaViolation(format!(
309 "receipt signer registry_did must be did:web, got '{registry_did}'"
310 )));
311 }
312 match key_id.split_once('#') {
313 Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
314 _ => {
315 return Err(AcdpError::SchemaViolation(format!(
316 "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
317 )));
318 }
319 }
320 Ok(Self {
321 key: key.into(),
322 key_id,
323 registry_did,
324 })
325 }
326
327 pub fn registry_did(&self) -> &str {
329 &self.registry_did
330 }
331
332 pub fn mint(
339 &self,
340 ctx_id: &CtxId,
341 lineage_id: &LineageId,
342 origin_registry: &str,
343 created_at: DateTime<Utc>,
344 content_hash: &ContentHash,
345 producer_key_fingerprint: &str,
346 ) -> Result<RegistryReceipt, AcdpError> {
347 let mut receipt = RegistryReceipt {
348 registry_did: self.registry_did.clone(),
349 ctx_id: ctx_id.clone(),
350 lineage_id: lineage_id.clone(),
351 origin_registry: origin_registry.to_string(),
352 created_at: acdp_primitives::time::trunc_ms(created_at),
353 content_hash: content_hash.clone(),
354 key_fingerprint: producer_key_fingerprint.to_string(),
355 signature: Signature {
356 algorithm: self.key.algorithm().into(),
357 key_id: self.key_id.clone(),
358 value: String::new(), },
360 };
361 let hash = receipt.preimage_hash()?;
362 let (algorithm, value) = self.key.sign_content_hash(&hash);
363 receipt.signature.algorithm = algorithm.into();
364 receipt.signature.value = value;
365 Ok(receipt)
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use acdp_crypto::SigningKey;
373
374 fn test_signer() -> ReceiptSigner {
375 ReceiptSigner::new(
376 SigningKey::from_bytes(&[1u8; 32]),
377 "did:web:registry.example.com",
378 "did:web:registry.example.com#receipt-key-1",
379 )
380 .unwrap()
381 }
382
383 fn test_receipt() -> RegistryReceipt {
384 test_signer()
385 .mint(
386 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
387 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
388 "registry.example.com",
389 chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
390 .unwrap()
391 .with_timezone(&chrono::Utc),
392 &ContentHash(format!("sha256:{}", "b".repeat(64))),
393 "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
394 )
395 .unwrap()
396 }
397
398 fn registry_pub() -> [u8; 32] {
399 SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
400 }
401
402 #[test]
403 fn mint_verify_round_trip() {
404 let receipt = test_receipt();
405 receipt
406 .verify_signature_with_key(Some(®istry_pub()), None)
407 .expect("freshly minted receipt must verify");
408 }
409
410 #[test]
411 fn tampered_fields_fail_verification() {
412 let pubkey = registry_pub();
414 let mut r = test_receipt();
415 r.created_at += chrono::Duration::milliseconds(1);
416 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
417
418 let mut r = test_receipt();
419 r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
420 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
421
422 let mut r = test_receipt();
423 r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
424 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
425 }
426
427 #[test]
430 fn unknown_receipt_fields_rejected() {
431 let mut wire = serde_json::to_value(test_receipt()).unwrap();
432 wire.as_object_mut()
433 .unwrap()
434 .insert("transparency_log_index".into(), serde_json::json!(42));
435 let err = RegistryReceipt::from_value(&wire).unwrap_err();
436 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
437 }
438
439 #[test]
445 fn raw_and_struct_preimages_agree_incl_whole_second() {
446 let receipt = test_receipt();
447 let wire = serde_json::to_value(&receipt).unwrap();
448 assert_eq!(
449 RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
450 receipt.preimage_hash().unwrap()
451 );
452 RegistryReceipt::validate_created_at_form(&wire).unwrap();
453
454 let signer = test_signer();
456 let r = signer
457 .mint(
458 &receipt.ctx_id,
459 &receipt.lineage_id,
460 "registry.example.com",
461 chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
462 .unwrap()
463 .with_timezone(&chrono::Utc),
464 &receipt.content_hash,
465 &receipt.key_fingerprint,
466 )
467 .unwrap();
468 let wire = serde_json::to_value(&r).unwrap();
469 assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
470 RegistryReceipt::validate_created_at_form(&wire).unwrap();
471 let parsed = RegistryReceipt::from_value(&wire).unwrap();
472 parsed
473 .verify_signature_with_key(Some(®istry_pub()), None)
474 .expect("whole-second receipt must round-trip and verify");
475 }
476
477 #[test]
478 fn cross_checks_fire() {
479 let r = test_receipt();
480 let ctx = r.ctx_id.clone();
481 let hash = r.content_hash.clone();
482 let fp = r.key_fingerprint.clone();
483
484 r.cross_check(&ctx, &hash, &fp).expect("all aligned");
485
486 let other =
488 CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
489 assert!(matches!(
490 r.cross_check(&other, &hash, &fp).unwrap_err(),
491 AcdpError::InvalidReceipt(_)
492 ));
493 assert!(r
495 .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
496 .is_err());
497 assert!(r
499 .cross_check(
500 &ctx,
501 &ContentHash(format!("sha256:{}", "c".repeat(64))),
502 &fp
503 )
504 .is_err());
505 }
506
507 #[test]
508 fn signer_rejects_malformed_identity() {
509 assert!(ReceiptSigner::new(
510 SigningKey::from_bytes(&[1u8; 32]),
511 "did:key:zNotWeb",
512 "did:key:zNotWeb#k",
513 )
514 .is_err());
515 assert!(ReceiptSigner::new(
516 SigningKey::from_bytes(&[1u8; 32]),
517 "did:web:registry.example.com",
518 "did:web:other.example.com#k",
519 )
520 .is_err());
521 assert!(ReceiptSigner::new(
522 SigningKey::from_bytes(&[1u8; 32]),
523 "did:web:registry.example.com",
524 "did:web:registry.example.com",
525 )
526 .is_err());
527 }
528}