Skip to main content

a1/
zk.rs

1use blake3::Hasher;
2use serde::{Deserialize, Serialize};
3use subtle::ConstantTimeEq;
4
5use crate::chain::DyoloChain;
6use crate::error::A1Error;
7use crate::identity::Signer;
8use crate::intent::IntentHash;
9
10const DOMAIN_ZK_COMMIT: &str = "a1::dyolo::zk::commit::v2.8.0";
11const DOMAIN_ZK_BIND: &str = "a1::dyolo::zk::bind::v2.8.0";
12
13/// How a `ZkChainCommitment` was produced.
14///
15/// `Blake3Commit` is the default: a cryptographic commitment derived from
16/// Blake3 over all chain state. It is verifiable offline by anyone with the
17/// chain — but it is not zero-knowledge (it reveals the chain length and
18/// fingerprint). This mode requires no extra dependencies and works
19/// everywhere A1 runs.
20///
21/// `ExternalZkvm` signals that a real zero-knowledge proof has been generated
22/// by an external zkVM backend (RISC Zero, Jolt, SP1, etc.) and attached as
23/// `zk_proof_bytes`. The verifier must use the same zkVM to check it.
24///
25/// Both modes share the same `ZkChainCommitment` wire format so consumers
26/// can upgrade from Blake3Commit to ExternalZkvm without changing any
27/// downstream code.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[repr(u8)]
30pub enum ZkProofMode {
31    Blake3Commit = 1,
32    ExternalZkvm = 2,
33}
34
35impl ZkProofMode {
36    pub fn as_u8(&self) -> u8 {
37        match self {
38            Self::Blake3Commit => 1,
39            Self::ExternalZkvm => 2,
40        }
41    }
42}
43
44/// A compact, verifiable commitment to the validity of a full delegation chain.
45///
46/// Instead of shipping the entire delegation chain to every verifier, a gateway
47/// or trusted service can compute a `ZkChainCommitment` and hand it to
48/// downstream consumers. Verification is O(1): one Blake3 hash + one Ed25519
49/// signature check, regardless of chain depth.
50///
51/// The `commitment` field is a domain-separated Blake3 hash over:
52/// - The chain fingerprint (covers all certs and the principal scope)
53/// - The authorized intent hash
54/// - The narrowing commitment (capability mask at authorization time)
55/// - The timestamp
56///
57/// Because the chain fingerprint already commits to every cert's signature and
58/// scope, this commitment transitively proves that the full chain is valid.
59///
60/// # Wire format
61///
62/// `ZkChainCommitment` is JSON-serializable (requires `wire` feature). Store it
63/// in your audit log, ship it to downstream services, or anchor it on-chain.
64///
65/// # Upgrade path to real ZK
66///
67/// Set `mode = ZkProofMode::ExternalZkvm` and populate `zk_proof_bytes` with
68/// the output of your zkVM (RISC Zero, Jolt, etc.) over the same `commitment`.
69/// The `verify_commitment` check stays unchanged — consumers that understand
70/// the ZK mode can additionally verify the zkVM proof.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ZkChainCommitment {
73    /// Blake3 hash binding chain fingerprint + intent + narrowing + timestamp.
74    pub commitment: [u8; 32],
75
76    /// Authorized intent hash.
77    pub intent: IntentHash,
78
79    /// Unix timestamp when this commitment was sealed.
80    pub sealed_at_unix: u64,
81
82    /// Hex of the chain fingerprint, for human-readable logs.
83    pub chain_fingerprint_hex: String,
84
85    /// Ed25519 signature over `commitment` from the sealing authority.
86    pub authority_signature: String,
87
88    /// DID of the sealing authority (hex public key in `did:a1:` format).
89    pub authority_did: String,
90
91    /// Proof mode. `Blake3Commit` unless a zkVM proof is attached.
92    pub mode: ZkProofMode,
93
94    /// Raw zkVM proof bytes (hex). Empty for `Blake3Commit` mode.
95    #[serde(default, skip_serializing_if = "String::is_empty")]
96    pub zk_proof_hex: String,
97
98    /// Optional passport namespace for human-readable audit records.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub passport_namespace: Option<String>,
101}
102
103impl ZkChainCommitment {
104    /// Seal a commitment over an authorized chain.
105    ///
106    /// Call this after `DyoloChain::authorize` succeeds. The `authority`
107    /// is typically the gateway's signing identity. The resulting commitment
108    /// can be distributed to downstream consumers in place of the full chain.
109    pub fn seal(
110        chain: &DyoloChain,
111        intent: &IntentHash,
112        narrowing_commitment: &[u8; 32],
113        sealed_at_unix: u64,
114        authority: &dyn Signer,
115        passport_namespace: Option<&str>,
116    ) -> Self {
117        let chain_fp = chain.fingerprint();
118        let commitment =
119            compute_commitment(&chain_fp, intent, narrowing_commitment, sealed_at_unix);
120        let sig = authority.sign_message(&commitment);
121        let authority_did = format!(
122            "did:a1:{}",
123            hex::encode(authority.verifying_key().as_bytes())
124        );
125
126        Self {
127            commitment,
128            intent: *intent,
129            sealed_at_unix,
130            chain_fingerprint_hex: hex::encode(chain_fp),
131            authority_signature: hex::encode(sig.to_bytes()),
132            authority_did,
133            mode: ZkProofMode::Blake3Commit,
134            zk_proof_hex: String::new(),
135            passport_namespace: passport_namespace.map(String::from),
136        }
137    }
138
139    /// Verify the authority signature and optionally check commitment freshness.
140    ///
141    /// This is an O(1) operation regardless of the original chain depth.
142    /// Pass `max_age_secs = None` to skip freshness checking.
143    pub fn verify_commitment(
144        &self,
145        narrowing_commitment: &[u8; 32],
146        now_unix: u64,
147        max_age_secs: Option<u64>,
148    ) -> Result<(), A1Error> {
149        let chain_fp_bytes = hex::decode(&self.chain_fingerprint_hex)
150            .map_err(|_| A1Error::WireFormatError("invalid chain_fingerprint_hex".into()))?;
151        let chain_fp: [u8; 32] = chain_fp_bytes
152            .try_into()
153            .map_err(|_| A1Error::WireFormatError("chain fingerprint must be 32 bytes".into()))?;
154
155        let expected = compute_commitment(
156            &chain_fp,
157            &self.intent,
158            narrowing_commitment,
159            self.sealed_at_unix,
160        );
161
162        if expected[..].ct_eq(&self.commitment[..]).unwrap_u8() == 0 {
163            return Err(A1Error::InvalidSubScopeProof);
164        }
165
166        if let Some(max_age) = max_age_secs {
167            let age = now_unix.saturating_sub(self.sealed_at_unix);
168            if age > max_age {
169                return Err(A1Error::Expired(0, self.sealed_at_unix + max_age, now_unix));
170            }
171        }
172
173        let pk_hex = self
174            .authority_did
175            .strip_prefix("did:a1:")
176            .ok_or_else(|| A1Error::WireFormatError("invalid authority DID".into()))?;
177        let pk_bytes = hex::decode(pk_hex)
178            .map_err(|_| A1Error::WireFormatError("invalid authority DID hex".into()))?;
179        let pk_arr: [u8; 32] = pk_bytes
180            .try_into()
181            .map_err(|_| A1Error::WireFormatError("authority key must be 32 bytes".into()))?;
182        let authority_vk = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr)
183            .map_err(|_| A1Error::WireFormatError("invalid authority Ed25519 key".into()))?;
184
185        let sig_bytes = hex::decode(&self.authority_signature)
186            .map_err(|_| A1Error::WireFormatError("invalid authority_signature hex".into()))?;
187        let sig_arr: [u8; 64] = sig_bytes
188            .try_into()
189            .map_err(|_| A1Error::WireFormatError("signature must be 64 bytes".into()))?;
190        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
191
192        use ed25519_dalek::Verifier;
193        authority_vk
194            .verify(&self.commitment, &sig)
195            .map_err(|_| A1Error::HybridSignatureInvalid {
196                component: "zk-commitment",
197            })
198    }
199
200    /// Attach an external zkVM proof to this commitment.
201    ///
202    /// Use this after running the chain through RISC Zero, Jolt, or any
203    /// compatible zkVM. The commitment bytes stay identical — consumers that
204    /// only check `verify_commitment` will continue to work unchanged, while
205    /// consumers that understand ZK can additionally verify `zk_proof_hex`.
206    pub fn with_zk_proof(mut self, proof_bytes: &[u8]) -> Self {
207        self.zk_proof_hex = hex::encode(proof_bytes);
208        self.mode = ZkProofMode::ExternalZkvm;
209        self
210    }
211
212    /// Returns `true` if this commitment carries a zkVM proof.
213    pub fn has_zk_proof(&self) -> bool {
214        self.mode == ZkProofMode::ExternalZkvm && !self.zk_proof_hex.is_empty()
215    }
216}
217
218fn compute_commitment(
219    chain_fp: &[u8; 32],
220    intent: &IntentHash,
221    narrowing_commitment: &[u8; 32],
222    sealed_at: u64,
223) -> [u8; 32] {
224    let mut h = Hasher::new_derive_key(DOMAIN_ZK_COMMIT);
225    h.update(chain_fp);
226    h.update(intent);
227    h.update(narrowing_commitment);
228    h.update(&sealed_at.to_le_bytes());
229    h.finalize().into()
230}
231
232/// Produce a binding hash over a `ZkChainCommitment` for on-chain anchoring.
233///
234/// This is the value to submit to a smart contract, a transparency log,
235/// or an audit ledger. It is a domain-separated Blake3 hash over the
236/// entire commitment so that the chain state can be anchored in 32 bytes.
237pub fn anchor_hash(commitment: &ZkChainCommitment) -> [u8; 32] {
238    let mut h = Hasher::new_derive_key(DOMAIN_ZK_BIND);
239    h.update(&commitment.commitment);
240    h.update(&commitment.sealed_at_unix.to_le_bytes());
241    h.update(commitment.authority_did.as_bytes());
242    h.finalize().into()
243}
244
245// ── Tests ─────────────────────────────────────────────────────────────────────
246
247// ── ZkTraceProof ──────────────────────────────────────────────────────────────
248
249/// A combined proof of both authorization (chain) and reasoning (trace).
250///
251/// `ZkTraceProof` binds a `ZkChainCommitment` to a `ProvenanceRoot` in a
252/// single 32-byte commitment. This proves not just that an agent was authorized
253/// to act, but that a specific, tamper-evident reasoning trace produced that
254/// action.
255///
256/// # Why this matters
257///
258/// `ZkChainCommitment` answers: *"Was the agent authorized?"*
259/// `ZkTraceProof` answers: *"Was the agent authorized, and did it reason correctly?"*
260///
261/// For EU AI Act high-risk systems and NIST AI RMF Govern 6.2 compliance,
262/// you need both. `ZkTraceProof` is the single artifact that satisfies both
263/// requirements.
264///
265/// # Upgrade path to full ZK
266///
267/// Set `zk_proof_hex` with the output of the RISC Zero guest at
268/// `src/zk_guest/src/main.rs`. The guest program can verify both the chain
269/// commitment and the trace Merkle root in a single proof. Consumers that
270/// only call `verify()` continue working unchanged when the zkVM proof is
271/// attached.
272#[derive(Debug, Clone)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274pub struct ZkTraceProof {
275    /// Chain authorization commitment.
276    pub chain_commitment: ZkChainCommitment,
277    /// Finalized Merkle root of the agent's reasoning trace.
278    pub trace_root: crate::provenance::ProvenanceRoot,
279    /// Blake3 commitment binding chain + trace: `Blake3(DOMAIN || chain_commit || merkle_root)`.
280    #[cfg_attr(feature = "serde", serde(with = "crate::zk::hex_32_serde"))]
281    pub combined_commitment: [u8; 32],
282    /// Ed25519 signature over `combined_commitment` from the sealing authority.
283    pub authority_signature: String,
284    /// `did:a1:` identifier of the sealing authority.
285    pub authority_did: String,
286    /// Optional zkVM proof bytes (hex). Activate with the RISC Zero guest program.
287    #[cfg_attr(
288        feature = "serde",
289        serde(default, skip_serializing_if = "String::is_empty")
290    )]
291    pub zk_proof_hex: String,
292}
293
294impl ZkTraceProof {
295    /// Seal a combined authorization + reasoning proof.
296    ///
297    /// The `authority` is typically the gateway's signing identity.
298    /// `trace_root` must have been finalized against the chain fingerprint
299    /// in `chain_commitment` via `ReasoningTrace::finalize()`.
300    pub fn seal(
301        chain_commitment: ZkChainCommitment,
302        trace_root: crate::provenance::ProvenanceRoot,
303        authority: &dyn crate::identity::Signer,
304    ) -> Self {
305        let combined =
306            trace_combined_commitment(&chain_commitment.commitment, &trace_root.merkle_root);
307        let sig = authority.sign_message(&combined);
308        let authority_did = format!(
309            "did:a1:{}",
310            hex::encode(authority.verifying_key().as_bytes())
311        );
312        Self {
313            chain_commitment,
314            trace_root,
315            combined_commitment: combined,
316            authority_signature: hex::encode(sig.to_bytes()),
317            authority_did,
318            zk_proof_hex: String::new(),
319        }
320    }
321
322    /// Verify the combined commitment and authority signature.
323    pub fn verify(&self) -> Result<(), crate::error::A1Error> {
324        let expected = trace_combined_commitment(
325            &self.chain_commitment.commitment,
326            &self.trace_root.merkle_root,
327        );
328        use subtle::ConstantTimeEq;
329        if expected[..]
330            .ct_eq(&self.combined_commitment[..])
331            .unwrap_u8()
332            == 0
333        {
334            return Err(crate::error::A1Error::InvalidSubScopeProof);
335        }
336
337        let pk_hex = self.authority_did.strip_prefix("did:a1:").ok_or_else(|| {
338            crate::error::A1Error::WireFormatError("invalid authority DID".into())
339        })?;
340        let pk_bytes = hex::decode(pk_hex)
341            .map_err(|_| crate::error::A1Error::WireFormatError("invalid DID hex".into()))?;
342        let pk_arr: [u8; 32] = pk_bytes.try_into().map_err(|_| {
343            crate::error::A1Error::WireFormatError("authority key must be 32 bytes".into())
344        })?;
345        let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr)
346            .map_err(|_| crate::error::A1Error::WireFormatError("invalid Ed25519 key".into()))?;
347
348        let sig_bytes = hex::decode(&self.authority_signature)
349            .map_err(|_| crate::error::A1Error::WireFormatError("invalid signature hex".into()))?;
350        let sig_arr: [u8; 64] = sig_bytes.try_into().map_err(|_| {
351            crate::error::A1Error::WireFormatError("signature must be 64 bytes".into())
352        })?;
353        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
354
355        use ed25519_dalek::Verifier;
356        vk.verify(&self.combined_commitment, &sig).map_err(|_| {
357            crate::error::A1Error::HybridSignatureInvalid {
358                component: "zk-trace",
359            }
360        })
361    }
362
363    /// Attach a zkVM proof to upgrade from commitment-only to full ZK.
364    #[must_use]
365    pub fn with_zk_proof(mut self, proof_bytes: &[u8]) -> Self {
366        self.zk_proof_hex = hex::encode(proof_bytes);
367        self
368    }
369
370    /// Returns `true` if a zkVM proof is attached.
371    pub fn has_zk_proof(&self) -> bool {
372        !self.zk_proof_hex.is_empty()
373    }
374}
375
376fn trace_combined_commitment(chain_commit: &[u8; 32], merkle_root: &[u8; 32]) -> [u8; 32] {
377    let mut h = Hasher::new_derive_key("a1::dyolo::zk::trace::v2.8.0");
378    h.update(chain_commit);
379    h.update(merkle_root);
380    h.finalize().into()
381}
382
383pub(crate) mod hex_32_serde {
384    use serde::{Deserialize, Deserializer, Serializer};
385    pub fn serialize<S: Serializer>(v: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
386        s.serialize_str(&hex::encode(v))
387    }
388    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
389        let h = String::deserialize(d)?;
390        hex::decode(&h)
391            .map_err(serde::de::Error::custom)?
392            .try_into()
393            .map_err(|_| serde::de::Error::custom("expected 32 bytes"))
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::{cert::CertBuilder, identity::DyoloIdentity, intent::Intent};
401
402    #[test]
403    fn seal_and_verify() {
404        let human = DyoloIdentity::generate();
405        let agent = DyoloIdentity::generate();
406        let now = 1_700_000_000u64;
407
408        let intent = Intent::new("trade.equity").unwrap().hash();
409        let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
410        let mut chain = DyoloChain::new(human.verifying_key(), intent);
411        chain.push(cert);
412
413        let narrowing = [0u8; 32];
414        let commitment =
415            ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, Some("acme-bot"));
416
417        assert!(commitment
418            .verify_commitment(&narrowing, now, Some(86400))
419            .is_ok());
420        assert_eq!(commitment.mode, ZkProofMode::Blake3Commit);
421        assert!(!commitment.has_zk_proof());
422    }
423
424    #[test]
425    fn tampered_commitment_fails() {
426        let human = DyoloIdentity::generate();
427        let agent = DyoloIdentity::generate();
428        let now = 1_700_000_000u64;
429        let intent = Intent::new("read").unwrap().hash();
430        let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
431        let mut chain = DyoloChain::new(human.verifying_key(), intent);
432        chain.push(cert);
433
434        let narrowing = [0u8; 32];
435        let mut commitment =
436            ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, None);
437        commitment.commitment[0] ^= 0xFF;
438        assert!(commitment.verify_commitment(&narrowing, now, None).is_err());
439    }
440
441    #[test]
442    fn expired_commitment_fails() {
443        let human = DyoloIdentity::generate();
444        let agent = DyoloIdentity::generate();
445        let now = 1_700_000_000u64;
446        let intent = Intent::new("read").unwrap().hash();
447        let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
448        let mut chain = DyoloChain::new(human.verifying_key(), intent);
449        chain.push(cert);
450
451        let narrowing = [0u8; 32];
452        let commitment = ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, None);
453        assert!(commitment
454            .verify_commitment(&narrowing, now + 7200, Some(3600))
455            .is_err());
456    }
457
458    #[test]
459    fn with_zk_proof_upgrades_mode() {
460        let human = DyoloIdentity::generate();
461        let agent = DyoloIdentity::generate();
462        let now = 1_700_000_000u64;
463        let intent = Intent::new("read").unwrap().hash();
464        let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
465        let mut chain = DyoloChain::new(human.verifying_key(), intent);
466        chain.push(cert);
467
468        let narrowing = [0u8; 32];
469        let commitment = ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, None)
470            .with_zk_proof(b"placeholder-proof-bytes");
471
472        assert_eq!(commitment.mode, ZkProofMode::ExternalZkvm);
473        assert!(commitment.has_zk_proof());
474        assert!(commitment.verify_commitment(&narrowing, now, None).is_ok());
475    }
476
477    #[test]
478    fn anchor_hash_is_deterministic() {
479        let human = DyoloIdentity::generate();
480        let agent = DyoloIdentity::generate();
481        let now = 1_700_000_000u64;
482        let intent = Intent::new("read").unwrap().hash();
483        let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
484        let mut chain = DyoloChain::new(human.verifying_key(), intent);
485        chain.push(cert);
486
487        let narrowing = [0u8; 32];
488        let c = ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, None);
489        assert_eq!(anchor_hash(&c), anchor_hash(&c));
490    }
491}
492
493#[test]
494fn zk_trace_proof_seal_verify() {
495    use crate::{
496        cert::CertBuilder,
497        identity::DyoloIdentity,
498        intent::Intent,
499        provenance::{ReasoningStepKind, ReasoningTrace},
500    };
501
502    let human = DyoloIdentity::generate();
503    let agent = DyoloIdentity::generate();
504    let now = 1_700_000_000u64;
505    let intent = Intent::new("trade.equity").unwrap().hash();
506    let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
507    let mut chain = DyoloChain::new(human.verifying_key(), intent);
508    chain.push(cert);
509
510    let narrowing = [0u8; 32];
511    let chain_fp = chain.fingerprint();
512    let commitment = ZkChainCommitment::seal(&chain, &intent, &narrowing, now, &human, None);
513
514    let mut trace = ReasoningTrace::new(now);
515    trace.record(ReasoningStepKind::Thought, b"analyzing trade", now + 1);
516    trace.record(
517        ReasoningStepKind::FinalAction,
518        b"execute trade.equity AAPL 100",
519        now + 2,
520    );
521    let root = trace.finalize(now + 3, &chain_fp).unwrap();
522
523    let proof = ZkTraceProof::seal(commitment, root, &human);
524    assert!(proof.verify().is_ok());
525    assert!(!proof.has_zk_proof());
526}
527
528#[test]
529fn zk_trace_proof_tampered_fails() {
530    use crate::{
531        cert::CertBuilder,
532        identity::DyoloIdentity,
533        intent::Intent,
534        provenance::{ReasoningStepKind, ReasoningTrace},
535    };
536
537    let human = DyoloIdentity::generate();
538    let agent = DyoloIdentity::generate();
539    let now = 1_700_000_000u64;
540    let intent = Intent::new("read").unwrap().hash();
541    let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(&human);
542    let mut chain = DyoloChain::new(human.verifying_key(), intent);
543    chain.push(cert);
544    let chain_fp = chain.fingerprint();
545
546    let mut trace = ReasoningTrace::new(now);
547    trace.record(ReasoningStepKind::Thought, b"step one", now + 1);
548    let root = trace.finalize(now + 2, &chain_fp).unwrap();
549
550    let commitment = ZkChainCommitment::seal(&chain, &intent, &[0u8; 32], now, &human, None);
551    let mut proof = ZkTraceProof::seal(commitment, root, &human);
552    proof.combined_commitment[0] ^= 0xFF;
553    assert!(proof.verify().is_err());
554}