Skip to main content

typesec_integrations/did/
envelope.rs

1//! DID message bodies, references, and the encrypted envelope type.
2
3use serde::{Deserialize, Serialize};
4
5use super::crypto::{
6    canonical_typedid_conversation, hex_encode, random_nonce, sha256_tagged, unix_time,
7};
8use super::document::DidResolver;
9use super::error::DidError;
10use super::gateway::{VerifiedDidPrompt, VerifiedTypeDidMessage};
11use super::identifier::Did;
12use super::keystore::DidKeyStore;
13use super::typedid::{TypeDidConversation, TypeDidMode};
14
15/// Message metadata that policy engines evaluate before payload use.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DidMessageBody {
18    /// Requested Typesec action, such as `ai:infer`.
19    pub action: String,
20    /// Resource identifier for policy evaluation.
21    pub resource: String,
22    /// Payload privacy label, such as `secret`.
23    pub privacy: String,
24    /// Prompt envelope this message is bound to, for reply envelopes.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub reply_to: Option<DidMessageReference>,
27}
28
29impl DidMessageBody {
30    /// Create a prompt body for AI inference.
31    pub fn infer_prompt(resource: impl Into<String>) -> Self {
32        Self {
33            action: "ai:infer".to_owned(),
34            resource: resource.into(),
35            privacy: "secret".to_owned(),
36            reply_to: None,
37        }
38    }
39
40    /// Create a reply body that inherits the prompt's policy-visible metadata.
41    pub fn reply_to_prompt(prompt: &VerifiedDidPrompt) -> Self {
42        Self {
43            action: prompt.body.action.clone(),
44            resource: prompt.body.resource.clone(),
45            privacy: prompt.body.privacy.clone(),
46            reply_to: Some(prompt.prompt_ref.clone()),
47        }
48    }
49
50    /// Create a general agent message body.
51    pub fn agent_message(resource: impl Into<String>, privacy: impl Into<String>) -> Self {
52        Self {
53            action: "agent:message".to_owned(),
54            resource: resource.into(),
55            privacy: privacy.into(),
56            reply_to: None,
57        }
58    }
59
60    /// Create an agent delegation body.
61    pub fn agent_delegate(resource: impl Into<String>, privacy: impl Into<String>) -> Self {
62        Self {
63            action: "agent:delegate".to_owned(),
64            resource: resource.into(),
65            privacy: privacy.into(),
66            reply_to: None,
67        }
68    }
69}
70
71/// The prompt context a reply envelope is bound to.
72#[derive(Debug, Clone)]
73pub struct DidReplyBinding {
74    /// Policy-visible metadata of the prompt being answered.
75    pub prompt_body: DidMessageBody,
76    /// Stable reference to the signed prompt envelope.
77    pub prompt_ref: DidMessageReference,
78}
79
80impl DidReplyBinding {
81    /// Bind a reply to a verified prompt.
82    pub fn for_prompt(prompt: &VerifiedDidPrompt) -> Self {
83        Self {
84            prompt_body: prompt.body.clone(),
85            prompt_ref: prompt.prompt_ref.clone(),
86        }
87    }
88}
89
90/// Stable reference to a DID message envelope.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct DidMessageReference {
93    /// Referenced DID message id.
94    pub id: String,
95    /// SHA-256 digest of the referenced signed envelope.
96    pub digest: String,
97}
98
99/// Encrypted DID message envelope.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct DidEnvelope {
102    /// Message id.
103    pub id: String,
104    /// Message type URI.
105    #[serde(rename = "type")]
106    pub message_type: String,
107    /// Sender DID.
108    pub from: Did,
109    /// Recipient DIDs.
110    pub to: Vec<Did>,
111    /// Creation time as unix seconds.
112    pub created_time: u64,
113    /// Expiration time as unix seconds.
114    pub expires_time: u64,
115    /// Policy-visible message metadata.
116    pub body: DidMessageBody,
117    /// Optional TypeDID conversation/profile metadata.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub typedid: Option<TypeDidConversation>,
120    /// Key id used for authentication.
121    pub kid: String,
122    /// Hex-encoded nonce.
123    pub nonce: String,
124    /// Hex-encoded ciphertext.
125    pub ciphertext: String,
126    /// Hex-encoded signature over the envelope signing input.
127    pub signature: String,
128}
129
130impl DidEnvelope {
131    /// Create an encrypted prompt envelope.
132    pub fn prompt(
133        id: impl Into<String>,
134        from: Did,
135        to: Did,
136        body: DidMessageBody,
137        plaintext: impl AsRef<[u8]>,
138        resolver: &dyn DidResolver,
139        key_store: &dyn DidKeyStore,
140    ) -> Result<Self, DidError> {
141        let id = id.into();
142        let now = unix_time();
143        let recipient_document = resolver.resolve(&to)?;
144        let recipient_key = recipient_document.key_agreement_key()?;
145        let recipient_public = recipient_key.public_key()?;
146        let sender_document = resolver.resolve(&from)?;
147        let kid = sender_document
148            .authentication
149            .first()
150            .cloned()
151            .ok_or(DidError::MissingAuthentication)?;
152        let nonce = random_nonce()?;
153        // Build the envelope first (empty ciphertext) so the AEAD can bind to its
154        // routing/timing identity, then encrypt and sign.
155        let mut envelope = Self {
156            id,
157            message_type: "https://typesec.dev/did/message/v1/prompt".to_owned(),
158            from,
159            to: vec![to],
160            created_time: now,
161            expires_time: now + 300,
162            body,
163            typedid: None,
164            kid,
165            nonce: hex_encode(&nonce),
166            ciphertext: String::new(),
167            signature: String::new(),
168        };
169        let aad = envelope.associated_data();
170        envelope.ciphertext = key_store.encrypt_for(
171            &envelope.from,
172            &recipient_public,
173            plaintext.as_ref(),
174            &nonce,
175            &aad,
176        )?;
177        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
178        Ok(envelope)
179    }
180
181    /// Create an encrypted reply envelope bound to a verified prompt envelope.
182    pub fn reply(
183        reply_did: Did,
184        from: Did,
185        to: Did,
186        binding: DidReplyBinding,
187        plaintext: impl AsRef<[u8]>,
188        resolver: &dyn DidResolver,
189        key_store: &dyn DidKeyStore,
190    ) -> Result<Self, DidError> {
191        let DidReplyBinding {
192            prompt_body,
193            prompt_ref,
194        } = binding;
195        let now = unix_time();
196        let recipient_document = resolver.resolve(&to)?;
197        let recipient_key = recipient_document.key_agreement_key()?;
198        let recipient_public = recipient_key.public_key()?;
199        let sender_document = resolver.resolve(&from)?;
200        let kid = sender_document
201            .authentication
202            .first()
203            .cloned()
204            .ok_or(DidError::MissingAuthentication)?;
205        let id = reply_did.to_string();
206        let nonce = random_nonce()?;
207        let mut envelope = Self {
208            id,
209            message_type: "https://typesec.dev/did/message/v1/reply".to_owned(),
210            from,
211            to: vec![to],
212            created_time: now,
213            expires_time: now + 300,
214            body: DidMessageBody {
215                action: prompt_body.action.clone(),
216                resource: prompt_body.resource.clone(),
217                privacy: prompt_body.privacy.clone(),
218                reply_to: Some(prompt_ref),
219            },
220            typedid: None,
221            kid,
222            nonce: hex_encode(&nonce),
223            ciphertext: String::new(),
224            signature: String::new(),
225        };
226        let aad = envelope.associated_data();
227        envelope.ciphertext = key_store.encrypt_for(
228            &envelope.from,
229            &recipient_public,
230            plaintext.as_ref(),
231            &nonce,
232            &aad,
233        )?;
234        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
235        Ok(envelope)
236    }
237
238    /// Create an encrypted TypeDID agent-message envelope.
239    #[allow(clippy::too_many_arguments)]
240    pub fn typedid(
241        id: impl Into<String>,
242        from: Did,
243        to: Did,
244        body: DidMessageBody,
245        typedid: TypeDidConversation,
246        plaintext: impl AsRef<[u8]>,
247        resolver: &dyn DidResolver,
248        key_store: &dyn DidKeyStore,
249    ) -> Result<Self, DidError> {
250        let mut envelope = Self::prompt(id, from, to, body, plaintext, resolver, key_store)?;
251        envelope.message_type = "https://typesec.dev/did/message/v1/typedid".to_owned();
252        envelope.typedid = Some(typedid);
253        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
254        Ok(envelope)
255    }
256
257    /// Create an encrypted TypeDID reply envelope bound to a verified request.
258    pub fn typedid_reply(
259        id: impl Into<String>,
260        from: Did,
261        to: Did,
262        request: &VerifiedTypeDidMessage,
263        plaintext: impl AsRef<[u8]>,
264        resolver: &dyn DidResolver,
265        key_store: &dyn DidKeyStore,
266    ) -> Result<Self, DidError> {
267        let mut body = request.body.clone();
268        body.reply_to = Some(request.message_ref.clone());
269        let conversation = TypeDidConversation {
270            conversation_id: request.conversation.conversation_id.clone(),
271            mode: TypeDidMode::RequestReply,
272            profile: request.conversation.profile.clone(),
273            protocol: request.conversation.protocol.clone(),
274            expires_at: request.conversation.expires_at,
275        };
276        Self::typedid(
277            id,
278            from,
279            to,
280            body,
281            conversation,
282            plaintext,
283            resolver,
284            key_store,
285        )
286    }
287
288    /// Stable reference to this signed envelope for reply binding.
289    pub fn reference(&self) -> DidMessageReference {
290        let seed = format!("{}\n{}", self.signing_input(), self.signature);
291        DidMessageReference {
292            id: self.id.clone(),
293            digest: hex_encode(&sha256_tagged(
294                b"typesec-did-envelope-reference",
295                seed.as_bytes(),
296            )),
297        }
298    }
299
300    /// AEAD associated data binding the ciphertext to the envelope's
301    /// routing/timing identity.
302    ///
303    /// Includes only fields that are set before encryption and never mutated
304    /// afterward (`id`, `from`, `to`, `created_time`, `expires_time`) — notably
305    /// **not** `message_type` or `typedid`, which [`typedid`][Self::typedid]
306    /// rewrites after encrypting. Those are still authenticated by the signature
307    /// (see [`signing_input`][Self::signing_input]); the AAD adds a second,
308    /// AEAD-level binding so the ciphertext can't be lifted into a different
309    /// envelope even if the signature layer were bypassed.
310    pub(super) fn associated_data(&self) -> Vec<u8> {
311        format!(
312            "{}\n{}\n{}\n{}\n{}",
313            self.id,
314            self.from,
315            self.to
316                .iter()
317                .map(Did::as_str)
318                .collect::<Vec<_>>()
319                .join(","),
320            self.created_time,
321            self.expires_time,
322        )
323        .into_bytes()
324    }
325
326    /// Canonical bytes the sender signs and the recipient verifies.
327    ///
328    /// This MUST cover every security-relevant field of the envelope. In
329    /// particular `kid` (which key authenticates the sender) and `nonce` (which
330    /// drives the AEAD) are included so they cannot be swapped without breaking
331    /// the signature. When adding a field to [`DidEnvelope`], add it here too.
332    pub(super) fn signing_input(&self) -> String {
333        let reply_to = self
334            .body
335            .reply_to
336            .as_ref()
337            .map(|reference| format!("{}\n{}", reference.id, reference.digest))
338            .unwrap_or_default();
339        let base = format!(
340            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
341            self.id,
342            self.message_type,
343            self.from,
344            self.to
345                .iter()
346                .map(Did::as_str)
347                .collect::<Vec<_>>()
348                .join(","),
349            self.created_time,
350            self.expires_time,
351            self.body.action,
352            self.body.resource,
353            self.body.privacy,
354            reply_to,
355            self.kid,
356            self.nonce,
357        );
358        if let Some(typedid) = self.typedid.as_ref() {
359            format!(
360                "{}\n{}\n{}",
361                base,
362                canonical_typedid_conversation(typedid),
363                self.ciphertext
364            )
365        } else {
366            format!("{}\n{}", base, self.ciphertext)
367        }
368    }
369}