Skip to main content

nostr_double_ratchet/
session.rs

1use crate::{
2    device_pubkey_from_secret_bytes, kdf, random_secret_key_bytes, secret_key_from_bytes,
3    DevicePubkey, DomainError, ProtocolContext, Result, UnixSeconds, MAX_SKIP,
4};
5use base64::Engine;
6use nostr::nips::nip44::{self, Version};
7use nostr::PublicKey;
8use rand::rngs::OsRng;
9use rand::{CryptoRng, RngCore};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "camelCase")]
15pub struct Header {
16    pub number: u32,
17    pub previous_chain_length: u32,
18    pub next_public_key: DevicePubkey,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct SerializableKeyPair {
23    pub public_key: DevicePubkey,
24    #[serde(with = "hex::serde")]
25    pub private_key: [u8; 32],
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
29pub struct SkippedKeysEntry {
30    #[serde(with = "crate::serde_hex::btreemap_u32")]
31    pub message_keys: BTreeMap<u32, [u8; 32]>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct SessionState {
36    #[serde(default)]
37    pub metadata_privacy_version: u8,
38    #[serde(with = "hex::serde")]
39    pub root_key: [u8; 32],
40    pub their_current_nostr_public_key: Option<DevicePubkey>,
41    pub their_next_nostr_public_key: Option<DevicePubkey>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub our_previous_nostr_key: Option<SerializableKeyPair>,
44    pub our_current_nostr_key: Option<SerializableKeyPair>,
45    pub our_next_nostr_key: SerializableKeyPair,
46    #[serde(default, with = "crate::serde_hex::option")]
47    pub receiving_chain_key: Option<[u8; 32]>,
48    #[serde(default, with = "crate::serde_hex::option")]
49    pub sending_chain_key: Option<[u8; 32]>,
50    pub sending_chain_message_number: u32,
51    pub receiving_chain_message_number: u32,
52    pub previous_sending_chain_message_count: u32,
53    pub skipped_keys: BTreeMap<DevicePubkey, SkippedKeysEntry>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct MessageEnvelope {
58    #[serde(default)]
59    pub metadata_privacy_version: u8,
60    pub sender: DevicePubkey,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub recipient: Option<DevicePubkey>,
63    pub signer_secret_key: [u8; 32],
64    pub created_at: UnixSeconds,
65    pub encrypted_header: String,
66    pub ciphertext: String,
67}
68
69#[derive(Debug, Clone)]
70pub struct SendPlan {
71    pub next_state: SessionState,
72    pub envelope: MessageEnvelope,
73    pub payload: Vec<u8>,
74}
75
76#[derive(Debug, Clone)]
77pub struct SendOutcome {
78    pub envelope: MessageEnvelope,
79    pub payload: Vec<u8>,
80}
81
82#[derive(Debug, Clone)]
83pub struct ReceivePlan {
84    pub next_state: SessionState,
85    pub payload: Vec<u8>,
86    pub sender: DevicePubkey,
87}
88
89#[derive(Debug, Clone)]
90pub struct ReceiveOutcome {
91    pub payload: Vec<u8>,
92    pub sender: DevicePubkey,
93}
94
95#[derive(Debug, Clone)]
96pub struct Session {
97    pub state: SessionState,
98    pub name: String,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum HeaderDecryptionTarget {
103    Current,
104    Next,
105    Previous,
106}
107
108impl Session {
109    pub fn from_state(state: SessionState) -> Self {
110        Self {
111            state,
112            name: String::new(),
113        }
114    }
115
116    pub fn new(state: SessionState, name: String) -> Self {
117        Self { state, name }
118    }
119
120    pub fn init(
121        their_ephemeral_nostr_public_key: PublicKey,
122        our_ephemeral_nostr_private_key: [u8; 32],
123        is_initiator: bool,
124        shared_secret: [u8; 32],
125        _name: Option<String>,
126    ) -> Result<Self> {
127        let mut rng = OsRng;
128        let mut ctx = ProtocolContext::new(
129            UnixSeconds(
130                std::time::SystemTime::now()
131                    .duration_since(std::time::UNIX_EPOCH)
132                    .unwrap()
133                    .as_secs(),
134            ),
135            &mut rng,
136        );
137        let peer = DevicePubkey::from_bytes(their_ephemeral_nostr_public_key.to_bytes());
138        let mut session = if is_initiator {
139            Self::new_initiator(
140                &mut ctx,
141                peer,
142                our_ephemeral_nostr_private_key,
143                shared_secret,
144            )
145        } else {
146            Self::new_responder(
147                &mut ctx,
148                peer,
149                our_ephemeral_nostr_private_key,
150                shared_secret,
151            )
152        }?;
153        session.name = _name.unwrap_or_default();
154        Ok(session)
155    }
156
157    pub fn new_initiator<R>(
158        ctx: &mut ProtocolContext<'_, R>,
159        their_ephemeral_public_key: DevicePubkey,
160        our_ephemeral_private_key: [u8; 32],
161        shared_secret: [u8; 32],
162    ) -> Result<Self>
163    where
164        R: RngCore + CryptoRng,
165    {
166        Self::init_with_context(
167            ctx,
168            their_ephemeral_public_key,
169            our_ephemeral_private_key,
170            true,
171            shared_secret,
172        )
173    }
174
175    pub fn new_responder<R>(
176        ctx: &mut ProtocolContext<'_, R>,
177        their_ephemeral_public_key: DevicePubkey,
178        our_ephemeral_private_key: [u8; 32],
179        shared_secret: [u8; 32],
180    ) -> Result<Self>
181    where
182        R: RngCore + CryptoRng,
183    {
184        Self::init_with_context(
185            ctx,
186            their_ephemeral_public_key,
187            our_ephemeral_private_key,
188            false,
189            shared_secret,
190        )
191    }
192
193    fn init_with_context<R>(
194        ctx: &mut ProtocolContext<'_, R>,
195        their_ephemeral_public_key: DevicePubkey,
196        our_ephemeral_private_key: [u8; 32],
197        is_initiator: bool,
198        shared_secret: [u8; 32],
199    ) -> Result<Self>
200    where
201        R: RngCore + CryptoRng,
202    {
203        let (their_ephemeral_public_key, our_ephemeral_private_key) = if is_initiator {
204            (
205                crate::metadata_privacy::private_responder_public_key(
206                    our_ephemeral_private_key,
207                    their_ephemeral_public_key,
208                )?,
209                our_ephemeral_private_key,
210            )
211        } else {
212            (
213                their_ephemeral_public_key,
214                crate::metadata_privacy::private_responder_secret_key(
215                    our_ephemeral_private_key,
216                    their_ephemeral_public_key,
217                )?,
218            )
219        };
220        let our_keys = nostr::Keys::new(secret_key_from_bytes(&our_ephemeral_private_key)?);
221        let our_next_private_key = random_secret_key_bytes(ctx.rng)?;
222        let our_next_keys = nostr::Keys::new(secret_key_from_bytes(&our_next_private_key)?);
223
224        let (root_key, sending_chain_key, our_current_nostr_key, our_next_nostr_key) =
225            if is_initiator {
226                let our_current_pubkey = DevicePubkey::from_nostr(our_keys.public_key());
227                let conversation_key = nip44::v2::ConversationKey::derive(
228                    our_next_keys.secret_key(),
229                    &their_ephemeral_public_key.to_nostr()?,
230                )?;
231                let kdf_outputs = kdf(&shared_secret, conversation_key.as_bytes(), 2);
232                (
233                    kdf_outputs[0],
234                    Some(kdf_outputs[1]),
235                    Some(SerializableKeyPair {
236                        public_key: our_current_pubkey,
237                        private_key: our_ephemeral_private_key,
238                    }),
239                    SerializableKeyPair {
240                        public_key: DevicePubkey::from_nostr(our_next_keys.public_key()),
241                        private_key: our_next_private_key,
242                    },
243                )
244            } else {
245                (
246                    shared_secret,
247                    None,
248                    None,
249                    SerializableKeyPair {
250                        public_key: DevicePubkey::from_nostr(our_keys.public_key()),
251                        private_key: our_ephemeral_private_key,
252                    },
253                )
254            };
255
256        Ok(Self {
257            state: SessionState {
258                metadata_privacy_version: crate::METADATA_PRIVACY_VERSION,
259                root_key,
260                their_current_nostr_public_key: None,
261                their_next_nostr_public_key: Some(their_ephemeral_public_key),
262                our_previous_nostr_key: None,
263                our_current_nostr_key,
264                our_next_nostr_key,
265                receiving_chain_key: None,
266                sending_chain_key,
267                sending_chain_message_number: 0,
268                receiving_chain_message_number: 0,
269                previous_sending_chain_message_count: 0,
270                skipped_keys: BTreeMap::new(),
271            },
272            name: String::new(),
273        })
274    }
275
276    pub fn can_send(&self) -> bool {
277        self.state.metadata_privacy_version == crate::METADATA_PRIVACY_VERSION
278            && self.state.their_next_nostr_public_key.is_some()
279            && self.state.our_current_nostr_key.is_some()
280    }
281
282    pub fn matches_sender(&self, sender: DevicePubkey) -> bool {
283        self.state.metadata_privacy_version == crate::METADATA_PRIVACY_VERSION
284            && (self.state.their_current_nostr_public_key == Some(sender)
285                || self.state.their_next_nostr_public_key == Some(sender)
286                || self.state.skipped_keys.contains_key(&sender))
287    }
288
289    pub fn plan_send(&self, payload: &[u8], now: UnixSeconds) -> Result<SendPlan> {
290        if !self.can_send() {
291            return Err(DomainError::CannotSendYet.into());
292        }
293
294        let mut next_state = self.state.clone();
295        let (header, ciphertext) = ratchet_encrypt(&mut next_state, payload)?;
296        let our_current = self
297            .state
298            .our_current_nostr_key
299            .as_ref()
300            .ok_or(DomainError::SessionNotReady)?;
301        let our_secret = secret_key_from_bytes(&our_current.private_key)?;
302        let their_next = self
303            .state
304            .their_next_nostr_public_key
305            .ok_or(DomainError::SessionNotReady)?;
306        let encrypted_header = nip44::encrypt(
307            &our_secret,
308            &their_next.to_nostr()?,
309            &serde_json::to_string(&header)?,
310            Version::V2,
311        )?;
312
313        Ok(SendPlan {
314            next_state,
315            envelope: MessageEnvelope {
316                metadata_privacy_version: crate::METADATA_PRIVACY_VERSION,
317                sender: our_current.public_key,
318                recipient: None,
319                signer_secret_key: our_current.private_key,
320                created_at: now,
321                encrypted_header,
322                ciphertext,
323            },
324            payload: payload.to_vec(),
325        })
326    }
327
328    pub fn apply_send(&mut self, plan: SendPlan) -> SendOutcome {
329        self.state = plan.next_state;
330        SendOutcome {
331            envelope: plan.envelope,
332            payload: plan.payload,
333        }
334    }
335
336    pub fn plan_receive<R>(
337        &self,
338        ctx: &mut ProtocolContext<'_, R>,
339        envelope: &MessageEnvelope,
340    ) -> Result<ReceivePlan>
341    where
342        R: RngCore + CryptoRng,
343    {
344        if envelope.metadata_privacy_version != crate::METADATA_PRIVACY_VERSION
345            || !self.matches_sender(envelope.sender)
346        {
347            return Err(DomainError::UnexpectedSender.into());
348        }
349
350        let mut next_state = self.state.clone();
351        let previous_chain_sender = next_state
352            .their_current_nostr_public_key
353            .or(next_state.their_next_nostr_public_key);
354        let (header, decryption_target) =
355            decrypt_header(&next_state, &envelope.encrypted_header, envelope.sender)?;
356        let should_ratchet = decryption_target == HeaderDecryptionTarget::Next;
357
358        let expected_next = next_state.their_next_nostr_public_key;
359        if should_ratchet && expected_next != Some(header.next_public_key) {
360            next_state.their_current_nostr_public_key = next_state.their_next_nostr_public_key;
361            next_state.their_next_nostr_public_key = Some(header.next_public_key);
362        }
363
364        if should_ratchet {
365            if next_state.receiving_chain_key.is_some() {
366                let skipped_sender = previous_chain_sender.ok_or(DomainError::SessionNotReady)?;
367                skip_message_keys(
368                    &mut next_state,
369                    header.previous_chain_length,
370                    skipped_sender,
371                )?;
372            }
373            ratchet_step(&mut next_state, ctx.rng)?;
374        }
375
376        let payload = ratchet_decrypt(
377            &mut next_state,
378            &header,
379            &envelope.ciphertext,
380            envelope.sender,
381        )?;
382
383        Ok(ReceivePlan {
384            next_state,
385            payload,
386            sender: envelope.sender,
387        })
388    }
389
390    pub fn apply_receive(&mut self, plan: ReceivePlan) -> ReceiveOutcome {
391        self.state = plan.next_state;
392        ReceiveOutcome {
393            payload: plan.payload,
394            sender: plan.sender,
395        }
396    }
397
398    pub fn close(&self) {}
399}
400
401fn ratchet_encrypt(state: &mut SessionState, plaintext: &[u8]) -> Result<(Header, String)> {
402    let sending_chain_key = state
403        .sending_chain_key
404        .ok_or(DomainError::SessionNotReady)?;
405
406    let kdf_outputs = kdf(&sending_chain_key, &[1u8], 2);
407    state.sending_chain_key = Some(kdf_outputs[0]);
408    let message_key = kdf_outputs[1];
409
410    let header = Header {
411        number: state.sending_chain_message_number,
412        next_public_key: state.our_next_nostr_key.public_key,
413        previous_chain_length: state.previous_sending_chain_message_count,
414    };
415
416    state.sending_chain_message_number += 1;
417
418    let conversation_key = nip44::v2::ConversationKey::new(message_key);
419    let encrypted_bytes = nip44::v2::encrypt_to_bytes(&conversation_key, plaintext)?;
420    let ciphertext = base64::engine::general_purpose::STANDARD.encode(encrypted_bytes);
421    Ok((header, ciphertext))
422}
423
424fn ratchet_decrypt(
425    state: &mut SessionState,
426    header: &Header,
427    ciphertext: &str,
428    sender: DevicePubkey,
429) -> Result<Vec<u8>> {
430    if let Some(plaintext) = try_skipped_message_keys(state, header, ciphertext, sender)? {
431        return Ok(plaintext);
432    }
433
434    if state.receiving_chain_key.is_none() {
435        return Err(DomainError::SessionNotReady.into());
436    }
437
438    skip_message_keys(state, header.number, sender)?;
439
440    let receiving_chain_key = state
441        .receiving_chain_key
442        .ok_or(DomainError::SessionNotReady)?;
443
444    let kdf_outputs = kdf(&receiving_chain_key, &[1u8], 2);
445    state.receiving_chain_key = Some(kdf_outputs[0]);
446    let message_key = kdf_outputs[1];
447    state.receiving_chain_message_number += 1;
448
449    let conversation_key = nip44::v2::ConversationKey::new(message_key);
450    let ciphertext_bytes = base64::engine::general_purpose::STANDARD
451        .decode(ciphertext)
452        .map_err(|e| crate::Error::Decryption(e.to_string()))?;
453
454    nip44::v2::decrypt_to_bytes(&conversation_key, &ciphertext_bytes).map_err(Into::into)
455}
456
457fn ratchet_step<R>(state: &mut SessionState, rng: &mut R) -> Result<()>
458where
459    R: RngCore + CryptoRng,
460{
461    state.previous_sending_chain_message_count = state.sending_chain_message_number;
462    state.sending_chain_message_number = 0;
463    state.receiving_chain_message_number = 0;
464
465    let our_next_sk = secret_key_from_bytes(&state.our_next_nostr_key.private_key)?;
466    let their_next_pk = state
467        .their_next_nostr_public_key
468        .ok_or(DomainError::SessionNotReady)?;
469
470    let conversation_key1 =
471        nip44::v2::ConversationKey::derive(&our_next_sk, &their_next_pk.to_nostr()?)?;
472    let kdf_outputs = kdf(&state.root_key, conversation_key1.as_bytes(), 2);
473    state.receiving_chain_key = Some(kdf_outputs[1]);
474    state.our_previous_nostr_key = state.our_current_nostr_key.clone();
475    state.our_current_nostr_key = Some(state.our_next_nostr_key.clone());
476
477    let our_next_private_key = random_secret_key_bytes(rng)?;
478    state.our_next_nostr_key = SerializableKeyPair {
479        public_key: device_pubkey_from_secret_bytes(&our_next_private_key)?,
480        private_key: our_next_private_key,
481    };
482
483    let our_next_sk2 = secret_key_from_bytes(&our_next_private_key)?;
484    let conversation_key2 =
485        nip44::v2::ConversationKey::derive(&our_next_sk2, &their_next_pk.to_nostr()?)?;
486    let kdf_outputs2 = kdf(&kdf_outputs[0], conversation_key2.as_bytes(), 2);
487    state.root_key = kdf_outputs2[0];
488    state.sending_chain_key = Some(kdf_outputs2[1]);
489    Ok(())
490}
491
492fn skip_message_keys(state: &mut SessionState, until: u32, sender: DevicePubkey) -> Result<()> {
493    if until <= state.receiving_chain_message_number {
494        return Ok(());
495    }
496
497    if (until - state.receiving_chain_message_number) as usize > MAX_SKIP {
498        return Err(DomainError::TooManySkippedMessages.into());
499    }
500
501    let entry = state.skipped_keys.entry(sender).or_default();
502
503    while state.receiving_chain_message_number < until {
504        let receiving_chain_key = state
505            .receiving_chain_key
506            .ok_or(DomainError::SessionNotReady)?;
507        let kdf_outputs = kdf(&receiving_chain_key, &[1u8], 2);
508        state.receiving_chain_key = Some(kdf_outputs[0]);
509        entry
510            .message_keys
511            .insert(state.receiving_chain_message_number, kdf_outputs[1]);
512        state.receiving_chain_message_number += 1;
513    }
514
515    prune_skipped_message_keys(&mut entry.message_keys);
516    Ok(())
517}
518
519fn try_skipped_message_keys(
520    state: &mut SessionState,
521    header: &Header,
522    ciphertext: &str,
523    sender: DevicePubkey,
524) -> Result<Option<Vec<u8>>> {
525    if let Some(entry) = state.skipped_keys.get_mut(&sender) {
526        if let Some(message_key) = entry.message_keys.remove(&header.number) {
527            let conversation_key = nip44::v2::ConversationKey::new(message_key);
528            let ciphertext_bytes = base64::engine::general_purpose::STANDARD
529                .decode(ciphertext)
530                .map_err(|e| crate::Error::Decryption(e.to_string()))?;
531            let plaintext = nip44::v2::decrypt_to_bytes(&conversation_key, &ciphertext_bytes)?;
532            if entry.message_keys.is_empty() {
533                state.skipped_keys.remove(&sender);
534            }
535            return Ok(Some(plaintext));
536        }
537    }
538
539    Ok(None)
540}
541
542fn decrypt_header(
543    state: &SessionState,
544    encrypted_header: &str,
545    sender: DevicePubkey,
546) -> Result<(Header, HeaderDecryptionTarget)> {
547    if let Some(current) = &state.our_current_nostr_key {
548        let current_sk = secret_key_from_bytes(&current.private_key)?;
549        if let Ok(decrypted) = nip44::decrypt(&current_sk, &sender.to_nostr()?, encrypted_header) {
550            let header: Header = serde_json::from_str(&decrypted)?;
551            return Ok((header, HeaderDecryptionTarget::Current));
552        }
553    }
554
555    let next_sk = secret_key_from_bytes(&state.our_next_nostr_key.private_key)?;
556    if let Ok(decrypted) = nip44::decrypt(&next_sk, &sender.to_nostr()?, encrypted_header) {
557        let header: Header = serde_json::from_str(&decrypted)?;
558        return Ok((header, HeaderDecryptionTarget::Next));
559    }
560
561    if let Some(previous) = &state.our_previous_nostr_key {
562        let previous_sk = secret_key_from_bytes(&previous.private_key)?;
563        if let Ok(decrypted) = nip44::decrypt(&previous_sk, &sender.to_nostr()?, encrypted_header) {
564            let header: Header = serde_json::from_str(&decrypted)?;
565            return Ok((header, HeaderDecryptionTarget::Previous));
566        }
567    }
568
569    Err(crate::Error::Parse("invalid header".to_string()))
570}
571
572fn prune_skipped_message_keys(map: &mut BTreeMap<u32, [u8; 32]>) {
573    while map.len() > MAX_SKIP {
574        let Some(first) = map.keys().next().copied() else {
575            break;
576        };
577        map.remove(&first);
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use rand::{rngs::StdRng, SeedableRng};
585
586    fn context(seed: u64) -> ProtocolContext<'static, StdRng> {
587        let rng = Box::new(StdRng::seed_from_u64(seed));
588        let rng = Box::leak(rng);
589        ProtocolContext::new(UnixSeconds(1_700_000_000), rng)
590    }
591
592    #[test]
593    fn header_json_uses_camel_case_wire_fields() {
594        let header = Header {
595            number: 3,
596            previous_chain_length: 2,
597            next_public_key: DevicePubkey::from_bytes([9u8; 32]),
598        };
599
600        let json = serde_json::to_value(&header).unwrap();
601        assert_eq!(json["number"], serde_json::json!(3));
602        assert_eq!(json["previousChainLength"], serde_json::json!(2));
603        assert_eq!(
604            json["nextPublicKey"],
605            serde_json::json!(header.next_public_key.to_string())
606        );
607        assert!(json.get("previous_chain_length").is_none());
608        assert!(json.get("next_public_key").is_none());
609
610        let decoded: Header = serde_json::from_value(json).unwrap();
611        assert_eq!(decoded, header);
612    }
613
614    #[test]
615    fn header_json_rejects_snake_case_wire_fields() {
616        let old_header = serde_json::json!({
617            "number": 3,
618            "previous_chain_length": 2,
619            "next_public_key": DevicePubkey::from_bytes([9u8; 32]).to_string(),
620        });
621
622        assert!(serde_json::from_value::<Header>(old_header).is_err());
623    }
624
625    #[test]
626    fn plan_send_and_apply_receive_roundtrip() {
627        let alice_secret = [1u8; 32];
628        let bob_secret = [2u8; 32];
629        let alice_pub = device_pubkey_from_secret_bytes(&alice_secret).unwrap();
630        let bob_pub = device_pubkey_from_secret_bytes(&bob_secret).unwrap();
631        let shared_secret = [7u8; 32];
632
633        let mut init_ctx_alice = context(1);
634        let alice =
635            Session::new_initiator(&mut init_ctx_alice, bob_pub, alice_secret, shared_secret)
636                .unwrap();
637        let mut init_ctx_bob = context(2);
638        let mut bob =
639            Session::new_responder(&mut init_ctx_bob, alice_pub, bob_secret, shared_secret)
640                .unwrap();
641
642        let payload = b"hello".to_vec();
643        let send_plan = alice
644            .plan_send(&payload, UnixSeconds(1_700_000_010))
645            .unwrap();
646        let send_outcome = alice.clone().apply_send(send_plan.clone());
647
648        let mut recv_ctx = context(10);
649        let receive_plan = bob
650            .plan_receive(&mut recv_ctx, &send_outcome.envelope)
651            .unwrap();
652        let outcome = bob.apply_receive(receive_plan);
653        assert_eq!(outcome.payload, payload);
654    }
655
656    #[test]
657    fn plan_receive_does_not_mutate_original_session() {
658        let alice_secret = [3u8; 32];
659        let bob_secret = [4u8; 32];
660        let alice_pub = device_pubkey_from_secret_bytes(&alice_secret).unwrap();
661        let bob_pub = device_pubkey_from_secret_bytes(&bob_secret).unwrap();
662        let shared_secret = [8u8; 32];
663
664        let mut init_ctx_alice = context(3);
665        let alice =
666            Session::new_initiator(&mut init_ctx_alice, bob_pub, alice_secret, shared_secret)
667                .unwrap();
668        let mut init_ctx_bob = context(4);
669        let bob = Session::new_responder(&mut init_ctx_bob, alice_pub, bob_secret, shared_secret)
670            .unwrap();
671        let bob_before = bob.state.clone();
672
673        let payload = b"typing".to_vec();
674        let send_plan = alice
675            .plan_send(&payload, UnixSeconds(1_700_000_011))
676            .unwrap();
677
678        let mut recv_ctx = context(13);
679        let _ = bob
680            .plan_receive(&mut recv_ctx, &send_plan.envelope)
681            .unwrap();
682
683        assert_eq!(bob.state, bob_before);
684    }
685
686    #[test]
687    fn duplicate_receive_fails_without_corrupting_state() {
688        let alice_secret = [5u8; 32];
689        let bob_secret = [6u8; 32];
690        let alice_pub = device_pubkey_from_secret_bytes(&alice_secret).unwrap();
691        let bob_pub = device_pubkey_from_secret_bytes(&bob_secret).unwrap();
692        let shared_secret = [9u8; 32];
693
694        let mut init_ctx_alice = context(5);
695        let alice =
696            Session::new_initiator(&mut init_ctx_alice, bob_pub, alice_secret, shared_secret)
697                .unwrap();
698        let mut init_ctx_bob = context(6);
699        let mut bob =
700            Session::new_responder(&mut init_ctx_bob, alice_pub, bob_secret, shared_secret)
701                .unwrap();
702
703        let payload = b"hello".to_vec();
704        let send_plan = alice
705            .plan_send(&payload, UnixSeconds(1_700_000_012))
706            .unwrap();
707        let envelope = alice.clone().apply_send(send_plan).envelope;
708
709        let mut recv_ctx = context(15);
710        let first_plan = bob.plan_receive(&mut recv_ctx, &envelope).unwrap();
711        let _ = bob.apply_receive(first_plan);
712        let after_first = bob.state.clone();
713
714        let mut replay_ctx = context(16);
715        let replay = bob.plan_receive(&mut replay_ctx, &envelope);
716        assert!(replay.is_err());
717        assert_eq!(bob.state, after_first);
718    }
719
720    #[test]
721    fn invalid_sender_is_rejected() {
722        let alice_secret = [7u8; 32];
723        let bob_secret = [8u8; 32];
724        let alice_pub = device_pubkey_from_secret_bytes(&alice_secret).unwrap();
725        let shared_secret = [10u8; 32];
726
727        let mut init_ctx_bob = context(7);
728        let bob = Session::new_responder(&mut init_ctx_bob, alice_pub, bob_secret, shared_secret)
729            .unwrap();
730
731        let mut recv_ctx = context(17);
732        let err = bob
733            .plan_receive(
734                &mut recv_ctx,
735                &MessageEnvelope {
736                    metadata_privacy_version: crate::METADATA_PRIVACY_VERSION,
737                    sender: device_pubkey_from_secret_bytes(&bob_secret).unwrap(),
738                    recipient: None,
739                    signer_secret_key: bob_secret,
740                    created_at: UnixSeconds(1),
741                    encrypted_header: "bad".to_string(),
742                    ciphertext: "bad".to_string(),
743                },
744            )
745            .unwrap_err();
746        assert!(matches!(
747            err,
748            crate::Error::Domain(DomainError::UnexpectedSender)
749        ));
750    }
751}