Skip to main content

typesec_integrations/
did.rs

1//! Decentralized identifier messaging helpers for Typesec.
2//!
3//! This module treats DIDs as identity, key-discovery, and routing handles.
4//! Runtime authorization still flows through [`typesec_core::PolicyEngine`]:
5//! a verified DID message identifies the subject, and a policy engine decides
6//! whether to mint the typed capability required to reveal or use the payload.
7//!
8//! [`Ed25519DidKeyStore`] is the production key store: Ed25519 signatures,
9//! X25519 key agreement, and ChaCha20-Poly1305 payload encryption. The
10//! deterministic, **non-cryptographic** `DemoDidKeyStore` is only compiled in
11//! tests or behind the `demo-crypto` feature — never enable that feature in
12//! production builds. Deployments with stronger requirements should implement
13//! [`DidKeyStore`] with JOSE/DIDComm, HPKE, or an HSM/KMS.
14
15use std::{
16    collections::{HashMap, HashSet},
17    fmt,
18    sync::Arc,
19    time::{SystemTime, UNIX_EPOCH},
20};
21
22use serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24use typesec_core::{
25    Capability, SecureValue,
26    permissions::{AiCanInfer, CanReadSensitive},
27    resource::GenericResource,
28    secure_value::Secret,
29};
30
31use crate::http::{HttpClient, ReqwestHttpClient};
32
33/// A decentralized identifier string.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(try_from = "String", into = "String")]
36pub struct Did(String);
37
38impl Did {
39    /// Parse a DID.
40    pub fn parse(value: impl Into<String>) -> Result<Self, DidError> {
41        let value = value.into();
42        let parts: Vec<_> = value.split(':').collect();
43        if parts.len() < 3 || parts.first() != Some(&"did") || parts[1].is_empty() {
44            return Err(DidError::InvalidDid(value));
45        }
46        Ok(Self(value))
47    }
48
49    /// Create a deterministic `did:key` identifier from public key material.
50    pub fn key(public_key: impl AsRef<[u8]>) -> Self {
51        Self(format!("did:key:z{}", hex_encode(public_key.as_ref())))
52    }
53
54    /// Create a `did:web` identifier for a host.
55    pub fn web(host: impl AsRef<str>) -> Result<Self, DidError> {
56        let host = host.as_ref().trim();
57        if host.is_empty() || host.contains('/') {
58            return Err(DidError::InvalidDid(format!("did:web:{host}")));
59        }
60        Ok(Self(format!("did:web:{host}")))
61    }
62
63    /// Borrow the DID as a string.
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69impl fmt::Display for Did {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(&self.0)
72    }
73}
74
75impl TryFrom<String> for Did {
76    type Error = DidError;
77
78    fn try_from(value: String) -> Result<Self, Self::Error> {
79        Self::parse(value)
80    }
81}
82
83impl From<Did> for String {
84    fn from(value: Did) -> Self {
85        value.0
86    }
87}
88
89/// A verification method from a DID document.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct VerificationMethod {
92    /// DID URL for this key.
93    pub id: String,
94    /// Verification method type.
95    #[serde(rename = "type")]
96    pub method_type: String,
97    /// Controller DID.
98    pub controller: Did,
99    /// Public key bytes encoded as hex for this local integration.
100    pub public_key_hex: String,
101    /// Optional local key version for rotation-aware DID documents.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub key_version: Option<u64>,
104    /// Optional local rotation status (`active` or `previous`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub key_status: Option<String>,
107}
108
109impl VerificationMethod {
110    /// Construct a local Ed25519-like method for examples and tests.
111    pub fn local(id: impl Into<String>, controller: Did, public_key: impl AsRef<[u8]>) -> Self {
112        Self {
113            id: id.into(),
114            method_type: "TypesecDemoKey2026".to_owned(),
115            controller,
116            public_key_hex: hex_encode(public_key.as_ref()),
117            key_version: None,
118            key_status: None,
119        }
120    }
121
122    fn public_key(&self) -> Result<Vec<u8>, DidError> {
123        hex_decode(&self.public_key_hex)
124    }
125}
126
127/// Service endpoint metadata from a DID document.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct DidService {
130    /// DID URL for this service.
131    pub id: String,
132    /// Service type.
133    #[serde(rename = "type")]
134    pub service_type: String,
135    /// Endpoint URL.
136    pub service_endpoint: String,
137}
138
139/// Minimal DID document model used by Typesec integrations.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct DidDocument {
142    /// Subject DID.
143    pub id: Did,
144    /// Verification methods available for this DID.
145    #[serde(default)]
146    pub verification_method: Vec<VerificationMethod>,
147    /// Authentication key references.
148    #[serde(default)]
149    pub authentication: Vec<String>,
150    /// Key-agreement key references.
151    #[serde(default)]
152    pub key_agreement: Vec<String>,
153    /// Service endpoints.
154    #[serde(default)]
155    pub service: Vec<DidService>,
156}
157
158impl DidDocument {
159    /// Create a document with one key used for authentication and key agreement.
160    pub fn single_key(did: Did, public_key: impl AsRef<[u8]>) -> Self {
161        let key_id = format!("{did}#key-1");
162        Self {
163            id: did.clone(),
164            verification_method: vec![VerificationMethod::local(&key_id, did, public_key)],
165            authentication: vec![key_id.clone()],
166            key_agreement: vec![key_id],
167            service: Vec::new(),
168        }
169    }
170
171    /// Create a document with separate Ed25519 (authentication) and X25519
172    /// (key-agreement) keys, as produced by [`Ed25519DidKey`].
173    pub fn with_signing_and_agreement_keys(
174        did: Did,
175        signing_public: impl AsRef<[u8]>,
176        agreement_public: impl AsRef<[u8]>,
177    ) -> Self {
178        let signing_id = format!("{did}#key-1");
179        let agreement_id = format!("{did}#key-2");
180        Self {
181            id: did.clone(),
182            verification_method: vec![
183                VerificationMethod {
184                    id: signing_id.clone(),
185                    method_type: "Ed25519VerificationKey2020".to_owned(),
186                    controller: did.clone(),
187                    public_key_hex: hex_encode(signing_public.as_ref()),
188                    key_version: Some(1),
189                    key_status: Some("active".to_owned()),
190                },
191                VerificationMethod {
192                    id: agreement_id.clone(),
193                    method_type: "X25519KeyAgreementKey2020".to_owned(),
194                    controller: did,
195                    public_key_hex: hex_encode(agreement_public.as_ref()),
196                    key_version: Some(1),
197                    key_status: Some("active".to_owned()),
198                },
199            ],
200            authentication: vec![signing_id],
201            key_agreement: vec![agreement_id],
202            service: Vec::new(),
203        }
204    }
205
206    fn method(&self, id: &str) -> Option<&VerificationMethod> {
207        self.verification_method
208            .iter()
209            .find(|method| method.id == id)
210    }
211
212    fn authentication_key(&self, kid: &str) -> Result<&VerificationMethod, DidError> {
213        if !self.authentication.iter().any(|id| id == kid) {
214            return Err(DidError::MissingVerificationMethod(kid.to_owned()));
215        }
216        self.method(kid)
217            .ok_or_else(|| DidError::MissingVerificationMethod(kid.to_owned()))
218    }
219
220    fn key_agreement_key(&self) -> Result<&VerificationMethod, DidError> {
221        let kid = self
222            .key_agreement
223            .first()
224            .ok_or(DidError::MissingKeyAgreement)?;
225        self.method(kid)
226            .ok_or_else(|| DidError::MissingVerificationMethod(kid.clone()))
227    }
228
229    fn key_agreement_keys(&self) -> Result<Vec<&VerificationMethod>, DidError> {
230        if self.key_agreement.is_empty() {
231            return Err(DidError::MissingKeyAgreement);
232        }
233
234        self.key_agreement
235            .iter()
236            .map(|kid| {
237                self.method(kid)
238                    .ok_or_else(|| DidError::MissingVerificationMethod(kid.clone()))
239            })
240            .collect()
241    }
242}
243
244/// DID resolver boundary.
245pub trait DidResolver: Send + Sync {
246    /// Resolve `did` to a DID document.
247    fn resolve(&self, did: &Did) -> Result<DidDocument, DidError>;
248}
249
250/// In-memory DID resolver for tests and local demos.
251#[derive(Debug, Default, Clone)]
252pub struct StaticDidResolver {
253    documents: HashMap<Did, DidDocument>,
254}
255
256impl StaticDidResolver {
257    /// Create an empty resolver.
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    /// Register a DID document.
263    pub fn with_document(mut self, document: DidDocument) -> Self {
264        self.documents.insert(document.id.clone(), document);
265        self
266    }
267}
268
269impl DidResolver for StaticDidResolver {
270    fn resolve(&self, did: &Did) -> Result<DidDocument, DidError> {
271        self.documents
272            .get(did)
273            .cloned()
274            .ok_or_else(|| DidError::Unresolved(did.to_string()))
275    }
276}
277
278/// Key-store and envelope crypto boundary.
279pub trait DidKeyStore: Send + Sync {
280    /// Sign bytes as `signer`.
281    fn sign(&self, signer: &Did, message: &[u8]) -> Result<String, DidError>;
282
283    /// Verify a signature with the public key in `method`.
284    fn verify(
285        &self,
286        method: &VerificationMethod,
287        message: &[u8],
288        signature: &str,
289    ) -> Result<(), DidError>;
290
291    /// Encrypt bytes from `sender` to the recipient public key.
292    fn encrypt_for(
293        &self,
294        sender: &Did,
295        recipient_public_key: &[u8],
296        plaintext: &[u8],
297        nonce: &[u8],
298    ) -> Result<String, DidError>;
299
300    /// Decrypt bytes addressed to `recipient` from the sender public key.
301    fn decrypt_for(
302        &self,
303        recipient: &Did,
304        sender_public_key: &[u8],
305        nonce: &[u8],
306        ciphertext_hex: &str,
307    ) -> Result<Vec<u8>, DidError>;
308}
309
310/// Public/private key material for a local DID subject.
311///
312/// **Not cryptography.** Key derivation is a non-cryptographic hash and the
313/// "public" key equals the private key. Tests and demos only.
314#[cfg(any(test, feature = "demo-crypto"))]
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct DemoDidKeyPair {
317    /// Public key bytes advertised in a DID document.
318    pub public_key: Vec<u8>,
319    private_key: Vec<u8>,
320}
321
322#[cfg(any(test, feature = "demo-crypto"))]
323impl DemoDidKeyPair {
324    /// Create deterministic key material from a seed.
325    pub fn from_seed(seed: impl AsRef<[u8]>) -> Self {
326        let private_key = derive_bytes(b"typesec-did-private", seed.as_ref(), 32);
327        let public_key = private_key.clone();
328        Self {
329            public_key,
330            private_key,
331        }
332    }
333}
334
335/// Local deterministic key store for DID envelope examples and tests.
336///
337/// **Not cryptography**: signatures are forgeable by anyone holding the public
338/// key, and "encryption" is a repeating-key XOR. Only available in tests or
339/// behind the `demo-crypto` feature; use [`Ed25519DidKeyStore`] in real code.
340#[cfg(any(test, feature = "demo-crypto"))]
341#[derive(Debug, Default, Clone)]
342pub struct DemoDidKeyStore {
343    keys: HashMap<Did, DemoDidKeyPair>,
344}
345
346#[cfg(any(test, feature = "demo-crypto"))]
347impl DemoDidKeyStore {
348    /// Create an empty key store.
349    pub fn new() -> Self {
350        Self::default()
351    }
352
353    /// Add a key pair for a DID.
354    pub fn with_key(mut self, did: Did, key: DemoDidKeyPair) -> Self {
355        self.keys.insert(did, key);
356        self
357    }
358
359    fn key(&self, did: &Did) -> Result<&DemoDidKeyPair, DidError> {
360        self.keys
361            .get(did)
362            .ok_or_else(|| DidError::MissingPrivateKey(did.to_string()))
363    }
364}
365
366#[cfg(any(test, feature = "demo-crypto"))]
367impl DidKeyStore for DemoDidKeyStore {
368    fn sign(&self, signer: &Did, message: &[u8]) -> Result<String, DidError> {
369        let key = self.key(signer)?;
370        Ok(hex_encode(&derive_bytes(&key.private_key, message, 32)))
371    }
372
373    fn verify(
374        &self,
375        method: &VerificationMethod,
376        message: &[u8],
377        signature: &str,
378    ) -> Result<(), DidError> {
379        let public = method.public_key()?;
380        let expected = hex_encode(&derive_bytes(&public, message, 32));
381        if constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
382            Ok(())
383        } else {
384            Err(DidError::InvalidSignature)
385        }
386    }
387
388    fn encrypt_for(
389        &self,
390        sender: &Did,
391        recipient_public_key: &[u8],
392        plaintext: &[u8],
393        nonce: &[u8],
394    ) -> Result<String, DidError> {
395        let sender_key = self.key(sender)?;
396        let ciphertext = xor_stream(
397            plaintext,
398            &derive_shared_key(&sender_key.private_key, recipient_public_key, nonce),
399        );
400        Ok(hex_encode(&ciphertext))
401    }
402
403    fn decrypt_for(
404        &self,
405        recipient: &Did,
406        sender_public_key: &[u8],
407        nonce: &[u8],
408        ciphertext_hex: &str,
409    ) -> Result<Vec<u8>, DidError> {
410        let recipient_key = self.key(recipient)?;
411        let ciphertext = hex_decode(ciphertext_hex)?;
412        Ok(xor_stream(
413            &ciphertext,
414            &derive_shared_key(&recipient_key.private_key, sender_public_key, nonce),
415        ))
416    }
417}
418
419// ── Production key store ──────────────────────────────────────────────────────
420
421/// Real key material for a local DID subject.
422///
423/// Holds an Ed25519 signing key (advertised as the DID document's
424/// authentication key) and an independent X25519 static secret (advertised as
425/// the key-agreement key).
426#[derive(Clone)]
427pub struct Ed25519DidKey {
428    signing: ed25519_dalek::SigningKey,
429    agreement: x25519_dalek::StaticSecret,
430}
431
432impl Ed25519DidKey {
433    /// Generate a key pair from the operating system RNG.
434    pub fn generate() -> Result<Self, DidError> {
435        let mut signing_seed = [0u8; 32];
436        let mut agreement_seed = [0u8; 32];
437        getrandom::getrandom(&mut signing_seed).map_err(|e| DidError::KeyGen(e.to_string()))?;
438        getrandom::getrandom(&mut agreement_seed).map_err(|e| DidError::KeyGen(e.to_string()))?;
439        Ok(Self::from_seeds(signing_seed, agreement_seed))
440    }
441
442    /// Derive a key pair deterministically from a seed via SHA-256 expansion.
443    ///
444    /// Only as strong as the seed's entropy — use [`generate`][Self::generate]
445    /// unless you need reproducible keys (tests, fixtures).
446    pub fn from_seed(seed: impl AsRef<[u8]>) -> Self {
447        let signing_seed = sha256_tagged(b"typesec-ed25519-signing", seed.as_ref());
448        let agreement_seed = sha256_tagged(b"typesec-x25519-agreement", seed.as_ref());
449        Self::from_seeds(signing_seed, agreement_seed)
450    }
451
452    fn from_seeds(signing_seed: [u8; 32], agreement_seed: [u8; 32]) -> Self {
453        Self {
454            signing: ed25519_dalek::SigningKey::from_bytes(&signing_seed),
455            agreement: x25519_dalek::StaticSecret::from(agreement_seed),
456        }
457    }
458
459    /// Ed25519 public key bytes (the DID document authentication key).
460    pub fn signing_public(&self) -> [u8; 32] {
461        self.signing.verifying_key().to_bytes()
462    }
463
464    /// X25519 public key bytes (the DID document key-agreement key).
465    pub fn agreement_public(&self) -> [u8; 32] {
466        x25519_dalek::PublicKey::from(&self.agreement).to_bytes()
467    }
468
469    /// Build a DID document advertising this key pair's public halves.
470    pub fn document(&self, did: Did) -> DidDocument {
471        DidDocument::with_signing_and_agreement_keys(
472            did,
473            self.signing_public(),
474            self.agreement_public(),
475        )
476    }
477}
478
479impl std::fmt::Debug for Ed25519DidKey {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        f.debug_struct("Ed25519DidKey")
482            .field("signing_public", &hex_encode(&self.signing_public()))
483            .field("agreement_public", &hex_encode(&self.agreement_public()))
484            .finish_non_exhaustive()
485    }
486}
487
488/// Production [`DidKeyStore`]: Ed25519 signatures, X25519 ECDH, and
489/// ChaCha20-Poly1305 authenticated payload encryption.
490#[derive(Debug, Default, Clone)]
491pub struct Ed25519DidKeyStore {
492    keys: HashMap<Did, Vec<Ed25519DidKeyRecord>>,
493    retired_methods: HashSet<String>,
494}
495
496#[derive(Debug, Clone)]
497struct Ed25519DidKeyRecord {
498    version: u64,
499    key: Ed25519DidKey,
500    retired: bool,
501}
502
503impl Ed25519DidKeyStore {
504    /// Create an empty key store.
505    pub fn new() -> Self {
506        Self::default()
507    }
508
509    /// Add a key pair for a DID.
510    pub fn with_key(mut self, did: Did, key: Ed25519DidKey) -> Self {
511        self.keys.insert(
512            did,
513            vec![Ed25519DidKeyRecord {
514                version: 1,
515                key,
516                retired: false,
517            }],
518        );
519        self
520    }
521
522    /// Rotate a DID to a new active key version.
523    ///
524    /// Existing non-retired versions remain in the DID document for in-flight
525    /// envelope verification until explicitly retired.
526    pub fn rotate_key(&mut self, did: &Did, key: Ed25519DidKey) -> Result<u64, DidError> {
527        let records = self
528            .keys
529            .get_mut(did)
530            .ok_or_else(|| DidError::MissingPrivateKey(did.to_string()))?;
531        let next_version = records
532            .iter()
533            .map(|record| record.version)
534            .max()
535            .unwrap_or(0)
536            + 1;
537        records.push(Ed25519DidKeyRecord {
538            version: next_version,
539            key,
540            retired: false,
541        });
542        Ok(next_version)
543    }
544
545    /// Retire an old key version.
546    ///
547    /// Retired authentication methods are omitted from newly generated DID
548    /// documents and are rejected by this store's verifier.
549    pub fn retire_key(&mut self, did: &Did, version: u64) -> Result<(), DidError> {
550        if self.active_key_version(did)? == version {
551            return Err(DidError::CannotRetireActiveKey {
552                did: did.to_string(),
553                version,
554            });
555        }
556
557        let records = self
558            .keys
559            .get_mut(did)
560            .ok_or_else(|| DidError::MissingPrivateKey(did.to_string()))?;
561        let record = records
562            .iter_mut()
563            .find(|record| record.version == version)
564            .ok_or_else(|| DidError::MissingKeyVersion {
565                did: did.to_string(),
566                version,
567            })?;
568        record.retired = true;
569        self.retired_methods
570            .insert(Self::signing_method_id(did, version));
571        self.retired_methods
572            .insert(Self::agreement_method_id(did, version));
573        Ok(())
574    }
575
576    /// Active signing/encryption version for `did`.
577    pub fn active_key_version(&self, did: &Did) -> Result<u64, DidError> {
578        Ok(self.active_record(did)?.version)
579    }
580
581    /// Build a rotation-aware DID document for one local DID.
582    pub fn document(&self, did: &Did) -> Result<DidDocument, DidError> {
583        let records = self
584            .keys
585            .get(did)
586            .ok_or_else(|| DidError::MissingPrivateKey(did.to_string()))?;
587        let active_version = self.active_key_version(did)?;
588        let mut verification_method = Vec::new();
589        let mut authentication = Vec::new();
590        let mut key_agreement = Vec::new();
591
592        for record in records.iter().filter(|record| !record.retired) {
593            let status = if record.version == active_version {
594                "active"
595            } else {
596                "previous"
597            };
598            let signing_id = Self::signing_method_id(did, record.version);
599            let agreement_id = Self::agreement_method_id(did, record.version);
600            verification_method.push(VerificationMethod {
601                id: signing_id.clone(),
602                method_type: "Ed25519VerificationKey2020".to_owned(),
603                controller: did.clone(),
604                public_key_hex: hex_encode(&record.key.signing_public()),
605                key_version: Some(record.version),
606                key_status: Some(status.to_owned()),
607            });
608            verification_method.push(VerificationMethod {
609                id: agreement_id.clone(),
610                method_type: "X25519KeyAgreementKey2020".to_owned(),
611                controller: did.clone(),
612                public_key_hex: hex_encode(&record.key.agreement_public()),
613                key_version: Some(record.version),
614                key_status: Some(status.to_owned()),
615            });
616
617            if record.version == active_version {
618                authentication.insert(0, signing_id);
619                key_agreement.insert(0, agreement_id);
620            } else {
621                authentication.push(signing_id);
622                key_agreement.push(agreement_id);
623            }
624        }
625
626        Ok(DidDocument {
627            id: did.clone(),
628            verification_method,
629            authentication,
630            key_agreement,
631            service: Vec::new(),
632        })
633    }
634
635    fn active_record(&self, did: &Did) -> Result<&Ed25519DidKeyRecord, DidError> {
636        self.keys
637            .get(did)
638            .and_then(|records| {
639                records
640                    .iter()
641                    .filter(|record| !record.retired)
642                    .max_by_key(|record| record.version)
643            })
644            .ok_or_else(|| DidError::MissingPrivateKey(did.to_string()))
645    }
646
647    fn signing_method_id(did: &Did, version: u64) -> String {
648        if version == 1 {
649            format!("{did}#key-1")
650        } else {
651            format!("{did}#key-signing-v{version}")
652        }
653    }
654
655    fn agreement_method_id(did: &Did, version: u64) -> String {
656        if version == 1 {
657            format!("{did}#key-2")
658        } else {
659            format!("{did}#key-agreement-v{version}")
660        }
661    }
662
663    fn aead_key(shared_secret: &[u8; 32]) -> chacha20poly1305::Key {
664        let digest = sha256_tagged(b"typesec-did-aead", shared_secret);
665        chacha20poly1305::Key::from(digest)
666    }
667}
668
669impl DidKeyStore for Ed25519DidKeyStore {
670    fn sign(&self, signer: &Did, message: &[u8]) -> Result<String, DidError> {
671        use ed25519_dalek::Signer;
672        let record = self.active_record(signer)?;
673        Ok(hex_encode(&record.key.signing.sign(message).to_bytes()))
674    }
675
676    fn verify(
677        &self,
678        method: &VerificationMethod,
679        message: &[u8],
680        signature: &str,
681    ) -> Result<(), DidError> {
682        use ed25519_dalek::Verifier;
683        if self.retired_methods.contains(&method.id) {
684            return Err(DidError::RetiredKey(method.id.clone()));
685        }
686        let public: [u8; 32] = method
687            .public_key()?
688            .try_into()
689            .map_err(|_| DidError::InvalidKey("ed25519 public key must be 32 bytes".into()))?;
690        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&public)
691            .map_err(|e| DidError::InvalidKey(e.to_string()))?;
692        let signature_bytes: [u8; 64] = hex_decode(signature)?
693            .try_into()
694            .map_err(|_| DidError::InvalidSignature)?;
695        verifying
696            .verify(
697                message,
698                &ed25519_dalek::Signature::from_bytes(&signature_bytes),
699            )
700            .map_err(|_| DidError::InvalidSignature)
701    }
702
703    fn encrypt_for(
704        &self,
705        sender: &Did,
706        recipient_public_key: &[u8],
707        plaintext: &[u8],
708        nonce: &[u8],
709    ) -> Result<String, DidError> {
710        use chacha20poly1305::KeyInit;
711        use chacha20poly1305::aead::Aead;
712        let sender_key = &self.active_record(sender)?.key;
713        let recipient: [u8; 32] = recipient_public_key
714            .try_into()
715            .map_err(|_| DidError::InvalidKey("x25519 public key must be 32 bytes".into()))?;
716        let shared = sender_key
717            .agreement
718            .diffie_hellman(&x25519_dalek::PublicKey::from(recipient));
719        let nonce: [u8; 12] = nonce.try_into().map_err(|_| DidError::InvalidNonce)?;
720        let cipher = chacha20poly1305::ChaCha20Poly1305::new(&Self::aead_key(shared.as_bytes()));
721        let ciphertext = cipher
722            .encrypt(&chacha20poly1305::Nonce::from(nonce), plaintext)
723            .map_err(|_| DidError::EncryptionFailed)?;
724        Ok(hex_encode(&ciphertext))
725    }
726
727    fn decrypt_for(
728        &self,
729        recipient: &Did,
730        sender_public_key: &[u8],
731        nonce: &[u8],
732        ciphertext_hex: &str,
733    ) -> Result<Vec<u8>, DidError> {
734        use chacha20poly1305::KeyInit;
735        use chacha20poly1305::aead::Aead;
736        let sender: [u8; 32] = sender_public_key
737            .try_into()
738            .map_err(|_| DidError::InvalidKey("x25519 public key must be 32 bytes".into()))?;
739        let nonce: [u8; 12] = nonce.try_into().map_err(|_| DidError::InvalidNonce)?;
740        let ciphertext = hex_decode(ciphertext_hex)?;
741        let records = self
742            .keys
743            .get(recipient)
744            .ok_or_else(|| DidError::MissingPrivateKey(recipient.to_string()))?;
745
746        for record in records.iter().filter(|record| !record.retired) {
747            let shared = record
748                .key
749                .agreement
750                .diffie_hellman(&x25519_dalek::PublicKey::from(sender));
751            let cipher =
752                chacha20poly1305::ChaCha20Poly1305::new(&Self::aead_key(shared.as_bytes()));
753            if let Ok(plaintext) =
754                cipher.decrypt(&chacha20poly1305::Nonce::from(nonce), ciphertext.as_slice())
755            {
756                return Ok(plaintext);
757            }
758        }
759
760        Err(DidError::DecryptionFailed)
761    }
762}
763
764/// Message metadata that policy engines evaluate before payload use.
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct DidMessageBody {
767    /// Requested Typesec action, such as `ai:infer`.
768    pub action: String,
769    /// Resource identifier for policy evaluation.
770    pub resource: String,
771    /// Payload privacy label, such as `secret`.
772    pub privacy: String,
773    /// Prompt envelope this message is bound to, for reply envelopes.
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub reply_to: Option<DidMessageReference>,
776}
777
778impl DidMessageBody {
779    /// Create a prompt body for AI inference.
780    pub fn infer_prompt(resource: impl Into<String>) -> Self {
781        Self {
782            action: "ai:infer".to_owned(),
783            resource: resource.into(),
784            privacy: "secret".to_owned(),
785            reply_to: None,
786        }
787    }
788
789    /// Create a reply body that inherits the prompt's policy-visible metadata.
790    pub fn reply_to_prompt(prompt: &VerifiedDidPrompt) -> Self {
791        Self {
792            action: prompt.body.action.clone(),
793            resource: prompt.body.resource.clone(),
794            privacy: prompt.body.privacy.clone(),
795            reply_to: Some(prompt.prompt_ref.clone()),
796        }
797    }
798
799    /// Create a general agent message body.
800    pub fn agent_message(resource: impl Into<String>, privacy: impl Into<String>) -> Self {
801        Self {
802            action: "agent:message".to_owned(),
803            resource: resource.into(),
804            privacy: privacy.into(),
805            reply_to: None,
806        }
807    }
808
809    /// Create an agent delegation body.
810    pub fn agent_delegate(resource: impl Into<String>, privacy: impl Into<String>) -> Self {
811        Self {
812            action: "agent:delegate".to_owned(),
813            resource: resource.into(),
814            privacy: privacy.into(),
815            reply_to: None,
816        }
817    }
818}
819
820/// TypeDID delivery mode for an agent message.
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
822#[serde(rename_all = "snake_case")]
823pub enum TypeDidMode {
824    /// Fire-and-forget delivery; no TypeDID reply is required.
825    Send,
826    /// The receiver is expected to answer with a reply-bound TypeDID envelope.
827    RequestReply,
828}
829
830/// TypeDID conversation metadata bound into the envelope signature.
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct TypeDidConversation {
833    /// Stable task, session, room, or thread id from the outer protocol.
834    pub conversation_id: String,
835    /// Delivery mode.
836    pub mode: TypeDidMode,
837    /// Negotiated TypeDID profile id.
838    pub profile: String,
839    /// Outer protocol hint, such as `a2a`, `acp`, `band`, or `https`.
840    pub protocol: String,
841    /// Optional payload expiry copied from the negotiated profile or caller.
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub expires_at: Option<u64>,
844}
845
846impl TypeDidConversation {
847    /// Construct TypeDID conversation metadata.
848    pub fn new(
849        conversation_id: impl Into<String>,
850        mode: TypeDidMode,
851        profile: impl Into<String>,
852        protocol: impl Into<String>,
853    ) -> Self {
854        Self {
855            conversation_id: conversation_id.into(),
856            mode,
857            profile: profile.into(),
858            protocol: protocol.into(),
859            expires_at: None,
860        }
861    }
862
863    /// Attach an absolute unix-seconds expiry to this conversation metadata.
864    pub fn with_expires_at(mut self, expires_at: u64) -> Self {
865        self.expires_at = Some(expires_at);
866        self
867    }
868}
869
870/// A negotiable TypeDID security profile.
871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
872pub struct TypeDidProfile {
873    /// Stable profile id.
874    pub id: String,
875    /// Supported DID methods, such as `did:web` or `did:key`.
876    #[serde(default)]
877    pub did_methods: Vec<String>,
878    /// Supported signing algorithms.
879    #[serde(default)]
880    pub signing: Vec<String>,
881    /// Supported key-agreement algorithms.
882    #[serde(default)]
883    pub key_agreement: Vec<String>,
884    /// Supported encryption profiles.
885    #[serde(default)]
886    pub encryption: Vec<String>,
887    /// Supported outer transport bindings.
888    #[serde(default)]
889    pub transport_bindings: Vec<String>,
890    /// Supported TypeDID send modes.
891    #[serde(default)]
892    pub modes: Vec<TypeDidMode>,
893    /// Optional maximum encrypted payload size.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub max_payload_bytes: Option<usize>,
896    /// Claims required by the remote boundary.
897    #[serde(default)]
898    pub required_claims: Vec<String>,
899    /// Policy actions this profile is willing to carry.
900    #[serde(default)]
901    pub policy_actions: Vec<String>,
902    /// Retention posture advertised by the receiver.
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub retention: Option<String>,
905    /// Audit posture advertised by the receiver.
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub audit: Option<String>,
908}
909
910impl TypeDidProfile {
911    /// Default local TypeDID profile backed by the built-in Ed25519/X25519
912    /// key store.
913    pub fn ed25519_x25519_chacha20() -> Self {
914        Self {
915            id: "typedid/v1/x25519-chacha20poly1305-ed25519".to_owned(),
916            did_methods: vec![
917                "did:web".to_owned(),
918                "did:key".to_owned(),
919                "did:indy".to_owned(),
920            ],
921            signing: vec!["Ed25519".to_owned()],
922            key_agreement: vec!["X25519".to_owned()],
923            encryption: vec!["ChaCha20-Poly1305".to_owned()],
924            transport_bindings: vec![
925                "a2a".to_owned(),
926                "acp".to_owned(),
927                "band".to_owned(),
928                "https".to_owned(),
929                "websocket".to_owned(),
930            ],
931            modes: vec![TypeDidMode::Send, TypeDidMode::RequestReply],
932            max_payload_bytes: Some(1024 * 1024),
933            required_claims: vec![
934                "org".to_owned(),
935                "agent_id".to_owned(),
936                "purpose".to_owned(),
937            ],
938            policy_actions: vec![
939                "agent:message".to_owned(),
940                "agent:delegate".to_owned(),
941                "ai:infer".to_owned(),
942            ],
943            retention: Some("sender-encrypted-payload-only".to_owned()),
944            audit: Some("envelope-metadata-and-policy-decision".to_owned()),
945        }
946    }
947
948    /// Return true when this local profile can safely communicate with `remote`.
949    pub fn is_compatible_with(&self, remote: &Self, protocol: &str, mode: TypeDidMode) -> bool {
950        self.id == remote.id
951            && contains(&self.transport_bindings, protocol)
952            && contains(&remote.transport_bindings, protocol)
953            && self.modes.contains(&mode)
954            && remote.modes.contains(&mode)
955            && intersects(&self.did_methods, &remote.did_methods)
956            && intersects(&self.signing, &remote.signing)
957            && intersects(&self.key_agreement, &remote.key_agreement)
958            && intersects(&self.encryption, &remote.encryption)
959    }
960
961    /// Select the first local profile compatible with the remote boundary.
962    pub fn negotiate<'a>(
963        local: &'a [Self],
964        remote: &[Self],
965        protocol: &str,
966        mode: TypeDidMode,
967    ) -> Result<&'a Self, DidError> {
968        local
969            .iter()
970            .find(|candidate| {
971                remote
972                    .iter()
973                    .any(|other| candidate.is_compatible_with(other, protocol, mode))
974            })
975            .ok_or(DidError::NoCompatibleTypeDidProfile)
976    }
977}
978
979/// Resolves TypeDID profiles for a remote agent or boundary.
980pub trait TypeDidProfileResolver: Send + Sync {
981    /// Resolve profiles advertised by `target`.
982    fn resolve_profiles(&self, target: &str) -> Result<Vec<TypeDidProfile>, DidError>;
983}
984
985/// In-memory TypeDID profile resolver for examples and tests.
986#[derive(Debug, Default, Clone)]
987pub struct StaticTypeDidProfileResolver {
988    profiles: HashMap<String, Vec<TypeDidProfile>>,
989}
990
991impl StaticTypeDidProfileResolver {
992    /// Create an empty profile resolver.
993    pub fn new() -> Self {
994        Self::default()
995    }
996
997    /// Register profiles for a target DID, contact, agent card, or endpoint.
998    pub fn with_profiles(
999        mut self,
1000        target: impl Into<String>,
1001        profiles: Vec<TypeDidProfile>,
1002    ) -> Self {
1003        self.profiles.insert(target.into(), profiles);
1004        self
1005    }
1006}
1007
1008impl TypeDidProfileResolver for StaticTypeDidProfileResolver {
1009    fn resolve_profiles(&self, target: &str) -> Result<Vec<TypeDidProfile>, DidError> {
1010        self.profiles
1011            .get(target)
1012            .cloned()
1013            .ok_or_else(|| DidError::Unresolved(target.to_owned()))
1014    }
1015}
1016
1017/// The prompt context a reply envelope is bound to.
1018#[derive(Debug, Clone)]
1019pub struct DidReplyBinding {
1020    /// Policy-visible metadata of the prompt being answered.
1021    pub prompt_body: DidMessageBody,
1022    /// Stable reference to the signed prompt envelope.
1023    pub prompt_ref: DidMessageReference,
1024}
1025
1026impl DidReplyBinding {
1027    /// Bind a reply to a verified prompt.
1028    pub fn for_prompt(prompt: &VerifiedDidPrompt) -> Self {
1029        Self {
1030            prompt_body: prompt.body.clone(),
1031            prompt_ref: prompt.prompt_ref.clone(),
1032        }
1033    }
1034}
1035
1036/// Stable reference to a DID message envelope.
1037#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1038pub struct DidMessageReference {
1039    /// Referenced DID message id.
1040    pub id: String,
1041    /// SHA-256 digest of the referenced signed envelope.
1042    pub digest: String,
1043}
1044
1045/// Encrypted DID message envelope.
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047pub struct DidEnvelope {
1048    /// Message id.
1049    pub id: String,
1050    /// Message type URI.
1051    #[serde(rename = "type")]
1052    pub message_type: String,
1053    /// Sender DID.
1054    pub from: Did,
1055    /// Recipient DIDs.
1056    pub to: Vec<Did>,
1057    /// Creation time as unix seconds.
1058    pub created_time: u64,
1059    /// Expiration time as unix seconds.
1060    pub expires_time: u64,
1061    /// Policy-visible message metadata.
1062    pub body: DidMessageBody,
1063    /// Optional TypeDID conversation/profile metadata.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub typedid: Option<TypeDidConversation>,
1066    /// Key id used for authentication.
1067    pub kid: String,
1068    /// Hex-encoded nonce.
1069    pub nonce: String,
1070    /// Hex-encoded ciphertext.
1071    pub ciphertext: String,
1072    /// Hex-encoded signature over the envelope signing input.
1073    pub signature: String,
1074}
1075
1076impl DidEnvelope {
1077    /// Create an encrypted prompt envelope.
1078    pub fn prompt(
1079        id: impl Into<String>,
1080        from: Did,
1081        to: Did,
1082        body: DidMessageBody,
1083        plaintext: impl AsRef<[u8]>,
1084        resolver: &dyn DidResolver,
1085        key_store: &dyn DidKeyStore,
1086    ) -> Result<Self, DidError> {
1087        let id = id.into();
1088        let now = unix_time();
1089        let recipient_document = resolver.resolve(&to)?;
1090        let recipient_key = recipient_document.key_agreement_key()?;
1091        let sender_document = resolver.resolve(&from)?;
1092        let kid = sender_document
1093            .authentication
1094            .first()
1095            .cloned()
1096            .ok_or(DidError::MissingAuthentication)?;
1097        let nonce = random_nonce()?;
1098        let ciphertext = key_store.encrypt_for(
1099            &from,
1100            &recipient_key.public_key()?,
1101            plaintext.as_ref(),
1102            &nonce,
1103        )?;
1104        let mut envelope = Self {
1105            id,
1106            message_type: "https://typesec.dev/did/message/v1/prompt".to_owned(),
1107            from,
1108            to: vec![to],
1109            created_time: now,
1110            expires_time: now + 300,
1111            body,
1112            typedid: None,
1113            kid,
1114            nonce: hex_encode(&nonce),
1115            ciphertext,
1116            signature: String::new(),
1117        };
1118        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
1119        Ok(envelope)
1120    }
1121
1122    /// Create an encrypted reply envelope bound to a verified prompt envelope.
1123    pub fn reply(
1124        reply_did: Did,
1125        from: Did,
1126        to: Did,
1127        binding: DidReplyBinding,
1128        plaintext: impl AsRef<[u8]>,
1129        resolver: &dyn DidResolver,
1130        key_store: &dyn DidKeyStore,
1131    ) -> Result<Self, DidError> {
1132        let DidReplyBinding {
1133            prompt_body,
1134            prompt_ref,
1135        } = binding;
1136        let now = unix_time();
1137        let recipient_document = resolver.resolve(&to)?;
1138        let recipient_key = recipient_document.key_agreement_key()?;
1139        let sender_document = resolver.resolve(&from)?;
1140        let kid = sender_document
1141            .authentication
1142            .first()
1143            .cloned()
1144            .ok_or(DidError::MissingAuthentication)?;
1145        let id = reply_did.to_string();
1146        let nonce = random_nonce()?;
1147        let ciphertext = key_store.encrypt_for(
1148            &from,
1149            &recipient_key.public_key()?,
1150            plaintext.as_ref(),
1151            &nonce,
1152        )?;
1153        let mut envelope = Self {
1154            id,
1155            message_type: "https://typesec.dev/did/message/v1/reply".to_owned(),
1156            from,
1157            to: vec![to],
1158            created_time: now,
1159            expires_time: now + 300,
1160            body: DidMessageBody {
1161                action: prompt_body.action.clone(),
1162                resource: prompt_body.resource.clone(),
1163                privacy: prompt_body.privacy.clone(),
1164                reply_to: Some(prompt_ref),
1165            },
1166            typedid: None,
1167            kid,
1168            nonce: hex_encode(&nonce),
1169            ciphertext,
1170            signature: String::new(),
1171        };
1172        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
1173        Ok(envelope)
1174    }
1175
1176    /// Create an encrypted TypeDID agent-message envelope.
1177    #[allow(clippy::too_many_arguments)]
1178    pub fn typedid(
1179        id: impl Into<String>,
1180        from: Did,
1181        to: Did,
1182        body: DidMessageBody,
1183        typedid: TypeDidConversation,
1184        plaintext: impl AsRef<[u8]>,
1185        resolver: &dyn DidResolver,
1186        key_store: &dyn DidKeyStore,
1187    ) -> Result<Self, DidError> {
1188        let mut envelope = Self::prompt(id, from, to, body, plaintext, resolver, key_store)?;
1189        envelope.message_type = "https://typesec.dev/did/message/v1/typedid".to_owned();
1190        envelope.typedid = Some(typedid);
1191        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
1192        Ok(envelope)
1193    }
1194
1195    /// Create an encrypted TypeDID reply envelope bound to a verified request.
1196    pub fn typedid_reply(
1197        id: impl Into<String>,
1198        from: Did,
1199        to: Did,
1200        request: &VerifiedTypeDidMessage,
1201        plaintext: impl AsRef<[u8]>,
1202        resolver: &dyn DidResolver,
1203        key_store: &dyn DidKeyStore,
1204    ) -> Result<Self, DidError> {
1205        let mut body = request.body.clone();
1206        body.reply_to = Some(request.message_ref.clone());
1207        let conversation = TypeDidConversation {
1208            conversation_id: request.conversation.conversation_id.clone(),
1209            mode: TypeDidMode::RequestReply,
1210            profile: request.conversation.profile.clone(),
1211            protocol: request.conversation.protocol.clone(),
1212            expires_at: request.conversation.expires_at,
1213        };
1214        Self::typedid(
1215            id,
1216            from,
1217            to,
1218            body,
1219            conversation,
1220            plaintext,
1221            resolver,
1222            key_store,
1223        )
1224    }
1225
1226    /// Stable reference to this signed envelope for reply binding.
1227    pub fn reference(&self) -> DidMessageReference {
1228        let seed = format!("{}\n{}", self.signing_input(), self.signature);
1229        DidMessageReference {
1230            id: self.id.clone(),
1231            digest: hex_encode(&sha256_tagged(
1232                b"typesec-did-envelope-reference",
1233                seed.as_bytes(),
1234            )),
1235        }
1236    }
1237
1238    fn signing_input(&self) -> String {
1239        let reply_to = self
1240            .body
1241            .reply_to
1242            .as_ref()
1243            .map(|reference| format!("{}\n{}", reference.id, reference.digest))
1244            .unwrap_or_default();
1245        let base = format!(
1246            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
1247            self.id,
1248            self.message_type,
1249            self.from,
1250            self.to
1251                .iter()
1252                .map(Did::as_str)
1253                .collect::<Vec<_>>()
1254                .join(","),
1255            self.created_time,
1256            self.expires_time,
1257            self.body.action,
1258            self.body.resource,
1259            self.body.privacy,
1260            reply_to
1261        );
1262        if let Some(typedid) = self.typedid.as_ref() {
1263            format!(
1264                "{}\n{}\n{}",
1265                base,
1266                canonical_typedid_conversation(typedid),
1267                self.ciphertext
1268            )
1269        } else {
1270            format!("{}\n{}", base, self.ciphertext)
1271        }
1272    }
1273}
1274
1275/// Verified and decrypted TypeDID agent message.
1276#[derive(Debug)]
1277pub struct VerifiedTypeDidMessage {
1278    /// Verified DID subject.
1279    pub subject: Did,
1280    /// Stable reference to the verified envelope.
1281    pub message_ref: DidMessageReference,
1282    /// Policy-visible message metadata.
1283    pub body: DidMessageBody,
1284    /// TypeDID conversation/profile metadata.
1285    pub conversation: TypeDidConversation,
1286    /// Resource associated with the payload.
1287    pub resource: GenericResource,
1288    /// Secret opaque payload bytes.
1289    pub payload: SecureValue<Secret, Vec<u8>, GenericResource>,
1290}
1291
1292/// Policy/audit-safe attestation derived from a verified TypeDID message.
1293///
1294/// This contains no plaintext payload and no raw signature material. It is the
1295/// compact boundary object downstream systems can persist after a
1296/// [`TypeDidGateway`] has verified the envelope signature, recipient, expiry,
1297/// conversation metadata, and payload authentication.
1298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1299pub struct TypeDidAttestation {
1300    /// Verified sender DID.
1301    pub subject: Did,
1302    /// Stable signed envelope id.
1303    pub envelope_id: String,
1304    /// SHA-256 digest of the signed envelope reference.
1305    pub envelope_digest: String,
1306    /// Policy-visible requested action.
1307    pub action: String,
1308    /// Policy-visible requested resource.
1309    pub resource: String,
1310    /// Policy-visible privacy class.
1311    pub privacy: String,
1312    /// TypeDID conversation id.
1313    pub conversation_id: String,
1314    /// TypeDID transport/protocol family.
1315    pub protocol: String,
1316    /// TypeDID delivery mode.
1317    pub mode: TypeDidMode,
1318    /// Negotiated TypeDID crypto/profile id.
1319    pub profile: String,
1320    /// Conversation expiry time as unix seconds, when supplied by the sender.
1321    #[serde(default, skip_serializing_if = "Option::is_none")]
1322    pub expires_at: Option<u64>,
1323}
1324
1325impl VerifiedTypeDidMessage {
1326    /// Return an audit-safe attestation for this verified message.
1327    pub fn attestation(&self) -> TypeDidAttestation {
1328        TypeDidAttestation {
1329            subject: self.subject.clone(),
1330            envelope_id: self.message_ref.id.clone(),
1331            envelope_digest: self.message_ref.digest.clone(),
1332            action: self.body.action.clone(),
1333            resource: self.body.resource.clone(),
1334            privacy: self.body.privacy.clone(),
1335            conversation_id: self.conversation.conversation_id.clone(),
1336            protocol: self.conversation.protocol.clone(),
1337            mode: self.conversation.mode,
1338            profile: self.conversation.profile.clone(),
1339            expires_at: self.conversation.expires_at,
1340        }
1341    }
1342}
1343
1344/// Verified and decrypted DID prompt.
1345#[derive(Debug)]
1346pub struct VerifiedDidPrompt {
1347    /// Verified DID subject.
1348    pub subject: Did,
1349    /// Stable reference to the verified prompt envelope.
1350    pub prompt_ref: DidMessageReference,
1351    /// Policy-visible metadata.
1352    pub body: DidMessageBody,
1353    /// Resource associated with the payload.
1354    pub resource: GenericResource,
1355    /// Secret prompt payload.
1356    pub prompt: SecureValue<Secret, String, GenericResource>,
1357}
1358
1359/// Verifies DID envelopes and converts encrypted payloads into `SecureValue`s.
1360pub struct DidMessageGateway {
1361    resolver: Arc<dyn DidResolver>,
1362    key_store: Arc<dyn DidKeyStore>,
1363    recipient: Did,
1364}
1365
1366impl DidMessageGateway {
1367    /// Create a gateway for one local recipient DID.
1368    pub fn new(
1369        resolver: Arc<dyn DidResolver>,
1370        key_store: Arc<dyn DidKeyStore>,
1371        recipient: Did,
1372    ) -> Self {
1373        Self {
1374            resolver,
1375            key_store,
1376            recipient,
1377        }
1378    }
1379
1380    /// Verify, decrypt, and protect a DID prompt envelope.
1381    pub fn open_prompt(&self, envelope: &DidEnvelope) -> Result<VerifiedDidPrompt, DidError> {
1382        let opened = self.open_bytes(envelope)?;
1383        let prompt = String::from_utf8(opened.plaintext).map_err(|_| DidError::InvalidUtf8)?;
1384        Ok(VerifiedDidPrompt {
1385            subject: opened.subject,
1386            prompt_ref: opened.message_ref,
1387            body: opened.body,
1388            prompt: SecureValue::protect(prompt, &opened.resource),
1389            resource: opened.resource,
1390        })
1391    }
1392
1393    fn open_bytes(&self, envelope: &DidEnvelope) -> Result<OpenedDidEnvelope, DidError> {
1394        if !envelope.to.iter().any(|did| did == &self.recipient) {
1395            return Err(DidError::WrongRecipient(self.recipient.to_string()));
1396        }
1397        let now = unix_time();
1398        if envelope.expires_time < now {
1399            return Err(DidError::Expired);
1400        }
1401
1402        let sender_document = self.resolver.resolve(&envelope.from)?;
1403        let sender_key = sender_document.authentication_key(&envelope.kid)?;
1404        self.key_store.verify(
1405            sender_key,
1406            envelope.signing_input().as_bytes(),
1407            &envelope.signature,
1408        )?;
1409
1410        // Decryption uses the sender's *key-agreement* key, which may be a
1411        // different key (X25519) than the authentication key (Ed25519). During
1412        // key rotation, older in-flight envelopes may have used a previous
1413        // sender agreement key, so try every non-retired key advertised by the
1414        // sender document.
1415        let sender_agreement_keys = sender_document.key_agreement_keys()?;
1416        let nonce = hex_decode(&envelope.nonce)?;
1417        let mut plaintext = None;
1418        for sender_agreement_key in sender_agreement_keys {
1419            match self.key_store.decrypt_for(
1420                &self.recipient,
1421                &sender_agreement_key.public_key()?,
1422                &nonce,
1423                &envelope.ciphertext,
1424            ) {
1425                Ok(opened) => {
1426                    plaintext = Some(opened);
1427                    break;
1428                }
1429                Err(DidError::DecryptionFailed) => {}
1430                Err(err) => return Err(err),
1431            }
1432        }
1433        let plaintext = plaintext.ok_or(DidError::DecryptionFailed)?;
1434        let resource = GenericResource::new(&envelope.body.resource, "did-prompt");
1435
1436        Ok(OpenedDidEnvelope {
1437            subject: envelope.from.clone(),
1438            message_ref: envelope.reference(),
1439            body: envelope.body.clone(),
1440            resource,
1441            plaintext,
1442        })
1443    }
1444}
1445
1446#[derive(Debug)]
1447struct OpenedDidEnvelope {
1448    subject: Did,
1449    message_ref: DidMessageReference,
1450    body: DidMessageBody,
1451    resource: GenericResource,
1452    plaintext: Vec<u8>,
1453}
1454
1455/// Verifies TypeDID envelopes and protects arbitrary agent payload bytes.
1456pub struct TypeDidGateway {
1457    inner: DidMessageGateway,
1458}
1459
1460impl TypeDidGateway {
1461    /// Create a TypeDID gateway for one local recipient DID.
1462    pub fn new(
1463        resolver: Arc<dyn DidResolver>,
1464        key_store: Arc<dyn DidKeyStore>,
1465        recipient: Did,
1466    ) -> Self {
1467        Self {
1468            inner: DidMessageGateway::new(resolver, key_store, recipient),
1469        }
1470    }
1471
1472    /// Verify, decrypt, and protect a TypeDID message envelope.
1473    pub fn open_message(&self, envelope: &DidEnvelope) -> Result<VerifiedTypeDidMessage, DidError> {
1474        let conversation = envelope
1475            .typedid
1476            .clone()
1477            .ok_or(DidError::MissingTypeDidMetadata)?;
1478        let opened = self.inner.open_bytes(envelope)?;
1479        Ok(VerifiedTypeDidMessage {
1480            subject: opened.subject,
1481            message_ref: opened.message_ref,
1482            body: opened.body,
1483            conversation,
1484            payload: SecureValue::protect(opened.plaintext, &opened.resource),
1485            resource: opened.resource,
1486        })
1487    }
1488}
1489
1490/// Common interface for TypeDID secure-envelope transport adapters.
1491pub trait SecureEnvelopeAdapter {
1492    /// Adapter protocol name.
1493    fn protocol(&self) -> &str;
1494
1495    /// Media type this adapter carries over its outer protocol.
1496    fn content_type(&self) -> &'static str {
1497        "application/vnd.typedid.envelope+json"
1498    }
1499
1500    /// Wrap a payload in a TypeDID envelope for this adapter's protocol.
1501    fn wrap(
1502        &self,
1503        request: TypeDidWrapRequest<'_>,
1504        resolver: &dyn DidResolver,
1505        key_store: &dyn DidKeyStore,
1506    ) -> Result<DidEnvelope, DidError> {
1507        let profile = TypeDidProfile::negotiate(
1508            request.local_profiles,
1509            request.remote_profiles,
1510            self.protocol(),
1511            request.mode,
1512        )?;
1513        let conversation = TypeDidConversation::new(
1514            request.conversation_id,
1515            request.mode,
1516            profile.id.clone(),
1517            self.protocol(),
1518        );
1519        DidEnvelope::typedid(
1520            request.id,
1521            request.from,
1522            request.to,
1523            request.body,
1524            conversation,
1525            request.payload,
1526            resolver,
1527            key_store,
1528        )
1529    }
1530}
1531
1532/// Inputs for wrapping a payload in a TypeDID transport adapter.
1533pub struct TypeDidWrapRequest<'a> {
1534    /// Envelope id.
1535    pub id: String,
1536    /// Sender DID.
1537    pub from: Did,
1538    /// Recipient DID.
1539    pub to: Did,
1540    /// Outer conversation/task/room/session id.
1541    pub conversation_id: String,
1542    /// Send mode.
1543    pub mode: TypeDidMode,
1544    /// Policy-visible body.
1545    pub body: DidMessageBody,
1546    /// Plaintext payload bytes.
1547    pub payload: &'a [u8],
1548    /// Local TypeDID profiles.
1549    pub local_profiles: &'a [TypeDidProfile],
1550    /// Remote TypeDID profiles.
1551    pub remote_profiles: &'a [TypeDidProfile],
1552}
1553
1554/// A2A TypeDID content adapter.
1555#[derive(Debug, Default, Clone, Copy)]
1556pub struct A2aTypeDidAdapter;
1557
1558impl SecureEnvelopeAdapter for A2aTypeDidAdapter {
1559    fn protocol(&self) -> &str {
1560        "a2a"
1561    }
1562}
1563
1564/// ACP TypeDID content adapter.
1565#[derive(Debug, Default, Clone, Copy)]
1566pub struct AcpTypeDidAdapter;
1567
1568impl SecureEnvelopeAdapter for AcpTypeDidAdapter {
1569    fn protocol(&self) -> &str {
1570        "acp"
1571    }
1572}
1573
1574/// BAND secure-envelope adapter for TypeDID payloads.
1575#[derive(Debug, Default, Clone, Copy)]
1576pub struct BandSecureEnvelopeAdapter;
1577
1578impl SecureEnvelopeAdapter for BandSecureEnvelopeAdapter {
1579    fn protocol(&self) -> &str {
1580        "band"
1581    }
1582}
1583
1584/// Direct HTTPS TypeDID content adapter.
1585#[derive(Debug, Default, Clone, Copy)]
1586pub struct HttpTypeDidAdapter;
1587
1588impl SecureEnvelopeAdapter for HttpTypeDidAdapter {
1589    fn protocol(&self) -> &str {
1590        "https"
1591    }
1592}
1593
1594/// Ollama client that can send verified DID prompts.
1595pub struct DidOllamaClient {
1596    base_url: String,
1597    model: String,
1598    http: Arc<dyn HttpClient>,
1599}
1600
1601impl DidOllamaClient {
1602    /// Create an Ollama client using reqwest.
1603    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
1604        Self::with_http(base_url, model, Arc::new(ReqwestHttpClient::new()))
1605    }
1606
1607    /// Create an Ollama client with an injected HTTP client.
1608    pub fn with_http(
1609        base_url: impl Into<String>,
1610        model: impl Into<String>,
1611        http: Arc<dyn HttpClient>,
1612    ) -> Self {
1613        Self {
1614            base_url: base_url.into().trim_end_matches('/').to_owned(),
1615            model: model.into(),
1616            http,
1617        }
1618    }
1619
1620    /// Reveal a verified prompt under typed authority and send it to Ollama.
1621    pub fn chat_verified_prompt(
1622        &self,
1623        prompt: VerifiedDidPrompt,
1624        _infer: &Capability<AiCanInfer, GenericResource>,
1625        read: &Capability<CanReadSensitive, GenericResource>,
1626    ) -> Result<Value, DidError> {
1627        let plaintext = prompt.prompt.reveal(read)?;
1628        let body = json!({
1629            "model": self.model,
1630            "stream": false,
1631            "messages": [{
1632                "role": "user",
1633                "content": plaintext
1634            }]
1635        });
1636        self.http
1637            .post_json(&format!("{}/api/chat", self.base_url), &[], &body)
1638            .map_err(DidError::Http)
1639    }
1640
1641    /// Send a verified prompt to Ollama and bind the assistant reply to it.
1642    pub fn chat_verified_prompt_bound(
1643        &self,
1644        prompt: VerifiedDidPrompt,
1645        reply_from: Did,
1646        resolver: &dyn DidResolver,
1647        key_store: &dyn DidKeyStore,
1648        _infer: &Capability<AiCanInfer, GenericResource>,
1649        read: &Capability<CanReadSensitive, GenericResource>,
1650    ) -> Result<DidEnvelope, DidError> {
1651        let reply_to = prompt.subject.clone();
1652        let binding = DidReplyBinding::for_prompt(&prompt);
1653        let plaintext = prompt.prompt.reveal(read)?;
1654        let body = json!({
1655            "model": self.model,
1656            "stream": false,
1657            "messages": [{
1658                "role": "user",
1659                "content": plaintext
1660            }]
1661        });
1662        let response = self
1663            .http
1664            .post_json(&format!("{}/api/chat", self.base_url), &[], &body)
1665            .map_err(DidError::Http)?;
1666        let reply = ollama_reply_content(&response)?;
1667        let reply_did = Did::key(sha256_tagged(
1668            b"typesec-did-ollama-reply",
1669            format!("{}\n{}", binding.prompt_ref.digest, reply).as_bytes(),
1670        ));
1671        DidEnvelope::reply(
1672            reply_did, reply_from, reply_to, binding, reply, resolver, key_store,
1673        )
1674    }
1675
1676    /// Forward an already wrapped DID prompt to a DID-aware Ollama fork.
1677    pub fn chat_wrapped_prompt(&self, envelope: &DidEnvelope) -> Result<Value, DidError> {
1678        let body = json!({
1679            "model": self.model,
1680            "stream": false,
1681            "did_envelope": envelope
1682        });
1683        self.http
1684            .post_json(&format!("{}/api/chat", self.base_url), &[], &body)
1685            .map_err(DidError::Http)
1686    }
1687}
1688
1689/// DID integration errors.
1690#[derive(Debug, thiserror::Error)]
1691pub enum DidError {
1692    /// DID syntax is invalid.
1693    #[error("invalid DID: {0}")]
1694    InvalidDid(String),
1695    /// DID could not be resolved.
1696    #[error("unresolved DID: {0}")]
1697    Unresolved(String),
1698    /// No private key is available for a local DID.
1699    #[error("missing private key for DID: {0}")]
1700    MissingPrivateKey(String),
1701    /// DID document did not contain an authentication key.
1702    #[error("DID document has no authentication key")]
1703    MissingAuthentication,
1704    /// DID document did not contain a key agreement key.
1705    #[error("DID document has no key agreement key")]
1706    MissingKeyAgreement,
1707    /// Referenced verification method is absent.
1708    #[error("missing verification method: {0}")]
1709    MissingVerificationMethod(String),
1710    /// Referenced key version is absent.
1711    #[error("missing key version {version} for DID {did}")]
1712    MissingKeyVersion {
1713        /// DID whose key version was requested.
1714        did: String,
1715        /// Missing key version.
1716        version: u64,
1717    },
1718    /// Active key versions cannot be retired.
1719    #[error("cannot retire active key version {version} for DID {did}")]
1720    CannotRetireActiveKey {
1721        /// DID whose active key would have been retired.
1722        did: String,
1723        /// Active key version.
1724        version: u64,
1725    },
1726    /// Referenced key has been retired.
1727    #[error("retired verification method: {0}")]
1728    RetiredKey(String),
1729    /// Envelope signature did not verify.
1730    #[error("invalid DID envelope signature")]
1731    InvalidSignature,
1732    /// Envelope recipient does not match this gateway.
1733    #[error("DID envelope was not addressed to {0}")]
1734    WrongRecipient(String),
1735    /// Envelope has expired.
1736    #[error("DID envelope has expired")]
1737    Expired,
1738    /// Key material has the wrong size or encoding.
1739    #[error("invalid key material: {0}")]
1740    InvalidKey(String),
1741    /// AEAD nonce must be exactly 12 bytes.
1742    #[error("invalid nonce: expected 12 bytes")]
1743    InvalidNonce,
1744    /// Payload encryption failed.
1745    #[error("DID payload encryption failed")]
1746    EncryptionFailed,
1747    /// Payload decryption or authentication failed.
1748    #[error("DID payload decryption failed")]
1749    DecryptionFailed,
1750    /// Operating system RNG was unavailable.
1751    #[error("key generation failed: {0}")]
1752    KeyGen(String),
1753    /// A typed capability did not cover the protected payload's resource.
1754    #[error("capability does not cover this payload: {0}")]
1755    Capability(#[from] typesec_core::secure_value::SecureAccessError),
1756    /// Hex input is malformed.
1757    #[error("invalid hex encoding")]
1758    InvalidHex,
1759    /// Decrypted payload is not UTF-8.
1760    #[error("decrypted DID payload is not valid UTF-8")]
1761    InvalidUtf8,
1762    /// HTTP request failed.
1763    #[error("DID HTTP integration failed: {0}")]
1764    Http(Box<dyn std::error::Error + Send + Sync>),
1765    /// Ollama response did not contain an assistant message.
1766    #[error("Ollama response did not contain message.content")]
1767    MissingOllamaReply,
1768    /// A TypeDID envelope did not include TypeDID metadata.
1769    #[error("DID envelope is missing TypeDID metadata")]
1770    MissingTypeDidMetadata,
1771    /// Local and remote TypeDID profiles did not overlap.
1772    #[error("no compatible TypeDID profile")]
1773    NoCompatibleTypeDidProfile,
1774}
1775
1776fn ollama_reply_content(response: &Value) -> Result<&str, DidError> {
1777    response
1778        .get("message")
1779        .and_then(|message| message.get("content"))
1780        .and_then(Value::as_str)
1781        .ok_or(DidError::MissingOllamaReply)
1782}
1783
1784fn unix_time() -> u64 {
1785    SystemTime::now()
1786        .duration_since(UNIX_EPOCH)
1787        .map(|duration| duration.as_secs())
1788        .unwrap_or_default()
1789}
1790
1791/// Domain-separated SHA-256: `SHA-256(domain || 0x00 || data)`.
1792fn sha256_tagged(domain: &[u8], data: &[u8]) -> [u8; 32] {
1793    use sha2::Digest;
1794    let mut hasher = sha2::Sha256::new();
1795    hasher.update(domain);
1796    hasher.update([0u8]);
1797    hasher.update(data);
1798    hasher.finalize().into()
1799}
1800
1801/// A fresh random 12-byte AEAD nonce from the OS RNG.
1802fn random_nonce() -> Result<[u8; 12], DidError> {
1803    let mut nonce = [0u8; 12];
1804    getrandom::getrandom(&mut nonce).map_err(|e| DidError::KeyGen(e.to_string()))?;
1805    Ok(nonce)
1806}
1807
1808fn canonical_typedid_conversation(conversation: &TypeDidConversation) -> String {
1809    format!(
1810        "{}\n{:?}\n{}\n{}\n{}",
1811        conversation.conversation_id,
1812        conversation.mode,
1813        conversation.profile,
1814        conversation.protocol,
1815        conversation
1816            .expires_at
1817            .map(|expires_at| expires_at.to_string())
1818            .unwrap_or_default()
1819    )
1820}
1821
1822fn contains(values: &[String], needle: &str) -> bool {
1823    values.iter().any(|value| value == needle)
1824}
1825
1826fn intersects(left: &[String], right: &[String]) -> bool {
1827    left.iter().any(|value| right.contains(value))
1828}
1829
1830#[cfg(any(test, feature = "demo-crypto"))]
1831fn derive_shared_key(private_key: &[u8], public_key: &[u8], nonce: &[u8]) -> Vec<u8> {
1832    let mut seed = Vec::with_capacity(private_key.len() + public_key.len() + nonce.len());
1833    if private_key <= public_key {
1834        seed.extend_from_slice(private_key);
1835        seed.extend_from_slice(public_key);
1836    } else {
1837        seed.extend_from_slice(public_key);
1838        seed.extend_from_slice(private_key);
1839    }
1840    seed.extend_from_slice(nonce);
1841    derive_bytes(b"typesec-did-shared", &seed, 32)
1842}
1843
1844/// Non-cryptographic FNV/xorshift expansion — demo key store only.
1845#[cfg(any(test, feature = "demo-crypto"))]
1846fn derive_bytes(domain: &[u8], seed: &[u8], len: usize) -> Vec<u8> {
1847    let mut out = Vec::with_capacity(len);
1848    let mut state: u64 = 0xcbf29ce484222325;
1849    for byte in domain.iter().chain(seed) {
1850        state ^= u64::from(*byte);
1851        state = state.wrapping_mul(0x100000001b3);
1852    }
1853    while out.len() < len {
1854        state ^= state >> 12;
1855        state ^= state << 25;
1856        state ^= state >> 27;
1857        state = state.wrapping_mul(0x2545f4914f6cdd1d);
1858        out.extend_from_slice(&state.to_le_bytes());
1859    }
1860    out.truncate(len);
1861    out
1862}
1863
1864#[cfg(any(test, feature = "demo-crypto"))]
1865fn xor_stream(input: &[u8], key: &[u8]) -> Vec<u8> {
1866    input
1867        .iter()
1868        .enumerate()
1869        .map(|(idx, byte)| byte ^ key[idx % key.len()])
1870        .collect()
1871}
1872
1873#[cfg(any(test, feature = "demo-crypto"))]
1874fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
1875    if left.len() != right.len() {
1876        return false;
1877    }
1878    left.iter()
1879        .zip(right)
1880        .fold(0u8, |acc, (a, b)| acc | (a ^ b))
1881        == 0
1882}
1883
1884fn hex_encode(bytes: &[u8]) -> String {
1885    const HEX: &[u8; 16] = b"0123456789abcdef";
1886    let mut out = String::with_capacity(bytes.len() * 2);
1887    for byte in bytes {
1888        out.push(HEX[(byte >> 4) as usize] as char);
1889        out.push(HEX[(byte & 0x0f) as usize] as char);
1890    }
1891    out
1892}
1893
1894fn hex_decode(value: &str) -> Result<Vec<u8>, DidError> {
1895    if !value.len().is_multiple_of(2) {
1896        return Err(DidError::InvalidHex);
1897    }
1898    let mut out = Vec::with_capacity(value.len() / 2);
1899    for chunk in value.as_bytes().chunks_exact(2) {
1900        let high = hex_nibble(chunk[0])?;
1901        let low = hex_nibble(chunk[1])?;
1902        out.push((high << 4) | low);
1903    }
1904    Ok(out)
1905}
1906
1907fn hex_nibble(byte: u8) -> Result<u8, DidError> {
1908    match byte {
1909        b'0'..=b'9' => Ok(byte - b'0'),
1910        b'a'..=b'f' => Ok(byte - b'a' + 10),
1911        b'A'..=b'F' => Ok(byte - b'A' + 10),
1912        _ => Err(DidError::InvalidHex),
1913    }
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918    use std::sync::Arc;
1919
1920    use serde_json::json;
1921    use typesec_core::{
1922        PolicyEngine, Resource, ResourceId, SubjectId,
1923        permissions::{AiCanInfer, CanReadSensitive},
1924        policy::{PolicyResult, mint_capability},
1925    };
1926
1927    use super::*;
1928    use crate::http::RecordingHttpClient;
1929
1930    struct PromptPolicy;
1931
1932    impl PolicyEngine for PromptPolicy {
1933        fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
1934            let subject = subject.as_str();
1935            let resource = resource.as_str();
1936            if subject == "did:key:z616c696365"
1937                && matches!(action, "ai:infer" | "read_sensitive")
1938                && resource == "prompt/session/123"
1939            {
1940                PolicyResult::Allow
1941            } else {
1942                PolicyResult::Deny("not allowed".to_owned())
1943            }
1944        }
1945    }
1946
1947    fn fixture() -> (Did, Did, StaticDidResolver, DemoDidKeyStore) {
1948        let alice = Did::key(b"alice");
1949        let agent = Did::key(b"agent");
1950        let alice_key = DemoDidKeyPair::from_seed(b"alice");
1951        let agent_key = DemoDidKeyPair::from_seed(b"agent");
1952        let resolver = StaticDidResolver::new()
1953            .with_document(DidDocument::single_key(
1954                alice.clone(),
1955                alice_key.public_key.clone(),
1956            ))
1957            .with_document(DidDocument::single_key(
1958                agent.clone(),
1959                agent_key.public_key.clone(),
1960            ));
1961        let keys = DemoDidKeyStore::new()
1962            .with_key(alice.clone(), alice_key)
1963            .with_key(agent.clone(), agent_key);
1964        (alice, agent, resolver, keys)
1965    }
1966
1967    struct AgentPolicy {
1968        allowed_subject: String,
1969    }
1970
1971    impl PolicyEngine for AgentPolicy {
1972        fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
1973            let subject = subject.as_str();
1974            let resource = resource.as_str();
1975            if subject == self.allowed_subject
1976                && matches!(
1977                    action,
1978                    "agent:message" | "agent:delegate" | "read_sensitive"
1979                )
1980                && resource == "room/acme-support"
1981            {
1982                PolicyResult::Allow
1983            } else {
1984                PolicyResult::Deny("agent message denied".to_owned())
1985            }
1986        }
1987    }
1988
1989    #[test]
1990    fn dids_parse_and_reject_bad_values() {
1991        assert!(Did::parse("did:web:example.com").is_ok());
1992        assert!(Did::parse("not-a-did").is_err());
1993        assert_eq!(
1994            Did::web("typesec.dev").unwrap().as_str(),
1995            "did:web:typesec.dev"
1996        );
1997    }
1998
1999    #[test]
2000    fn encrypted_prompt_opens_as_secret_secure_value() {
2001        let (alice, agent, resolver, keys) = fixture();
2002        let envelope = DidEnvelope::prompt(
2003            "msg-1",
2004            alice.clone(),
2005            agent.clone(),
2006            DidMessageBody::infer_prompt("prompt/session/123"),
2007            "summarize this confidential record",
2008            &resolver,
2009            &keys,
2010        )
2011        .expect("envelope");
2012        assert_ne!(envelope.ciphertext, "summarize this confidential record");
2013
2014        let gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2015        let verified = gateway.open_prompt(&envelope).expect("verified prompt");
2016        assert_eq!(verified.subject, alice);
2017        assert_eq!(verified.resource.resource_id(), "prompt/session/123");
2018        assert_eq!(
2019            SecureValue::<Secret, String, GenericResource>::label_name(),
2020            "secret"
2021        );
2022
2023        let infer = mint_capability::<AiCanInfer, _>(
2024            &PromptPolicy,
2025            verified.subject.as_str(),
2026            &verified.resource,
2027        )
2028        .expect("infer cap");
2029        let read = mint_capability::<CanReadSensitive, _>(
2030            &PromptPolicy,
2031            verified.subject.as_str(),
2032            &verified.resource,
2033        )
2034        .expect("read cap");
2035        assert_eq!(infer.resource_id(), "prompt/session/123");
2036        assert_eq!(
2037            verified.prompt.reveal(&read).expect("matching resource"),
2038            "summarize this confidential record"
2039        );
2040    }
2041
2042    #[test]
2043    fn did_ollama_client_sends_plaintext_only_after_capabilities() {
2044        let (alice, agent, resolver, keys) = fixture();
2045        let envelope = DidEnvelope::prompt(
2046            "msg-1",
2047            alice,
2048            agent.clone(),
2049            DidMessageBody::infer_prompt("prompt/session/123"),
2050            "private prompt",
2051            &resolver,
2052            &keys,
2053        )
2054        .expect("envelope");
2055        let gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2056        let verified = gateway.open_prompt(&envelope).expect("verified prompt");
2057        let infer = mint_capability::<AiCanInfer, _>(
2058            &PromptPolicy,
2059            verified.subject.as_str(),
2060            &verified.resource,
2061        )
2062        .expect("infer cap");
2063        let read = mint_capability::<CanReadSensitive, _>(
2064            &PromptPolicy,
2065            verified.subject.as_str(),
2066            &verified.resource,
2067        )
2068        .expect("read cap");
2069
2070        let http = RecordingHttpClient::new().with_response(
2071            "http://localhost:11434/api/chat",
2072            json!({ "message": { "content": "ok" } }),
2073        );
2074        let client = DidOllamaClient::with_http(
2075            "http://localhost:11434",
2076            "llama3.2",
2077            Arc::new(http.clone()),
2078        );
2079        let response = client
2080            .chat_verified_prompt(verified, &infer, &read)
2081            .expect("ollama call");
2082
2083        assert_eq!(response["message"]["content"], "ok");
2084        let requests = http.requests();
2085        assert_eq!(requests.len(), 1);
2086        assert_eq!(requests[0].url, "http://localhost:11434/api/chat");
2087        assert_eq!(
2088            requests[0].body.as_ref().unwrap()["messages"][0]["content"],
2089            "private prompt"
2090        );
2091    }
2092
2093    #[test]
2094    fn typedid_profile_negotiates_on_protocol_and_mode() {
2095        let local = vec![TypeDidProfile::ed25519_x25519_chacha20()];
2096        let remote = vec![TypeDidProfile::ed25519_x25519_chacha20()];
2097        let selected = TypeDidProfile::negotiate(&local, &remote, "a2a", TypeDidMode::RequestReply)
2098            .expect("compatible profile");
2099        assert_eq!(selected.id, "typedid/v1/x25519-chacha20poly1305-ed25519");
2100
2101        assert!(matches!(
2102            TypeDidProfile::negotiate(&local, &remote, "smtp", TypeDidMode::Send),
2103            Err(DidError::NoCompatibleTypeDidProfile)
2104        ));
2105    }
2106
2107    #[test]
2108    fn typedid_adapter_wraps_and_gateway_opens_opaque_payload() {
2109        let (alice, agent, resolver, keys) = fixture();
2110        let profiles = vec![TypeDidProfile::ed25519_x25519_chacha20()];
2111        let adapter = A2aTypeDidAdapter;
2112        let payload =
2113            br#"{"jsonrpc":"2.0","method":"message/send","params":{"text":"triage case"}}"#;
2114
2115        let envelope = adapter
2116            .wrap(
2117                TypeDidWrapRequest {
2118                    id: "a2a-msg-1".to_owned(),
2119                    from: alice.clone(),
2120                    to: agent.clone(),
2121                    conversation_id: "task/a2a-123".to_owned(),
2122                    mode: TypeDidMode::RequestReply,
2123                    body: DidMessageBody::agent_delegate("room/acme-support", "secret"),
2124                    payload,
2125                    local_profiles: &profiles,
2126                    remote_profiles: &profiles,
2127                },
2128                &resolver,
2129                &keys,
2130            )
2131            .expect("wrapped envelope");
2132
2133        assert_eq!(
2134            adapter.content_type(),
2135            "application/vnd.typedid.envelope+json"
2136        );
2137        assert_eq!(
2138            envelope.message_type,
2139            "https://typesec.dev/did/message/v1/typedid"
2140        );
2141        assert_eq!(envelope.typedid.as_ref().unwrap().protocol, "a2a");
2142        assert_ne!(envelope.ciphertext.as_bytes(), payload);
2143
2144        let gateway = TypeDidGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2145        let verified = gateway.open_message(&envelope).expect("verified typedid");
2146        assert_eq!(verified.subject, alice);
2147        assert_eq!(verified.conversation.conversation_id, "task/a2a-123");
2148        assert_eq!(verified.body.action, "agent:delegate");
2149
2150        let read = mint_capability::<CanReadSensitive, _>(
2151            &AgentPolicy {
2152                allowed_subject: verified.subject.to_string(),
2153            },
2154            verified.subject.as_str(),
2155            &verified.resource,
2156        )
2157        .expect("read cap");
2158        assert_eq!(verified.payload.reveal(&read).expect("payload"), payload);
2159    }
2160
2161    #[test]
2162    fn typedid_verified_message_exposes_audit_safe_attestation() {
2163        let (alice, agent, resolver, keys) = ed25519_fixture();
2164        let envelope = DidEnvelope::typedid(
2165            "typedid-attestation-1",
2166            alice.clone(),
2167            agent.clone(),
2168            DidMessageBody::agent_message("lakecat:table:events", "internal"),
2169            TypeDidConversation::new(
2170                "conversation-1",
2171                TypeDidMode::RequestReply,
2172                TypeDidProfile::ed25519_x25519_chacha20().id,
2173                "a2a",
2174            )
2175            .with_expires_at(unix_time() + 300),
2176            b"secret payload",
2177            &resolver,
2178            &keys,
2179        )
2180        .expect("typedid envelope");
2181        let gateway = TypeDidGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2182        let verified = gateway.open_message(&envelope).expect("verified typedid");
2183        let attestation = verified.attestation();
2184
2185        assert_eq!(attestation.subject, alice);
2186        assert_eq!(attestation.envelope_id, "typedid-attestation-1");
2187        assert_eq!(attestation.action, "agent:message");
2188        assert_eq!(attestation.resource, "lakecat:table:events");
2189        assert_eq!(attestation.privacy, "internal");
2190        assert_eq!(attestation.protocol, "a2a");
2191        assert_eq!(attestation.mode, TypeDidMode::RequestReply);
2192        let serialized = serde_json::to_string(&attestation).unwrap();
2193        assert!(!serialized.contains("secret payload"));
2194        assert!(!serialized.contains(&envelope.signature));
2195    }
2196
2197    #[test]
2198    fn typedid_reply_is_bound_to_request_envelope() {
2199        let (alice, agent, resolver, keys) = fixture();
2200        let request = DidEnvelope::typedid(
2201            "band-room-msg-1",
2202            alice.clone(),
2203            agent.clone(),
2204            DidMessageBody::agent_message("room/acme-support", "secret"),
2205            TypeDidConversation::new(
2206                "room/acme-support",
2207                TypeDidMode::RequestReply,
2208                TypeDidProfile::ed25519_x25519_chacha20().id,
2209                "band",
2210            ),
2211            b"please coordinate with the support agent",
2212            &resolver,
2213            &keys,
2214        )
2215        .expect("request envelope");
2216        let request_ref = request.reference();
2217        let gateway = TypeDidGateway::new(
2218            Arc::new(resolver.clone()),
2219            Arc::new(keys.clone()),
2220            agent.clone(),
2221        );
2222        let verified = gateway.open_message(&request).expect("verified request");
2223        let reply = DidEnvelope::typedid_reply(
2224            "band-room-reply-1",
2225            agent.clone(),
2226            alice.clone(),
2227            &verified,
2228            b"support agent accepted the handoff",
2229            &resolver,
2230            &keys,
2231        )
2232        .expect("reply envelope");
2233
2234        assert_eq!(reply.typedid.as_ref().unwrap().protocol, "band");
2235        assert_eq!(reply.body.reply_to, Some(request_ref));
2236
2237        let reply_gateway = TypeDidGateway::new(Arc::new(resolver), Arc::new(keys), alice);
2238        let opened_reply = reply_gateway.open_message(&reply).expect("opened reply");
2239        assert_eq!(opened_reply.subject, agent);
2240    }
2241
2242    #[test]
2243    fn typedid_signature_covers_conversation_metadata() {
2244        let (alice, agent, resolver, keys) = fixture();
2245        let mut envelope = DidEnvelope::typedid(
2246            "acp-session-msg-1",
2247            alice,
2248            agent.clone(),
2249            DidMessageBody::agent_message("room/acme-support", "secret"),
2250            TypeDidConversation::new(
2251                "session/editor-1",
2252                TypeDidMode::Send,
2253                TypeDidProfile::ed25519_x25519_chacha20().id,
2254                "acp",
2255            ),
2256            b"review this private diff",
2257            &resolver,
2258            &keys,
2259        )
2260        .expect("typedid envelope");
2261        envelope.typedid.as_mut().unwrap().protocol = "band".to_owned();
2262
2263        let gateway = TypeDidGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2264        assert!(matches!(
2265            gateway.open_message(&envelope),
2266            Err(DidError::InvalidSignature)
2267        ));
2268    }
2269
2270    #[test]
2271    fn bound_ollama_reply_creates_signed_reply_envelope_for_prompt() {
2272        let (alice, agent, resolver, keys) = fixture();
2273        let prompt_envelope = DidEnvelope::prompt(
2274            "msg-1",
2275            alice.clone(),
2276            agent.clone(),
2277            DidMessageBody::infer_prompt("prompt/session/123"),
2278            "private prompt",
2279            &resolver,
2280            &keys,
2281        )
2282        .expect("prompt envelope");
2283        let prompt_ref = prompt_envelope.reference();
2284        let gateway = DidMessageGateway::new(
2285            Arc::new(resolver.clone()),
2286            Arc::new(keys.clone()),
2287            agent.clone(),
2288        );
2289        let verified = gateway
2290            .open_prompt(&prompt_envelope)
2291            .expect("verified prompt");
2292        let infer = mint_capability::<AiCanInfer, _>(
2293            &PromptPolicy,
2294            verified.subject.as_str(),
2295            &verified.resource,
2296        )
2297        .expect("infer cap");
2298        let read = mint_capability::<CanReadSensitive, _>(
2299            &PromptPolicy,
2300            verified.subject.as_str(),
2301            &verified.resource,
2302        )
2303        .expect("read cap");
2304
2305        let http = RecordingHttpClient::new().with_response(
2306            "http://localhost:11434/api/chat",
2307            json!({ "message": { "content": "bound reply" } }),
2308        );
2309        let client = DidOllamaClient::with_http(
2310            "http://localhost:11434",
2311            "llama3.2",
2312            Arc::new(http.clone()),
2313        );
2314        let reply_envelope = client
2315            .chat_verified_prompt_bound(verified, agent.clone(), &resolver, &keys, &infer, &read)
2316            .expect("bound reply");
2317
2318        assert!(reply_envelope.id.starts_with("did:key:z"));
2319        assert_eq!(
2320            reply_envelope.message_type,
2321            "https://typesec.dev/did/message/v1/reply"
2322        );
2323        assert_eq!(reply_envelope.from, agent);
2324        assert_eq!(reply_envelope.to, vec![alice.clone()]);
2325        assert_eq!(reply_envelope.body.resource, "prompt/session/123");
2326        assert_eq!(reply_envelope.body.privacy, "secret");
2327        assert_eq!(reply_envelope.body.reply_to, Some(prompt_ref));
2328        assert_ne!(reply_envelope.ciphertext, "bound reply");
2329
2330        let reply_gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), alice);
2331        let opened_reply = reply_gateway
2332            .open_prompt(&reply_envelope)
2333            .expect("verified reply");
2334        assert_eq!(opened_reply.subject, reply_envelope.from);
2335        assert_eq!(
2336            opened_reply
2337                .prompt
2338                .reveal(&read)
2339                .expect("matching resource"),
2340            "bound reply"
2341        );
2342    }
2343
2344    #[test]
2345    fn reply_signature_covers_prompt_reference() {
2346        let (alice, agent, resolver, keys) = fixture();
2347        let prompt_envelope = DidEnvelope::prompt(
2348            "msg-1",
2349            alice.clone(),
2350            agent.clone(),
2351            DidMessageBody::infer_prompt("prompt/session/123"),
2352            "private prompt",
2353            &resolver,
2354            &keys,
2355        )
2356        .expect("prompt envelope");
2357        let gateway = DidMessageGateway::new(
2358            Arc::new(resolver.clone()),
2359            Arc::new(keys.clone()),
2360            agent.clone(),
2361        );
2362        let verified = gateway
2363            .open_prompt(&prompt_envelope)
2364            .expect("verified prompt");
2365        let mut reply_envelope = DidEnvelope::reply(
2366            Did::key(b"reply-1"),
2367            agent,
2368            alice.clone(),
2369            DidReplyBinding::for_prompt(&verified),
2370            "bound reply",
2371            &resolver,
2372            &keys,
2373        )
2374        .expect("reply envelope");
2375        reply_envelope
2376            .body
2377            .reply_to
2378            .as_mut()
2379            .expect("prompt reference")
2380            .digest = "tampered".to_owned();
2381
2382        let reply_gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), alice);
2383        assert!(matches!(
2384            reply_gateway.open_prompt(&reply_envelope),
2385            Err(DidError::InvalidSignature)
2386        ));
2387    }
2388
2389    fn ed25519_fixture() -> (Did, Did, StaticDidResolver, Ed25519DidKeyStore) {
2390        let alice_key = Ed25519DidKey::from_seed(b"alice-ed25519");
2391        let agent_key = Ed25519DidKey::from_seed(b"agent-ed25519");
2392        let alice = Did::key(alice_key.signing_public());
2393        let agent = Did::key(agent_key.signing_public());
2394        let resolver = StaticDidResolver::new()
2395            .with_document(alice_key.document(alice.clone()))
2396            .with_document(agent_key.document(agent.clone()));
2397        let keys = Ed25519DidKeyStore::new()
2398            .with_key(alice.clone(), alice_key)
2399            .with_key(agent.clone(), agent_key);
2400        (alice, agent, resolver, keys)
2401    }
2402
2403    #[test]
2404    fn ed25519_envelope_roundtrip() {
2405        let (alice, agent, resolver, keys) = ed25519_fixture();
2406        let envelope = DidEnvelope::prompt(
2407            "msg-ed-1",
2408            alice.clone(),
2409            agent.clone(),
2410            DidMessageBody::infer_prompt("prompt/session/ed"),
2411            "confidential prompt over real crypto",
2412            &resolver,
2413            &keys,
2414        )
2415        .expect("envelope");
2416        assert_ne!(envelope.ciphertext, "confidential prompt over real crypto");
2417
2418        let gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2419        let verified = gateway.open_prompt(&envelope).expect("verified prompt");
2420        assert_eq!(verified.subject, alice);
2421
2422        let cap: typesec_core::Capability<CanReadSensitive, GenericResource> = mint_capability(
2423            &AllowAllForTest,
2424            verified.subject.as_str(),
2425            &verified.resource,
2426        )
2427        .expect("read cap");
2428        assert_eq!(
2429            verified.prompt.reveal(&cap).expect("matching resource"),
2430            "confidential prompt over real crypto"
2431        );
2432    }
2433
2434    #[test]
2435    fn ed25519_rejects_tampered_envelope() {
2436        let (alice, agent, resolver, keys) = ed25519_fixture();
2437        let mut envelope = DidEnvelope::prompt(
2438            "msg-ed-2",
2439            alice,
2440            agent.clone(),
2441            DidMessageBody::infer_prompt("prompt/session/ed"),
2442            "payload",
2443            &resolver,
2444            &keys,
2445        )
2446        .expect("envelope");
2447        envelope.body.resource = "prompt/session/other".to_owned();
2448
2449        let gateway = DidMessageGateway::new(Arc::new(resolver), Arc::new(keys), agent);
2450        assert!(matches!(
2451            gateway.open_prompt(&envelope),
2452            Err(DidError::InvalidSignature)
2453        ));
2454    }
2455
2456    #[test]
2457    fn ed25519_signature_is_not_forgeable_from_public_key() {
2458        // With the demo store, anyone holding the public key could mint a
2459        // valid signature. The Ed25519 store must not allow that.
2460        let (alice, _agent, resolver, _keys) = ed25519_fixture();
2461        let document = resolver.resolve(&alice).expect("document");
2462        let auth_method = &document.verification_method[0];
2463
2464        // An attacker key store that does NOT hold alice's private key but
2465        // knows her public key cannot produce a signature that verifies.
2466        let attacker_key = Ed25519DidKey::from_seed(b"attacker");
2467        let attacker_store = Ed25519DidKeyStore::new().with_key(alice.clone(), attacker_key);
2468        let forged = attacker_store.sign(&alice, b"message").expect("sign");
2469
2470        let honest_store = Ed25519DidKeyStore::new();
2471        assert!(matches!(
2472            honest_store.verify(auth_method, b"message", &forged),
2473            Err(DidError::InvalidSignature)
2474        ));
2475    }
2476
2477    #[test]
2478    fn ed25519_rotation_keeps_old_envelopes_until_retired() {
2479        let alice = Did::web("alice.example").expect("alice did");
2480        let agent = Did::web("agent.example").expect("agent did");
2481        let mut keys = Ed25519DidKeyStore::new()
2482            .with_key(alice.clone(), Ed25519DidKey::from_seed(b"alice-v1"))
2483            .with_key(agent.clone(), Ed25519DidKey::from_seed(b"agent-v1"));
2484        let resolver_v1 = StaticDidResolver::new()
2485            .with_document(keys.document(&alice).expect("alice v1 document"))
2486            .with_document(keys.document(&agent).expect("agent v1 document"));
2487
2488        let old_envelope = DidEnvelope::prompt(
2489            "msg-rot-1",
2490            alice.clone(),
2491            agent.clone(),
2492            DidMessageBody::infer_prompt("prompt/session/rot"),
2493            "old in-flight payload",
2494            &resolver_v1,
2495            &keys,
2496        )
2497        .expect("old envelope");
2498        assert_eq!(old_envelope.kid, format!("{alice}#key-1"));
2499
2500        assert_eq!(
2501            keys.rotate_key(&alice, Ed25519DidKey::from_seed(b"alice-v2"))
2502                .expect("rotate alice"),
2503            2
2504        );
2505        assert_eq!(
2506            keys.rotate_key(&agent, Ed25519DidKey::from_seed(b"agent-v2"))
2507                .expect("rotate agent"),
2508            2
2509        );
2510        assert_eq!(keys.active_key_version(&alice).expect("active alice"), 2);
2511
2512        let resolver_v2 = StaticDidResolver::new()
2513            .with_document(keys.document(&alice).expect("alice v2 document"))
2514            .with_document(keys.document(&agent).expect("agent v2 document"));
2515        let alice_doc = resolver_v2.resolve(&alice).expect("alice document");
2516        assert_eq!(
2517            alice_doc.authentication[0],
2518            format!("{alice}#key-signing-v2")
2519        );
2520        assert_eq!(
2521            alice_doc
2522                .verification_method
2523                .iter()
2524                .find(|method| method.id == old_envelope.kid)
2525                .and_then(|method| method.key_status.as_deref()),
2526            Some("previous")
2527        );
2528
2529        let gateway =
2530            DidMessageGateway::new(Arc::new(resolver_v2.clone()), Arc::new(keys.clone()), agent);
2531        let verified = gateway
2532            .open_prompt(&old_envelope)
2533            .expect("old envelope remains valid while previous key is advertised");
2534        assert_eq!(
2535            verified.resource.resource_id(),
2536            "prompt/session/rot",
2537            "old payload opened after sender and recipient rotation"
2538        );
2539
2540        let new_envelope = DidEnvelope::prompt(
2541            "msg-rot-2",
2542            alice.clone(),
2543            Did::web("agent.example").expect("agent did"),
2544            DidMessageBody::infer_prompt("prompt/session/rot-new"),
2545            "new payload",
2546            &resolver_v2,
2547            &keys,
2548        )
2549        .expect("new envelope");
2550        assert_eq!(new_envelope.kid, format!("{alice}#key-signing-v2"));
2551    }
2552
2553    #[test]
2554    fn ed25519_retired_key_rejects_old_signatures() {
2555        let alice = Did::web("alice-retired.example").expect("alice did");
2556        let agent = Did::web("agent-retired.example").expect("agent did");
2557        let mut keys = Ed25519DidKeyStore::new()
2558            .with_key(alice.clone(), Ed25519DidKey::from_seed(b"alice-retired-v1"))
2559            .with_key(agent.clone(), Ed25519DidKey::from_seed(b"agent-retired-v1"));
2560        let resolver_v1 = StaticDidResolver::new()
2561            .with_document(keys.document(&alice).expect("alice v1 document"))
2562            .with_document(keys.document(&agent).expect("agent v1 document"));
2563        let envelope = DidEnvelope::prompt(
2564            "msg-retired-1",
2565            alice.clone(),
2566            agent.clone(),
2567            DidMessageBody::infer_prompt("prompt/session/retired"),
2568            "payload",
2569            &resolver_v1,
2570            &keys,
2571        )
2572        .expect("envelope");
2573        let old_method = resolver_v1
2574            .resolve(&alice)
2575            .expect("alice document")
2576            .authentication_key(&envelope.kid)
2577            .expect("old auth method")
2578            .clone();
2579
2580        keys.rotate_key(&alice, Ed25519DidKey::from_seed(b"alice-retired-v2"))
2581            .expect("rotate alice");
2582        keys.retire_key(&alice, 1).expect("retire old alice key");
2583
2584        assert!(matches!(
2585            keys.verify(&old_method, envelope.signing_input().as_bytes(), &envelope.signature),
2586            Err(DidError::RetiredKey(method)) if method == old_method.id
2587        ));
2588        let rotated_doc = keys.document(&alice).expect("rotated alice document");
2589        assert!(
2590            !rotated_doc
2591                .authentication
2592                .iter()
2593                .any(|kid| kid == &envelope.kid)
2594        );
2595    }
2596
2597    struct AllowAllForTest;
2598    impl PolicyEngine for AllowAllForTest {
2599        fn check(&self, _: &SubjectId, _: &str, _: &ResourceId) -> PolicyResult {
2600            PolicyResult::Allow
2601        }
2602    }
2603
2604    #[test]
2605    fn wrapped_prompt_passthrough_keeps_envelope() {
2606        let (alice, agent, resolver, keys) = fixture();
2607        let envelope = DidEnvelope::prompt(
2608            "msg-1",
2609            alice,
2610            agent,
2611            DidMessageBody::infer_prompt("prompt/session/123"),
2612            "private prompt",
2613            &resolver,
2614            &keys,
2615        )
2616        .expect("envelope");
2617        let http = RecordingHttpClient::new().with_response(
2618            "http://localhost:11434/api/chat",
2619            json!({ "message": { "content": "ok" } }),
2620        );
2621        let client = DidOllamaClient::with_http(
2622            "http://localhost:11434",
2623            "codata-did",
2624            Arc::new(http.clone()),
2625        );
2626
2627        client.chat_wrapped_prompt(&envelope).expect("ollama call");
2628
2629        let requests = http.requests();
2630        assert_eq!(
2631            requests[0].body.as_ref().unwrap()["did_envelope"]["ciphertext"],
2632            envelope.ciphertext
2633        );
2634    }
2635}