use crate::body::Signature;
use crate::log::{decode_sha256_hex, parse_log_id, LogCheckpoint};
use crate::receipt::{
is_canonical_ms_utc, ms_rfc3339, preimage_hash_of_object_with, verify_signature_over_hash_with,
};
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::ContentHash;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const COSIGNATURE_VERSION: &str = "acdp-cosig/1";
fn is_witness_did(id: &str) -> bool {
id.starts_with("did:web:") && id.len() > "did:web:".len()
|| id.starts_with("did:key:z") && id.len() > "did:key:z".len()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WitnessedCheckpoint {
pub log_id: String,
pub tree_size: u64,
pub root_hash: String,
#[serde(with = "ms_rfc3339")]
pub timestamp: DateTime<Utc>,
}
impl WitnessedCheckpoint {
pub fn from_checkpoint(checkpoint: &LogCheckpoint) -> Self {
Self {
log_id: checkpoint.log_id.clone(),
tree_size: checkpoint.tree_size,
root_hash: checkpoint.root_hash.clone(),
timestamp: checkpoint.timestamp,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LogCosignature {
pub cosignature_version: String,
pub witness_id: String,
pub witnessed_checkpoint: WitnessedCheckpoint,
#[serde(with = "ms_rfc3339")]
pub witnessed_at: DateTime<Utc>,
pub signature: Signature,
}
impl LogCosignature {
pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
let cosig = Self::deserialize(value).map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("log_cosignature does not parse: {e}"))
})?;
if cosig.cosignature_version != COSIGNATURE_VERSION {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature cosignature_version '{}' ≠ '{COSIGNATURE_VERSION}' \
(RFC-ACDP-0015 §4)",
cosig.cosignature_version
)));
}
if !is_witness_did(&cosig.witness_id) {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witness_id '{}' is not a did:web or did:key DID \
(RFC-ACDP-0015 §4)",
cosig.witness_id
)));
}
parse_log_id(&cosig.witnessed_checkpoint.log_id).map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("witnessed_checkpoint: {e}"))
})?;
decode_sha256_hex(&cosig.witnessed_checkpoint.root_hash).map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("witnessed_checkpoint: {e}"))
})?;
let wc_ts = value
.get("witnessed_checkpoint")
.and_then(|v| v.get("timestamp"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
AcdpError::InvalidWitnessCosignature(
"log_cosignature witnessed_checkpoint.timestamp missing or not a string".into(),
)
})?;
if !is_canonical_ms_utc(wc_ts) {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_checkpoint.timestamp '{wc_ts}' is not canonical \
millisecond-precision RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0015 §4)"
)));
}
let witnessed_at = value
.get("witnessed_at")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AcdpError::InvalidWitnessCosignature(
"log_cosignature witnessed_at missing or not a string".into(),
)
})?;
if !is_canonical_ms_utc(witnessed_at) {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_at '{witnessed_at}' is not canonical \
millisecond-precision RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0015 §4)"
)));
}
cosig.witness_key_did()?;
Ok(cosig)
}
pub fn witness_key_did(&self) -> Result<&str, AcdpError> {
match self.signature.key_id.split_once('#') {
Some((did, frag)) if did == self.witness_id && !frag.is_empty() => Ok(did),
_ => Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature signature.key_id '{}' is not a DID URL under witness_id '{}' \
(RFC-ACDP-0015 §8 step 3)",
self.signature.key_id, self.witness_id
))),
}
}
pub fn checkpoint_tuple(&self) -> (&str, u64, &str) {
(
&self.witnessed_checkpoint.log_id,
self.witnessed_checkpoint.tree_size,
&self.witnessed_checkpoint.root_hash,
)
}
pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
preimage_hash_of_object_with(
value,
"log_cosignature",
AcdpError::InvalidWitnessCosignature,
)
}
pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
Self::preimage_hash_of_value(&serde_json::to_value(self)?)
}
pub fn verify_signature_with_key(
&self,
witness_pub_ed25519: Option<&[u8; 32]>,
witness_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
let hash = self.preimage_hash()?;
self.verify_signature_against_hash(&hash, witness_pub_ed25519, witness_pub_p256_sec1)
}
pub fn verify_signature_against_hash(
&self,
hash: &ContentHash,
witness_pub_ed25519: Option<&[u8; 32]>,
witness_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
verify_signature_over_hash_with(
&self.signature,
hash,
witness_pub_ed25519,
witness_pub_p256_sec1,
"log_cosignature",
AcdpError::InvalidWitnessCosignature,
)
}
pub fn cross_check_against_checkpoint(
&self,
checkpoint: &LogCheckpoint,
) -> Result<(), AcdpError> {
let wc = &self.witnessed_checkpoint;
if wc.log_id != checkpoint.log_id {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_checkpoint.log_id '{}' ≠ evaluated checkpoint log_id \
'{}' (RFC-ACDP-0015 §8 step 4)",
wc.log_id, checkpoint.log_id
)));
}
if wc.tree_size != checkpoint.tree_size {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_checkpoint.tree_size {} ≠ evaluated checkpoint \
tree_size {} (RFC-ACDP-0015 §8 step 4)",
wc.tree_size, checkpoint.tree_size
)));
}
if wc.root_hash != checkpoint.root_hash {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_checkpoint.root_hash '{}' ≠ evaluated checkpoint \
root_hash '{}' (RFC-ACDP-0015 §8 step 4)",
wc.root_hash, checkpoint.root_hash
)));
}
Ok(())
}
pub fn check_witnessed_at_skew(
&self,
now: DateTime<Utc>,
max_clock_skew: chrono::Duration,
) -> Result<(), AcdpError> {
if self.witnessed_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
return Err(AcdpError::InvalidWitnessCosignature(
"log_cosignature witnessed_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
.into(),
));
}
if self.witnessed_at > now + max_clock_skew {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"log_cosignature witnessed_at '{}' is in the future beyond the {}s clock-skew \
allowance (consumer clock '{}') — forged observation-time claim \
(RFC-ACDP-0015 §8 step 5)",
self.witnessed_at.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
max_clock_skew.num_seconds(),
now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
)));
}
Ok(())
}
pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
now - self.witnessed_at
}
}
pub struct WitnessSigner {
key: acdp_crypto::sign::AcdpSigningKey,
key_id: String,
witness_id: String,
}
impl WitnessSigner {
pub fn new(
key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
witness_id: impl Into<String>,
key_id: impl Into<String>,
) -> Result<Self, AcdpError> {
let witness_id = witness_id.into();
let key_id = key_id.into();
if !is_witness_did(&witness_id) {
return Err(AcdpError::SchemaViolation(format!(
"witness signer witness_id must be did:web or did:key, got '{witness_id}'"
)));
}
match key_id.split_once('#') {
Some((did, frag)) if did == witness_id && !frag.is_empty() => {}
_ => {
return Err(AcdpError::SchemaViolation(format!(
"witness signer key_id '{key_id}' must be '<witness_id>#<fragment>'"
)));
}
}
Ok(Self {
key: key.into(),
key_id,
witness_id,
})
}
pub fn witness_id(&self) -> &str {
&self.witness_id
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn mint(
&self,
checkpoint: &LogCheckpoint,
witnessed_at: DateTime<Utc>,
) -> Result<LogCosignature, AcdpError> {
let mut cosig = LogCosignature {
cosignature_version: COSIGNATURE_VERSION.to_string(),
witness_id: self.witness_id.clone(),
witnessed_checkpoint: WitnessedCheckpoint::from_checkpoint(checkpoint),
witnessed_at: acdp_primitives::time::trunc_ms(witnessed_at),
signature: Signature {
algorithm: self.key.algorithm().into(),
key_id: self.key_id.clone(),
value: String::new(), },
};
let hash = cosig.preimage_hash()?;
let (algorithm, value) = self.key.sign_content_hash(&hash);
cosig.signature.algorithm = algorithm.into();
cosig.signature.value = value;
Ok(cosig)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receipt::ReceiptSigner;
use acdp_crypto::SigningKey;
const REGISTRY_DID: &str = "did:web:registry.example.com";
const LOG_ID: &str = "did:web:registry.example.com/log/1";
const WITNESS_DID: &str = "did:web:witness.example.org";
fn checkpoint() -> LogCheckpoint {
let root = crate::log::encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
ReceiptSigner::new(
SigningKey::from_bytes(&[0x11u8; 32]),
REGISTRY_DID,
format!("{REGISTRY_DID}#receipt-key-1"),
)
.unwrap()
.mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
.unwrap()
}
fn witness_signer() -> WitnessSigner {
WitnessSigner::new(
SigningKey::from_bytes(&[0x33u8; 32]),
WITNESS_DID,
format!("{WITNESS_DID}#witness-key-1"),
)
.unwrap()
}
fn witness_pub() -> [u8; 32] {
SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes()
}
#[test]
fn mint_verify_round_trip() {
let cp = checkpoint();
let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
assert_eq!(cosig.cosignature_version, COSIGNATURE_VERSION);
cosig
.verify_signature_with_key(Some(&witness_pub()), None)
.expect("freshly minted cosignature must verify");
let wire = serde_json::to_value(&cosig).unwrap();
let parsed = LogCosignature::from_value(&wire).unwrap();
assert_eq!(
LogCosignature::preimage_hash_of_value(&wire).unwrap(),
cosig.preimage_hash().unwrap()
);
parsed
.verify_signature_with_key(Some(&witness_pub()), None)
.unwrap();
parsed.cross_check_against_checkpoint(&cp).unwrap();
parsed
.check_witnessed_at_skew(Utc::now(), chrono::Duration::seconds(120))
.unwrap();
}
#[test]
fn closed_schema_and_domain_separation() {
let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
let mut wire = serde_json::to_value(&cosig).unwrap();
wire.as_object_mut()
.unwrap()
.insert("extra".into(), serde_json::json!(1));
assert!(matches!(
LogCosignature::from_value(&wire).unwrap_err(),
AcdpError::InvalidWitnessCosignature(_)
));
let mut wire = serde_json::to_value(&cosig).unwrap();
wire["cosignature_version"] = serde_json::json!("acdp-cosig/2");
assert!(LogCosignature::from_value(&wire).is_err());
let wire = serde_json::to_value(&cosig).unwrap();
assert!(LogCheckpoint::from_value(&wire).is_err());
let mut wire = serde_json::to_value(&cosig).unwrap();
wire["witnessed_at"] = serde_json::json!("2026-07-04T12:00:05Z");
assert!(LogCosignature::from_value(&wire).is_err());
}
#[test]
fn tampered_fields_fail_verification() {
let pubkey = witness_pub();
let base = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
let mut c = base.clone();
c.witnessed_at += chrono::Duration::milliseconds(1);
assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut c = base.clone();
c.witnessed_checkpoint.tree_size += 1;
assert!(c.verify_signature_with_key(Some(&pubkey), None).is_err());
let wrong = SigningKey::from_bytes(&[0x44u8; 32]).verifying_key_bytes();
assert!(base.verify_signature_with_key(Some(&wrong), None).is_err());
}
#[test]
fn cross_checks_fire() {
let cp = checkpoint();
let cosig = witness_signer().mint(&cp, Utc::now()).unwrap();
cosig.cross_check_against_checkpoint(&cp).unwrap();
let root5 = crate::log::encode_sha256_hex(&[0xabu8; 32]);
let other = ReceiptSigner::new(
SigningKey::from_bytes(&[0x11u8; 32]),
REGISTRY_DID,
format!("{REGISTRY_DID}#receipt-key-1"),
)
.unwrap()
.mint_log_checkpoint(LOG_ID, 5, &root5, Utc::now())
.unwrap();
assert!(matches!(
cosig.cross_check_against_checkpoint(&other).unwrap_err(),
AcdpError::InvalidWitnessCosignature(_)
));
let mut wire = serde_json::to_value(&cosig).unwrap();
wire["signature"]["key_id"] = serde_json::json!("did:web:evil.example#k");
assert!(LogCosignature::from_value(&wire).is_err());
}
#[test]
fn signer_rejects_malformed_identity() {
assert!(WitnessSigner::new(
SigningKey::from_bytes(&[0x33u8; 32]),
"not-a-did",
"not-a-did#k",
)
.is_err());
assert!(WitnessSigner::new(
SigningKey::from_bytes(&[0x33u8; 32]),
WITNESS_DID,
"did:web:other.example.org#k",
)
.is_err());
assert!(WitnessSigner::new(
SigningKey::from_bytes(&[0x33u8; 32]),
WITNESS_DID,
WITNESS_DID,
)
.is_err());
assert!(WitnessSigner::new(
SigningKey::from_bytes(&[0x33u8; 32]),
"did:key:z6MkExample",
"did:key:z6MkExample#z6MkExample",
)
.is_ok());
}
#[test]
fn witnessed_at_skew_and_age() {
let cosig = witness_signer().mint(&checkpoint(), Utc::now()).unwrap();
let skew = chrono::Duration::seconds(120);
let now = cosig.witnessed_at + chrono::Duration::seconds(3600);
cosig
.check_witnessed_at_skew(now, skew)
.expect("stale is not a step-5 failure");
assert_eq!(cosig.age_at(now), chrono::Duration::seconds(3600));
let now = cosig.witnessed_at - chrono::Duration::seconds(300);
assert!(cosig.check_witnessed_at_skew(now, skew).is_err());
}
}