Skip to main content

acdp_types/
cosignature.rs

1//! Transparency-log witness cosignatures (ACDP 0.4, RFC-ACDP-0015 —
2//! promoted from the RFC-ACDP-0009 §2.12 reservation).
3//!
4//! A **cosignature** is an independent **witness's** signed observation
5//! of an RFC-ACDP-0012 transparency-log checkpoint: the witness observed
6//! a specific checkpoint tuple `{log_id, tree_size, root_hash,
7//! timestamp}`, verified its signature and consistency from its retained
8//! head (§7), and cosigned it at `witnessed_at`. A consumer that trusts
9//! any one honest witness inherits split-view protection: a checkpoint is
10//! believed only when parties the registry does not control also saw,
11//! consistency-checked, and signed it.
12//!
13//! ## The one load-bearing departure from RFC-ACDP-0010/0011/0012
14//!
15//! RFC-ACDP-0011 head receipts and RFC-ACDP-0012 checkpoints all sign
16//! with the **registry receipt key**. A cosignature signs with the
17//! **witness's own key**, under the witness's own DID (§5, §12) — the
18//! whole value of a cosignature is that it is *not* the registry's
19//! signature. Witnesses are **not** registries: they have no publish
20//! surface and mint no receipts (§3). The [`WitnessSigner`] therefore
21//! introduces a new key role, distinct from [`crate::receipt::ReceiptSigner`].
22//!
23//! ## Signing construction
24//!
25//! Reuses RFC-ACDP-0010 §5 **verbatim** (§5): the preimage is the JCS
26//! canonicalization (RFC 8785) of the object **minus `signature` only**
27//! (there is no exclusion set beyond `signature`), SHA-256'd; the witness
28//! signs the **ASCII bytes of the `"sha256:<hex>"` string**, not the raw
29//! 32-byte digest, with `cosignature_version` acting as the in-preimage
30//! domain separator.
31//!
32//! All verification failures here map to
33//! [`AcdpError::InvalidWitnessCosignature`] — deliberately **not**
34//! `InvalidLogProof`: a cosignature failure indicts a *witness's*
35//! attestation (an independent signer), never the *log* itself; the
36//! verdicts are independent (RFC-ACDP-0015 §8, §10).
37
38use crate::body::Signature;
39use crate::log::{decode_sha256_hex, parse_log_id, LogCheckpoint};
40use crate::receipt::{
41    is_canonical_ms_utc, ms_rfc3339, preimage_hash_of_object_with, verify_signature_over_hash_with,
42};
43use acdp_primitives::error::AcdpError;
44use acdp_primitives::primitives::ContentHash;
45use chrono::{DateTime, Utc};
46use serde::{Deserialize, Serialize};
47
48/// The cosignature envelope version (RFC-ACDP-0015 §4). In-preimage
49/// domain separator (the RFC-ACDP-0011 `receipt_version` /
50/// RFC-ACDP-0012 `checkpoint_version` convention): a cosignature can
51/// never be mistaken for — or replayed as — a checkpoint, a receipt, or
52/// any other JCS-canonicalized ACDP object.
53pub const COSIGNATURE_VERSION: &str = "acdp-cosig/1";
54
55/// True when `id` is a well-formed witness DID (`did:web` or `did:key`)
56/// — the RFC-ACDP-0015 §4 `witness_id` shape (a light structural check;
57/// full resolution is the `client` feature's job).
58fn is_witness_did(id: &str) -> bool {
59    id.starts_with("did:web:") && id.len() > "did:web:".len()
60        || id.starts_with("did:key:z") && id.len() > "did:key:z".len()
61}
62
63// ── Witnessed checkpoint (the identity-bearing subset) ───────────────────────
64
65/// The identity-bearing subset of the RFC-ACDP-0012 §6 checkpoint the
66/// witness observed: `{log_id, tree_size, root_hash, timestamp}`, copied
67/// **verbatim** from the verified checkpoint (RFC-ACDP-0015 §4).
68///
69/// CLOSED schema (`additionalProperties: false`): the registry's
70/// `checkpoint_version` and `signature` are deliberately NOT restated —
71/// the consumer verifies the registry checkpoint signature independently
72/// (RFC-ACDP-0012 §9.3). Every member is signed, so an unknown member
73/// changes the preimage and is rejected at parse time.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct WitnessedCheckpoint {
77    /// The observed log instantiation identifier
78    /// (`"<registry_did>/log/<instance>"`), copied verbatim from the
79    /// checkpoint the witness verified.
80    pub log_id: String,
81    /// The observed checkpoint's `tree_size`.
82    pub tree_size: u64,
83    /// The observed checkpoint's `root_hash` (`"sha256:<hex>"`).
84    /// Together with `log_id` and `tree_size` this is the tuple the
85    /// N-witnessed count is computed over (RFC-ACDP-0015 §8).
86    pub root_hash: String,
87    /// The **registry-claimed** checkpoint timestamp (the RFC-ACDP-0012
88    /// §6 `checkpoint.timestamp`), copied verbatim; canonical
89    /// millisecond-precision RFC 3339 UTC. Registry-asserted — the
90    /// witness does NOT vouch for it (`witnessed_at` is what the witness
91    /// attests, RFC-ACDP-0015 §4).
92    #[serde(with = "ms_rfc3339")]
93    pub timestamp: DateTime<Utc>,
94}
95
96impl WitnessedCheckpoint {
97    /// Copy the identity-bearing subset out of a verified checkpoint
98    /// (RFC-ACDP-0015 §7 step 3 — `{log_id, tree_size, root_hash,
99    /// timestamp}` verbatim).
100    pub fn from_checkpoint(checkpoint: &LogCheckpoint) -> Self {
101        Self {
102            log_id: checkpoint.log_id.clone(),
103            tree_size: checkpoint.tree_size,
104            root_hash: checkpoint.root_hash.clone(),
105            timestamp: checkpoint.timestamp,
106        }
107    }
108}
109
110// ── Cosignature ──────────────────────────────────────────────────────────────
111
112/// A witness-signed cosignature of a transparency-log checkpoint
113/// (RFC-ACDP-0015 §4).
114///
115/// CLOSED schema (`acdp-log-cosignature.schema.json`,
116/// `additionalProperties: false`): every member is signed, so an unknown
117/// member changes the preimage and is rejected at parse time; extensions
118/// require a `cosignature_version` bump.
119///
120/// Cosignatures are **ephemeral, per-observation** evidence (the
121/// RFC-ACDP-0011 §4 posture): a witness produces a fresh cosignature
122/// (fresh `witnessed_at`) each time it re-observes the log, including at
123/// an unchanged `tree_size` as a liveness signal.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct LogCosignature {
127    /// MUST be exactly [`COSIGNATURE_VERSION`] (`"acdp-cosig/1"`).
128    pub cosignature_version: String,
129    /// The **witness's** DID (`did:web` or `did:key`) — the witness's
130    /// own identity, distinct from the registry's `registry_did`
131    /// (RFC-ACDP-0015 §3). The N-witnessed count (§8) is over DISTINCT
132    /// `witness_id` values.
133    pub witness_id: String,
134    /// The identity-bearing subset of the checkpoint the witness
135    /// observed, copied verbatim.
136    pub witnessed_checkpoint: WitnessedCheckpoint,
137    /// The **witness-clock** time at which the witness observed and
138    /// cosigned the checkpoint; canonical millisecond-precision RFC 3339
139    /// UTC (RFC-ACDP-0001 §5.3). Anchored against a party the registry
140    /// does not control — it bounds when the checkpoint existed
141    /// regardless of the registry's claimed `timestamp` (§4).
142    #[serde(with = "ms_rfc3339")]
143    pub witnessed_at: DateTime<Utc>,
144    /// The **witness's** signature over the cosignature hash (§5 — the
145    /// RFC-ACDP-0010 §5 construction verbatim, keyed by the witness's
146    /// own `assertionMethod` key). `signature.key_id` MUST be a DID URL
147    /// under `witness_id`.
148    pub signature: Signature,
149}
150
151impl LogCosignature {
152    /// RFC-ACDP-0015 §8 step 1 — schema-closed parse plus the §4/§5
153    /// semantic invariants: exact `cosignature_version`, a well-formed
154    /// witness DID, a well-formed `witnessed_checkpoint`
155    /// (`log_id`/`root_hash` shape, canonical millisecond `timestamp`
156    /// byte form), the canonical millisecond `witnessed_at` byte form
157    /// (both checked on the RAW wire strings before any parsing
158    /// normalization), and the §8 step 3 witness binding —
159    /// `signature.key_id` is a DID URL under `witness_id`.
160    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
161        let cosig = Self::deserialize(value).map_err(|e| {
162            AcdpError::InvalidWitnessCosignature(format!("log_cosignature does not parse: {e}"))
163        })?;
164        if cosig.cosignature_version != COSIGNATURE_VERSION {
165            return Err(AcdpError::InvalidWitnessCosignature(format!(
166                "log_cosignature cosignature_version '{}' ≠ '{COSIGNATURE_VERSION}' \
167                 (RFC-ACDP-0015 §4)",
168                cosig.cosignature_version
169            )));
170        }
171        if !is_witness_did(&cosig.witness_id) {
172            return Err(AcdpError::InvalidWitnessCosignature(format!(
173                "log_cosignature witness_id '{}' is not a did:web or did:key DID \
174                 (RFC-ACDP-0015 §4)",
175                cosig.witness_id
176            )));
177        }
178        // witnessed_checkpoint identity-field shapes.
179        parse_log_id(&cosig.witnessed_checkpoint.log_id).map_err(|e| {
180            AcdpError::InvalidWitnessCosignature(format!("witnessed_checkpoint: {e}"))
181        })?;
182        decode_sha256_hex(&cosig.witnessed_checkpoint.root_hash).map_err(|e| {
183            AcdpError::InvalidWitnessCosignature(format!("witnessed_checkpoint: {e}"))
184        })?;
185
186        // §4 canonical millisecond byte forms, checked on the RAW wire
187        // strings (before parsing normalization).
188        let wc_ts = value
189            .get("witnessed_checkpoint")
190            .and_then(|v| v.get("timestamp"))
191            .and_then(|v| v.as_str())
192            .ok_or_else(|| {
193                AcdpError::InvalidWitnessCosignature(
194                    "log_cosignature witnessed_checkpoint.timestamp missing or not a string".into(),
195                )
196            })?;
197        if !is_canonical_ms_utc(wc_ts) {
198            return Err(AcdpError::InvalidWitnessCosignature(format!(
199                "log_cosignature witnessed_checkpoint.timestamp '{wc_ts}' is not canonical \
200                 millisecond-precision RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0015 §4)"
201            )));
202        }
203        let witnessed_at = value
204            .get("witnessed_at")
205            .and_then(|v| v.as_str())
206            .ok_or_else(|| {
207                AcdpError::InvalidWitnessCosignature(
208                    "log_cosignature witnessed_at missing or not a string".into(),
209                )
210            })?;
211        if !is_canonical_ms_utc(witnessed_at) {
212            return Err(AcdpError::InvalidWitnessCosignature(format!(
213                "log_cosignature witnessed_at '{witnessed_at}' is not canonical \
214                 millisecond-precision RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0015 §4)"
215            )));
216        }
217
218        // §8 step 3 witness binding: signature.key_id is a DID URL under
219        // witness_id.
220        cosig.witness_key_did()?;
221        Ok(cosig)
222    }
223
224    /// RFC-ACDP-0015 §8 step 3 — the witness DID that `signature.key_id`
225    /// is a DID URL under, after checking that its DID portion equals
226    /// `witness_id` and it carries a non-empty fragment.
227    pub fn witness_key_did(&self) -> Result<&str, AcdpError> {
228        match self.signature.key_id.split_once('#') {
229            Some((did, frag)) if did == self.witness_id && !frag.is_empty() => Ok(did),
230            _ => Err(AcdpError::InvalidWitnessCosignature(format!(
231                "log_cosignature signature.key_id '{}' is not a DID URL under witness_id '{}' \
232                 (RFC-ACDP-0015 §8 step 3)",
233                self.signature.key_id, self.witness_id
234            ))),
235        }
236    }
237
238    /// The `(log_id, tree_size, root_hash)` tuple the N-witnessed count
239    /// is computed over (RFC-ACDP-0015 §8). Two cosignatures count
240    /// toward the same checkpoint iff this tuple is byte/numerically
241    /// equal.
242    pub fn checkpoint_tuple(&self) -> (&str, u64, &str) {
243        (
244            &self.witnessed_checkpoint.log_id,
245            self.witnessed_checkpoint.tree_size,
246            &self.witnessed_checkpoint.root_hash,
247        )
248    }
249
250    /// Compute the cosignature hash from the RAW wire JSON (the value
251    /// minus `signature`, JCS-canonicalized as received, SHA-256'd).
252    /// Verifiers MUST hash the cosignature exactly as received — the same
253    /// raw-JSON rule as [`crate::receipt::RegistryReceipt::preimage_hash_of_value`].
254    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
255        preimage_hash_of_object_with(
256            value,
257            "log_cosignature",
258            AcdpError::InvalidWitnessCosignature,
259        )
260    }
261
262    /// Compute the cosignature hash from the struct. Used at MINT time
263    /// (the struct's serializer emits the canonical three-digit-
264    /// millisecond timestamps); verifiers should prefer
265    /// [`Self::preimage_hash_of_value`] over the raw wire JSON.
266    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
267        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
268    }
269
270    /// RFC-ACDP-0015 §8 step 2 — verify the witness signature against a
271    /// known witness public key (pure — no DID resolution; the `client`
272    /// feature's `verify_witness_cosignature_value` resolves the witness
273    /// DID and calls this).
274    pub fn verify_signature_with_key(
275        &self,
276        witness_pub_ed25519: Option<&[u8; 32]>,
277        witness_pub_p256_sec1: Option<&[u8]>,
278    ) -> Result<(), AcdpError> {
279        let hash = self.preimage_hash()?;
280        self.verify_signature_against_hash(&hash, witness_pub_ed25519, witness_pub_p256_sec1)
281    }
282
283    /// Like [`Self::verify_signature_with_key`] but over an
284    /// already-computed cosignature hash — pair with
285    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
286    pub fn verify_signature_against_hash(
287        &self,
288        hash: &ContentHash,
289        witness_pub_ed25519: Option<&[u8; 32]>,
290        witness_pub_p256_sec1: Option<&[u8]>,
291    ) -> Result<(), AcdpError> {
292        verify_signature_over_hash_with(
293            &self.signature,
294            hash,
295            witness_pub_ed25519,
296            witness_pub_p256_sec1,
297            "log_cosignature",
298            AcdpError::InvalidWitnessCosignature,
299        )
300    }
301
302    /// RFC-ACDP-0015 §8 step 4 — checkpoint binding: the
303    /// `witnessed_checkpoint`'s `log_id`, `tree_size`, and `root_hash`
304    /// MUST equal, byte-for-byte / numerically, the corresponding fields
305    /// of the checkpoint the consumer independently holds and verified
306    /// (RFC-ACDP-0012 §9.3). A cosignature over a *different* tuple is
307    /// evidence about a different checkpoint and MUST NOT be counted for
308    /// this one. `timestamp` is deliberately not compared here — it is
309    /// registry-asserted and the witness merely copies it (§4).
310    pub fn cross_check_against_checkpoint(
311        &self,
312        checkpoint: &LogCheckpoint,
313    ) -> Result<(), AcdpError> {
314        let wc = &self.witnessed_checkpoint;
315        if wc.log_id != checkpoint.log_id {
316            return Err(AcdpError::InvalidWitnessCosignature(format!(
317                "log_cosignature witnessed_checkpoint.log_id '{}' ≠ evaluated checkpoint log_id \
318                 '{}' (RFC-ACDP-0015 §8 step 4)",
319                wc.log_id, checkpoint.log_id
320            )));
321        }
322        if wc.tree_size != checkpoint.tree_size {
323            return Err(AcdpError::InvalidWitnessCosignature(format!(
324                "log_cosignature witnessed_checkpoint.tree_size {} ≠ evaluated checkpoint \
325                 tree_size {} (RFC-ACDP-0015 §8 step 4)",
326                wc.tree_size, checkpoint.tree_size
327            )));
328        }
329        if wc.root_hash != checkpoint.root_hash {
330            return Err(AcdpError::InvalidWitnessCosignature(format!(
331                "log_cosignature witnessed_checkpoint.root_hash '{}' ≠ evaluated checkpoint \
332                 root_hash '{}' (RFC-ACDP-0015 §8 step 4)",
333                wc.root_hash, checkpoint.root_hash
334            )));
335        }
336        Ok(())
337    }
338
339    /// RFC-ACDP-0015 §8 step 5 — `witnessed_at` sanity against the
340    /// consumer's clock: millisecond-truncated (RFC-ACDP-0001 §5.3) and
341    /// not in the future beyond `max_clock_skew` (RECOMMENDED 120 s, the
342    /// RFC-ACDP-0011 §7 step 6 allowance). A future-dated `witnessed_at`
343    /// is a forged observation-time claim.
344    ///
345    /// This is the *verification* half only. Staleness (an old but
346    /// honest `witnessed_at`) is consumer freshness policy (§8.1) —
347    /// evaluate it separately via [`Self::age_at`]. For the
348    /// anti-backdating use an old cosignature is *stronger* evidence.
349    pub fn check_witnessed_at_skew(
350        &self,
351        now: DateTime<Utc>,
352        max_clock_skew: chrono::Duration,
353    ) -> Result<(), AcdpError> {
354        if !self
355            .witnessed_at
356            .timestamp_subsec_nanos()
357            .is_multiple_of(1_000_000)
358        {
359            return Err(AcdpError::InvalidWitnessCosignature(
360                "log_cosignature witnessed_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
361                    .into(),
362            ));
363        }
364        if self.witnessed_at > now + max_clock_skew {
365            return Err(AcdpError::InvalidWitnessCosignature(format!(
366                "log_cosignature witnessed_at '{}' is in the future beyond the {}s clock-skew \
367                 allowance (consumer clock '{}') — forged observation-time claim \
368                 (RFC-ACDP-0015 §8 step 5)",
369                self.witnessed_at.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
370                max_clock_skew.num_seconds(),
371                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
372            )));
373        }
374        Ok(())
375    }
376
377    /// The cosignature's age at `now` — the input to the consumer's §8.1
378    /// freshness policy (RECOMMENDED maximum: 300 seconds for
379    /// current-ness-sensitive decisions). Negative when `witnessed_at`
380    /// is ahead of `now` (bounded by the step-5 skew check).
381    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
382        now - self.witnessed_at
383    }
384}
385
386// ── Witness-side minting ─────────────────────────────────────────────────────
387
388/// Witness-side cosignature minting identity: the **witness's** signing
389/// key plus the DID URL it is published under in the witness's own DID
390/// document (RFC-ACDP-0015 §5, §9).
391///
392/// Deliberately **not** [`crate::receipt::ReceiptSigner`]: the whole
393/// value of a cosignature is that the signer is the witness, under the
394/// witness's own DID and key — never the registry's receipt key (§5,
395/// §12). Witnesses are independent parties (§3).
396///
397/// Key lifecycle (RFC-ACDP-0015 §9, the RFC-ACDP-0010 §9 rule applied to
398/// the witness's own key): retired witness keys remain in the witness DID
399/// document's `verificationMethod` indefinitely so historical
400/// cosignatures stay verifiable; rotation removes a key from
401/// `assertionMethod` only.
402pub struct WitnessSigner {
403    key: acdp_crypto::sign::AcdpSigningKey,
404    /// e.g. `did:web:witness.example.org#witness-key-1`.
405    key_id: String,
406    /// e.g. `did:web:witness.example.org`.
407    witness_id: String,
408}
409
410impl WitnessSigner {
411    /// Create a witness signer. `witness_id` MUST be a `did:web` or
412    /// `did:key` DID; `key_id`'s DID portion MUST equal `witness_id` and
413    /// carry a non-empty fragment.
414    pub fn new(
415        key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
416        witness_id: impl Into<String>,
417        key_id: impl Into<String>,
418    ) -> Result<Self, AcdpError> {
419        let witness_id = witness_id.into();
420        let key_id = key_id.into();
421        if !is_witness_did(&witness_id) {
422            return Err(AcdpError::SchemaViolation(format!(
423                "witness signer witness_id must be did:web or did:key, got '{witness_id}'"
424            )));
425        }
426        match key_id.split_once('#') {
427            Some((did, frag)) if did == witness_id && !frag.is_empty() => {}
428            _ => {
429                return Err(AcdpError::SchemaViolation(format!(
430                    "witness signer key_id '{key_id}' must be '<witness_id>#<fragment>'"
431                )));
432            }
433        }
434        Ok(Self {
435            key: key.into(),
436            key_id,
437            witness_id,
438        })
439    }
440
441    /// The witness DID this signer mints under.
442    pub fn witness_id(&self) -> &str {
443        &self.witness_id
444    }
445
446    /// The DID URL the witness signing key is published under (e.g.
447    /// `did:web:witness.example.org#witness-key-1`).
448    pub fn key_id(&self) -> &str {
449        &self.key_id
450    }
451
452    /// Mint a signed cosignature over an observed checkpoint
453    /// (RFC-ACDP-0015 §5, §7 step 3): copy the checkpoint's
454    /// `{log_id, tree_size, root_hash, timestamp}` verbatim into
455    /// `witnessed_checkpoint`, stamp `witnessed_at` (truncated here to
456    /// milliseconds) from the witness's own clock, and sign with the
457    /// witness key.
458    ///
459    /// This is the **raw** mint — it performs no witness obligation
460    /// (§7): it does not verify the checkpoint's own signature or its
461    /// consistency against a retained head. Callers acting as a
462    /// production witness MUST use the `client` feature's
463    /// `mint_cosignature_checked` (which runs the §7 obligation first),
464    /// or run those checks themselves. A witness that cosigns a
465    /// checkpoint failing consistency provides negative security value
466    /// (§7).
467    pub fn mint(
468        &self,
469        checkpoint: &LogCheckpoint,
470        witnessed_at: DateTime<Utc>,
471    ) -> Result<LogCosignature, AcdpError> {
472        let mut cosig = LogCosignature {
473            cosignature_version: COSIGNATURE_VERSION.to_string(),
474            witness_id: self.witness_id.clone(),
475            witnessed_checkpoint: WitnessedCheckpoint::from_checkpoint(checkpoint),
476            witnessed_at: acdp_primitives::time::trunc_ms(witnessed_at),
477            signature: Signature {
478                algorithm: self.key.algorithm().into(),
479                key_id: self.key_id.clone(),
480                value: String::new(), // filled below
481            },
482        };
483        let hash = cosig.preimage_hash()?;
484        let (algorithm, value) = self.key.sign_content_hash(&hash);
485        cosig.signature.algorithm = algorithm.into();
486        cosig.signature.value = value;
487        Ok(cosig)
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::receipt::ReceiptSigner;
495    use acdp_crypto::SigningKey;
496
497    const REGISTRY_DID: &str = "did:web:registry.example.com";
498    const LOG_ID: &str = "did:web:registry.example.com/log/1";
499    const WITNESS_DID: &str = "did:web:witness.example.org";
500
501    fn checkpoint() -> LogCheckpoint {
502        let root = crate::log::encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
503        ReceiptSigner::new(
504            SigningKey::from_bytes(&[0x11u8; 32]),
505            REGISTRY_DID,
506            format!("{REGISTRY_DID}#receipt-key-1"),
507        )
508        .unwrap()
509        .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
510        .unwrap()
511    }
512
513    fn witness_signer() -> WitnessSigner {
514        WitnessSigner::new(
515            SigningKey::from_bytes(&[0x33u8; 32]),
516            WITNESS_DID,
517            format!("{WITNESS_DID}#witness-key-1"),
518        )
519        .unwrap()
520    }
521
522    fn witness_pub() -> [u8; 32] {
523        SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes()
524    }
525
526    #[test]
527    fn mint_verify_round_trip() {
528        let cp = checkpoint();
529        let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
530        assert_eq!(cosig.cosignature_version, COSIGNATURE_VERSION);
531        cosig
532            .verify_signature_with_key(Some(&witness_pub()), None)
533            .expect("freshly minted cosignature must verify");
534
535        // Wire round trip through the closed parse; raw and struct
536        // preimages agree.
537        let wire = serde_json::to_value(&cosig).unwrap();
538        let parsed = LogCosignature::from_value(&wire).unwrap();
539        assert_eq!(
540            LogCosignature::preimage_hash_of_value(&wire).unwrap(),
541            cosig.preimage_hash().unwrap()
542        );
543        parsed
544            .verify_signature_with_key(Some(&witness_pub()), None)
545            .unwrap();
546        parsed.cross_check_against_checkpoint(&cp).unwrap();
547        parsed
548            .check_witnessed_at_skew(Utc::now(), chrono::Duration::seconds(120))
549            .unwrap();
550    }
551
552    /// Closed schema + domain separation: an unknown member, a wrong
553    /// `cosignature_version`, or a non-canonical timestamp all fail the
554    /// parse.
555    #[test]
556    fn closed_schema_and_domain_separation() {
557        let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
558
559        let mut wire = serde_json::to_value(&cosig).unwrap();
560        wire.as_object_mut()
561            .unwrap()
562            .insert("extra".into(), serde_json::json!(1));
563        assert!(matches!(
564            LogCosignature::from_value(&wire).unwrap_err(),
565            AcdpError::InvalidWitnessCosignature(_)
566        ));
567
568        let mut wire = serde_json::to_value(&cosig).unwrap();
569        wire["cosignature_version"] = serde_json::json!("acdp-cosig/2");
570        assert!(LogCosignature::from_value(&wire).is_err());
571
572        // A cosignature never parses as a checkpoint and vice versa.
573        let wire = serde_json::to_value(&cosig).unwrap();
574        assert!(LogCheckpoint::from_value(&wire).is_err());
575
576        let mut wire = serde_json::to_value(&cosig).unwrap();
577        wire["witnessed_at"] = serde_json::json!("2026-07-04T12:00:05Z");
578        assert!(LogCosignature::from_value(&wire).is_err());
579    }
580
581    /// Tampering any signed field breaks the signature (wit-004 shape at
582    /// the type level).
583    #[test]
584    fn tampered_fields_fail_verification() {
585        let pubkey = witness_pub();
586        let base = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
587
588        let mut c = base.clone();
589        c.witnessed_at += chrono::Duration::milliseconds(1);
590        assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
591
592        let mut c = base.clone();
593        c.witnessed_checkpoint.tree_size += 1;
594        assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
595
596        // Key mismatch: witness B's key over witness A's body.
597        let wrong = SigningKey::from_bytes(&[0x44u8; 32]).verifying_key_bytes();
598        assert!(base.verify_signature_with_key(Some(&wrong), None).is_err());
599    }
600
601    /// §8 step 4 checkpoint binding fires on a different tuple; §8 step 3
602    /// witness binding fires on a foreign key_id DID.
603    #[test]
604    fn cross_checks_fire() {
605        let cp = checkpoint();
606        let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
607        cosig.cross_check_against_checkpoint(&cp).unwrap();
608
609        // A checkpoint at a different size / root is a different tuple.
610        let root5 = crate::log::encode_sha256_hex(&[0xabu8; 32]);
611        let other = ReceiptSigner::new(
612            SigningKey::from_bytes(&[0x11u8; 32]),
613            REGISTRY_DID,
614            format!("{REGISTRY_DID}#receipt-key-1"),
615        )
616        .unwrap()
617        .mint_log_checkpoint(LOG_ID, 5, &root5, Utc::now())
618        .unwrap();
619        assert!(matches!(
620            cosig.cross_check_against_checkpoint(&other).unwrap_err(),
621            AcdpError::InvalidWitnessCosignature(_)
622        ));
623
624        // Foreign key_id DID → witness binding fails at parse.
625        let mut wire = serde_json::to_value(&cosig).unwrap();
626        wire["signature"]["key_id"] = serde_json::json!("did:web:evil.example#k");
627        assert!(LogCosignature::from_value(&wire).is_err());
628    }
629
630    #[test]
631    fn signer_rejects_malformed_identity() {
632        // Non-witness DID form.
633        assert!(WitnessSigner::new(
634            SigningKey::from_bytes(&[0x33u8; 32]),
635            "not-a-did",
636            "not-a-did#k",
637        )
638        .is_err());
639        // key_id DID ≠ witness_id.
640        assert!(WitnessSigner::new(
641            SigningKey::from_bytes(&[0x33u8; 32]),
642            WITNESS_DID,
643            "did:web:other.example.org#k",
644        )
645        .is_err());
646        // No fragment.
647        assert!(WitnessSigner::new(
648            SigningKey::from_bytes(&[0x33u8; 32]),
649            WITNESS_DID,
650            WITNESS_DID,
651        )
652        .is_err());
653        // did:key witness is accepted.
654        assert!(WitnessSigner::new(
655            SigningKey::from_bytes(&[0x33u8; 32]),
656            "did:key:z6MkExample",
657            "did:key:z6MkExample#z6MkExample",
658        )
659        .is_ok());
660    }
661
662    /// An old cosignature passes step 5 — staleness is policy, not a
663    /// verification failure; a future-dated one fails.
664    #[test]
665    fn witnessed_at_skew_and_age() {
666        let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
667        let skew = chrono::Duration::seconds(120);
668
669        let now = cosig.witnessed_at + chrono::Duration::seconds(3600);
670        cosig
671            .check_witnessed_at_skew(now, skew)
672            .expect("stale is not a step-5 failure");
673        assert_eq!(cosig.age_at(now), chrono::Duration::seconds(3600));
674
675        let now = cosig.witnessed_at - chrono::Duration::seconds(300);
676        assert!(cosig.check_witnessed_at_skew(now, skew).is_err());
677    }
678}