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.witnessed_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
355            return Err(AcdpError::InvalidWitnessCosignature(
356                "log_cosignature witnessed_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
357                    .into(),
358            ));
359        }
360        if self.witnessed_at > now + max_clock_skew {
361            return Err(AcdpError::InvalidWitnessCosignature(format!(
362                "log_cosignature witnessed_at '{}' is in the future beyond the {}s clock-skew \
363                 allowance (consumer clock '{}') — forged observation-time claim \
364                 (RFC-ACDP-0015 §8 step 5)",
365                self.witnessed_at.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
366                max_clock_skew.num_seconds(),
367                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
368            )));
369        }
370        Ok(())
371    }
372
373    /// The cosignature's age at `now` — the input to the consumer's §8.1
374    /// freshness policy (RECOMMENDED maximum: 300 seconds for
375    /// current-ness-sensitive decisions). Negative when `witnessed_at`
376    /// is ahead of `now` (bounded by the step-5 skew check).
377    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
378        now - self.witnessed_at
379    }
380}
381
382// ── Witness-side minting ─────────────────────────────────────────────────────
383
384/// Witness-side cosignature minting identity: the **witness's** signing
385/// key plus the DID URL it is published under in the witness's own DID
386/// document (RFC-ACDP-0015 §5, §9).
387///
388/// Deliberately **not** [`crate::receipt::ReceiptSigner`]: the whole
389/// value of a cosignature is that the signer is the witness, under the
390/// witness's own DID and key — never the registry's receipt key (§5,
391/// §12). Witnesses are independent parties (§3).
392///
393/// Key lifecycle (RFC-ACDP-0015 §9, the RFC-ACDP-0010 §9 rule applied to
394/// the witness's own key): retired witness keys remain in the witness DID
395/// document's `verificationMethod` indefinitely so historical
396/// cosignatures stay verifiable; rotation removes a key from
397/// `assertionMethod` only.
398pub struct WitnessSigner {
399    key: acdp_crypto::sign::AcdpSigningKey,
400    /// e.g. `did:web:witness.example.org#witness-key-1`.
401    key_id: String,
402    /// e.g. `did:web:witness.example.org`.
403    witness_id: String,
404}
405
406impl WitnessSigner {
407    /// Create a witness signer. `witness_id` MUST be a `did:web` or
408    /// `did:key` DID; `key_id`'s DID portion MUST equal `witness_id` and
409    /// carry a non-empty fragment.
410    pub fn new(
411        key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
412        witness_id: impl Into<String>,
413        key_id: impl Into<String>,
414    ) -> Result<Self, AcdpError> {
415        let witness_id = witness_id.into();
416        let key_id = key_id.into();
417        if !is_witness_did(&witness_id) {
418            return Err(AcdpError::SchemaViolation(format!(
419                "witness signer witness_id must be did:web or did:key, got '{witness_id}'"
420            )));
421        }
422        match key_id.split_once('#') {
423            Some((did, frag)) if did == witness_id && !frag.is_empty() => {}
424            _ => {
425                return Err(AcdpError::SchemaViolation(format!(
426                    "witness signer key_id '{key_id}' must be '<witness_id>#<fragment>'"
427                )));
428            }
429        }
430        Ok(Self {
431            key: key.into(),
432            key_id,
433            witness_id,
434        })
435    }
436
437    /// The witness DID this signer mints under.
438    pub fn witness_id(&self) -> &str {
439        &self.witness_id
440    }
441
442    /// The DID URL the witness signing key is published under (e.g.
443    /// `did:web:witness.example.org#witness-key-1`).
444    pub fn key_id(&self) -> &str {
445        &self.key_id
446    }
447
448    /// Mint a signed cosignature over an observed checkpoint
449    /// (RFC-ACDP-0015 §5, §7 step 3): copy the checkpoint's
450    /// `{log_id, tree_size, root_hash, timestamp}` verbatim into
451    /// `witnessed_checkpoint`, stamp `witnessed_at` (truncated here to
452    /// milliseconds) from the witness's own clock, and sign with the
453    /// witness key.
454    ///
455    /// This is the **raw** mint — it performs no witness obligation
456    /// (§7): it does not verify the checkpoint's own signature or its
457    /// consistency against a retained head. Callers acting as a
458    /// production witness MUST use the `client` feature's
459    /// `mint_cosignature_checked` (which runs the §7 obligation first),
460    /// or run those checks themselves. A witness that cosigns a
461    /// checkpoint failing consistency provides negative security value
462    /// (§7).
463    pub fn mint(
464        &self,
465        checkpoint: &LogCheckpoint,
466        witnessed_at: DateTime<Utc>,
467    ) -> Result<LogCosignature, AcdpError> {
468        let mut cosig = LogCosignature {
469            cosignature_version: COSIGNATURE_VERSION.to_string(),
470            witness_id: self.witness_id.clone(),
471            witnessed_checkpoint: WitnessedCheckpoint::from_checkpoint(checkpoint),
472            witnessed_at: acdp_primitives::time::trunc_ms(witnessed_at),
473            signature: Signature {
474                algorithm: self.key.algorithm().into(),
475                key_id: self.key_id.clone(),
476                value: String::new(), // filled below
477            },
478        };
479        let hash = cosig.preimage_hash()?;
480        let (algorithm, value) = self.key.sign_content_hash(&hash);
481        cosig.signature.algorithm = algorithm.into();
482        cosig.signature.value = value;
483        Ok(cosig)
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::receipt::ReceiptSigner;
491    use acdp_crypto::SigningKey;
492
493    const REGISTRY_DID: &str = "did:web:registry.example.com";
494    const LOG_ID: &str = "did:web:registry.example.com/log/1";
495    const WITNESS_DID: &str = "did:web:witness.example.org";
496
497    fn checkpoint() -> LogCheckpoint {
498        let root = crate::log::encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
499        ReceiptSigner::new(
500            SigningKey::from_bytes(&[0x11u8; 32]),
501            REGISTRY_DID,
502            format!("{REGISTRY_DID}#receipt-key-1"),
503        )
504        .unwrap()
505        .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
506        .unwrap()
507    }
508
509    fn witness_signer() -> WitnessSigner {
510        WitnessSigner::new(
511            SigningKey::from_bytes(&[0x33u8; 32]),
512            WITNESS_DID,
513            format!("{WITNESS_DID}#witness-key-1"),
514        )
515        .unwrap()
516    }
517
518    fn witness_pub() -> [u8; 32] {
519        SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes()
520    }
521
522    #[test]
523    fn mint_verify_round_trip() {
524        let cp = checkpoint();
525        let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
526        assert_eq!(cosig.cosignature_version, COSIGNATURE_VERSION);
527        cosig
528            .verify_signature_with_key(Some(&witness_pub()), None)
529            .expect("freshly minted cosignature must verify");
530
531        // Wire round trip through the closed parse; raw and struct
532        // preimages agree.
533        let wire = serde_json::to_value(&cosig).unwrap();
534        let parsed = LogCosignature::from_value(&wire).unwrap();
535        assert_eq!(
536            LogCosignature::preimage_hash_of_value(&wire).unwrap(),
537            cosig.preimage_hash().unwrap()
538        );
539        parsed
540            .verify_signature_with_key(Some(&witness_pub()), None)
541            .unwrap();
542        parsed.cross_check_against_checkpoint(&cp).unwrap();
543        parsed
544            .check_witnessed_at_skew(Utc::now(), chrono::Duration::seconds(120))
545            .unwrap();
546    }
547
548    /// Closed schema + domain separation: an unknown member, a wrong
549    /// `cosignature_version`, or a non-canonical timestamp all fail the
550    /// parse.
551    #[test]
552    fn closed_schema_and_domain_separation() {
553        let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
554
555        let mut wire = serde_json::to_value(&cosig).unwrap();
556        wire.as_object_mut()
557            .unwrap()
558            .insert("extra".into(), serde_json::json!(1));
559        assert!(matches!(
560            LogCosignature::from_value(&wire).unwrap_err(),
561            AcdpError::InvalidWitnessCosignature(_)
562        ));
563
564        let mut wire = serde_json::to_value(&cosig).unwrap();
565        wire["cosignature_version"] = serde_json::json!("acdp-cosig/2");
566        assert!(LogCosignature::from_value(&wire).is_err());
567
568        // A cosignature never parses as a checkpoint and vice versa.
569        let wire = serde_json::to_value(&cosig).unwrap();
570        assert!(LogCheckpoint::from_value(&wire).is_err());
571
572        let mut wire = serde_json::to_value(&cosig).unwrap();
573        wire["witnessed_at"] = serde_json::json!("2026-07-04T12:00:05Z");
574        assert!(LogCosignature::from_value(&wire).is_err());
575    }
576
577    /// Tampering any signed field breaks the signature (wit-004 shape at
578    /// the type level).
579    #[test]
580    fn tampered_fields_fail_verification() {
581        let pubkey = witness_pub();
582        let base = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
583
584        let mut c = base.clone();
585        c.witnessed_at += chrono::Duration::milliseconds(1);
586        assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
587
588        let mut c = base.clone();
589        c.witnessed_checkpoint.tree_size += 1;
590        assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
591
592        // Key mismatch: witness B's key over witness A's body.
593        let wrong = SigningKey::from_bytes(&[0x44u8; 32]).verifying_key_bytes();
594        assert!(base.verify_signature_with_key(Some(&wrong), None).is_err());
595    }
596
597    /// §8 step 4 checkpoint binding fires on a different tuple; §8 step 3
598    /// witness binding fires on a foreign key_id DID.
599    #[test]
600    fn cross_checks_fire() {
601        let cp = checkpoint();
602        let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
603        cosig.cross_check_against_checkpoint(&cp).unwrap();
604
605        // A checkpoint at a different size / root is a different tuple.
606        let root5 = crate::log::encode_sha256_hex(&[0xabu8; 32]);
607        let other = ReceiptSigner::new(
608            SigningKey::from_bytes(&[0x11u8; 32]),
609            REGISTRY_DID,
610            format!("{REGISTRY_DID}#receipt-key-1"),
611        )
612        .unwrap()
613        .mint_log_checkpoint(LOG_ID, 5, &root5, Utc::now())
614        .unwrap();
615        assert!(matches!(
616            cosig.cross_check_against_checkpoint(&other).unwrap_err(),
617            AcdpError::InvalidWitnessCosignature(_)
618        ));
619
620        // Foreign key_id DID → witness binding fails at parse.
621        let mut wire = serde_json::to_value(&cosig).unwrap();
622        wire["signature"]["key_id"] = serde_json::json!("did:web:evil.example#k");
623        assert!(LogCosignature::from_value(&wire).is_err());
624    }
625
626    #[test]
627    fn signer_rejects_malformed_identity() {
628        // Non-witness DID form.
629        assert!(WitnessSigner::new(
630            SigningKey::from_bytes(&[0x33u8; 32]),
631            "not-a-did",
632            "not-a-did#k",
633        )
634        .is_err());
635        // key_id DID ≠ witness_id.
636        assert!(WitnessSigner::new(
637            SigningKey::from_bytes(&[0x33u8; 32]),
638            WITNESS_DID,
639            "did:web:other.example.org#k",
640        )
641        .is_err());
642        // No fragment.
643        assert!(WitnessSigner::new(
644            SigningKey::from_bytes(&[0x33u8; 32]),
645            WITNESS_DID,
646            WITNESS_DID,
647        )
648        .is_err());
649        // did:key witness is accepted.
650        assert!(WitnessSigner::new(
651            SigningKey::from_bytes(&[0x33u8; 32]),
652            "did:key:z6MkExample",
653            "did:key:z6MkExample#z6MkExample",
654        )
655        .is_ok());
656    }
657
658    /// An old cosignature passes step 5 — staleness is policy, not a
659    /// verification failure; a future-dated one fails.
660    #[test]
661    fn witnessed_at_skew_and_age() {
662        let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
663        let skew = chrono::Duration::seconds(120);
664
665        let now = cosig.witnessed_at + chrono::Duration::seconds(3600);
666        cosig
667            .check_witnessed_at_skew(now, skew)
668            .expect("stale is not a step-5 failure");
669        assert_eq!(cosig.age_at(now), chrono::Duration::seconds(3600));
670
671        let now = cosig.witnessed_at - chrono::Duration::seconds(300);
672        assert!(cosig.check_witnessed_at_skew(now, skew).is_err());
673    }
674}