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}