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}