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    /// Resolve recipient/sender, encrypt, and sign one envelope.
132    ///
133    /// The single home for the prompt / reply / typedid construction path: it
134    /// resolves the recipient's key-agreement key and the sender's authentication
135    /// `kid`, builds the envelope with an empty ciphertext, computes the AEAD
136    /// associated data over its routing/timing identity, then encrypts and signs.
137    /// The four public constructors differ only in `id`, `message_type`, `body`,
138    /// and whether a `typedid` conversation is attached.
139    #[allow(clippy::too_many_arguments)]
140    fn seal(
141        id: String,
142        message_type: &str,
143        from: Did,
144        to: Did,
145        body: DidMessageBody,
146        typedid: Option<TypeDidConversation>,
147        plaintext: &[u8],
148        resolver: &dyn DidResolver,
149        key_store: &dyn DidKeyStore,
150    ) -> Result<Self, DidError> {
151        let now = unix_time();
152        let recipient_document = resolver.resolve(&to)?;
153        let recipient_public = recipient_document.key_agreement_key()?.public_key()?;
154        let sender_document = resolver.resolve(&from)?;
155        let kid = sender_document
156            .authentication
157            .first()
158            .cloned()
159            .ok_or(DidError::MissingAuthentication)?;
160        let nonce = random_nonce()?;
161        // Build the envelope first (empty ciphertext) so the AEAD can bind to its
162        // routing/timing identity, then encrypt and sign.
163        let mut envelope = Self {
164            id,
165            message_type: message_type.to_owned(),
166            from,
167            to: vec![to],
168            created_time: now,
169            expires_time: now + 300,
170            body,
171            typedid,
172            kid,
173            nonce: hex_encode(&nonce),
174            ciphertext: String::new(),
175            signature: String::new(),
176        };
177        let aad = envelope.associated_data();
178        envelope.ciphertext =
179            key_store.encrypt_for(&envelope.from, &recipient_public, plaintext, &nonce, &aad)?;
180        envelope.signature = key_store.sign(&envelope.from, envelope.signing_input().as_bytes())?;
181        Ok(envelope)
182    }
183
184    /// Create an encrypted prompt envelope.
185    pub fn prompt(
186        id: impl Into<String>,
187        from: Did,
188        to: Did,
189        body: DidMessageBody,
190        plaintext: impl AsRef<[u8]>,
191        resolver: &dyn DidResolver,
192        key_store: &dyn DidKeyStore,
193    ) -> Result<Self, DidError> {
194        Self::seal(
195            id.into(),
196            "https://typesec.dev/did/message/v1/prompt",
197            from,
198            to,
199            body,
200            None,
201            plaintext.as_ref(),
202            resolver,
203            key_store,
204        )
205    }
206
207    /// Create an encrypted reply envelope bound to a verified prompt envelope.
208    pub fn reply(
209        reply_did: Did,
210        from: Did,
211        to: Did,
212        binding: DidReplyBinding,
213        plaintext: impl AsRef<[u8]>,
214        resolver: &dyn DidResolver,
215        key_store: &dyn DidKeyStore,
216    ) -> Result<Self, DidError> {
217        let DidReplyBinding {
218            prompt_body,
219            prompt_ref,
220        } = binding;
221        Self::seal(
222            reply_did.to_string(),
223            "https://typesec.dev/did/message/v1/reply",
224            from,
225            to,
226            DidMessageBody {
227                action: prompt_body.action.clone(),
228                resource: prompt_body.resource.clone(),
229                privacy: prompt_body.privacy.clone(),
230                reply_to: Some(prompt_ref),
231            },
232            None,
233            plaintext.as_ref(),
234            resolver,
235            key_store,
236        )
237    }
238
239    /// Create an encrypted TypeDID agent-message envelope.
240    #[allow(clippy::too_many_arguments)]
241    pub fn typedid(
242        id: impl Into<String>,
243        from: Did,
244        to: Did,
245        body: DidMessageBody,
246        typedid: TypeDidConversation,
247        plaintext: impl AsRef<[u8]>,
248        resolver: &dyn DidResolver,
249        key_store: &dyn DidKeyStore,
250    ) -> Result<Self, DidError> {
251        Self::seal(
252            id.into(),
253            "https://typesec.dev/did/message/v1/typedid",
254            from,
255            to,
256            body,
257            Some(typedid),
258            plaintext.as_ref(),
259            resolver,
260            key_store,
261        )
262    }
263
264    /// Create an encrypted TypeDID reply envelope bound to a verified request.
265    pub fn typedid_reply(
266        id: impl Into<String>,
267        from: Did,
268        to: Did,
269        request: &VerifiedTypeDidMessage,
270        plaintext: impl AsRef<[u8]>,
271        resolver: &dyn DidResolver,
272        key_store: &dyn DidKeyStore,
273    ) -> Result<Self, DidError> {
274        let mut body = request.body.clone();
275        body.reply_to = Some(request.message_ref.clone());
276        let conversation = TypeDidConversation {
277            conversation_id: request.conversation.conversation_id.clone(),
278            mode: TypeDidMode::RequestReply,
279            profile: request.conversation.profile.clone(),
280            protocol: request.conversation.protocol.clone(),
281            expires_at: request.conversation.expires_at,
282        };
283        Self::typedid(
284            id,
285            from,
286            to,
287            body,
288            conversation,
289            plaintext,
290            resolver,
291            key_store,
292        )
293    }
294
295    /// Stable reference to this signed envelope for reply binding.
296    pub fn reference(&self) -> DidMessageReference {
297        let seed = format!("{}\n{}", self.signing_input(), self.signature);
298        DidMessageReference {
299            id: self.id.clone(),
300            digest: hex_encode(&sha256_tagged(
301                b"typesec-did-envelope-reference",
302                seed.as_bytes(),
303            )),
304        }
305    }
306
307    /// AEAD associated data binding the ciphertext to the envelope's
308    /// routing/timing identity.
309    ///
310    /// Binds the envelope's routing/timing identity (`id`, `from`, `to`,
311    /// `created_time`, `expires_time`) — notably **not** `message_type` or
312    /// `typedid`. Those are still authenticated by the signature (see
313    /// [`signing_input`][Self::signing_input]); the AAD adds a second, AEAD-level
314    /// binding so the ciphertext can't be lifted into a different envelope even if
315    /// the signature layer were bypassed.
316    pub(super) fn associated_data(&self) -> Vec<u8> {
317        format!(
318            "{}\n{}\n{}\n{}\n{}",
319            self.id,
320            self.from,
321            self.to
322                .iter()
323                .map(Did::as_str)
324                .collect::<Vec<_>>()
325                .join(","),
326            self.created_time,
327            self.expires_time,
328        )
329        .into_bytes()
330    }
331
332    /// Canonical bytes the sender signs and the recipient verifies.
333    ///
334    /// This MUST cover every security-relevant field of the envelope. In
335    /// particular `kid` (which key authenticates the sender) and `nonce` (which
336    /// drives the AEAD) are included so they cannot be swapped without breaking
337    /// the signature. When adding a field to [`DidEnvelope`], add it here too.
338    pub(super) fn signing_input(&self) -> String {
339        let reply_to = self
340            .body
341            .reply_to
342            .as_ref()
343            .map(|reference| format!("{}\n{}", reference.id, reference.digest))
344            .unwrap_or_default();
345        let base = format!(
346            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
347            self.id,
348            self.message_type,
349            self.from,
350            self.to
351                .iter()
352                .map(Did::as_str)
353                .collect::<Vec<_>>()
354                .join(","),
355            self.created_time,
356            self.expires_time,
357            self.body.action,
358            self.body.resource,
359            self.body.privacy,
360            reply_to,
361            self.kid,
362            self.nonce,
363        );
364        if let Some(typedid) = self.typedid.as_ref() {
365            format!(
366                "{}\n{}\n{}",
367                base,
368                canonical_typedid_conversation(typedid),
369                self.ciphertext
370            )
371        } else {
372            format!("{}\n{}", base, self.ciphertext)
373        }
374    }
375}