Skip to main content

sentinelpass_protocol/
session.rs

1//! Directional session keys, AAD-bound frames, replay protection, and
2//! deadlines for IPC connections (WBS-509/510/511, TD-ROB-16).
3//!
4//! Wire design (v1):
5//!
6//! * Handshake — the CLIENT sends the first frame (the same 4-byte
7//!   length-prefixed framing as everything else): a JSON
8//!   `SessionHello { v: 1, cr: <hex 32B client random> }`. The server
9//!   replies `SessionAccept { v: 1, sr: <hex 32B server random> }`.
10//! * Key schedule (WBS-509) — HKDF-SHA256 with the daemon auth token as
11//!   IKM and `client_random || server_random` as salt yields two
12//!   DIRECTIONAL 32-byte keys (`c2s`, `s2c`); the client encrypts with
13//!   `c2s`, the server with `s2c`, so a frame replayed into the opposite
14//!   direction (reflection) fails authentication.
15//! * Frames (WBS-510) — nonce `4 zero bytes || u64 BE counter` and AAD
16//!   `SPIS || proto(u16 LE) || direction(u8) || counter(u64 LE)`: the
17//!   ciphertext is bound to the protocol, the direction, and the counter.
18//! * Replay (WBS-511) — each direction's counter must be STRICTLY
19//!   increasing; a duplicate or lower counter is refused before delivery.
20//! * Deadlines — every frame read is bounded by [`SESSION_READ_DEADLINE`]
21//!   (stalled peers cannot wedge the other side); frame bounds stay at
22//!   [`MAX_MESSAGE_SIZE`].
23//!
24//! Compatibility: legacy (pre-session) clients speaking plaintext
25//! envelopes are accepted in PLAIN mode when the server negotiates (the
26//! first frame is not a SessionHello); plain mode is the ADR-007 migration
27//! window and is removed in 1.0. New clients ALWAYS negotiate.
28
29use crate::transport::{TransportError, TransportResult, MAX_MESSAGE_SIZE};
30use hkdf::Hkdf;
31use rand::{rngs::OsRng, RngCore};
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35/// Session protocol version.
36pub const SESSION_PROTO_VERSION: u16 = 1;
37/// Bound on every frame read (deadlines, WBS-511).
38pub const SESSION_READ_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
39/// Magic prefix of the session AAD ("SPIS" = SentinelPass IPC Session).
40const AAD_MAGIC: &[u8; 4] = b"SPIS";
41
42/// Handshake frame (client → server).
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SessionHello {
45    /// Protocol version; must equal [`SESSION_PROTO_VERSION`].
46    pub v: u16,
47    /// 32-byte client random, hex.
48    pub cr: String,
49}
50
51/// Handshake frame (server → client).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionAccept {
54    pub v: u16,
55    /// 32-byte server random, hex.
56    pub sr: String,
57}
58
59/// Which direction frames flow in. Directional keys (WBS-509): the client
60/// encrypts with `c2s`, the server with `s2c`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Direction {
63    ClientToServer,
64    ServerToClient,
65}
66
67impl Direction {
68    pub fn opposite(self) -> Direction {
69        match self {
70            Direction::ClientToServer => Direction::ServerToClient,
71            Direction::ServerToClient => Direction::ClientToServer,
72        }
73    }
74
75    fn tag(self) -> u8 {
76        match self {
77            Direction::ClientToServer => 1,
78            Direction::ServerToClient => 2,
79        }
80    }
81
82    fn info(self) -> &'static str {
83        match self {
84            Direction::ClientToServer => "sentinelpass-ipc v1 client-to-server",
85            Direction::ServerToClient => "sentinelpass-ipc v1 server-to-client",
86        }
87    }
88}
89
90/// One direction's crypto state: directional key + strictly-increasing
91/// counter (the core of replay protection, WBS-511).
92struct DirectionState {
93    key: [u8; 32],
94    direction: Direction,
95    /// Send: last counter SENT. Receive: last counter ACCEPTED.
96    counter: u64,
97}
98
99impl DirectionState {
100    fn new(key: [u8; 32], direction: Direction) -> Self {
101        Self {
102            key,
103            direction,
104            counter: 0,
105        }
106    }
107
108    fn next_counter(&mut self) -> TransportResult<u64> {
109        self.counter = self
110            .counter
111            .checked_add(1)
112            .ok_or_else(|| TransportError::Other("session counter exhausted".to_string()))?;
113        Ok(self.counter)
114    }
115
116    fn accept_counter(&mut self, counter: u64) -> TransportResult<()> {
117        if counter <= self.counter {
118            return Err(TransportError::Other(format!(
119                "rejected frame: counter {counter} is not newer than {} (replay or reorder)",
120                self.counter
121            )));
122        }
123        self.counter = counter;
124        Ok(())
125    }
126}
127
128/// AAD for one frame (WBS-510): binds the ciphertext to the protocol, the
129/// SENDER's direction, and the counter.
130fn frame_aad(direction: Direction, proto: u16, counter: u64) -> [u8; 15] {
131    let mut aad = [0u8; 15];
132    aad[..4].copy_from_slice(AAD_MAGIC);
133    aad[4..6].copy_from_slice(&proto.to_le_bytes());
134    aad[6] = direction.tag();
135    aad[7..15].copy_from_slice(&counter.to_le_bytes());
136    aad
137}
138
139fn counter_nonce(counter: u64) -> [u8; 12] {
140    // Deterministic per-direction counter nonce: 4 zero bytes || u64 BE.
141    // Unique per (key, counter); keys are directional and session-bound, so
142    // no nonce reuse occurs.
143    let mut nonce = [0u8; 12];
144    nonce[4..12].copy_from_slice(&counter.to_be_bytes());
145    nonce
146}
147
148/// Derive the two directional keys (WBS-509). `token` is the daemon auth
149/// token (32 random bytes, hex); the session randoms bind the keys to one
150/// specific session.
151pub fn derive_directional_keys(
152    token: &str,
153    client_random: &[u8; 32],
154    server_random: &[u8; 32],
155) -> TransportResult<([u8; 32], [u8; 32])> {
156    // Production tokens are 32 random bytes hex-encoded. Non-hex tokens
157    // (embedders, tests) are normalized through SHA-256 so the schedule
158    // always has exactly 32 bytes of key material.
159    let token_bytes = match hex::decode(token.trim()) {
160        Ok(bytes) if bytes.len() == 32 => bytes,
161        _ => Sha256::digest(token.trim().as_bytes()).to_vec(),
162    };
163
164    let salt = [client_random.as_slice(), server_random.as_slice()].concat();
165    let hk = Hkdf::<Sha256>::new(Some(salt.as_slice()), &token_bytes);
166    let mut c2s = [0u8; 32];
167    let mut s2c = [0u8; 32];
168    hk.expand(Direction::ClientToServer.info().as_bytes(), &mut c2s)
169        .map_err(|e| TransportError::Other(format!("hkdf expand failed: {e}")))?;
170    hk.expand(Direction::ServerToClient.info().as_bytes(), &mut s2c)
171        .map_err(|e| TransportError::Other(format!("hkdf expand failed: {e}")))?;
172    Ok((c2s, s2c))
173}
174
175/// Session crypto for ONE connection endpoint: seals in the endpoint's
176/// send direction, opens in the peer's.
177pub struct SessionCrypto {
178    send: DirectionState,
179    recv: DirectionState,
180}
181
182impl SessionCrypto {
183    /// Client endpoint (sends c2s, receives s2c).
184    pub fn client(c2s: [u8; 32], s2c: [u8; 32]) -> Self {
185        Self {
186            send: DirectionState::new(c2s, Direction::ClientToServer),
187            recv: DirectionState::new(s2c, Direction::ServerToClient),
188        }
189    }
190
191    /// Server endpoint (sends s2c, receives c2s).
192    pub fn server(c2s: [u8; 32], s2c: [u8; 32]) -> Self {
193        Self {
194            send: DirectionState::new(s2c, Direction::ServerToClient),
195            recv: DirectionState::new(c2s, Direction::ClientToServer),
196        }
197    }
198
199    /// Seal one frame: counter nonce + AAD binding direction/protocol/
200    /// counter (WBS-509/510).
201    pub fn seal(&mut self, plaintext: &[u8]) -> TransportResult<Vec<u8>> {
202        use aes_gcm::aead::{Aead, KeyInit};
203        use aes_gcm::Aes256Gcm;
204
205        let counter = self.send.next_counter()?;
206        let cipher = Aes256Gcm::new_from_slice(&self.send.key)
207            .map_err(|e| TransportError::Other(format!("session cipher init: {e}")))?;
208        let nonce = counter_nonce(counter);
209        let aad = frame_aad(self.send.direction, SESSION_PROTO_VERSION, counter);
210
211        let mut frame = nonce.to_vec();
212        frame.extend_from_slice(
213            &cipher
214                .encrypt(
215                    (&nonce).into(),
216                    aes_gcm::aead::Payload {
217                        msg: plaintext,
218                        aad: &aad,
219                    },
220                )
221                .map_err(|_| TransportError::Other("session seal failed".to_string()))?,
222        );
223        Ok(frame)
224    }
225
226    /// Open one frame: enforces the strictly-increasing counter (replay,
227    /// WBS-511) and the AAD binding to OUR receive direction (reflection,
228    /// WBS-510) before returning the plaintext.
229    pub fn open(&mut self, frame: &[u8]) -> TransportResult<Vec<u8>> {
230        use aes_gcm::aead::{Aead, KeyInit};
231        use aes_gcm::Aes256Gcm;
232
233        if frame.len() <= 12 || frame.len() > MAX_MESSAGE_SIZE + 16 {
234            return Err(TransportError::Other(format!(
235                "session frame out of bounds: {} bytes",
236                frame.len()
237            )));
238        }
239        let (nonce_bytes, ciphertext) = frame.split_at(12);
240        let mut nonce = [0u8; 12];
241        nonce.copy_from_slice(nonce_bytes);
242        let counter = u64::from_be_bytes(nonce[4..12].try_into().expect("8 bytes"));
243        self.recv.accept_counter(counter)?;
244
245        let cipher = Aes256Gcm::new_from_slice(&self.recv.key)
246            .map_err(|e| TransportError::Other(format!("session cipher init: {e}")))?;
247        // AAD uses the SENDER's (= our receive) direction: a reflected
248        // frame carries the wrong tag and fails authentication.
249        let aad = frame_aad(self.recv.direction, SESSION_PROTO_VERSION, counter);
250        cipher
251            .decrypt(
252                (&nonce).into(),
253                aes_gcm::aead::Payload {
254                    msg: ciphertext,
255                    aad: &aad,
256                },
257            )
258            .map_err(|_| {
259                TransportError::Other(
260                    "session frame failed authentication (wrong key, direction, or tampered)"
261                        .to_string(),
262                )
263            })
264    }
265}
266
267/// True when the first plaintext frame is a SessionHello (server-side
268/// negotiation detection). A plaintext envelope frame is NOT a hello.
269pub fn is_session_hello(first_frame: &[u8]) -> bool {
270    serde_json::from_slice::<SessionHello>(first_frame)
271        .map(|hello| hello.v == SESSION_PROTO_VERSION && hello.cr.len() == 64)
272        .unwrap_or(false)
273}
274
275pub fn parse_hello(frame: &[u8]) -> TransportResult<SessionHello> {
276    let hello: SessionHello = serde_json::from_slice(frame)
277        .map_err(|e| TransportError::Other(format!("invalid SessionHello: {e}")))?;
278    if hello.v != SESSION_PROTO_VERSION {
279        return Err(TransportError::Other(format!(
280            "unsupported session protocol {}",
281            hello.v
282        )));
283    }
284    if hello.cr.len() != 64 {
285        return Err(TransportError::Other(
286            "SessionHello client random must be 32 hex bytes".to_string(),
287        ));
288    }
289    Ok(hello)
290}
291
292pub fn parse_accept(frame: &[u8]) -> TransportResult<SessionAccept> {
293    let accept: SessionAccept = serde_json::from_slice(frame)
294        .map_err(|e| TransportError::Other(format!("invalid SessionAccept: {e}")))?;
295    if accept.v != SESSION_PROTO_VERSION {
296        return Err(TransportError::Other(format!(
297            "unsupported session protocol {}",
298            accept.v
299        )));
300    }
301    if accept.sr.len() != 64 {
302        return Err(TransportError::Other(
303            "SessionAccept server random must be 32 hex bytes".to_string(),
304        ));
305    }
306    Ok(accept)
307}
308
309/// Fresh client hello + its random (kept by the client for key derivation).
310pub fn new_hello() -> (SessionHello, [u8; 32]) {
311    let mut cr = [0u8; 32];
312    OsRng.fill_bytes(&mut cr);
313    (
314        SessionHello {
315            v: SESSION_PROTO_VERSION,
316            cr: hex::encode(cr),
317        },
318        cr,
319    )
320}
321
322/// Fresh server accept + its random.
323pub fn new_accept() -> (SessionAccept, [u8; 32]) {
324    let mut sr = [0u8; 32];
325    OsRng.fill_bytes(&mut sr);
326    (
327        SessionAccept {
328            v: SESSION_PROTO_VERSION,
329            sr: hex::encode(sr),
330        },
331        sr,
332    )
333}
334
335pub fn client_random_of(hello: &SessionHello) -> TransportResult<[u8; 32]> {
336    let mut out = [0u8; 32];
337    hex::decode_to_slice(&hello.cr, &mut out)
338        .map_err(|e| TransportError::Other(format!("invalid client random: {e}")))?;
339    Ok(out)
340}
341
342pub fn server_random_of(accept: &SessionAccept) -> TransportResult<[u8; 32]> {
343    let mut out = [0u8; 32];
344    hex::decode_to_slice(&accept.sr, &mut out)
345        .map_err(|e| TransportError::Other(format!("invalid server random: {e}")))?;
346    Ok(out)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    const TOKEN: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
354
355    #[test]
356    fn directional_keys_differ_and_bind_to_session_and_token() {
357        let (_hello, cr) = new_hello();
358        let (_accept, sr) = new_accept();
359        let (c2s, s2c) = derive_directional_keys(TOKEN, &cr, &sr).unwrap();
360        assert_ne!(c2s, s2c, "directions must derive different keys");
361
362        // Different session randoms → different keys.
363        let (hello2, cr2) = new_hello();
364        let (_, sr2) = new_accept();
365        let (c2s2, _) = derive_directional_keys(TOKEN, &cr2, &sr2).unwrap();
366        assert_ne!(c2s, c2s2, "keys must be session-specific");
367        let _ = hello2;
368
369        // Wrong token → different keys.
370        let (c2s3, _) = derive_directional_keys(
371            "ff112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
372            &cr,
373            &sr,
374        )
375        .unwrap();
376        assert_ne!(c2s, c2s3);
377    }
378
379    /// Positive: both directions round-trip. Negative: replay, reflection,
380    /// tamper, and reorder are all refused (WBS-510/511).
381    #[test]
382    fn seal_open_round_trip_and_replay_reflection_rejected() {
383        let (_, cr) = new_hello();
384        let (_, sr) = new_accept();
385        let (c2s, s2c) = derive_directional_keys(TOKEN, &cr, &sr).unwrap();
386        let mut client = SessionCrypto::client(c2s, s2c);
387        let mut server = SessionCrypto::server(c2s, s2c);
388
389        let plaintext = br#"{"token":"t","message":"CheckVault"}"#;
390
391        // Bidirectional round trips.
392        let frame = client.seal(plaintext).unwrap();
393        assert_eq!(server.open(&frame).unwrap().as_slice(), &plaintext[..]);
394        let reply = server.seal(b"ok").unwrap();
395        assert_eq!(client.open(&reply).unwrap(), b"ok");
396
397        // Replay: the SAME frame delivered AGAIN is refused (counter not
398        // newer), while the next legitimate frame is accepted.
399        let frame2 = client.seal(b"second").unwrap();
400        assert_eq!(server.open(&frame2).unwrap(), b"second");
401        assert!(
402            server.open(&frame2).is_err(),
403            "replayed frame must be refused"
404        );
405
406        // Reflection: a c2s frame fed to the CLIENT endpoint (its own
407        // receive direction is s2c) fails authentication.
408        let frame3 = client.seal(b"third").unwrap();
409        assert!(
410            client.open(&frame3).is_err(),
411            "reflected frame must be refused"
412        );
413
414        // Reorder: once c5 is accepted, the older c4 is refused (strictly
415        // increasing per direction; gaps are allowed, regressions are not).
416        let frame4 = client.seal(b"fourth").unwrap();
417        let frame5 = client.seal(b"fifth").unwrap();
418        assert_eq!(server.open(&frame5).unwrap(), b"fifth");
419        assert!(server.open(&frame4).is_err(), "older frame must be refused");
420
421        // Tamper: one flipped bit breaks authentication.
422        let mut frame6 = client.seal(b"sixth").unwrap();
423        let last = frame6.len() - 1;
424        frame6[last] ^= 1;
425        assert!(server.open(&frame6).is_err());
426    }
427
428    #[test]
429    fn hello_detection_and_rejects() {
430        let (hello, _) = new_hello();
431        let bytes = serde_json::to_vec(&hello).unwrap();
432        assert!(is_session_hello(&bytes));
433
434        // A plaintext envelope frame is NOT a hello (legacy detection).
435        let envelope = br#"{"token":"t","message":"CheckVault"}"#;
436        assert!(!is_session_hello(envelope));
437
438        // Wrong version refused by parse_hello.
439        let bad = serde_json::json!({ "v": 99, "cr": hex::encode([0u8; 32]) });
440        assert!(parse_hello(serde_json::to_vec(&bad).unwrap().as_slice()).is_err());
441
442        // Short client random refused.
443        let bad = serde_json::json!({ "v": 1, "cr": "aabb" });
444        assert!(parse_hello(serde_json::to_vec(&bad).unwrap().as_slice()).is_err());
445    }
446}