Skip to main content

acdp_types/
event_receipt.rs

1//! Lifecycle event receipts (ACDP 0.5.0, RFC-ACDP-0017 §10, amending
2//! RFC-ACDP-0013).
3//!
4//! An event receipt is a registry-signed acceptance time for one
5//! lifecycle event. It gives the event a provable evidence time
6//! (`accepted_at`), where the event's own `occurred_at` is only the
7//! actor's claim. Registries serve the receipts in
8//! `registry_state.event_receipts`, an array parallel to
9//! `lifecycle_events`.
10//!
11//! The signing construction is RFC-ACDP-0010 §5 verbatim: JCS of the
12//! object minus `signature`, SHA-256, and a signature over the ASCII
13//! bytes of the full `"sha256:<hex>"` string. `receipt_type` is the
14//! in-preimage domain separator.
15
16use crate::body::Signature;
17use crate::receipt::{
18    is_canonical_ms_utc, preimage_hash_of_object, verify_signature_over_hash_with, ReceiptSigner,
19};
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{ContentHash, CtxId};
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26/// The `receipt_type` literal of an event receipt.
27pub const EVENT_RECEIPT_TYPE: &str = "acdp-event-receipt/1";
28
29/// A registry-signed lifecycle event receipt (RFC-ACDP-0017 §10.1).
30///
31/// CLOSED schema (`acdp-event-receipt.schema.json`,
32/// `additionalProperties: false`): every member is signed, so an unknown
33/// member would change the preimage and is rejected at parse time.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct EventReceipt {
37    /// Always [`EVENT_RECEIPT_TYPE`].
38    pub receipt_type: String,
39    /// The registry's identity, `did:web:<origin_registry>`.
40    pub registry_did: String,
41    /// The context the event belongs to.
42    pub ctx_id: CtxId,
43    /// The event's `event_id` (canonical lowercase UUID).
44    pub event_id: String,
45    /// `sha256:` + hex(SHA-256(JCS(event, *including* its signature))).
46    pub event_hash: String,
47    /// The event's zero-based position in `lifecycle_events`.
48    pub index: u64,
49    /// The registry's clock when it accepted the event (canonical
50    /// millisecond RFC 3339 UTC).
51    #[serde(with = "crate::receipt::ms_rfc3339")]
52    pub accepted_at: DateTime<Utc>,
53    /// Registry signature over the receipt preimage.
54    pub signature: Signature,
55}
56
57impl EventReceipt {
58    /// Parse an event receipt from raw wire JSON, enforcing the closed
59    /// schema: exact `receipt_type`, canonical-UUID `event_id`,
60    /// `sha256:<64 hex>` `event_hash`, the canonical millisecond byte
61    /// form of the RAW `accepted_at`, and a `signature.key_id` under
62    /// `registry_did`. Failures are [`AcdpError::InvalidReceipt`].
63    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
64        let raw_accepted_at = value.get("accepted_at").and_then(|v| v.as_str());
65        if !raw_accepted_at.is_some_and(is_canonical_ms_utc) {
66            return Err(AcdpError::InvalidReceipt(
67                "event receipt accepted_at is missing or not canonical millisecond-precision \
68                 RFC 3339 UTC (RFC-ACDP-0017 §10.1)"
69                    .into(),
70            ));
71        }
72        let receipt = Self::deserialize(value)
73            .map_err(|e| AcdpError::InvalidReceipt(format!("event receipt does not parse: {e}")))?;
74        receipt.validate()?;
75        Ok(receipt)
76    }
77
78    /// The schema invariants serde does not enforce.
79    pub fn validate(&self) -> Result<(), AcdpError> {
80        if self.receipt_type != EVENT_RECEIPT_TYPE {
81            return Err(AcdpError::InvalidReceipt(format!(
82                "event receipt receipt_type '{}' ≠ '{EVENT_RECEIPT_TYPE}'",
83                self.receipt_type
84            )));
85        }
86        CtxId::parse(self.ctx_id.as_str().to_string())
87            .map_err(|e| AcdpError::InvalidReceipt(format!("event receipt ctx_id: {e}")))?;
88        if !is_canonical_uuid(&self.event_id) {
89            return Err(AcdpError::InvalidReceipt(format!(
90                "event receipt event_id '{}' is not a canonical lowercase UUID",
91                self.event_id
92            )));
93        }
94        if !is_sha256_digest(&self.event_hash) {
95            return Err(AcdpError::InvalidReceipt(format!(
96                "event receipt event_hash '{}' is not sha256:<64 lowercase hex>",
97                self.event_hash
98            )));
99        }
100        match self.signature.key_id.split_once('#') {
101            Some((did, frag)) if did == self.registry_did && !frag.is_empty() => Ok(()),
102            _ => Err(AcdpError::InvalidReceipt(format!(
103                "event receipt signature.key_id '{}' is not a DID URL under registry_did '{}'",
104                self.signature.key_id, self.registry_did
105            ))),
106        }
107    }
108
109    /// `event_hash` for a raw lifecycle event: `sha256:` +
110    /// hex(SHA-256(JCS(event))), over the event exactly as stored,
111    /// *including* its `signature` member (RFC-ACDP-0017 §10.1).
112    pub fn event_hash_of_value(raw_event: &serde_json::Value) -> Result<String, AcdpError> {
113        let canonical = acdp_jcs::try_canonicalize_value(raw_event)?;
114        Ok(format!(
115            "sha256:{}",
116            hex::encode(Sha256::digest(&canonical))
117        ))
118    }
119
120    /// RFC-ACDP-0017 §10.3 steps 2–6 and 9: the receipt binds the
121    /// lifecycle event at `index` of the context `ctx_id`, served from
122    /// `origin_registry` and created at `created_at`. The signature
123    /// (step 7) and the cross-receipt `accepted_at` ordering (step 8) are
124    /// separate: see [`Self::verify_signature_against_hash`]. A receipt
125    /// failing any step is treated as absent (§10.3).
126    pub fn check_binding(
127        &self,
128        origin_registry: &str,
129        ctx_id: &CtxId,
130        raw_event: &serde_json::Value,
131        index: u64,
132        created_at: DateTime<Utc>,
133    ) -> Result<(), AcdpError> {
134        let fail = |m: String| Err(AcdpError::InvalidReceipt(m));
135        let expected_did = acdp_did::web::authority_to_did_web(origin_registry);
136        if self.registry_did != expected_did {
137            return fail(format!(
138                "event receipt registry_did '{}' ≠ '{expected_did}' (§10.3 step 2)",
139                self.registry_did
140            ));
141        }
142        if &self.ctx_id != ctx_id {
143            return fail("event receipt ctx_id ≠ the context's (§10.3 step 3)".into());
144        }
145        if raw_event.get("event_id").and_then(|v| v.as_str()) != Some(self.event_id.as_str()) {
146            return fail("event receipt event_id ≠ the event's (§10.3 step 4)".into());
147        }
148        if self.index != index {
149            return fail(format!(
150                "event receipt index {} ≠ position {index} (§10.3 step 5)",
151                self.index
152            ));
153        }
154        if Self::event_hash_of_value(raw_event)? != self.event_hash {
155            return fail("event receipt event_hash does not recompute (§10.3 step 6)".into());
156        }
157        if self.accepted_at < created_at {
158            return fail(
159                "event receipt accepted_at precedes the context's created_at (§10.3 step 9)".into(),
160            );
161        }
162        Ok(())
163    }
164
165    /// Preimage hash from the RAW wire JSON (the value minus
166    /// `signature`). Verifiers MUST hash the receipt as received.
167    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
168        preimage_hash_of_object(value, "event receipt")
169    }
170
171    /// Struct-based preimage hash, used at mint time.
172    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
173        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
174    }
175
176    /// Verify the signature over an already-computed preimage hash
177    /// against a known registry public key (pure; no DID resolution).
178    pub fn verify_signature_against_hash(
179        &self,
180        hash: &ContentHash,
181        registry_pub_ed25519: Option<&[u8; 32]>,
182        registry_pub_p256_sec1: Option<&[u8]>,
183    ) -> Result<(), AcdpError> {
184        verify_signature_over_hash_with(
185            &self.signature,
186            hash,
187            registry_pub_ed25519,
188            registry_pub_p256_sec1,
189            "event receipt",
190            AcdpError::InvalidReceipt,
191        )
192    }
193}
194
195impl ReceiptSigner {
196    /// Mint an event receipt for the lifecycle event at `index`
197    /// (RFC-ACDP-0017 §10.1), signed with the receipt signing key.
198    ///
199    /// `raw_event` is the event exactly as stored, including its
200    /// `signature`; `event_hash` is computed from it here. The caller
201    /// owns the §10.1 clock rules: `accepted_at` MUST NOT be backdated
202    /// and MUST be ≥ the previous event's `accepted_at` and the
203    /// context's `created_at`.
204    pub fn mint_event_receipt(
205        &self,
206        ctx_id: &CtxId,
207        raw_event: &serde_json::Value,
208        index: u64,
209        accepted_at: DateTime<Utc>,
210    ) -> Result<EventReceipt, AcdpError> {
211        let event_id = raw_event
212            .get("event_id")
213            .and_then(|v| v.as_str())
214            .ok_or_else(|| {
215                AcdpError::SchemaViolation("lifecycle event has no string event_id".into())
216            })?
217            .to_string();
218        let mut receipt = EventReceipt {
219            receipt_type: EVENT_RECEIPT_TYPE.to_string(),
220            registry_did: self.registry_did().to_string(),
221            ctx_id: ctx_id.clone(),
222            event_id,
223            event_hash: EventReceipt::event_hash_of_value(raw_event)?,
224            index,
225            accepted_at: acdp_primitives::time::trunc_ms(accepted_at),
226            signature: Signature {
227                algorithm: self.signing_key().algorithm().into(),
228                key_id: self.key_id().to_string(),
229                value: String::new(),
230            },
231        };
232        receipt.validate()?;
233        let hash = receipt.preimage_hash()?;
234        let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
235        receipt.signature.algorithm = algorithm.into();
236        receipt.signature.value = value;
237        Ok(receipt)
238    }
239}
240
241fn is_canonical_uuid(s: &str) -> bool {
242    let b = s.as_bytes();
243    b.len() == 36
244        && b.iter().enumerate().all(|(i, &c)| match i {
245            8 | 13 | 18 | 23 => c == b'-',
246            _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
247        })
248}
249
250fn is_sha256_digest(s: &str) -> bool {
251    s.strip_prefix("sha256:").is_some_and(|hex| {
252        hex.len() == 64
253            && hex
254                .bytes()
255                .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
256    })
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use acdp_crypto::SigningKey;
263
264    const CTX: &str = "acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
265
266    fn signer() -> ReceiptSigner {
267        ReceiptSigner::new(
268            SigningKey::from_bytes(&[0x11; 32]),
269            "did:web:registry.example.com",
270            "did:web:registry.example.com#receipt-key-1",
271        )
272        .unwrap()
273    }
274
275    fn event() -> serde_json::Value {
276        serde_json::json!({
277            "event_id": "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f",
278            "ctx_id": CTX,
279            "event_type": "retracted",
280            "occurred_at": "2026-10-03T00:00:00.000Z",
281            "actor": "did:web:registry.example.com"
282        })
283    }
284
285    fn accepted() -> DateTime<Utc> {
286        DateTime::parse_from_rfc3339("2026-10-03T00:00:01.000Z")
287            .unwrap()
288            .with_timezone(&Utc)
289    }
290
291    #[test]
292    fn mint_round_trips_and_verifies() {
293        let receipt = signer()
294            .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
295            .unwrap();
296        let wire = serde_json::to_value(&receipt).unwrap();
297        assert_eq!(wire["accepted_at"], "2026-10-03T00:00:01.000Z");
298        let parsed = EventReceipt::from_value(&wire).unwrap();
299        assert_eq!(parsed, receipt);
300        let hash = EventReceipt::preimage_hash_of_value(&wire).unwrap();
301        let key = SigningKey::from_bytes(&[0x11; 32]).verifying_key_bytes();
302        parsed
303            .verify_signature_against_hash(&hash, Some(&key), None)
304            .unwrap();
305        assert_eq!(
306            parsed.event_hash,
307            EventReceipt::event_hash_of_value(&event()).unwrap()
308        );
309    }
310
311    #[test]
312    fn check_binding_covers_steps_2_to_6_and_9() {
313        let ctx = CtxId::parse(CTX).unwrap();
314        let created = DateTime::parse_from_rfc3339("2026-09-01T00:00:00.000Z")
315            .unwrap()
316            .with_timezone(&Utc);
317        let r = signer()
318            .mint_event_receipt(&ctx, &event(), 0, accepted())
319            .unwrap();
320        r.check_binding("registry.example.com", &ctx, &event(), 0, created)
321            .unwrap();
322        // Step 2: another registry.
323        assert!(r
324            .check_binding("other.example.com", &ctx, &event(), 0, created)
325            .is_err());
326        // Step 3: another context.
327        let other =
328            CtxId::parse("acdp://registry.example.com/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
329                .unwrap();
330        assert!(r
331            .check_binding("registry.example.com", &other, &event(), 0, created)
332            .is_err());
333        // Step 5: another position.
334        assert!(r
335            .check_binding("registry.example.com", &ctx, &event(), 1, created)
336            .is_err());
337        // Steps 4 and 6: another event.
338        let mut changed = event();
339        changed["occurred_at"] = "2026-10-03T00:00:00.001Z".into();
340        assert!(r
341            .check_binding("registry.example.com", &ctx, &changed, 0, created)
342            .is_err());
343        changed = event();
344        changed["event_id"] = "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e30".into();
345        assert!(r
346            .check_binding("registry.example.com", &ctx, &changed, 0, created)
347            .is_err());
348        // Step 9: accepted before the context was created.
349        let later = accepted() + chrono::Duration::days(1);
350        assert!(r
351            .check_binding("registry.example.com", &ctx, &event(), 0, later)
352            .is_err());
353    }
354
355    #[test]
356    fn registry_state_event_receipts_round_trip() {
357        use crate::body::RegistryState;
358        let r = signer()
359            .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
360            .unwrap();
361        let mut state: RegistryState =
362            serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
363        assert!(state.event_receipts.is_none());
364        state.event_receipts = Some(vec![r.clone()]);
365        let wire = serde_json::to_value(&state).unwrap();
366        assert_eq!(
367            wire["event_receipts"][0]["receipt_type"],
368            EVENT_RECEIPT_TYPE
369        );
370        assert!(!wire.as_object().unwrap().contains_key("extensions"));
371        let back: RegistryState = serde_json::from_value(wire).unwrap();
372        assert_eq!(back.event_receipts, Some(vec![r]));
373        assert!(back.extensions.is_empty());
374        // Omitted, never `[]`.
375        state.event_receipts = None;
376        assert!(serde_json::to_value(&state)
377            .unwrap()
378            .get("event_receipts")
379            .is_none());
380        // A malformed element, or a non-array, is malformed registry state.
381        for bad in [
382            serde_json::json!({"status": "active", "event_receipts": [{"x": 1}]}),
383            serde_json::json!({"status": "active", "event_receipts": {"x": 1}}),
384            serde_json::json!({"status": "active", "event_receipts": null}),
385        ] {
386            assert!(serde_json::from_value::<RegistryState>(bad).is_err());
387        }
388    }
389
390    /// Regression: a fresh context from a lifecycle registry carries no
391    /// `lifecycle_events` (RFC-ACDP-0013 §4.1 omits the empty array), and
392    /// that means not retracted, not unknown.
393    #[test]
394    fn retraction_absent_events_from_lifecycle_registry_is_not_retracted() {
395        use crate::body::RegistryState;
396        let fresh: RegistryState =
397            serde_json::from_value(serde_json::json!({"status": "active"})).unwrap();
398        assert_eq!(fresh.retraction(true), Some(false));
399        assert_eq!(fresh.retraction(false), None);
400
401        let retracted_event = {
402            let mut e = event();
403            e["signature"] = serde_json::json!({
404                "algorithm": "ed25519",
405                "key_id": "did:web:registry.example.com#receipt-key-1",
406                "value": "AA=="
407            });
408            e
409        };
410        let state: RegistryState = serde_json::from_value(serde_json::json!({
411            "status": "active",
412            "lifecycle_events": [retracted_event]
413        }))
414        .unwrap();
415        assert_eq!(state.retraction(true), Some(true));
416        assert_eq!(state.retraction(false), Some(true));
417
418        let by_status: RegistryState =
419            serde_json::from_value(serde_json::json!({"status": "retracted"})).unwrap();
420        assert_eq!(by_status.retraction(false), Some(true));
421    }
422
423    #[test]
424    fn closed_schema_rejects_unknown_member_and_bad_forms() {
425        let receipt = signer()
426            .mint_event_receipt(&CtxId::parse(CTX).unwrap(), &event(), 0, accepted())
427            .unwrap();
428        let wire = serde_json::to_value(&receipt).unwrap();
429
430        let mut extra = wire.clone();
431        extra["note"] = "x".into();
432        assert!(EventReceipt::from_value(&extra).is_err());
433
434        let mut short_ts = wire.clone();
435        short_ts["accepted_at"] = "2026-10-03T00:00:01Z".into();
436        assert!(EventReceipt::from_value(&short_ts).is_err());
437
438        let mut wrong_type = wire.clone();
439        wrong_type["receipt_type"] = "acdp-event-receipt/2".into();
440        assert!(EventReceipt::from_value(&wrong_type).is_err());
441
442        let mut foreign_key = wire;
443        foreign_key["signature"]["key_id"] = "did:web:evil.example.com#k".into();
444        assert!(EventReceipt::from_value(&foreign_key).is_err());
445    }
446}