Skip to main content

typesec_integrations/did/
gateway.rs

1//! Envelope-verifying gateways and the verified-message/attestation types.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex, PoisonError};
5
6use serde::{Deserialize, Serialize};
7use typesec_core::{SecureValue, resource::GenericResource, secure_value::Secret};
8
9use super::crypto::{hex_decode, unix_time};
10use super::document::DidResolver;
11use super::envelope::{DidEnvelope, DidMessageBody, DidMessageReference};
12use super::error::DidError;
13use super::identifier::Did;
14use super::keystore::DidKeyStore;
15use super::typedid::{TypeDidConversation, TypeDidMode};
16
17/// Verified and decrypted TypeDID agent message.
18#[derive(Debug)]
19pub struct VerifiedTypeDidMessage {
20    /// Verified DID subject.
21    pub subject: Did,
22    /// Stable reference to the verified envelope.
23    pub message_ref: DidMessageReference,
24    /// Policy-visible message metadata.
25    pub body: DidMessageBody,
26    /// TypeDID conversation/profile metadata.
27    pub conversation: TypeDidConversation,
28    /// Resource associated with the payload.
29    pub resource: GenericResource,
30    /// Secret opaque payload bytes.
31    pub payload: SecureValue<Secret, Vec<u8>, GenericResource>,
32}
33
34/// Policy/audit-safe attestation derived from a verified TypeDID message.
35///
36/// This contains no plaintext payload and no raw signature material. It is the
37/// compact boundary object downstream systems can persist after a
38/// [`TypeDidGateway`] has verified the envelope signature, recipient, expiry,
39/// conversation metadata, and payload authentication.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct TypeDidAttestation {
42    /// Verified sender DID.
43    pub subject: Did,
44    /// Stable signed envelope id.
45    pub envelope_id: String,
46    /// SHA-256 digest of the signed envelope reference.
47    pub envelope_digest: String,
48    /// Policy-visible requested action.
49    pub action: String,
50    /// Policy-visible requested resource.
51    pub resource: String,
52    /// Policy-visible privacy class.
53    pub privacy: String,
54    /// TypeDID conversation id.
55    pub conversation_id: String,
56    /// TypeDID transport/protocol family.
57    pub protocol: String,
58    /// TypeDID delivery mode.
59    pub mode: TypeDidMode,
60    /// Negotiated TypeDID crypto/profile id.
61    pub profile: String,
62    /// Conversation expiry time as unix seconds, when supplied by the sender.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub expires_at: Option<u64>,
65}
66
67impl VerifiedTypeDidMessage {
68    /// Return an audit-safe attestation for this verified message.
69    pub fn attestation(&self) -> TypeDidAttestation {
70        TypeDidAttestation {
71            subject: self.subject.clone(),
72            envelope_id: self.message_ref.id.clone(),
73            envelope_digest: self.message_ref.digest.clone(),
74            action: self.body.action.clone(),
75            resource: self.body.resource.clone(),
76            privacy: self.body.privacy.clone(),
77            conversation_id: self.conversation.conversation_id.clone(),
78            protocol: self.conversation.protocol.clone(),
79            mode: self.conversation.mode,
80            profile: self.conversation.profile.clone(),
81            expires_at: self.conversation.expires_at,
82        }
83    }
84}
85
86/// Verified and decrypted DID prompt.
87#[derive(Debug)]
88pub struct VerifiedDidPrompt {
89    /// Verified DID subject.
90    pub subject: Did,
91    /// Stable reference to the verified prompt envelope.
92    pub prompt_ref: DidMessageReference,
93    /// Policy-visible metadata.
94    pub body: DidMessageBody,
95    /// Resource associated with the payload.
96    pub resource: GenericResource,
97    /// Secret prompt payload.
98    pub prompt: SecureValue<Secret, String, GenericResource>,
99}
100
101/// Tolerated clock skew (seconds) for an envelope dated in the future.
102const CLOCK_SKEW_SECS: u64 = 300;
103
104/// Verifies DID envelopes and converts encrypted payloads into `SecureValue`s.
105pub struct DidMessageGateway {
106    resolver: Arc<dyn DidResolver>,
107    key_store: Arc<dyn DidKeyStore>,
108    recipient: Did,
109    /// Signatures of already-opened envelopes mapped to their expiry, for replay
110    /// rejection. Pruned to the active (non-expired) window on each open.
111    seen: Mutex<HashMap<String, u64>>,
112}
113
114impl DidMessageGateway {
115    /// Create a gateway for one local recipient DID.
116    pub fn new(
117        resolver: Arc<dyn DidResolver>,
118        key_store: Arc<dyn DidKeyStore>,
119        recipient: Did,
120    ) -> Self {
121        Self {
122            resolver,
123            key_store,
124            recipient,
125            seen: Mutex::new(HashMap::new()),
126        }
127    }
128
129    /// Reject an envelope already opened within its validity window (replay).
130    /// Call only after the signature has verified, so the cache holds only
131    /// authentic envelopes.
132    fn guard_replay(&self, envelope: &DidEnvelope, now: u64) -> Result<(), DidError> {
133        let mut seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner);
134        seen.retain(|_, expires| *expires >= now);
135        if seen
136            .insert(envelope.signature.clone(), envelope.expires_time)
137            .is_some()
138        {
139            return Err(DidError::Replayed(envelope.id.clone()));
140        }
141        Ok(())
142    }
143
144    /// Verify, decrypt, and protect a DID prompt envelope.
145    pub fn open_prompt(&self, envelope: &DidEnvelope) -> Result<VerifiedDidPrompt, DidError> {
146        let opened = self.open_bytes(envelope)?;
147        let prompt = String::from_utf8(opened.plaintext).map_err(|_| DidError::InvalidUtf8)?;
148        Ok(VerifiedDidPrompt {
149            subject: opened.subject,
150            prompt_ref: opened.message_ref,
151            body: opened.body,
152            prompt: SecureValue::protect(prompt, &opened.resource),
153            resource: opened.resource,
154        })
155    }
156
157    pub(super) fn open_bytes(&self, envelope: &DidEnvelope) -> Result<OpenedDidEnvelope, DidError> {
158        if !envelope.to.iter().any(|did| did == &self.recipient) {
159            return Err(DidError::WrongRecipient(self.recipient.to_string()));
160        }
161        let now = unix_time();
162        if envelope.expires_time < now {
163            return Err(DidError::Expired);
164        }
165        // Reject envelopes dated implausibly far in the future (clock skew or a
166        // forged timestamp), which bounds the replay window from both ends.
167        if envelope.created_time > now.saturating_add(CLOCK_SKEW_SECS) {
168            return Err(DidError::NotYetValid {
169                created: envelope.created_time,
170                now,
171            });
172        }
173
174        let sender_document = self.resolver.resolve(&envelope.from)?;
175        let sender_key = sender_document.authentication_key(&envelope.kid)?;
176        self.key_store.verify(
177            sender_key,
178            envelope.signing_input().as_bytes(),
179            &envelope.signature,
180        )?;
181
182        // Signature is authentic: reject a replay of an already-seen envelope.
183        self.guard_replay(envelope, now)?;
184
185        // Decryption uses the sender's *key-agreement* key, which may be a
186        // different key (X25519) than the authentication key (Ed25519). During
187        // key rotation, older in-flight envelopes may have used a previous
188        // sender agreement key, so try every non-retired key advertised by the
189        // sender document.
190        let sender_agreement_keys = sender_document.key_agreement_keys()?;
191        let nonce = hex_decode(&envelope.nonce)?;
192        let aad = envelope.associated_data();
193        let mut plaintext = None;
194        for sender_agreement_key in sender_agreement_keys {
195            match self.key_store.decrypt_for(
196                &self.recipient,
197                &sender_agreement_key.public_key()?,
198                &nonce,
199                &envelope.ciphertext,
200                &aad,
201            ) {
202                Ok(opened) => {
203                    plaintext = Some(opened);
204                    break;
205                }
206                Err(DidError::DecryptionFailed) => {}
207                Err(err) => return Err(err),
208            }
209        }
210        let plaintext = plaintext.ok_or(DidError::DecryptionFailed)?;
211        let resource = GenericResource::new(&envelope.body.resource, "did-prompt");
212
213        Ok(OpenedDidEnvelope {
214            subject: envelope.from.clone(),
215            message_ref: envelope.reference(),
216            body: envelope.body.clone(),
217            resource,
218            plaintext,
219        })
220    }
221}
222
223#[derive(Debug)]
224pub(super) struct OpenedDidEnvelope {
225    pub(super) subject: Did,
226    pub(super) message_ref: DidMessageReference,
227    pub(super) body: DidMessageBody,
228    pub(super) resource: GenericResource,
229    pub(super) plaintext: Vec<u8>,
230}
231
232/// Verifies TypeDID envelopes and protects arbitrary agent payload bytes.
233pub struct TypeDidGateway {
234    inner: DidMessageGateway,
235}
236
237impl TypeDidGateway {
238    /// Create a TypeDID gateway for one local recipient DID.
239    pub fn new(
240        resolver: Arc<dyn DidResolver>,
241        key_store: Arc<dyn DidKeyStore>,
242        recipient: Did,
243    ) -> Self {
244        Self {
245            inner: DidMessageGateway::new(resolver, key_store, recipient),
246        }
247    }
248
249    /// Verify, decrypt, and protect a TypeDID message envelope.
250    pub fn open_message(&self, envelope: &DidEnvelope) -> Result<VerifiedTypeDidMessage, DidError> {
251        let conversation = envelope
252            .typedid
253            .clone()
254            .ok_or(DidError::MissingTypeDidMetadata)?;
255        let opened = self.inner.open_bytes(envelope)?;
256        Ok(VerifiedTypeDidMessage {
257            subject: opened.subject,
258            message_ref: opened.message_ref,
259            body: opened.body,
260            conversation,
261            payload: SecureValue::protect(opened.plaintext, &opened.resource),
262            resource: opened.resource,
263        })
264    }
265}