Skip to main content

pamoja_session/
session.rs

1//! Sessions: an ordered, replay-protected channel of authenticated-encrypted
2//! messages between two devices that have agreed a key.
3
4use crate::aead;
5use crate::kex::{self, AgreementKey, AgreementPublicKey};
6use crate::SessionError;
7
8// The anti-replay window tracks the 64 counters below the highest accepted one, in a
9// single machine word, exactly as the IPsec (RFC 4303) and DTLS (RFC 6347) sliding
10// windows do.
11const WINDOW: u64 = 64;
12
13/// Which end of a session a device is.
14///
15/// Both ends derive the same key, but each tags its outgoing messages with a
16/// different direction byte in the nonce, so the two directions never share a
17/// nonce under the one key and a message a device sends can never be opened as one
18/// it expected to receive.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Role {
21    /// The device that opens the session. Its public key is ordered first when the
22    /// key is derived, and it tags the messages it sends as the initiator direction.
23    Initiator,
24    /// The device that answers. Its public key is ordered second, and it tags the
25    /// messages it sends as the responder direction.
26    Responder,
27}
28
29/// The out-of-band header of a sealed message: the counter that orders it and the
30/// tag that authenticates it.
31///
32/// Both values travel alongside the ciphertext to the peer. The peer needs the
33/// counter to rebuild the nonce and to reject replays, and the tag to verify the
34/// message was not altered.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct Sealed {
37    /// The monotonically increasing counter naming this message within the session.
38    pub counter: u64,
39    /// The 16-byte ChaCha20-Poly1305 tag over the ciphertext and its associated data.
40    pub tag: [u8; 16],
41}
42
43/// A confidential, tamper-evident, replay-protected channel with one peer.
44///
45/// A session holds the agreed key, the counter for the messages this device sends,
46/// and a sliding window of the counters it has accepted from the peer. Sealing a
47/// message encrypts it and stamps it with the next counter; opening one verifies it
48/// and rejects anything that fails authentication or repeats a counter.
49///
50/// A session is deliberately not `Clone`: two copies would reuse counters and so
51/// reuse nonces, which breaks the AEAD's guarantees. Establish a fresh session
52/// instead.
53///
54/// # Examples
55///
56/// ```
57/// use pamoja_session::{AgreementKey, Role, Session};
58///
59/// // Each device is provisioned with its own seed and knows the other's public key.
60/// let fridge = AgreementKey::from_seed(&[1u8; 32]);
61/// let gateway = AgreementKey::from_seed(&[2u8; 32]);
62///
63/// // A fresh salt is agreed in the clear at the start of each session.
64/// let salt = [9u8; 16];
65/// let mut device = Session::establish(&fridge, &gateway.public(), &salt, Role::Initiator);
66/// let mut peer = Session::establish(&gateway, &fridge.public(), &salt, Role::Responder);
67///
68/// // The device seals a reading; the ciphertext, counter, and tag go on the wire.
69/// let mut message = *b"4.8C";
70/// let sealed = device.seal(&mut message, b"fridge-1");
71///
72/// // The gateway opens it, recovering the reading and proving it is authentic.
73/// peer.open(&sealed, &mut message, b"fridge-1").expect("authentic message");
74/// assert_eq!(&message, b"4.8C");
75/// ```
76pub struct Session {
77    key: [u8; 32],
78    nonce_prefix: [u8; 3],
79    send_dir: u8,
80    recv_dir: u8,
81    send_counter: u64,
82    recv_highest: u64,
83    recv_window: u64,
84}
85
86impl Session {
87    /// Establishes a session with a peer from this device's agreement key and the
88    /// peer's authenticated public key.
89    ///
90    /// Both devices call this with the same `salt` and opposite [`Role`]s and arrive
91    /// at the same key. The salt is a fresh per-session value the two sides exchange
92    /// in the clear before sealing anything; reusing a salt with the same pair of
93    /// keys reuses the session key, so it must change each session (a counter kept in
94    /// power-loss-safe storage, or a nonce from a handshake, both work).
95    ///
96    /// # Arguments
97    ///
98    /// * `local` - this device's key-agreement secret.
99    /// * `peer` - the peer's public key, already authenticated by pinning or signature.
100    /// * `salt` - the fresh per-session salt both sides share.
101    /// * `role` - whether this device is the [`Role::Initiator`] or [`Role::Responder`].
102    ///
103    /// # Returns
104    ///
105    /// A session ready to seal and open messages with the peer.
106    pub fn establish(
107        local: &AgreementKey,
108        peer: &AgreementPublicKey,
109        salt: &[u8],
110        role: Role,
111    ) -> Self {
112        let shared = local.shared_secret(peer);
113        let local_public = local.public().to_bytes();
114        let peer_public = peer.to_bytes();
115        let (initiator, responder) = match role {
116            Role::Initiator => (local_public, peer_public),
117            Role::Responder => (peer_public, local_public),
118        };
119
120        let okm = kex::derive(&shared, salt, &initiator, &responder);
121        let mut key = [0u8; 32];
122        key.copy_from_slice(&okm[..32]);
123        let mut nonce_prefix = [0u8; 3];
124        nonce_prefix.copy_from_slice(&okm[32..]);
125
126        let (send_dir, recv_dir) = match role {
127            Role::Initiator => (0, 1),
128            Role::Responder => (1, 0),
129        };
130
131        Self {
132            key,
133            nonce_prefix,
134            send_dir,
135            recv_dir,
136            send_counter: 0,
137            recv_highest: 0,
138            recv_window: 0,
139        }
140    }
141
142    /// Seals a message for the peer, encrypting `buf` in place and stamping it with
143    /// the next counter.
144    ///
145    /// The associated data `aad` is authenticated but not encrypted, so it is
146    /// readable on the wire yet cannot be altered: a device identifier or a routing
147    /// header belongs here. After this returns, `buf` holds the ciphertext and the
148    /// returned [`Sealed`] holds the counter and tag to send with it.
149    ///
150    /// # Arguments
151    ///
152    /// * `buf` - the plaintext, replaced in place by the ciphertext of equal length.
153    /// * `aad` - associated data to authenticate alongside the message.
154    ///
155    /// # Returns
156    ///
157    /// The [`Sealed`] header (counter and tag) for this message.
158    pub fn seal(&mut self, buf: &mut [u8], aad: &[u8]) -> Sealed {
159        let counter = self.send_counter;
160        let nonce = nonce(&self.nonce_prefix, self.send_dir, counter);
161        let tag = aead::seal(&self.key, &nonce, aad, buf);
162        // A session must be re-established long before 2^64 messages; this never wraps
163        // in any real deployment.
164        self.send_counter += 1;
165        Sealed { counter, tag }
166    }
167
168    /// Opens a message from the peer, verifying it and decrypting `buf` in place.
169    ///
170    /// The message is rejected if its counter has already been seen or is older than
171    /// the replay window still tracks, and if its tag does not authenticate. On any
172    /// rejection `buf` is left zeroed, so a failed open never yields readable bytes.
173    /// The replay window only advances on a message that authenticates, so a forged
174    /// counter cannot push genuine messages out of the window.
175    ///
176    /// # Arguments
177    ///
178    /// * `sealed` - the counter and tag that arrived with the ciphertext.
179    /// * `buf` - the ciphertext, replaced in place by the plaintext on success.
180    /// * `aad` - the same associated data the sender authenticated.
181    ///
182    /// # Returns
183    ///
184    /// `Ok(())` if the message is authentic and fresh, with `buf` now the plaintext.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`SessionError::Replayed`] if the counter repeats or is too old, or
189    /// [`SessionError::Inauthentic`] if the message fails authentication.
190    pub fn open(
191        &mut self,
192        sealed: &Sealed,
193        buf: &mut [u8],
194        aad: &[u8],
195    ) -> Result<(), SessionError> {
196        if !self.replay_ok(sealed.counter) {
197            return Err(SessionError::Replayed);
198        }
199        let nonce = nonce(&self.nonce_prefix, self.recv_dir, sealed.counter);
200        aead::open(&self.key, &nonce, aad, buf, &sealed.tag)?;
201        self.commit(sealed.counter);
202        Ok(())
203    }
204
205    // Whether `counter` could still be accepted: it is newer than the highest seen,
206    // or it falls inside the window and has not been seen there yet.
207    fn replay_ok(&self, counter: u64) -> bool {
208        if counter > self.recv_highest {
209            return true;
210        }
211        let behind = self.recv_highest - counter;
212        if behind >= WINDOW {
213            return false;
214        }
215        (self.recv_window >> behind) & 1 == 0
216    }
217
218    // Records `counter` as accepted, sliding the window forward if it is a new high.
219    fn commit(&mut self, counter: u64) {
220        if counter > self.recv_highest {
221            let shift = counter - self.recv_highest;
222            self.recv_window = if shift >= WINDOW {
223                1
224            } else {
225                (self.recv_window << shift) | 1
226            };
227            self.recv_highest = counter;
228        } else {
229            let behind = self.recv_highest - counter;
230            self.recv_window |= 1 << behind;
231        }
232    }
233}
234
235// Builds the 12-byte ChaCha20-Poly1305 nonce: a direction byte, the per-session
236// prefix, and the big-endian counter. The direction byte separates the two
237// directions under the shared key, and the counter makes every nonce in a direction
238// unique, which is the discipline the AEAD requires.
239fn nonce(prefix: &[u8; 3], direction: u8, counter: u64) -> [u8; 12] {
240    let mut nonce = [0u8; 12];
241    nonce[0] = direction;
242    nonce[1..4].copy_from_slice(prefix);
243    nonce[4..].copy_from_slice(&counter.to_be_bytes());
244    nonce
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn pair() -> (Session, Session) {
252        let initiator = AgreementKey::from_seed(&[1u8; 32]);
253        let responder = AgreementKey::from_seed(&[2u8; 32]);
254        let salt = [3u8; 16];
255        let a = Session::establish(&initiator, &responder.public(), &salt, Role::Initiator);
256        let b = Session::establish(&responder, &initiator.public(), &salt, Role::Responder);
257        (a, b)
258    }
259
260    #[test]
261    fn a_sealed_message_opens_on_the_peer() {
262        let (mut a, mut b) = pair();
263        let mut buf = *b"hello";
264        let sealed = a.seal(&mut buf, b"meta");
265        b.open(&sealed, &mut buf, b"meta").expect("authentic");
266        assert_eq!(&buf, b"hello");
267    }
268
269    #[test]
270    fn the_two_sides_derive_the_same_key() {
271        // If the keys or directions disagreed, this cross-direction exchange would
272        // fail to authenticate.
273        let (mut a, mut b) = pair();
274        let mut up = *b"up";
275        let sealed_up = a.seal(&mut up, b"");
276        b.open(&sealed_up, &mut up, b"").expect("a to b");
277        let mut down = *b"down";
278        let sealed_down = b.seal(&mut down, b"");
279        a.open(&sealed_down, &mut down, b"").expect("b to a");
280    }
281
282    #[test]
283    fn a_replayed_message_is_rejected() {
284        let (mut a, mut b) = pair();
285        let mut buf = *b"once";
286        let sealed = a.seal(&mut buf, b"");
287        let mut first = buf;
288        b.open(&sealed, &mut first, b"").expect("first delivery");
289        let mut again = buf;
290        assert_eq!(
291            b.open(&sealed, &mut again, b""),
292            Err(SessionError::Replayed)
293        );
294    }
295
296    #[test]
297    fn out_of_order_within_the_window_is_accepted_once_each() {
298        let (mut a, mut b) = pair();
299        let mut payloads = [*b"00", *b"01", *b"02", *b"03"];
300        let sealed: [Sealed; 4] = core::array::from_fn(|i| a.seal(&mut payloads[i], b""));
301        // Deliver newest first, then the older ones: all fresh, all accepted.
302        for i in [3, 1, 2, 0] {
303            let mut buf = payloads[i];
304            b.open(&sealed[i], &mut buf, b"")
305                .expect("fresh within window");
306        }
307        // Re-delivering any of them now repeats a counter already in the window.
308        let mut buf = payloads[2];
309        assert_eq!(
310            b.open(&sealed[2], &mut buf, b""),
311            Err(SessionError::Replayed)
312        );
313    }
314
315    #[test]
316    fn a_counter_older_than_the_window_is_rejected() {
317        let (mut a, mut b) = pair();
318        // Advance the receiver past a full window with a high counter.
319        a.send_counter = 100;
320        let mut new = *b"new";
321        let sealed_new = a.seal(&mut new, b"");
322        b.open(&sealed_new, &mut new, b"")
323            .expect("new high counter");
324        // A message at counter 0 is now far below the window's reach.
325        a.send_counter = 0;
326        let mut old = *b"old";
327        let sealed_old = a.seal(&mut old, b"");
328        assert_eq!(
329            b.open(&sealed_old, &mut old, b""),
330            Err(SessionError::Replayed)
331        );
332    }
333
334    #[test]
335    fn a_forged_tag_does_not_advance_the_window() {
336        let (mut a, mut b) = pair();
337        // A forgery at a high counter must not push the window forward.
338        let forged = Sealed {
339            counter: 50,
340            tag: [0u8; 16],
341        };
342        let mut junk = *b"junk";
343        assert_eq!(
344            b.open(&forged, &mut junk, b""),
345            Err(SessionError::Inauthentic)
346        );
347        // The genuine first message still opens, proving the window did not move.
348        let mut buf = *b"first";
349        let sealed = a.seal(&mut buf, b"");
350        b.open(&sealed, &mut buf, b"")
351            .expect("window was not advanced by the forgery");
352    }
353
354    #[test]
355    fn a_different_salt_yields_an_incompatible_session() {
356        let initiator = AgreementKey::from_seed(&[1u8; 32]);
357        let responder = AgreementKey::from_seed(&[2u8; 32]);
358        let mut a =
359            Session::establish(&initiator, &responder.public(), &[3u8; 16], Role::Initiator);
360        let mut b =
361            Session::establish(&responder, &initiator.public(), &[4u8; 16], Role::Responder);
362        let mut buf = *b"hello";
363        let sealed = a.seal(&mut buf, b"");
364        assert_eq!(
365            b.open(&sealed, &mut buf, b""),
366            Err(SessionError::Inauthentic)
367        );
368    }
369}