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}