Skip to main content

acdp_types/
receipt.rs

1//! Registry receipts (ACDP 0.2, RFC-ACDP-0010 — promoted from the
2//! RFC-ACDP-0009 §2.7 reservation).
3//!
4//! A receipt is a **registry-signed attestation** binding the
5//! registry-assigned identifiers (`ctx_id`, `lineage_id`,
6//! `origin_registry`, `created_at`) and the resolved producer key
7//! (`key_fingerprint`) to the producer's `content_hash`. It closes the
8//! two v0.1.0 trust gaps documented in RFC-ACDP-0008 §9:
9//!
10//! - **Registry honesty (§9.1)** — without a receipt, a malicious
11//!   registry can republish signed content under a different `ctx_id`
12//!   or backdate `created_at` and producer-signature verification
13//!   still passes. A receipt makes those claims attributable and
14//!   non-repudiable (though not unforgeable at mint time — a registry
15//!   can still lie when it first issues; the transparency log reserved
16//!   by RFC-ACDP-0009 §2.11 is the next layer).
17//! - **Historical key validity (§9.3)** — `key_fingerprint` records
18//!   *which* producer key the registry resolved and verified at publish
19//!   time, so consumers can verify old contexts after the producer
20//!   rotates.
21//!
22//! ## Signing construction
23//!
24//! Deliberately identical to the producer signature (RFC-ACDP-0001
25//! §5.8): the preimage is the JCS-canonicalized receipt object **minus
26//! the `signature` field only** (no §5.7 exclusion set — the
27//! registry-assigned fields are the entire point), hashed with SHA-256,
28//! and the signature is over the **ASCII bytes of the
29//! `"sha256:<hex>"` string**, not the raw digest.
30
31use 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/// A registry-signed publication receipt.
40///
41/// CLOSED schema (RFC-ACDP-0010 §4, `additionalProperties: false`):
42/// a receipt has exactly the eight specified members. Future receipt
43/// fields require a schema bump, not field-level extensibility —
44/// unknown members are rejected at parse time.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RegistryReceipt {
48    /// The registry's own identity — MUST be `did:web:<authority>`
49    /// where `<authority>` is the authority the context is served from.
50    pub registry_did: String,
51    /// The ctx_id this receipt attests.
52    pub ctx_id: CtxId,
53    /// The lineage the registry assigned.
54    pub lineage_id: LineageId,
55    /// The registry's authority (bare hostname form).
56    pub origin_registry: String,
57    /// Publication acceptance time. Canonical millisecond-precision
58    /// RFC 3339 UTC, always serialized with exactly three fractional
59    /// digits (`…SS.mmmZ`, RFC-ACDP-0010 §8 step 6) — chrono's default
60    /// drops trailing zeros, which would change the preimage bytes.
61    #[serde(with = "ms_rfc3339")]
62    pub created_at: DateTime<Utc>,
63    /// The producer's content hash this receipt binds.
64    pub content_hash: ContentHash,
65    /// Fingerprint of the producer key the registry resolved and
66    /// verified at publish time (see [`acdp_crypto::fingerprint`]).
67    pub key_fingerprint: String,
68    /// Registry signature over the receipt preimage.
69    pub signature: Signature,
70}
71
72/// Fixed three-digit-millisecond RFC 3339 serde for receipt
73/// `created_at` (RFC-ACDP-0010 §8 step 6: `…T…SS.mmmZ`).
74mod 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    /// Parse a receipt from the opaque JSON value carried in
92    /// [`crate::body::FullContext::registry_receipt`].
93    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    /// Compute the preimage hash from the RAW wire JSON of a receipt
99    /// (the value minus `signature`, canonicalized as received).
100    ///
101    /// Verifiers MUST hash the receipt exactly as received rather than
102    /// re-serializing a parsed struct — the same "hash verification
103    /// over raw JSON" rule as RFC-ACDP-0001 §6 bodies. Re-serialization
104    /// can normalize byte details (e.g. timestamp fraction digits) and
105    /// falsely fail an honest receipt.
106    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    /// Validate the §8 step 6 byte form of the receipt's raw
118    /// `created_at`: canonical millisecond-precision RFC 3339 UTC with
119    /// exactly three fractional digits and a literal `Z`.
120    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    /// §8 step 3 body bindings: `lineage_id`, `origin_registry`, and
144    /// `created_at` MUST equal the corresponding fields of the
145    /// accompanying body. (`ctx_id` is bound separately against the
146    /// *requested* identifier in [`Self::cross_check`].)
147    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    /// Compute the receipt's signature preimage hash: SHA-256 over the
170    /// JCS canonical form of the receipt **minus `signature` only**.
171    ///
172    /// Struct-based form, used at MINT time (the struct's serializer
173    /// emits the canonical three-digit-millisecond `created_at`).
174    /// Verifiers should prefer [`Self::preimage_hash_of_value`] over
175    /// the raw wire JSON.
176    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
177        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
178    }
179
180    /// Verify the receipt signature against a known registry public
181    /// key (pure — no DID resolution; the `client` feature's
182    /// `verify_receipt_value` resolves the registry DID and calls
183    /// this).
184    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    /// Like [`Self::verify_signature_with_key`] but over an
194    /// already-computed preimage hash — pair with
195    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
196    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    /// The pure (offline) subset of the RFC-ACDP-0010 cross-checks —
228    /// everything except registry-DID resolution and the
229    /// served-authority comparison, which need the `client` feature:
230    ///
231    /// - `ctx_id` equals the requested one.
232    /// - `content_hash` equals the *independently recomputed* body hash
233    ///   (pass the recomputed value, never the body's echoed field).
234    /// - `key_fingerprint` equals the fingerprint of the resolved
235    ///   producer key.
236    /// - `created_at` is millisecond-truncated.
237    /// - `registry_did` is `did:web:<origin_registry>` (internal
238    ///   consistency; the serving-authority comparison is the client's
239    ///   job).
240    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
280/// Registry-side receipt minting identity: the signing key plus the
281/// DID URL it is published under in the registry's own DID document.
282///
283/// Lifecycle rule (RFC-ACDP-0010, normative): retired receipt keys
284/// MUST remain in the registry DID document's `verificationMethod`
285/// indefinitely — rotation removes them from `assertionMethod` only
286/// (stops new receipts, keeps every previously minted receipt
287/// verifiable). Deleting an old key bricks every receipt it signed.
288pub struct ReceiptSigner {
289    key: acdp_crypto::sign::AcdpSigningKey,
290    /// e.g. `did:web:registry.example.com#receipt-key-1`.
291    key_id: String,
292    /// e.g. `did:web:registry.example.com`.
293    registry_did: String,
294}
295
296impl ReceiptSigner {
297    /// Create a signer. `key_id`'s DID portion MUST equal
298    /// `registry_did`, which MUST be the `did:web` form of the
299    /// registry's serving authority.
300    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    /// The registry DID this signer mints under.
328    pub fn registry_did(&self) -> &str {
329        &self.registry_did
330    }
331
332    /// Mint a signed receipt for an accepted publication.
333    ///
334    /// `producer_key_fingerprint` MUST be the fingerprint of the key
335    /// the validator *actually used* for producer-signature
336    /// verification — not re-resolved later (that is the whole
337    /// historical-validity guarantee).
338    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(), // filled below
359            },
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(&registry_pub()), None)
407            .expect("freshly minted receipt must verify");
408    }
409
410    #[test]
411    fn tampered_fields_fail_verification() {
412        // rcpt-002-style: any mutated bound field breaks the signature.
413        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    /// RFC-ACDP-0010 §4: the receipt schema is CLOSED. A receipt
428    /// carrying an unknown member MUST be rejected at parse time.
429    #[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    /// Raw-JSON preimage equals the struct preimage for a minted
440    /// receipt, and the fixed three-digit-millisecond `created_at`
441    /// serialization survives a parse → re-serialize round trip even
442    /// for whole-second timestamps (chrono would otherwise drop the
443    /// `.000`).
444    #[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        // Whole-second created_at: serialization MUST keep `.000`.
455        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(&registry_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        // rcpt-004-style: wrong ctx_id.
487        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        // rcpt-003-style: fingerprint mismatch.
494        assert!(r
495            .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
496            .is_err());
497        // Body-hash mismatch.
498        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}