1use crate::transport::{TransportError, TransportResult, MAX_MESSAGE_SIZE};
30use hkdf::Hkdf;
31use rand::{rngs::OsRng, RngCore};
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35pub const SESSION_PROTO_VERSION: u16 = 1;
37pub const SESSION_READ_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
39const AAD_MAGIC: &[u8; 4] = b"SPIS";
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SessionHello {
45 pub v: u16,
47 pub cr: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionAccept {
54 pub v: u16,
55 pub sr: String,
57}
58
59#[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
90struct DirectionState {
93 key: [u8; 32],
94 direction: Direction,
95 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
128fn 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 let mut nonce = [0u8; 12];
144 nonce[4..12].copy_from_slice(&counter.to_be_bytes());
145 nonce
146}
147
148pub fn derive_directional_keys(
152 token: &str,
153 client_random: &[u8; 32],
154 server_random: &[u8; 32],
155) -> TransportResult<([u8; 32], [u8; 32])> {
156 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
175pub struct SessionCrypto {
178 send: DirectionState,
179 recv: DirectionState,
180}
181
182impl SessionCrypto {
183 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 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 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 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 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
267pub 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
309pub 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
322pub 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 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 let (c2s3, _) = derive_directional_keys(
371 "ff112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
372 &cr,
373 &sr,
374 )
375 .unwrap();
376 assert_ne!(c2s, c2s3);
377 }
378
379 #[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 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 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 let frame3 = client.seal(b"third").unwrap();
409 assert!(
410 client.open(&frame3).is_err(),
411 "reflected frame must be refused"
412 );
413
414 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 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 let envelope = br#"{"token":"t","message":"CheckVault"}"#;
436 assert!(!is_session_hello(envelope));
437
438 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 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}