use std::collections::{BTreeSet, HashMap, HashSet};
use acdp_did::web::WebResolver;
use acdp_did::DidDocument;
use acdp_primitives::error::AcdpError;
use acdp_types::cosignature::{LogCosignature, WitnessSigner};
use acdp_types::log::{LogCheckpoint, LogConsistencyProof};
use chrono::{DateTime, Utc};
use crate::log::verify_log_checkpoint_value;
pub const DEFAULT_WITNESS_MAX_CLOCK_SKEW_SECONDS: u32 = 120;
pub const DEFAULT_WITNESS_MAX_AGE_SECONDS: u32 = 300;
pub fn verify_witness_cosignature_value(
cosig_value: &serde_json::Value,
witness_did_doc: &serde_json::Value,
expected_checkpoint: &LogCheckpoint,
now: Option<DateTime<Utc>>,
max_clock_skew_seconds: Option<u32>,
) -> Result<LogCosignature, AcdpError> {
let cosig = LogCosignature::from_value(cosig_value)?;
cosig.cross_check_against_checkpoint(expected_checkpoint)?;
let doc: DidDocument = serde_json::from_value(witness_did_doc.clone()).map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("witness DID document does not parse: {e}"))
})?;
if doc.id != cosig.witness_id {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"witness DID document id '{}' ≠ cosignature witness_id '{}' \
(RFC-ACDP-0015 §8 step 3)",
doc.id, cosig.witness_id
)));
}
let key_id = &cosig.signature.key_id;
let (_did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
AcdpError::InvalidWitnessCosignature(format!(
"witness cosignature signature.key_id '{key_id}' has no fragment"
))
})?;
let method = doc.find_by_fragment(fragment).ok_or_else(|| {
AcdpError::InvalidWitnessCosignature(format!(
"witness DID document has no verification method '#{fragment}' — witness keys \
(including retired ones) must remain in verificationMethod (RFC-ACDP-0015 §9)"
))
})?;
let raw_hash = LogCosignature::preimage_hash_of_value(cosig_value)?;
match cosig.signature.algorithm.as_str() {
"ed25519" => {
let key = method.ed25519_public_key_bytes().map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("witness key extraction: {e}"))
})?;
cosig.verify_signature_against_hash(&raw_hash, Some(&key), None)?;
}
"ecdsa-p256" => {
let key = method.ecdsa_p256_public_key_sec1().map_err(|e| {
AcdpError::InvalidWitnessCosignature(format!("witness key extraction: {e}"))
})?;
cosig.verify_signature_against_hash(&raw_hash, None, Some(&key))?;
}
other => {
return Err(AcdpError::InvalidWitnessCosignature(format!(
"witness cosignature signature algorithm '{other}' is not supported"
)));
}
}
let now = now.unwrap_or_else(Utc::now);
let skew = chrono::Duration::seconds(
max_clock_skew_seconds.unwrap_or(DEFAULT_WITNESS_MAX_CLOCK_SKEW_SECONDS) as i64,
);
cosig.check_witnessed_at_skew(now, skew)?;
Ok(cosig)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WitnessPolicy {
pub min_witnesses: u32,
pub max_age_seconds: Option<u32>,
pub max_clock_skew_seconds: u32,
}
impl Default for WitnessPolicy {
fn default() -> Self {
Self {
min_witnesses: 1,
max_age_seconds: Some(DEFAULT_WITNESS_MAX_AGE_SECONDS),
max_clock_skew_seconds: DEFAULT_WITNESS_MAX_CLOCK_SKEW_SECONDS,
}
}
}
#[derive(Debug)]
pub struct WitnessQuorumReport {
pub witnessed_count: usize,
pub witnesses: Vec<String>,
pub meets_quorum: bool,
pub fresh_witnessed_count: usize,
pub meets_fresh_quorum: bool,
pub failures: Vec<AcdpError>,
}
pub fn evaluate_witness_quorum(
cosignatures: &[serde_json::Value],
witness_did_docs: &HashMap<String, serde_json::Value>,
trusted_witnesses: &HashSet<String>,
expected_checkpoint: &LogCheckpoint,
policy: &WitnessPolicy,
now: Option<DateTime<Utc>>,
) -> WitnessQuorumReport {
let now = now.unwrap_or_else(Utc::now);
let expected_tuple = (
expected_checkpoint.log_id.as_str(),
expected_checkpoint.tree_size,
expected_checkpoint.root_hash.as_str(),
);
let mut verified: BTreeSet<String> = BTreeSet::new();
let mut fresh: BTreeSet<String> = BTreeSet::new();
let mut failures: Vec<AcdpError> = Vec::new();
for value in cosignatures {
let Ok(peek) = LogCosignature::from_value(value) else {
continue;
};
if peek.checkpoint_tuple() != expected_tuple {
continue; }
if !trusted_witnesses.contains(&peek.witness_id) {
continue; }
let Some(doc) = witness_did_docs.get(&peek.witness_id) else {
failures.push(AcdpError::InvalidWitnessCosignature(format!(
"no DID document supplied for trusted witness '{}' — cannot verify its \
cosignature (RFC-ACDP-0015 §8 step 2)",
peek.witness_id
)));
continue;
};
match verify_witness_cosignature_value(
value,
doc,
expected_checkpoint,
Some(now),
Some(policy.max_clock_skew_seconds),
) {
Ok(cosig) => {
verified.insert(cosig.witness_id.clone());
let within_age = policy
.max_age_seconds
.is_none_or(|max| cosig.age_at(now) <= chrono::Duration::seconds(max as i64));
if within_age {
fresh.insert(cosig.witness_id);
}
}
Err(e) => failures.push(e),
}
}
let witnessed_count = verified.len();
let fresh_witnessed_count = fresh.len();
let min = policy.min_witnesses as usize;
WitnessQuorumReport {
witnessed_count,
witnesses: verified.into_iter().collect(),
meets_quorum: witnessed_count >= min,
fresh_witnessed_count,
meets_fresh_quorum: fresh_witnessed_count >= min,
failures,
}
}
pub struct WitnessConsistencyCheck<'a> {
pub retained_root_hash: &'a str,
pub consistency_proof: &'a serde_json::Value,
}
#[allow(clippy::too_many_arguments)]
pub async fn mint_cosignature_checked(
signer: &WitnessSigner,
checkpoint_value: &serde_json::Value,
serving_authority: &str,
capabilities_registry_did: &str,
consistency: Option<WitnessConsistencyCheck<'_>>,
witnessed_at: DateTime<Utc>,
max_clock_skew: chrono::Duration,
resolver: &WebResolver,
) -> Result<LogCosignature, AcdpError> {
let checkpoint = verify_log_checkpoint_value(
checkpoint_value,
serving_authority,
capabilities_registry_did,
max_clock_skew,
resolver,
)
.await?;
if let Some(cc) = consistency {
let proof = LogConsistencyProof::from_value(cc.consistency_proof)?;
if proof.log_id != checkpoint.log_id
|| proof.second_tree_size != checkpoint.tree_size
|| proof.log_checkpoint.root_hash != checkpoint.root_hash
{
return Err(AcdpError::InvalidLogProof(
"witness obligation: the consistency proof's checkpoint does not match the \
checkpoint being cosigned (RFC-ACDP-0015 §7 step 2)"
.into(),
));
}
proof.verify_against_first_root(cc.retained_root_hash)?;
}
signer.mint(&checkpoint, witnessed_at)
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_crypto::SigningKey;
use acdp_types::cosignature::WitnessSigner;
use acdp_types::receipt::ReceiptSigner;
const REGISTRY_DID: &str = "did:web:registry.example.com";
const LOG_ID: &str = "did:web:registry.example.com/log/1";
const WITNESS_A: &str = "did:web:witness.example.org";
const WITNESS_B: &str = "did:web:witness-2.example.org";
fn checkpoint(tree_size: u64, root: &str) -> LogCheckpoint {
ReceiptSigner::new(
SigningKey::from_bytes(&[0x11u8; 32]),
REGISTRY_DID,
format!("{REGISTRY_DID}#receipt-key-1"),
)
.unwrap()
.mint_log_checkpoint(LOG_ID, tree_size, root, Utc::now())
.unwrap()
}
fn witness_doc(did: &str, fragment: &str, pub_key: &[u8; 32]) -> serde_json::Value {
let vm_id = format!("{did}#{fragment}");
serde_json::json!({
"id": did,
"verificationMethod": [{
"id": vm_id,
"type": "Ed25519VerificationKey2020",
"controller": did,
"publicKeyJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": base64_url(pub_key),
}
}],
"assertionMethod": [vm_id],
})
}
fn base64_url(bytes: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(bytes)
}
fn signer(seed: u8, witness_id: &str) -> WitnessSigner {
WitnessSigner::new(
SigningKey::from_bytes(&[seed; 32]),
witness_id,
format!("{witness_id}#witness-key-1"),
)
.unwrap()
}
#[test]
fn single_cosignature_verifies_and_binds() {
let root = acdp_types::log::encode_sha256_hex(&[0x0bu8; 32]);
let cp = checkpoint(5, &root);
let pub_a = SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes();
let doc = witness_doc(WITNESS_A, "witness-key-1", &pub_a);
let cosig = signer(0x33, WITNESS_A).mint(&cp, Utc::now()).unwrap();
let wire = serde_json::to_value(&cosig).unwrap();
verify_witness_cosignature_value(&wire, &doc, &cp, None, None)
.expect("golden-shaped cosignature verifies");
let other = checkpoint(6, &root);
assert!(matches!(
verify_witness_cosignature_value(&wire, &doc, &other, None, None).unwrap_err(),
AcdpError::InvalidWitnessCosignature(_)
));
let wrong_doc = witness_doc(WITNESS_B, "witness-key-1", &pub_a);
assert!(verify_witness_cosignature_value(&wire, &wrong_doc, &cp, None, None).is_err());
}
#[test]
fn key_mismatch_fails() {
let root = acdp_types::log::encode_sha256_hex(&[0x0bu8; 32]);
let cp = checkpoint(5, &root);
let mut cosig = signer(0x33, WITNESS_A).mint(&cp, Utc::now()).unwrap();
let hash = cosig.preimage_hash().unwrap();
let (_alg, wrong_value) =
acdp_crypto::sign::AcdpSigningKey::from(SigningKey::from_bytes(&[0x44u8; 32]))
.sign_content_hash(&hash);
cosig.signature.value = wrong_value;
let wire = serde_json::to_value(&cosig).unwrap();
let pub_a = SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes();
let doc = witness_doc(WITNESS_A, "witness-key-1", &pub_a);
let err = verify_witness_cosignature_value(&wire, &doc, &cp, None, None).unwrap_err();
assert!(
matches!(err, AcdpError::InvalidWitnessCosignature(_)),
"got {err:?}"
);
assert!(!err.is_transient());
}
#[test]
fn quorum_counts_distinct_trusted_witnesses() {
let root = acdp_types::log::encode_sha256_hex(&[0x0bu8; 32]);
let cp = checkpoint(5, &root);
let pub_a = SigningKey::from_bytes(&[0x33u8; 32]).verifying_key_bytes();
let pub_b = SigningKey::from_bytes(&[0x44u8; 32]).verifying_key_bytes();
let cosig_a =
serde_json::to_value(signer(0x33, WITNESS_A).mint(&cp, Utc::now()).unwrap()).unwrap();
let cosig_b =
serde_json::to_value(signer(0x44, WITNESS_B).mint(&cp, Utc::now()).unwrap()).unwrap();
let cosig_a2 = serde_json::to_value(
signer(0x33, WITNESS_A)
.mint(&cp, Utc::now() + chrono::Duration::seconds(1))
.unwrap(),
)
.unwrap();
let mut docs = HashMap::new();
docs.insert(
WITNESS_A.to_string(),
witness_doc(WITNESS_A, "witness-key-1", &pub_a),
);
docs.insert(
WITNESS_B.to_string(),
witness_doc(WITNESS_B, "witness-key-1", &pub_b),
);
let trusted: HashSet<String> = [WITNESS_A.to_string(), WITNESS_B.to_string()]
.into_iter()
.collect();
let report = evaluate_witness_quorum(
&[cosig_a.clone(), cosig_b, cosig_a2],
&docs,
&trusted,
&cp,
&WitnessPolicy::default(),
None,
);
assert_eq!(report.witnessed_count, 2, "distinct witnesses A and B");
assert!(report.meets_quorum);
assert_eq!(
report.witnesses,
vec![WITNESS_B.to_string(), WITNESS_A.to_string()]
);
assert!(report.failures.is_empty());
let only_a: HashSet<String> = [WITNESS_A.to_string()].into_iter().collect();
let strict = WitnessPolicy {
min_witnesses: 2,
..WitnessPolicy::default()
};
let report = evaluate_witness_quorum(&[cosig_a], &docs, &only_a, &cp, &strict, None);
assert_eq!(report.witnessed_count, 1);
assert!(!report.meets_quorum);
}
}