use ed25519_dalek::VerifyingKey;
use crate::cert::CertBuilder;
use crate::chain::{Clock, DyoloChain, SystemClock};
use crate::error::A1Error;
use crate::identity::narrowing::NarrowingMatrix;
use crate::identity::receipt::ProvableReceipt;
use crate::identity::Signer;
use crate::intent::{Intent, IntentHash, IntentTree, MerkleProof, SubScopeProof};
use crate::registry::{MemoryNonceStore, MemoryRevocationStore, NonceStore, RevocationStore};
#[cfg(feature = "wire")]
use crate::cert_extensions::{CertExtensions, ExtValue};
#[rustfmt::skip]
#[allow(dead_code)]
pub(crate) const PASSPORT_PROTOCOL_TAG: &[u8] = &[
0x44, 0x79, 0x6f, 0x6c, 0x6f, 0x50, 0x61, 0x73, 0x73, 0x70, 0x6f, 0x72, 0x74,
0x20, 0x76, 0x32, 0x2e, 0x38, 0x2e, 0x30,
0x7c, 0x64, 0x79, 0x6f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x69, 0x61, 0x6e,
];
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DyoloPassport {
pub namespace: String,
pub capability_mask: NarrowingMatrix,
pub(crate) capabilities: Vec<String>,
pub cert: crate::cert::DelegationCert,
}
impl DyoloPassport {
pub fn issue(
namespace: impl Into<String>,
capabilities: &[&str],
ttl_secs: u64,
signer: &dyn Signer,
clock: &dyn Clock,
) -> Result<Self, A1Error> {
let namespace = namespace.into();
let caps_owned: Vec<String> = capabilities.iter().map(|s| s.to_string()).collect();
Self::issue_inner(namespace, caps_owned, ttl_secs, signer, clock)
}
pub fn issue_from_csv(
namespace: impl Into<String>,
capabilities_csv: &str,
ttl_secs: u64,
signer: &dyn Signer,
clock: &dyn Clock,
) -> Result<Self, A1Error> {
let namespace = namespace.into();
let caps_owned: Vec<String> = capabilities_csv
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Self::issue_inner(namespace, caps_owned, ttl_secs, signer, clock)
}
fn issue_inner(
namespace: String,
capabilities: Vec<String>,
ttl_secs: u64,
signer: &dyn Signer,
clock: &dyn Clock,
) -> Result<Self, A1Error> {
let mask = NarrowingMatrix::from_capabilities(
&capabilities.iter().map(String::as_str).collect::<Vec<_>>(),
);
let hashes = cap_hashes(&capabilities)?;
let tree = IntentTree::build(hashes)?;
let scope_root = tree.root();
let now = clock.unix_now();
let expiry = now.saturating_add(ttl_secs);
#[cfg(feature = "wire")]
let ext = CertExtensions::new()
.set("dyolo.passport.v", ExtValue::U64(1))
.set("dyolo.passport.namespace", ExtValue::Str(namespace.clone()))
.set("dyolo.passport.mask", ExtValue::Str(mask.to_hex()))
.set(
"dyolo.passport.caps",
ExtValue::Strings(capabilities.clone()),
);
#[cfg(feature = "wire")]
let cert = CertBuilder::new(signer.verifying_key(), scope_root, now, expiry)
.max_depth(255)
.extensions(ext)
.build(signer)?;
#[cfg(not(feature = "wire"))]
let cert = CertBuilder::new(signer.verifying_key(), scope_root, now, expiry)
.max_depth(255)
.build(signer)?;
Ok(Self {
namespace,
capability_mask: mask,
capabilities,
cert,
})
}
pub fn verifying_key(&self) -> VerifyingKey {
self.cert.delegator_pk
}
pub fn scope_root(&self) -> Result<[u8; 32], A1Error> {
Ok(self.capability_tree()?.root())
}
pub fn new_chain(&self) -> Result<DyoloChain, A1Error> {
let mut chain = DyoloChain::new(self.cert.delegator_pk, self.cert.scope_root);
chain.namespace = Some(self.namespace.clone());
Ok(chain)
}
pub fn issue_sub(
&self,
delegate_pk: VerifyingKey,
capabilities: &[&str],
ttl_secs: u64,
signer: &dyn Signer,
clock: &dyn Clock,
) -> Result<crate::cert::DelegationCert, A1Error> {
let requested = NarrowingMatrix::from_capabilities(capabilities);
requested.enforce_narrowing(&self.capability_mask)?;
let parent_tree = self.capability_tree()?;
let sub_hashes: Vec<IntentHash> = capabilities
.iter()
.map(|c| Intent::new(*c).map(|i| i.hash()))
.collect::<Result<_, _>>()?;
let scope_proof = SubScopeProof::build(&parent_tree, &sub_hashes)?;
let sub_tree = IntentTree::build(sub_hashes)?;
let sub_scope_root = sub_tree.root();
let now = clock.unix_now();
let expiry = now.saturating_add(ttl_secs);
#[cfg(feature = "wire")]
let ext = CertExtensions::new()
.set("dyolo.passport.v", ExtValue::U64(1))
.set(
"dyolo.passport.namespace",
ExtValue::Str(self.namespace.clone()),
)
.set("dyolo.passport.mask", ExtValue::Str(requested.to_hex()));
#[cfg(feature = "wire")]
return CertBuilder::new(delegate_pk, sub_scope_root, now, expiry)
.scope_proof(scope_proof)
.extensions(ext)
.build(signer);
#[cfg(not(feature = "wire"))]
CertBuilder::new(delegate_pk, sub_scope_root, now, expiry)
.scope_proof(scope_proof)
.build(signer)
}
pub fn issue_sub_from_csv(
&self,
delegate_pk: VerifyingKey,
capabilities_csv: &str,
ttl_secs: u64,
signer: &dyn Signer,
clock: &dyn Clock,
) -> Result<crate::cert::DelegationCert, A1Error> {
let caps: Vec<&str> = capabilities_csv
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
self.issue_sub(delegate_pk, &caps, ttl_secs, signer, clock)
}
#[allow(clippy::too_many_arguments)]
pub fn guard(
&self,
chain: &DyoloChain,
agent_pk: &VerifyingKey,
intent: &Intent,
proof: &MerkleProof,
clock: &(dyn Clock + Send + Sync),
revocation: &(dyn RevocationStore + Send + Sync),
nonces: &(dyn NonceStore + Send + Sync),
) -> Result<ProvableReceipt, A1Error> {
let requested = NarrowingMatrix::from_capabilities(&[intent.action.as_str()]);
requested.enforce_narrowing(&self.capability_mask)?;
let intent_hash = intent.hash();
let action = chain.authorize(agent_pk, &intent_hash, proof, clock, revocation, nonces)?;
Ok(ProvableReceipt::new(
action.receipt,
self.namespace.clone(),
&self.capability_mask,
))
}
pub fn guard_local(
&self,
chain: &DyoloChain,
agent_pk: &VerifyingKey,
intent: &Intent,
) -> Result<ProvableReceipt, A1Error> {
let requested = NarrowingMatrix::from_capabilities(&[intent.action.as_str()]);
requested.enforce_narrowing(&self.capability_mask)?;
let proof = MerkleProof::default();
let clock = SystemClock;
let revocation = MemoryRevocationStore::new();
let nonces = MemoryNonceStore::new();
let intent_hash = intent.hash();
let action =
chain.authorize(agent_pk, &intent_hash, &proof, &clock, &revocation, &nonces)?;
Ok(ProvableReceipt::new(
action.receipt,
self.namespace.clone(),
&self.capability_mask,
))
}
#[cfg(feature = "wire")]
#[cfg_attr(docsrs, doc(cfg(feature = "wire")))]
pub fn save(&self, path: impl AsRef<std::path::Path>) -> Result<(), A1Error> {
let file = PassportFile {
a1_passport: 1,
namespace: self.namespace.clone(),
capability_mask_hex: self.capability_mask.to_hex(),
capabilities: self.capabilities.clone(),
cert: self.cert.clone(),
_magic: default_dyolo_magic(),
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| A1Error::WireFormatError(e.to_string()))?;
std::fs::write(path, json).map_err(|e| A1Error::WireFormatError(e.to_string()))
}
#[cfg(feature = "wire")]
#[cfg_attr(docsrs, doc(cfg(feature = "wire")))]
pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self, A1Error> {
let json =
std::fs::read_to_string(path).map_err(|e| A1Error::WireFormatError(e.to_string()))?;
let file: PassportFile =
serde_json::from_str(&json).map_err(|e| A1Error::WireFormatError(e.to_string()))?;
let mask = NarrowingMatrix::from_hex(&file.capability_mask_hex)?;
Ok(Self {
namespace: file.namespace,
capability_mask: mask,
capabilities: file.capabilities,
cert: file.cert,
})
}
fn capability_tree(&self) -> Result<IntentTree, A1Error> {
IntentTree::build(cap_hashes(&self.capabilities)?)
}
}
fn cap_hashes(caps: &[String]) -> Result<Vec<IntentHash>, A1Error> {
caps.iter()
.map(|c| Intent::new(c.as_str()).map(|i| i.hash()))
.collect()
}
#[cfg(feature = "wire")]
#[derive(serde::Serialize, serde::Deserialize)]
struct PassportFile {
a1_passport: u8,
namespace: String,
capability_mask_hex: String,
capabilities: Vec<String>,
cert: crate::cert::DelegationCert,
#[serde(default = "default_dyolo_magic")]
_magic: String,
}
#[cfg(feature = "wire")]
fn default_dyolo_magic() -> String {
"dyolo_v2.8.0".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chain::SystemClock;
use crate::identity::DyoloIdentity;
use crate::intent::Intent;
fn make_passport(caps: &[&str]) -> (DyoloIdentity, DyoloPassport) {
let root = DyoloIdentity::generate();
let clock = SystemClock;
let passport = DyoloPassport::issue("test-agent", caps, 3600, &root, &clock).unwrap();
(root, passport)
}
#[test]
fn issue_stores_namespace_and_mask() {
let (_root, passport) = make_passport(&["trade.equity", "portfolio.read"]);
assert_eq!(passport.namespace, "test-agent");
assert!(!passport.capability_mask.is_empty());
}
#[test]
fn scope_root_is_deterministic() {
let (_root, passport) = make_passport(&["trade.equity", "portfolio.read"]);
let r1 = passport.scope_root().unwrap();
let r2 = passport.scope_root().unwrap();
assert_eq!(r1, r2);
}
#[test]
fn new_chain_uses_passport_key_and_scope() {
let (_root, passport) = make_passport(&["trade.equity"]);
let chain = passport.new_chain().unwrap();
assert_eq!(chain.principal_pk, passport.verifying_key());
assert_eq!(chain.principal_scope, passport.scope_root().unwrap());
}
#[test]
fn issue_sub_rejects_escalation() {
let (root, passport) = make_passport(&["trade.equity"]);
let agent = DyoloIdentity::generate();
let clock = SystemClock;
let result = passport.issue_sub(
agent.verifying_key(),
&["trade.equity", "portfolio.write"],
1800,
&root,
&clock,
);
assert!(result.is_err());
}
#[test]
fn issue_sub_accepts_valid_subset() {
let (root, passport) = make_passport(&["trade.equity", "portfolio.read"]);
let agent = DyoloIdentity::generate();
let clock = SystemClock;
let result = passport.issue_sub(
agent.verifying_key(),
&["trade.equity"],
1800,
&root,
&clock,
);
assert!(result.is_ok());
}
#[test]
fn guard_local_single_capability_end_to_end() {
let (root, passport) = make_passport(&["trade.equity", "portfolio.read"]);
let agent = DyoloIdentity::generate();
let clock = SystemClock;
let sub = passport
.issue_sub(
agent.verifying_key(),
&["trade.equity"],
1800,
&root,
&clock,
)
.unwrap();
let mut chain = passport.new_chain().unwrap();
chain.push(sub);
let intent = Intent::new("trade.equity").unwrap();
let receipt = passport
.guard_local(&chain, &agent.verifying_key(), &intent)
.unwrap();
assert_eq!(receipt.passport_namespace, "test-agent");
assert!(receipt.verify_commitment());
}
#[test]
fn guard_rejects_out_of_scope_intent() {
let (root, passport) = make_passport(&["portfolio.read"]);
let agent = DyoloIdentity::generate();
let clock = SystemClock;
let sub = passport
.issue_sub(
agent.verifying_key(),
&["portfolio.read"],
1800,
&root,
&clock,
)
.unwrap();
let mut chain = passport.new_chain().unwrap();
chain.push(sub);
let intent = Intent::new("trade.equity").unwrap();
assert!(passport
.guard_local(&chain, &agent.verifying_key(), &intent)
.is_err());
}
#[test]
fn issue_from_csv_matches_slice() {
let root = DyoloIdentity::generate();
let clock = SystemClock;
let a = DyoloPassport::issue(
"a",
&["trade.equity", "portfolio.read"],
3600,
&root,
&clock,
)
.unwrap();
let b =
DyoloPassport::issue_from_csv("a", "trade.equity, portfolio.read", 3600, &root, &clock)
.unwrap();
assert_eq!(a.capability_mask, b.capability_mask);
}
}