Writing a Custom Evidence Adapter
Evidence adapters normalize external proof sources into JACS attestation claims and evidence references. JACS ships with A2A and Email adapters; you can add your own for JWT tokens, TLSNotary proofs, or any custom evidence source.
The built-in A2A and Email adapters are deliberately digest-only. They record
and later compare the exact evidence digest, but do not verify A2A protocol
signatures, DKIM, S/MIME, PGP, JACS signatures, sender identity, or signer trust.
Their generated claims therefore use self-asserted assurance and names ending in
evidence-digest-recorded. First verify protocol provenance with the corresponding
protocol API, then create an explicit claim from that typed verification result if
your application needs to attest signature validity.
What Is an EvidenceAdapter?
An EvidenceAdapter is a Rust trait with three methods:
#![allow(unused)] fn main() { pub trait EvidenceAdapter: Send + Sync + std::fmt::Debug { /// Returns the kind string (e.g., "jwt", "tlsnotary", "custom"). fn kind(&self) -> &str; /// Normalize raw evidence bytes + metadata into claims + evidence reference. fn normalize( &self, raw: &[u8], metadata: &serde_json::Value, ) -> Result<(Vec<Claim>, EvidenceRef), Box<dyn Error>>; /// Verify a previously created evidence reference. fn verify_evidence( &self, evidence: &EvidenceRef, ) -> Result<EvidenceVerificationResult, Box<dyn Error>>; } }
The adapter lifecycle:
- At attestation creation time:
normalize()is called with raw evidence bytes and optional metadata. It returns structured claims and anEvidenceRefthat will be embedded in the attestation document. - At verification time (full tier):
verify_evidence()is called with the storedEvidenceRefto re-validate the evidence.
The normalize() Contract
normalize() must:
- Compute content-addressable digests of the raw evidence using
compute_digest_set_bytes() - Decide whether to embed the evidence (recommended for data under 64KB)
- Extract meaningful claims from the evidence
- Set appropriate
confidenceandassuranceLevelvalues - Include a
collectedAttimestamp - Return a
VerifierInfoidentifying your adapter and version
normalize() must NOT:
- Make network calls (normalization should be deterministic and fast)
- Modify the raw evidence
- Set confidence to 1.0 unless the evidence is self-verifying (e.g., a valid cryptographic proof)
- Treat caller-supplied metadata or successful digest computation as proof that a protocol signature, sender identity, or trust policy was verified
The verify_evidence() Contract
verify_evidence() must:
- Verify the digest integrity (re-hash and compare)
- Check freshness (is the
collectedAttimestamp within acceptable bounds?) - Return a detailed
EvidenceVerificationResultwithdigest_valid,freshness_valid, and human-readabledetail
verify_evidence() may:
- Make network calls (for remote evidence resolution)
- Access the file system (for local evidence files)
- Return partial results (e.g., digest valid but freshness expired)
Step-by-Step: Building a JWT Adapter
Here is a complete example of a JWT evidence adapter:
#![allow(unused)] fn main() { use crate::attestation::adapters::EvidenceAdapter; use crate::attestation::digest::compute_digest_set_bytes; use crate::attestation::types::*; use serde_json::Value; use std::error::Error; #[derive(Debug)] pub struct JwtAdapter; impl EvidenceAdapter for JwtAdapter { fn kind(&self) -> &str { "jwt" } fn normalize( &self, raw: &[u8], metadata: &Value, ) -> Result<(Vec<Claim>, EvidenceRef), Box<dyn Error>> { // 1. Parse the JWT (header.payload.signature) let jwt_str = std::str::from_utf8(raw)?; let parts: Vec<&str> = jwt_str.split('.').collect(); if parts.len() != 3 { return Err("Invalid JWT: expected 3 dot-separated parts".into()); } // 2. Decode the payload (base64url). These fields are UNVERIFIED until // a separate JWT verifier validates the signature, algorithm, issuer, // audience, and time claims against an explicit trust policy. let payload_bytes = base64::Engine::decode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, parts[1], )?; let payload: Value = serde_json::from_slice(&payload_bytes)?; // 3. Compute content-addressable digests let digests = compute_digest_set_bytes(raw); // 4. Extract claims (only non-PII fields per TRD Decision 14) let mut claims = vec![]; if let Some(iss) = payload.get("iss") { claims.push(Claim { name: "jwt-unverified-issuer".into(), value: iss.clone(), confidence: None, assurance_level: Some(AssuranceLevel::SelfAsserted), issuer: None, issued_at: Some(crate::time_utils::now_rfc3339()), }); } if let Some(sub) = payload.get("sub") { claims.push(Claim { name: "jwt-unverified-subject".into(), value: sub.clone(), confidence: None, assurance_level: Some(AssuranceLevel::SelfAsserted), issuer: None, issued_at: None, }); } // 5. Build the evidence reference let evidence = EvidenceRef { kind: EvidenceKind::Jwt, digests, uri: metadata.get("uri").and_then(|v| v.as_str()).map(String::from), embedded: raw.len() < 65536, embedded_data: if raw.len() < 65536 { Some(Value::String(jwt_str.to_string())) } else { None }, collected_at: crate::time_utils::now_rfc3339(), resolved_at: None, sensitivity: EvidenceSensitivity::Restricted, // JWTs may contain PII verifier: VerifierInfo { name: "jacs-jwt-adapter".into(), version: env!("CARGO_PKG_VERSION").into(), }, }; Ok((claims, evidence)) } fn verify_evidence( &self, evidence: &EvidenceRef, ) -> Result<EvidenceVerificationResult, Box<dyn Error>> { // Re-verify the digest let digest_valid = if let Some(ref data) = evidence.embedded_data { let raw = data.as_str().unwrap_or("").as_bytes(); let recomputed = compute_digest_set_bytes(raw); recomputed.sha256 == evidence.digests.sha256 } else { // Cannot verify without embedded data or fetchable URI false }; // Check freshness (example: 5 minute max age) let freshness_valid = true; // Implement actual time check Ok(EvidenceVerificationResult { kind: "jwt".into(), digest_valid, freshness_valid, detail: if digest_valid { "JWT bytes match the recorded digest; JWT signature and claims were not verified".into() } else { "JWT digest mismatch or data unavailable; JWT signature and claims were not verified".into() }, }) } } }
Testing Your Adapter
Write tests that cover:
- Normal case: Valid evidence normalizes to expected claims
- Invalid input: Malformed evidence returns a clear error
- Digest verification: Round-trip through normalize + verify_evidence
- Empty evidence: Edge case handling
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn jwt_normalize_extracts_issuer() { let adapter = JwtAdapter; // Build a minimal JWT (header.payload.signature) let header = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, b"{\"alg\":\"EdDSA\"}", ); let payload = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, b"{\"iss\":\"auth.example.com\",\"sub\":\"user-123\"}", ); let jwt = format!("{}.{}.fake-sig", header, payload); let (claims, evidence) = adapter .normalize(jwt.as_bytes(), &json!({})) .expect("normalize should succeed"); assert!(claims.iter().any(|c| c.name == "jwt-unverified-issuer")); assert!(claims.iter().all(|c| c.assurance_level != Some(AssuranceLevel::Verified))); assert_eq!(evidence.kind, EvidenceKind::Jwt); } } }
Registering Your Adapter with the Agent
Adapters are registered on the Agent struct via the evidence adapter list. To add
your adapter to the default set, modify adapters/mod.rs:
#![allow(unused)] fn main() { pub fn default_adapters() -> Vec<Box<dyn EvidenceAdapter>> { vec![ Box::new(a2a::A2aAdapter), Box::new(email::EmailAdapter), Box::new(jwt::JwtAdapter), // Add your adapter here ] } }
For runtime registration (without modifying JACS source), use the agent's adapter API (when available in a future release).
Privacy Considerations
The EvidenceSensitivity enum controls how evidence is handled:
- Public: Evidence can be freely shared and embedded
- Restricted: Evidence should be handled with care; consider redacting PII
- Confidential: Evidence should not be embedded; use content-addressable URI references only
For JWTs and other credential-based evidence, default to Restricted and only
include non-PII fields (iss, sub, aud, iat, exp) in claims.