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