use blake3::Hasher;
use ed25519_dalek::VerifyingKey;
use crate::cert::DelegationCert;
use crate::crypto::DOMAIN_CHAIN_FP;
use crate::error::KyaError;
use crate::intent::{IntentHash, MerkleProof};
use crate::registry::{NonceStore, RevocationStore};
pub trait Clock {
fn unix_now(&self) -> u64;
}
pub struct SystemClock;
impl Clock for SystemClock {
fn unix_now(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is before the Unix epoch")
.as_secs()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct VerificationReceipt {
pub chain_depth: usize,
pub verified_scope_root: IntentHash,
pub intent: IntentHash,
pub verified_at_unix: u64,
pub chain_fingerprint: [u8; 32],
}
#[cfg(feature = "prove")]
pub use zk_rollup::*;
#[cfg(feature = "prove")]
mod zk_rollup {
use super::*;
use risc0_zkvm::{Receipt, sha::Digest};
use subtle::{ConstantTimeEq, Choice};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DyoloZkRollup {
pub receipt: Receipt,
pub principal_pk: VerifyingKey,
pub executor_pk: VerifyingKey,
pub intent: IntentHash,
pub state_root: [u8; 32],
}
impl DyoloZkRollup {
#[inline(always)]
pub fn verify_state_transition(&self, image_id: impl Into<Digest>) -> Result<(), KyaError> {
self.receipt.verify(image_id).map_err(|_| KyaError::InvalidSubScopeProof)?;
let journal = self.receipt.journal.bytes.as_slice();
if journal.len() != 128 {
return Err(KyaError::InvalidSubScopeProof);
}
let p_match = journal[0..32].ct_eq(self.principal_pk.as_bytes());
let e_match = journal[32..64].ct_eq(self.executor_pk.as_bytes());
let i_match = journal[64..96].ct_eq(self.intent.as_slice());
let s_match = journal[96..128].ct_eq(&self.state_root);
let valid_transition: Choice = p_match & e_match & i_match & s_match;
if valid_transition.unwrap_u8() == 0 {
return Err(KyaError::InvalidSubScopeProof);
}
Ok(())
}
}
}
#[must_use]
pub struct AuthorizedAction {
pub receipt: VerificationReceipt,
_sealed: (),
}
impl AuthorizedAction {
pub(crate) fn new(receipt: VerificationReceipt) -> Self {
Self { receipt, _sealed: () }
}
pub fn receipt(&self) -> &VerificationReceipt {
&self.receipt
}
}
impl std::fmt::Debug for AuthorizedAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthorizedAction")
.field("receipt", &self.receipt)
.finish()
}
}
pub struct DyoloChain {
pub principal_pk: VerifyingKey,
pub principal_scope: IntentHash,
certs: Vec<DelegationCert>,
}
impl DyoloChain {
pub fn new(principal_pk: VerifyingKey, principal_scope: IntentHash) -> Self {
Self { principal_pk, principal_scope, certs: Vec::new() }
}
pub fn push(&mut self, cert: DelegationCert) -> &mut Self {
self.certs.push(cert);
self
}
pub fn len(&self) -> usize {
self.certs.len()
}
pub fn is_empty(&self) -> bool {
self.certs.is_empty()
}
pub fn certs(&self) -> &[DelegationCert] {
&self.certs
}
pub fn fingerprint(&self) -> [u8; 32] {
let mut base_hasher = Hasher::new_derive_key(DOMAIN_CHAIN_FP);
base_hasher.update(self.principal_pk.as_bytes());
base_hasher.update(&self.principal_scope);
self.certs
.iter()
.fold(base_hasher, |mut h, cert| {
h.update(&cert.fingerprint());
h
})
.finalize()
.into()
}
pub fn authorize(
&self,
agent_pk: &VerifyingKey,
intent: &IntentHash,
proof: &MerkleProof,
clock: &(dyn Clock + Send + Sync),
revocation: &(dyn RevocationStore + Send + Sync),
nonces: &(dyn NonceStore + Send + Sync),
) -> Result<AuthorizedAction, KyaError> {
if self.certs.is_empty() {
return Err(KyaError::EmptyChain);
}
if self.certs[0].delegator_pk != self.principal_pk {
return Err(KyaError::RootMismatch);
}
static DRIFT_TOLERANCE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let drift_tolerance = *DRIFT_TOLERANCE.get_or_init(|| {
std::env::var("DYOLO_CLOCK_DRIFT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(15)
});
let now = clock.unix_now();
let tolerated_now_early = now.saturating_add(drift_tolerance);
let tolerated_now_late = now.saturating_sub(drift_tolerance);
let mut current_scope = self.principal_scope;
let mut expected_delegator = self.principal_pk;
let mut depth: usize = 0;
let mut max_allowed_depth = u8::MAX;
let mut parent_expiry = u64::MAX;
let chain_len = self.certs.len();
if chain_len > 255 {
return Err(KyaError::MaxDepthExceeded(255, 255));
}
let mut seen_nonces: Vec<[u8; 16]> = Vec::with_capacity(chain_len);
let mut batch_signatures: Vec<ed25519_dalek::Signature> = Vec::with_capacity(chain_len);
let mut batch_public_keys: Vec<VerifyingKey> = Vec::with_capacity(chain_len);
let mut compiled_messages: Vec<Vec<u8>> = Vec::with_capacity(chain_len);
for (i, cert) in self.certs.iter().enumerate() {
if cert.delegator_pk != expected_delegator {
return Err(KyaError::BrokenLinkage(i));
}
compiled_messages.push(DelegationCert::signable_bytes(
&cert.delegator_pk, &cert.delegate_pk, &cert.scope_root,
&cert.scope_proof, &cert.nonce, cert.issued_at,
cert.expiration_unix, cert.max_depth,
));
batch_signatures.push(cert.signature);
batch_public_keys.push(cert.delegator_pk);
let is_early = (tolerated_now_early < cert.issued_at) as u8;
let is_expired = (cert.expiration_unix < tolerated_now_late) as u8;
let is_escalated = (cert.expiration_unix > parent_expiry) as u8;
if is_early == 1 { return Err(KyaError::NotYetValid(i, cert.issued_at, now)); }
if is_expired == 1 { return Err(KyaError::Expired(i, cert.expiration_unix, now)); }
if is_escalated == 1 { return Err(KyaError::TemporalViolation(i, cert.expiration_unix, parent_expiry)); }
depth += 1;
if depth > max_allowed_depth as usize {
return Err(KyaError::MaxDepthExceeded(i, max_allowed_depth));
}
if cert.max_depth < max_allowed_depth {
max_allowed_depth = cert.max_depth;
}
if revocation.is_revoked(&cert.fingerprint()).map_err(KyaError::StorageFailure)? {
return Err(KyaError::Revoked);
}
let mut internal_replay = false;
for seen in &seen_nonces {
if seen == &cert.nonce {
internal_replay = true;
break;
}
}
if internal_replay || nonces.is_consumed(&cert.nonce).map_err(KyaError::StorageFailure)? {
return Err(KyaError::NonceReplay);
}
seen_nonces.push(cert.nonce);
let is_declared_passthrough =
cert.scope_proof.subset_intents.is_empty() && cert.scope_proof.proofs.is_empty();
if is_declared_passthrough {
use subtle::ConstantTimeEq;
if cert.scope_root.ct_eq(¤t_scope).unwrap_u8() == 0 {
return Err(KyaError::ScopeEscalation(i));
}
} else {
let derived = cert
.scope_proof
.verify_and_derive_root(¤t_scope)
.map_err(|_| KyaError::ScopeEscalation(i))?;
use subtle::ConstantTimeEq;
if derived.ct_eq(&cert.scope_root).unwrap_u8() == 0 {
return Err(KyaError::ScopeEscalation(i));
}
}
parent_expiry = cert.expiration_unix;
current_scope = cert.scope_root;
expected_delegator = cert.delegate_pk;
}
if expected_delegator != *agent_pk {
return Err(KyaError::UnauthorizedLeaf);
}
{
let messages_refs: Vec<&[u8]> = compiled_messages.iter().map(|m| m.as_slice()).collect();
if ed25519_dalek::verify_batch(&messages_refs, &batch_signatures, &batch_public_keys).is_err() {
for (i, cert) in self.certs.iter().enumerate() {
if !cert.verify_signature() {
return Err(KyaError::InvalidSignature(i));
}
}
return Err(KyaError::InvalidSignature(0));
}
}
let intent_authorized = if proof.siblings.is_empty() {
use subtle::ConstantTimeEq;
intent.ct_eq(¤t_scope).into()
} else {
proof.verify(intent, ¤t_scope)
};
if !intent_authorized {
return Err(KyaError::ScopeViolation);
}
for nonce in &seen_nonces {
nonces.mark_consumed(nonce).map_err(KyaError::StorageFailure)?;
}
Ok(AuthorizedAction::new(VerificationReceipt {
chain_depth: depth,
verified_scope_root: current_scope,
intent: *intent,
verified_at_unix: now,
chain_fingerprint: self.fingerprint(),
}))
}
#[cfg(feature = "prove")]
pub fn authorize_zk_rollup(
&self,
agent_pk: &VerifyingKey,
intent: &IntentHash,
proof: &MerkleProof,
clock: &(dyn Clock + Send + Sync),
revocation: &(dyn RevocationStore + Send + Sync),
nonces: &(dyn NonceStore + Send + Sync),
) -> Result<DyoloZkRollup, KyaError> {
use risc0_zkvm::{default_prover, ExecutorEnv};
let action = self.authorize(agent_pk, intent, proof, clock, revocation, nonces)?;
let elf_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/dyolo_zk_guest.elf"));
let env = ExecutorEnv::builder()
.write(&self.principal_pk.to_bytes()).map_err(|_| KyaError::InvalidSubScopeProof)?
.write(&self.principal_scope).map_err(|_| KyaError::InvalidSubScopeProof)?
.write(&self.certs).map_err(|_| KyaError::InvalidSubScopeProof)?
.write(&agent_pk.to_bytes()).map_err(|_| KyaError::InvalidSubScopeProof)?
.write(intent).map_err(|_| KyaError::InvalidSubScopeProof)?
.write(&action.receipt.chain_fingerprint).map_err(|_| KyaError::InvalidSubScopeProof)?
.build()
.map_err(|_| KyaError::InvalidSubScopeProof)?;
let receipt = default_prover()
.prove(env, elf_bytes)
.map_err(|_| KyaError::InvalidSubScopeProof)?
.receipt;
Ok(DyoloZkRollup {
receipt,
principal_pk: self.principal_pk,
executor_pk: *agent_pk,
intent: *intent,
state_root: action.receipt.chain_fingerprint,
})
}
}
impl Clone for DyoloChain {
fn clone(&self) -> Self {
Self {
principal_pk: self.principal_pk,
principal_scope: self.principal_scope,
certs: self.certs.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
cert::CertBuilder,
identity::DyoloIdentity,
intent::{intent_hash, IntentTree, SubScopeProof},
registry::{MemoryNonceStore, MemoryRevocationStore, RevocationStore, fresh_nonce},
};
struct FixedClock(u64);
impl Clock for FixedClock {
fn unix_now(&self) -> u64 { self.0 }
}
fn setup() -> (DyoloIdentity, DyoloIdentity, DyoloIdentity, IntentTree, u64) {
let human = DyoloIdentity::generate();
let agent_a = DyoloIdentity::generate();
let agent_b = DyoloIdentity::generate();
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let query = intent_hash("QUERY_PORTFOLIO", b"");
let tree = IntentTree::build(vec![trade, query]).unwrap();
let now = 1_700_000_000u64;
(human, agent_a, agent_b, tree, now)
}
fn build_two_hop_chain(
human: &DyoloIdentity,
agent_a: &DyoloIdentity,
agent_b: &DyoloIdentity,
scope_root: IntentHash,
now: u64,
) -> DyoloChain {
let expiry = now + 3600;
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, expiry).sign(human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), scope_root, now, expiry).sign(agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
chain
}
#[test]
fn full_delegation_chain_succeeds() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
let chain = build_two_hop_chain(&human, &agent_a, &agent_b, scope_root, now);
let action = chain
.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new())
.unwrap();
assert_eq!(action.receipt.chain_depth, 2);
assert_eq!(action.receipt.intent, trade);
assert_ne!(action.receipt.chain_fingerprint, [0u8; 32]);
}
#[test]
fn sub_scope_delegation_succeeds() {
let (human, agent_a, agent_b, human_tree, now) = setup();
let human_scope = human_tree.root();
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let expiry = now + 3600;
let sub_proof = SubScopeProof::build(&human_tree, &[trade]).unwrap();
let sub_scope = IntentTree::build(vec![trade]).unwrap().root();
let cert_a = CertBuilder::new(agent_a.verifying_key(), human_scope, now, expiry).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), sub_scope, now, expiry)
.scope_proof(sub_proof)
.max_depth(5)
.sign(&agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), human_scope);
chain.push(cert_a).push(cert_b);
assert!(chain
.authorize(&agent_b.verifying_key(), &trade, &MerkleProof::default(), &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new())
.is_ok());
}
#[test]
fn scope_escalation_is_rejected() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let drain = intent_hash("DRAIN_ACCOUNT", b"all");
let fake_scope = IntentTree::build(vec![drain]).unwrap().root();
let expiry = now + 3600;
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, expiry).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), fake_scope, now, expiry).sign(&agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
assert_eq!(
chain.authorize(&agent_b.verifying_key(), &drain, &MerkleProof::default(), &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new()),
Err(KyaError::ScopeEscalation(1))
);
}
#[test]
fn temporal_monotonicity_is_enforced() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, now + 100).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), scope_root, now, now + 9999).sign(&agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
assert!(matches!(
chain.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new()),
Err(KyaError::TemporalViolation(1, _, _))
));
}
#[test]
fn expired_cert_is_rejected() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now - 10, now - 1).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), scope_root, now, now + 3600).sign(&agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
assert!(matches!(
chain.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new()),
Err(KyaError::Expired(0, _, _))
));
}
#[test]
fn revoked_cert_is_rejected() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let expiry = now + 3600;
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, expiry).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), scope_root, now, expiry).sign(&agent_a);
let rev = MemoryRevocationStore::new();
rev.revoke(&cert_a.fingerprint()).unwrap();
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
assert_eq!(
chain.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &rev, &MemoryNonceStore::new()),
Err(KyaError::Revoked)
);
}
#[test]
fn replay_attack_is_rejected() {
let (human, agent_a, _, tree, now) = setup();
let scope_root = tree.root();
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
let nonces = MemoryNonceStore::new();
let pinned = fresh_nonce();
let cert = CertBuilder::new(agent_a.verifying_key(), scope_root, now, now + 3600)
.nonce(pinned)
.sign(&human);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert.clone());
chain.authorize(&agent_a.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &nonces).unwrap();
let mut chain2 = DyoloChain::new(human.verifying_key(), scope_root);
chain2.push(cert);
assert_eq!(
chain2.authorize(&agent_a.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &nonces),
Err(KyaError::NonceReplay)
);
}
#[test]
fn duplicate_nonce_within_chain_is_rejected() {
let (human, agent_a, agent_b, tree, now) = setup();
let scope_root = tree.root();
let shared = fresh_nonce();
let expiry = now + 3600;
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, expiry).nonce(shared).sign(&human);
let cert_b = CertBuilder::new(agent_b.verifying_key(), scope_root, now, expiry).nonce(shared).sign(&agent_a);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_b);
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
assert_eq!(
chain.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new()),
Err(KyaError::NonceReplay)
);
}
#[test]
fn broken_linkage_is_rejected() {
let (human, agent_a, agent_b, tree, now) = setup();
let impostor = DyoloIdentity::generate();
let scope_root = tree.root();
let expiry = now + 3600;
let cert_a = CertBuilder::new(agent_a.verifying_key(), scope_root, now, expiry).sign(&human);
let cert_fake = CertBuilder::new(agent_b.verifying_key(), scope_root, now, expiry).sign(&impostor);
let mut chain = DyoloChain::new(human.verifying_key(), scope_root);
chain.push(cert_a).push(cert_fake);
let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
let proof = tree.prove(&trade).unwrap();
assert_eq!(
chain.authorize(&agent_b.verifying_key(), &trade, &proof, &FixedClock(now), &MemoryRevocationStore::new(), &MemoryNonceStore::new()),
Err(KyaError::BrokenLinkage(1))
);
}
#[test]
fn chain_fingerprint_is_stable() {
let (human, agent_a, agent_b, tree, now) = setup();
let chain = build_two_hop_chain(&human, &agent_a, &agent_b, tree.root(), now);
assert_eq!(chain.fingerprint(), chain.clone().fingerprint());
}
#[test]
fn identity_round_trip() {
let id = DyoloIdentity::generate();
let bytes = id.to_signing_bytes();
let restored = DyoloIdentity::from_signing_bytes(&bytes);
assert_eq!(id.verifying_key().as_bytes(), restored.verifying_key().as_bytes());
}
}