acdp 0.2.0

Rust client library for the Agent Context Distribution Protocol (ACDP v0.1.0)
Documentation
//! Public-key fingerprints for registry receipts (ACDP 0.2, RFC-ACDP-0010).
//!
//! `key_fingerprint` binds a receipt to the *specific producer key* the
//! registry resolved and verified at publish time — the field that
//! closes the historical-key-validity gap (RFC-ACDP-0008 §9.3): after
//! the producer rotates, a consumer can still establish that the old
//! key was the authorized one when the context was accepted.
//!
//! Encoding (pinned byte-for-byte; divergence here would be the
//! timestamp-precision bug all over again):
//!
//! - Ed25519 — `"sha256:" + lowercase_hex(SHA-256(raw 32-byte public key))`
//! - ECDSA-P256 — `"sha256:" + lowercase_hex(SHA-256(SEC1 *compressed*
//!   33-byte point))`. Uncompressed input is compressed first.

use crate::error::AcdpError;
use sha2::{Digest, Sha256};

/// Fingerprint a raw 32-byte Ed25519 public key.
pub fn fingerprint_ed25519(public_key: &[u8; 32]) -> String {
    format!("sha256:{}", hex::encode(Sha256::digest(public_key)))
}

/// Fingerprint a SEC1 P-256 public key (compressed 33-byte or
/// uncompressed 65-byte input; the digest is always over the
/// compressed form).
pub fn fingerprint_p256_sec1(sec1: &[u8]) -> Result<String, AcdpError> {
    let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
        .map_err(|e| AcdpError::KeyResolution(format!("p256 SEC1 parse: {e}")))?;
    let compressed = vk.to_encoded_point(true);
    Ok(format!(
        "sha256:{}",
        hex::encode(Sha256::digest(compressed.as_bytes()))
    ))
}

/// Fingerprint resolved `did:key` material.
pub fn fingerprint_did_key_material(
    material: &crate::did::key::DidKeyMaterial,
) -> Result<String, AcdpError> {
    match material {
        crate::did::key::DidKeyMaterial::Ed25519(pk) => Ok(fingerprint_ed25519(pk)),
        crate::did::key::DidKeyMaterial::EcdsaP256(sec1) => fingerprint_p256_sec1(sec1),
    }
}

/// Fingerprint a DID-document verification method, dispatching on the
/// algorithm string (`"ed25519"` / `"ecdsa-p256"`).
pub fn fingerprint_verification_method(
    method: &crate::did::document::VerificationMethod,
    algorithm: &str,
) -> Result<String, AcdpError> {
    match algorithm {
        "ed25519" => Ok(fingerprint_ed25519(&method.ed25519_public_key_bytes()?)),
        "ecdsa-p256" => fingerprint_p256_sec1(&method.ecdsa_p256_public_key_sec1()?),
        other => Err(AcdpError::UnsupportedAlgorithm(format!(
            "cannot fingerprint keys for algorithm '{other}'"
        ))),
    }
}

/// Resolve and fingerprint the key named by a `signature.key_id` DID
/// URL — did:key purely, did:web via the resolver. Looks the key up in
/// `verificationMethod` WITHOUT requiring `assertionMethod` membership:
/// fingerprints identify keys, including historically retained ones
/// (RFC-ACDP-0010 key lifecycle); authorization is checked elsewhere.
#[cfg(feature = "client")]
pub async fn fingerprint_for_key_id(
    key_id: &str,
    algorithm: &str,
    resolver: &crate::did::web::WebResolver,
) -> Result<String, AcdpError> {
    let (did_part, fragment) = key_id
        .split_once('#')
        .ok_or_else(|| AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'")))?;
    if did_part.starts_with("did:key:") {
        // Full key-URL resolution (fragment MUST equal the
        // method-specific identifier) — the same rule the signature
        // verifier and schema validator enforce, so the fingerprint
        // path cannot accept a key_id form the rest of the protocol
        // refuses.
        let material = crate::did::key::resolve_did_key_url(key_id)?;
        return fingerprint_did_key_material(&material);
    }
    let doc = resolver.resolve(did_part).await?;
    let method = doc.find_by_fragment(fragment).ok_or_else(|| {
        AcdpError::KeyResolution(format!(
            "no verification method with fragment '#{fragment}'"
        ))
    })?;
    fingerprint_verification_method(method, algorithm)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// fp-001 pinned vector: the all-zero-seed Ed25519 public key
    /// (`3b6a27…da29`, the sig-001 test key). Generated by this crate
    /// and proposed as the canonical `fp-001-key-fingerprint-vectors`
    /// spec fixture entry.
    #[test]
    fn fp_001_ed25519_zero_seed() {
        let key = crate::crypto::SigningKey::from_bytes(&[0u8; 32]);
        let fp = fingerprint_ed25519(&key.verifying_key_bytes());
        assert_eq!(
            fp,
            "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070"
        );
    }

    /// P-256 fingerprints are over the COMPRESSED point: compressed and
    /// uncompressed input forms of the same key must fingerprint
    /// identically.
    #[test]
    fn p256_compressed_and_uncompressed_agree() {
        let key = crate::crypto::sign::P256SigningKey::generate();
        let uncompressed = key.verifying_key_sec1();
        let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&uncompressed).unwrap();
        let compressed = vk.to_encoded_point(true);

        let fp_a = fingerprint_p256_sec1(&uncompressed).unwrap();
        let fp_b = fingerprint_p256_sec1(compressed.as_bytes()).unwrap();
        assert_eq!(fp_a, fp_b);
    }

    /// did:key material fingerprints match the raw-key helpers — the
    /// receipt fingerprint for a did:key producer is derivable from the
    /// DID alone.
    #[test]
    fn did_key_material_matches_raw() {
        let key = crate::crypto::SigningKey::from_bytes(&[3u8; 32]);
        let did = crate::did::key::did_key_from_ed25519(&key.verifying_key_bytes());
        let material = crate::did::key::resolve_did_key(&did).unwrap();
        assert_eq!(
            fingerprint_did_key_material(&material).unwrap(),
            fingerprint_ed25519(&key.verifying_key_bytes())
        );
    }
}