Skip to main content

a1/
did.rs

1use blake3::Hasher;
2use ed25519_dalek::{Verifier, VerifyingKey};
3use serde::{Deserialize, Serialize};
4
5use crate::error::A1Error;
6use crate::identity::Signer;
7
8const DOMAIN_VC_SIGN: &str = "a1::dyolo::vc::sign::v2.8.0";
9const DID_METHOD: &str = "a1";
10
11/// A permanent, portable identifier for an A1 agent.
12///
13/// Format: `did:a1:{hex-encoded-ed25519-verifying-key}`
14///
15/// Every DyoloPassport holder has exactly one DID derived deterministically
16/// from their Ed25519 verifying key — no registry, no network call, no
17/// external system required for generation or verification.
18///
19/// The DID is compatible with the W3C DID Core specification and can be
20/// resolved by any system that holds the public key — including other agents,
21/// blockchains, enterprise IAM platforms, and EU eIDAS wallets.
22///
23/// # Example
24///
25/// ```rust,ignore
26/// use a1::{DyoloIdentity, did::AgentDid};
27///
28/// let identity = DyoloIdentity::generate();
29/// let did = AgentDid::from_key(&identity.verifying_key());
30/// println!("{did}"); // did:a1:abc123...
31///
32/// let resolved = did.verifying_key().unwrap();
33/// assert_eq!(resolved.as_bytes(), identity.verifying_key().as_bytes());
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct AgentDid(String);
37
38impl AgentDid {
39    /// Derive a DID from an Ed25519 verifying key.
40    pub fn from_key(vk: &VerifyingKey) -> Self {
41        Self(format!("did:{}:{}", DID_METHOD, hex::encode(vk.as_bytes())))
42    }
43
44    /// Parse and validate a `did:a1:` string.
45    pub fn parse(did: &str) -> Result<Self, A1Error> {
46        let mut parts = did.splitn(3, ':');
47        let scheme = parts.next().unwrap_or("");
48        let method = parts.next().unwrap_or("");
49        let id = parts.next().unwrap_or("");
50
51        if scheme != "did" || method != DID_METHOD || id.is_empty() {
52            return Err(A1Error::WireFormatError(format!(
53                "expected did:a1:<hex>, got: {did}"
54            )));
55        }
56        let bytes = hex::decode(id)
57            .map_err(|_| A1Error::WireFormatError("DID identifier must be hex".into()))?;
58        if bytes.len() != 32 {
59            return Err(A1Error::WireFormatError(
60                "DID identifier must be 32 bytes (Ed25519 key)".into(),
61            ));
62        }
63        Ok(Self(did.to_owned()))
64    }
65
66    /// Recover the verifying key encoded in this DID.
67    pub fn verifying_key(&self) -> Result<VerifyingKey, A1Error> {
68        let hex_part = self.0.splitn(3, ':').nth(2).unwrap_or("");
69        let bytes = hex::decode(hex_part)
70            .map_err(|_| A1Error::WireFormatError("invalid DID hex".into()))?;
71        let arr: [u8; 32] = bytes
72            .try_into()
73            .map_err(|_| A1Error::WireFormatError("DID key must be 32 bytes".into()))?;
74        VerifyingKey::from_bytes(&arr)
75            .map_err(|e| A1Error::WireFormatError(format!("invalid DID key: {e}")))
76    }
77
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81
82    /// Verification method fragment ID used in DID Documents and VC proofs.
83    pub fn key_id(&self) -> String {
84        format!("{}#key-0", self.0)
85    }
86}
87
88impl std::fmt::Display for AgentDid {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(&self.0)
91    }
92}
93
94// ── DID Document ──────────────────────────────────────────────────────────────
95
96/// A W3C DID Document for an A1 agent.
97///
98/// Serializes to standard W3C DID JSON-LD, readable by any DID resolver,
99/// enterprise identity platform, or EU eIDAS wallet without A1-specific code.
100///
101/// # Example
102///
103/// ```rust,ignore
104/// use a1::{DyoloIdentity, did::DidDocument};
105///
106/// let identity = DyoloIdentity::generate();
107/// let doc = DidDocument::for_identity(&identity.verifying_key());
108/// println!("{}", doc.to_json().unwrap());
109/// ```
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct DidDocument {
112    #[serde(rename = "@context")]
113    pub context: Vec<String>,
114    pub id: String,
115    #[serde(rename = "verificationMethod")]
116    pub verification_method: Vec<VerificationMethod>,
117    pub authentication: Vec<String>,
118    #[serde(rename = "assertionMethod")]
119    pub assertion_method: Vec<String>,
120    #[serde(rename = "capabilityDelegation")]
121    pub capability_delegation: Vec<String>,
122    #[serde(
123        rename = "a1PassportNamespace",
124        skip_serializing_if = "Option::is_none"
125    )]
126    pub passport_namespace: Option<String>,
127    #[serde(
128        rename = "a1CapabilityMaskHex",
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub capability_mask_hex: Option<String>,
132    #[serde(rename = "a1Version")]
133    pub a1_version: String,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct VerificationMethod {
138    pub id: String,
139    #[serde(rename = "type")]
140    pub method_type: String,
141    pub controller: String,
142    #[serde(rename = "publicKeyHex")]
143    pub public_key_hex: String,
144}
145
146impl DidDocument {
147    /// Generate a W3C DID Document for an Ed25519 identity.
148    pub fn for_identity(vk: &VerifyingKey) -> Self {
149        let did = AgentDid::from_key(vk);
150        let key_id = did.key_id();
151        Self {
152            context: vec![
153                "https://www.w3.org/ns/did/v1".into(),
154                "https://w3id.org/security/suites/ed25519-2020/v1".into(),
155                "https://a1.dev/contexts/v1".into(),
156            ],
157            id: did.to_string(),
158            verification_method: vec![VerificationMethod {
159                id: key_id.clone(),
160                method_type: "Ed25519VerificationKey2020".into(),
161                controller: did.to_string(),
162                public_key_hex: hex::encode(vk.as_bytes()),
163            }],
164            authentication: vec![key_id.clone()],
165            assertion_method: vec![key_id.clone()],
166            capability_delegation: vec![key_id],
167            passport_namespace: None,
168            capability_mask_hex: None,
169            a1_version: "2.8.0".into(),
170        }
171    }
172
173    /// Attach passport capability metadata to this DID Document.
174    ///
175    /// Allows verifiers to read the agent's authorized capability scope
176    /// directly from the DID Document without needing the passport file.
177    pub fn with_passport_metadata(
178        mut self,
179        namespace: impl Into<String>,
180        mask_hex: impl Into<String>,
181    ) -> Self {
182        self.passport_namespace = Some(namespace.into());
183        self.capability_mask_hex = Some(mask_hex.into());
184        self
185    }
186
187    /// Serialize to a W3C JSON-LD string.
188    pub fn to_json(&self) -> Result<String, A1Error> {
189        serde_json::to_string_pretty(self).map_err(|e| A1Error::WireFormatError(e.to_string()))
190    }
191}
192
193// ── Verifiable Credential ─────────────────────────────────────────────────────
194
195/// A W3C Verifiable Credential proving an agent's authorized capabilities.
196///
197/// VCs are portable, self-contained proofs that work without network calls.
198/// Any system — another agent, blockchain, enterprise IAM, EU eIDAS wallet —
199/// can verify this credential offline using only the issuer's public key.
200///
201/// The signature covers a domain-separated Blake3 hash of all credential
202/// fields, making it immune to JSON canonicalization attacks.
203///
204/// # Example
205///
206/// ```rust,ignore
207/// use a1::{DyoloIdentity, did::{AgentDid, VerifiableCredential}};
208///
209/// let issuer = DyoloIdentity::generate();
210/// let agent  = DyoloIdentity::generate();
211/// let agent_did = AgentDid::from_key(&agent.verifying_key());
212///
213/// let vc = VerifiableCredential::issue_capability(
214///     &issuer,
215///     &agent_did,
216///     "acme-trading-bot",
217///     &["trade.equity", "portfolio.read"],
218///     now_unix,
219///     now_unix + 86400,
220///     &chain_fingerprint,
221/// ).unwrap();
222///
223/// assert!(vc.verify().is_ok());
224/// let json = vc.to_json().unwrap();
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct VerifiableCredential {
228    #[serde(rename = "@context")]
229    pub context: Vec<String>,
230    #[serde(rename = "type")]
231    pub vc_type: Vec<String>,
232    pub id: String,
233    pub issuer: String,
234    #[serde(rename = "issuanceDate")]
235    pub issuance_date: String,
236    #[serde(rename = "expirationDate", skip_serializing_if = "Option::is_none")]
237    pub expiration_date: Option<String>,
238    #[serde(rename = "credentialSubject")]
239    pub credential_subject: CredentialSubject,
240    pub proof: VcProof,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct CredentialSubject {
245    pub id: String,
246    #[serde(rename = "a1PassportNamespace")]
247    pub passport_namespace: String,
248    #[serde(rename = "a1Capabilities")]
249    pub capabilities: Vec<String>,
250    #[serde(rename = "a1ChainFingerprint")]
251    pub chain_fingerprint: String,
252    #[serde(rename = "a1Version")]
253    pub a1_version: String,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct VcProof {
258    #[serde(rename = "type")]
259    pub proof_type: String,
260    pub created: String,
261    #[serde(rename = "verificationMethod")]
262    pub verification_method: String,
263    #[serde(rename = "proofPurpose")]
264    pub proof_purpose: String,
265    #[serde(rename = "proofValue")]
266    pub proof_value: String,
267}
268
269impl VerifiableCredential {
270    /// Issue a capability VC from a DyoloPassport holder.
271    ///
272    /// The VC asserts that the agent identified by `subject_did` is authorized
273    /// to perform the listed `capabilities` under the named passport.
274    ///
275    /// The signature is computed as `Ed25519(Blake3(domain ‖ id ‖ issuer ‖
276    /// issuance ‖ expiry ‖ subject_did ‖ namespace ‖ caps ‖ fingerprint))`,
277    /// preventing any tampering with the credential fields.
278    pub fn issue_capability(
279        issuer: &dyn Signer,
280        subject_did: &AgentDid,
281        passport_namespace: &str,
282        capabilities: &[&str],
283        issued_at_unix: u64,
284        expiry_unix: u64,
285        chain_fingerprint: &[u8; 32],
286    ) -> Result<Self, A1Error> {
287        let issuer_vk = issuer.verifying_key();
288        let issuer_did = AgentDid::from_key(&issuer_vk);
289
290        let cred_id = format!("urn:a1:cred:{}", hex::encode(&chain_fingerprint[..16]));
291        let issuance = unix_to_iso8601(issued_at_unix);
292        let expiry = unix_to_iso8601(expiry_unix);
293
294        let subject = CredentialSubject {
295            id: subject_did.to_string(),
296            passport_namespace: passport_namespace.to_owned(),
297            capabilities: capabilities.iter().map(|s| s.to_string()).collect(),
298            chain_fingerprint: hex::encode(chain_fingerprint),
299            a1_version: "2.8.0".into(),
300        };
301
302        let signable =
303            vc_signable_bytes(&cred_id, issuer_did.as_str(), &issuance, &expiry, &subject);
304        let sig = issuer.sign_message(&signable);
305
306        Ok(Self {
307            context: vec![
308                "https://www.w3.org/2018/credentials/v1".into(),
309                "https://a1.dev/contexts/v1".into(),
310            ],
311            vc_type: vec![
312                "VerifiableCredential".into(),
313                "A1CapabilityCredential".into(),
314            ],
315            id: cred_id.clone(),
316            issuer: issuer_did.to_string(),
317            issuance_date: issuance.clone(),
318            expiration_date: Some(expiry.clone()),
319            credential_subject: subject,
320            proof: VcProof {
321                proof_type: "Ed25519Signature2020".into(),
322                created: issuance,
323                verification_method: issuer_did.key_id(),
324                proof_purpose: "assertionMethod".into(),
325                proof_value: hex::encode(sig.to_bytes()),
326            },
327        })
328    }
329
330    /// Verify the Ed25519 signature on this VC.
331    ///
332    /// Recovers the issuer public key from the `issuer` DID, recomputes the
333    /// canonical signable bytes, and checks the signature in constant time.
334    pub fn verify(&self) -> Result<(), A1Error> {
335        let issuer_did = AgentDid::parse(&self.issuer)?;
336        let vk = issuer_did.verifying_key()?;
337
338        let expiry = self.expiration_date.as_deref().unwrap_or("");
339        let signable = vc_signable_bytes(
340            &self.id,
341            &self.issuer,
342            &self.issuance_date,
343            expiry,
344            &self.credential_subject,
345        );
346
347        let sig_bytes = hex::decode(&self.proof.proof_value)
348            .map_err(|_| A1Error::WireFormatError("invalid proof_value hex".into()))?;
349        let sig_arr: [u8; 64] = sig_bytes
350            .try_into()
351            .map_err(|_| A1Error::WireFormatError("signature must be 64 bytes".into()))?;
352        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
353
354        vk.verify(&signable, &sig)
355            .map_err(|_| A1Error::HybridSignatureInvalid {
356                component: "vc-ed25519",
357            })
358    }
359
360    /// Serialize to a W3C JSON-LD string.
361    pub fn to_json(&self) -> Result<String, A1Error> {
362        serde_json::to_string_pretty(self).map_err(|e| A1Error::WireFormatError(e.to_string()))
363    }
364}
365
366// ── Signable bytes ────────────────────────────────────────────────────────────
367
368fn vc_signable_bytes(
369    id: &str,
370    issuer: &str,
371    issuance: &str,
372    expiry: &str,
373    subject: &CredentialSubject,
374) -> Vec<u8> {
375    let mut h = Hasher::new_derive_key(DOMAIN_VC_SIGN);
376    h.update(&(id.len() as u64).to_le_bytes());
377    h.update(id.as_bytes());
378    h.update(&(issuer.len() as u64).to_le_bytes());
379    h.update(issuer.as_bytes());
380    h.update(&(issuance.len() as u64).to_le_bytes());
381    h.update(issuance.as_bytes());
382    h.update(&(expiry.len() as u64).to_le_bytes());
383    h.update(expiry.as_bytes());
384    h.update(&(subject.passport_namespace.len() as u64).to_le_bytes());
385    h.update(subject.passport_namespace.as_bytes());
386    h.update(&(subject.capabilities.len() as u64).to_le_bytes());
387    for cap in &subject.capabilities {
388        h.update(&(cap.len() as u64).to_le_bytes());
389        h.update(cap.as_bytes());
390    }
391    h.update(subject.chain_fingerprint.as_bytes());
392    h.finalize().as_bytes().to_vec()
393}
394
395// ── ISO 8601 formatting (no chrono dep) ───────────────────────────────────────
396
397fn unix_to_iso8601(unix: u64) -> String {
398    let s = unix % 60;
399    let m = (unix / 60) % 60;
400    let h = (unix / 3600) % 24;
401    let days = unix / 86400;
402    let (year, month, day) = days_to_ymd(days);
403    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
404}
405
406fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
407    let mut year = 1970u64;
408    loop {
409        let y_days = if is_leap(year) { 366 } else { 365 };
410        if days < y_days {
411            break;
412        }
413        days -= y_days;
414        year += 1;
415    }
416    let month_days: [u64; 12] = if is_leap(year) {
417        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
418    } else {
419        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
420    };
421    let mut month = 1u64;
422    for mlen in month_days {
423        if days < mlen {
424            break;
425        }
426        days -= mlen;
427        month += 1;
428    }
429    (year, month, days + 1)
430}
431
432fn is_leap(y: u64) -> bool {
433    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
434}
435
436// ── Tests ─────────────────────────────────────────────────────────────────────
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::identity::DyoloIdentity;
442
443    #[test]
444    fn did_roundtrip() {
445        let id = DyoloIdentity::generate();
446        let did = AgentDid::from_key(&id.verifying_key());
447        assert!(did.as_str().starts_with("did:a1:"));
448        let recovered = did.verifying_key().unwrap();
449        assert_eq!(id.verifying_key().as_bytes(), recovered.as_bytes());
450    }
451
452    #[test]
453    fn did_parse_rejects_malformed() {
454        assert!(AgentDid::parse("did:key:abc").is_err());
455        assert!(AgentDid::parse("did:a1:notvalidhex!").is_err());
456        assert!(AgentDid::parse("notadid").is_err());
457        assert!(AgentDid::parse("did:a1:deadbeef").is_err()); // wrong length
458    }
459
460    #[test]
461    fn did_document_structure() {
462        let id = DyoloIdentity::generate();
463        let doc = DidDocument::for_identity(&id.verifying_key());
464        assert!(doc.id.starts_with("did:a1:"));
465        assert_eq!(doc.verification_method.len(), 1);
466        assert_eq!(
467            doc.verification_method[0].method_type,
468            "Ed25519VerificationKey2020"
469        );
470        assert_eq!(doc.a1_version, "2.8.0");
471        assert!(doc.passport_namespace.is_none());
472    }
473
474    #[test]
475    fn did_document_with_passport_metadata() {
476        let id = DyoloIdentity::generate();
477        let doc = DidDocument::for_identity(&id.verifying_key())
478            .with_passport_metadata("acme-bot", "ff00ff00");
479        assert_eq!(doc.passport_namespace.as_deref(), Some("acme-bot"));
480        assert_eq!(doc.capability_mask_hex.as_deref(), Some("ff00ff00"));
481    }
482
483    #[test]
484    fn vc_issue_and_verify() {
485        let issuer = DyoloIdentity::generate();
486        let agent = DyoloIdentity::generate();
487        let agent_did = AgentDid::from_key(&agent.verifying_key());
488        let fp = [7u8; 32];
489        let now = 1_700_000_000u64;
490
491        let vc = VerifiableCredential::issue_capability(
492            &issuer,
493            &agent_did,
494            "acme-trading-bot",
495            &["trade.equity", "portfolio.read"],
496            now,
497            now + 86400,
498            &fp,
499        )
500        .unwrap();
501
502        assert!(vc.verify().is_ok());
503        assert_eq!(vc.vc_type[1], "A1CapabilityCredential");
504        assert_eq!(
505            vc.credential_subject.capabilities,
506            ["trade.equity", "portfolio.read"]
507        );
508    }
509
510    #[test]
511    fn tampered_capabilities_fail_verify() {
512        let issuer = DyoloIdentity::generate();
513        let agent = DyoloIdentity::generate();
514        let agent_did = AgentDid::from_key(&agent.verifying_key());
515        let fp = [1u8; 32];
516        let now = 1_700_000_000u64;
517
518        let mut vc = VerifiableCredential::issue_capability(
519            &issuer,
520            &agent_did,
521            "acme-trading-bot",
522            &["trade.equity"],
523            now,
524            now + 86400,
525            &fp,
526        )
527        .unwrap();
528
529        vc.credential_subject
530            .capabilities
531            .push("admin.everything".into());
532        assert!(vc.verify().is_err());
533    }
534
535    #[test]
536    fn tampered_proof_fails_verify() {
537        let issuer = DyoloIdentity::generate();
538        let agent = DyoloIdentity::generate();
539        let agent_did = AgentDid::from_key(&agent.verifying_key());
540        let fp = [2u8; 32];
541        let now = 1_700_000_000u64;
542
543        let mut vc = VerifiableCredential::issue_capability(
544            &issuer,
545            &agent_did,
546            "acme-bot",
547            &["read"],
548            now,
549            now + 3600,
550            &fp,
551        )
552        .unwrap();
553
554        let mut bad = vc.proof.proof_value.clone().into_bytes();
555        bad[0] ^= 0xFF;
556        vc.proof.proof_value = String::from_utf8(bad).unwrap_or_default();
557        assert!(vc.verify().is_err());
558    }
559
560    #[test]
561    fn iso8601_epoch() {
562        assert_eq!(unix_to_iso8601(0), "1970-01-01T00:00:00Z");
563    }
564
565    #[test]
566    fn iso8601_known_date() {
567        let s = unix_to_iso8601(1_700_000_000);
568        assert!(s.starts_with("2023-"));
569    }
570}