Skip to main content

tf_types/
session.rs

1//! Session protocol — Phase 3 prototype. Mirrors
2//! `tools/tf-types-ts/src/core/session.ts` byte-for-byte where deterministic.
3//!
4//! 3-message handshake (HelloI, HelloR, Auth) followed by sequence-numbered
5//! AEAD frames. Rekey is in-band via rekey-req / rekey-ack.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::actor_id::derive_peer_actor;
11use crate::canonical::canonicalize;
12use crate::crypto::{
13    b64decode, b64encode, chacha20poly1305_decrypt, chacha20poly1305_encrypt, ed25519_verify,
14    hkdf_sha256, x25519_diffie_hellman, x25519_from_bytes, x25519_generate, AeadError, CryptoError,
15    Ed25519Signer, X25519KeyPair,
16};
17use crate::crypto_pq::{ml_dsa_65_sign, ml_dsa_65_verify};
18
19fn is_hybrid_suite(suite: &str) -> bool {
20    suite.ends_with("+ml-dsa-65")
21}
22
23pub const SESSION_VERSION: u32 = 0;
24pub const SESSION_SUITE: &str = "x25519-hkdf-sha256-chacha20poly1305-ed25519";
25pub const SESSION_SUITE_HYBRID_ED25519_MLDSA65: &str =
26    "x25519-hkdf-sha256-chacha20poly1305-ed25519+ml-dsa-65";
27
28/// Suites this build of TrustForge knows how to honour. Order is preference.
29pub const KNOWN_SESSION_SUITES: &[&str] = &[SESSION_SUITE, SESSION_SUITE_HYBRID_ED25519_MLDSA65];
30
31#[derive(Debug, thiserror::Error, PartialEq, Eq)]
32pub enum SessionError {
33    #[error("session error: {0}")]
34    Generic(String),
35    #[error("aead failure at seq {0}")]
36    Aead(u64),
37    #[error("crypto error: {0}")]
38    Crypto(String),
39}
40
41impl From<CryptoError> for SessionError {
42    fn from(e: CryptoError) -> Self {
43        SessionError::Crypto(e.to_string())
44    }
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(tag = "kind", rename = "hello-i")]
49pub struct HelloI {
50    pub version: u32,
51    pub suite: String,
52    /// Suite preference list. Earlier entries are preferred. The default
53    /// classical suite is always implicit so existing peers still
54    /// interoperate.
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub supported_suites: Option<Vec<String>>,
57    pub session_id: String,
58    pub peer_hint: String,
59    /// Initiator's self-claimed actor URI (advisory; not bound to the key).
60    #[serde(skip_serializing_if = "Option::is_none", default)]
61    pub self_hint: Option<String>,
62    pub eph_pub: String,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(tag = "kind", rename = "hello-r")]
67pub struct HelloR {
68    pub eph_pub: String,
69    pub ident_pub: String,
70    /// Suite the responder selected from the initiator's supported_suites.
71    /// When omitted, the responder agrees to the suite the initiator named
72    /// in HelloI.suite.
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    pub selected_suite: Option<String>,
75    /// Responder's self-claimed actor URI (advisory).
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub self_hint: Option<String>,
78    /// Hybrid-PQ companion signature over the same transcript_hash. When
79    /// present, both the ed25519 `signature` and `signature_mldsa` MUST
80    /// verify for the handshake to be accepted.
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub signature_mldsa: Option<String>,
83    /// Public ml-dsa key used to verify `signature_mldsa`. Required when
84    /// `signature_mldsa` is present.
85    #[serde(skip_serializing_if = "Option::is_none", default)]
86    pub ident_pub_mldsa: Option<String>,
87    pub signature: String,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(tag = "kind", rename = "auth")]
92pub struct Auth {
93    pub ident_pub: String,
94    /// Hybrid-PQ companion signature; required when the negotiated suite
95    /// is the hybrid `*+ml-dsa-65` variant.
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub signature_mldsa: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    pub ident_pub_mldsa: Option<String>,
100    pub signature: String,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(tag = "kind", rename_all = "kebab-case")]
105pub enum SessionFrame {
106    Data {
107        payload: serde_json::Value,
108    },
109    RekeyReq {
110        eph_pub: String,
111    },
112    RekeyAck {
113        eph_pub: String,
114    },
115    Close {
116        #[serde(skip_serializing_if = "Option::is_none")]
117        reason: Option<String>,
118    },
119    Ping {
120        nonce: String,
121    },
122    Pong {
123        nonce: String,
124    },
125}
126
127#[derive(Clone, Debug, Default)]
128pub struct SessionConfig {
129    pub self_actor: String,
130    pub peer_hint: Option<String>,
131    /// Optional self-claimed actor URI advertised in HelloI / HelloR.
132    pub self_hint: Option<String>,
133    pub identity_priv: [u8; 32],
134    pub identity_pub: [u8; 32],
135    /// Preferred suite. Default: SESSION_SUITE.
136    pub preferred_suite: Option<String>,
137    /// Suites this peer is willing to accept; defaults to KNOWN_SESSION_SUITES.
138    pub supported_suites: Option<Vec<String>>,
139    /// ml-dsa-65 secret key. Required when the negotiated suite is hybrid.
140    pub identity_mldsa_priv: Option<Vec<u8>>,
141    /// ml-dsa-65 public key. Required when the negotiated suite is hybrid.
142    pub identity_mldsa_pub: Option<Vec<u8>>,
143    pub eph_seed: Option<[u8; 32]>,
144    pub session_id_seed: Option<[u8; 16]>,
145}
146
147pub struct Initiator {
148    cfg: SessionConfig,
149    state: InitiatorState,
150}
151
152enum InitiatorState {
153    Fresh,
154    AwaitingHelloR { hello_i: HelloI, eph_priv: [u8; 32] },
155    Established(SessionState),
156}
157
158impl Initiator {
159    pub fn new(cfg: SessionConfig) -> Self {
160        Initiator {
161            cfg,
162            state: InitiatorState::Fresh,
163        }
164    }
165
166    /// Returns the established session state when the handshake has completed,
167    /// otherwise None. Callers use this to inspect the session without having
168    /// to retain a separate copy.
169    pub fn established_session(&self) -> Option<&SessionState> {
170        match &self.state {
171            InitiatorState::Established(s) => Some(s),
172            _ => None,
173        }
174    }
175
176    pub fn start(&mut self) -> Result<HelloI, SessionError> {
177        let InitiatorState::Fresh = self.state else {
178            return Err(SessionError::Generic("initiator already started".into()));
179        };
180        let eph = make_ephemeral(&self.cfg.eph_seed);
181        let session_id_bytes = match &self.cfg.session_id_seed {
182            Some(seed) => *seed,
183            None => {
184                let mut buf = [0u8; 16];
185                use rand::RngCore;
186                rand::thread_rng().fill_bytes(&mut buf);
187                buf
188            }
189        };
190        let preferred = self
191            .cfg
192            .preferred_suite
193            .clone()
194            .unwrap_or_else(|| SESSION_SUITE.to_owned());
195        let mut supported = self
196            .cfg
197            .supported_suites
198            .clone()
199            .unwrap_or_else(|| KNOWN_SESSION_SUITES.iter().map(|s| s.to_string()).collect());
200        // Move preferred to the front so the responder's first-match
201        // negotiation honours preference.
202        supported.retain(|s| s != &preferred);
203        supported.insert(0, preferred.clone());
204        let hello_i = HelloI {
205            version: SESSION_VERSION,
206            suite: preferred,
207            supported_suites: Some(supported),
208            session_id: b64encode(&session_id_bytes),
209            peer_hint: self.cfg.peer_hint.clone().unwrap_or_default(),
210            self_hint: self.cfg.self_hint.clone(),
211            eph_pub: b64encode(&eph.public),
212        };
213        self.state = InitiatorState::AwaitingHelloR {
214            hello_i: hello_i.clone(),
215            eph_priv: eph.private,
216        };
217        Ok(hello_i)
218    }
219
220    pub fn process_hello_r(&mut self, msg: HelloR) -> Result<(Auth, SessionState), SessionError> {
221        let (hello_i, eph_priv) = match std::mem::replace(&mut self.state, InitiatorState::Fresh) {
222            InitiatorState::AwaitingHelloR { hello_i, eph_priv } => (hello_i, eph_priv),
223            _ => return Err(SessionError::Generic("not awaiting hello-r".into())),
224        };
225
226        // For canonical transcript bytes both parties drop ed25519+mldsa
227        // signature fields together so neither side embeds the wrong pair
228        // of signatures.
229        let hello_r_unsigned = HelloR {
230            signature: String::new(),
231            signature_mldsa: None,
232            ..msg.clone()
233        };
234        let transcript = canonical_concat(&[
235            &serde_json::to_value(&hello_i).map_err(|e| SessionError::Generic(e.to_string()))?,
236            &serde_json::to_value(&hello_r_unsigned)
237                .map_err(|e| SessionError::Generic(e.to_string()))?,
238        ])?;
239        let transcript_hash = Sha256::digest(transcript.as_bytes());
240
241        let ident_pub = b64decode(&msg.ident_pub)?;
242        let sig = b64decode(&msg.signature)?;
243        ed25519_verify(&ident_pub, &transcript_hash, &sig)
244            .map_err(|_| SessionError::Generic("responder identity signature invalid".into()))?;
245
246        let negotiated_suite = msg
247            .selected_suite
248            .clone()
249            .unwrap_or_else(|| hello_i.suite.clone());
250        if is_hybrid_suite(&negotiated_suite) {
251            let pq_sig_b64 = msg.signature_mldsa.as_deref().ok_or_else(|| {
252                SessionError::Generic(format!(
253                    "negotiated hybrid suite {} but HelloR missing signature_mldsa",
254                    negotiated_suite
255                ))
256            })?;
257            let pq_pub_b64 = msg.ident_pub_mldsa.as_deref().ok_or_else(|| {
258                SessionError::Generic(format!(
259                    "negotiated hybrid suite {} but HelloR missing ident_pub_mldsa",
260                    negotiated_suite
261                ))
262            })?;
263            let pq_sig = b64decode(pq_sig_b64)?;
264            let pq_pub = b64decode(pq_pub_b64)?;
265            if !ml_dsa_65_verify(&pq_pub, transcript_hash.as_slice(), &pq_sig) {
266                return Err(SessionError::Generic(
267                    "responder ml-dsa-65 signature invalid".into(),
268                ));
269            }
270        }
271
272        let peer_eph: [u8; 32] = b64decode(&msg.eph_pub)?
273            .try_into()
274            .map_err(|_| SessionError::Generic("eph_pub not 32 bytes".into()))?;
275        let shared = x25519_diffie_hellman(&eph_priv, &peer_eph);
276
277        let auth_unsigned = Auth {
278            ident_pub: b64encode(&self.cfg.identity_pub),
279            signature_mldsa: None,
280            ident_pub_mldsa: if is_hybrid_suite(&negotiated_suite) {
281                self.cfg.identity_mldsa_pub.as_deref().map(b64encode)
282            } else {
283                None
284            },
285            signature: String::new(),
286        };
287        let full_transcript = canonical_concat(&[
288            &serde_json::to_value(&hello_i).map_err(|e| SessionError::Generic(e.to_string()))?,
289            &serde_json::to_value(&msg).map_err(|e| SessionError::Generic(e.to_string()))?,
290            &serde_json::to_value(&auth_unsigned)
291                .map_err(|e| SessionError::Generic(e.to_string()))?,
292        ])?;
293        let full_hash = Sha256::digest(full_transcript.as_bytes());
294
295        let signer = Ed25519Signer::from_bytes(&self.cfg.identity_priv);
296        let auth_sig = signer.sign(&full_hash);
297        let auth_pq_sig = if is_hybrid_suite(&negotiated_suite) {
298            let priv_bytes = self.cfg.identity_mldsa_priv.as_ref().ok_or_else(|| {
299                SessionError::Generic(format!(
300                    "negotiated hybrid suite {} but initiator is missing identity_mldsa_priv",
301                    negotiated_suite
302                ))
303            })?;
304            Some(b64encode(
305                &ml_dsa_65_sign(priv_bytes, full_hash.as_slice())
306                    .map_err(|e| SessionError::Generic(e.to_string()))?,
307            ))
308        } else {
309            None
310        };
311        let auth = Auth {
312            ident_pub: b64encode(&self.cfg.identity_pub),
313            signature_mldsa: auth_pq_sig,
314            ident_pub_mldsa: auth_unsigned.ident_pub_mldsa.clone(),
315            signature: b64encode(&auth_sig),
316        };
317
318        let session_id_bytes = b64decode(&hello_i.session_id)?;
319        let peer_actor = derive_peer_actor(&ident_pub)
320            .map_err(|e| SessionError::Generic(format!("derive_peer_actor: {e}")))?;
321        // Responder's self-claimed actor URI travels in HelloR.self_hint.
322        let peer_claim = msg.self_hint.clone().filter(|s| !s.is_empty());
323        let session = SessionState::derive_with_claim(
324            Role::Initiator,
325            &shared,
326            &session_id_bytes,
327            &full_hash,
328            &self.cfg.self_actor,
329            &peer_actor,
330            peer_claim,
331        );
332        self.state = InitiatorState::Established(session.clone());
333        Ok((auth, session))
334    }
335}
336
337pub struct Responder {
338    cfg: SessionConfig,
339    state: ResponderState,
340}
341
342enum ResponderState {
343    Fresh,
344    AwaitingAuth {
345        hello_i: HelloI,
346        hello_r: HelloR,
347        shared: [u8; 32],
348    },
349    Established(SessionState),
350}
351
352impl Responder {
353    /// Returns the established session state when the handshake has completed,
354    /// otherwise None.
355    pub fn established_session(&self) -> Option<&SessionState> {
356        match &self.state {
357            ResponderState::Established(s) => Some(s),
358            _ => None,
359        }
360    }
361
362    pub fn new(cfg: SessionConfig) -> Self {
363        Responder {
364            cfg,
365            state: ResponderState::Fresh,
366        }
367    }
368
369    pub fn process_hello_i(&mut self, msg: HelloI) -> Result<HelloR, SessionError> {
370        let ResponderState::Fresh = self.state else {
371            return Err(SessionError::Generic("responder already engaged".into()));
372        };
373        if msg.version != SESSION_VERSION {
374            return Err(SessionError::Generic(format!(
375                "unsupported version {}",
376                msg.version
377            )));
378        }
379        // Suite negotiation: pick the first entry of the initiator's
380        // supported_suites that we know AND that we accept; fall back to
381        // msg.suite for legacy peers without supported_suites.
382        let our_supported: Vec<String> = self
383            .cfg
384            .supported_suites
385            .clone()
386            .unwrap_or_else(|| KNOWN_SESSION_SUITES.iter().map(|s| s.to_string()).collect());
387        let chosen = match &msg.supported_suites {
388            Some(client_supports) => client_supports
389                .iter()
390                .find(|s| our_supported.iter().any(|o| o == *s))
391                .cloned()
392                .ok_or_else(|| {
393                    SessionError::Generic(format!(
394                        "no mutually-supported suite (peer offered {:?}, we support {:?})",
395                        client_supports, our_supported
396                    ))
397                })?,
398            None => {
399                if !our_supported.iter().any(|s| s == &msg.suite) {
400                    return Err(SessionError::Generic(format!(
401                        "unsupported suite {}",
402                        msg.suite
403                    )));
404                }
405                msg.suite.clone()
406            }
407        };
408
409        let eph = make_ephemeral(&self.cfg.eph_seed);
410        let peer_eph: [u8; 32] = b64decode(&msg.eph_pub)?
411            .try_into()
412            .map_err(|_| SessionError::Generic("eph_pub not 32 bytes".into()))?;
413        let shared = x25519_diffie_hellman(&eph.private, &peer_eph);
414
415        let hello_r_unsigned = HelloR {
416            eph_pub: b64encode(&eph.public),
417            ident_pub: b64encode(&self.cfg.identity_pub),
418            selected_suite: Some(chosen.clone()),
419            self_hint: self.cfg.self_hint.clone(),
420            signature_mldsa: None,
421            ident_pub_mldsa: if is_hybrid_suite(&chosen) {
422                self.cfg.identity_mldsa_pub.as_deref().map(b64encode)
423            } else {
424                None
425            },
426            signature: String::new(),
427        };
428        let transcript = canonical_concat(&[
429            &serde_json::to_value(&msg).map_err(|e| SessionError::Generic(e.to_string()))?,
430            &serde_json::to_value(&hello_r_unsigned)
431                .map_err(|e| SessionError::Generic(e.to_string()))?,
432        ])?;
433        let transcript_hash = Sha256::digest(transcript.as_bytes());
434
435        let signer = Ed25519Signer::from_bytes(&self.cfg.identity_priv);
436        let sig = signer.sign(&transcript_hash);
437        let pq_sig = if is_hybrid_suite(&chosen) {
438            let priv_bytes = self.cfg.identity_mldsa_priv.as_ref().ok_or_else(|| {
439                SessionError::Generic(format!(
440                    "negotiated hybrid suite {} but responder is missing identity_mldsa_priv",
441                    chosen
442                ))
443            })?;
444            Some(b64encode(
445                &ml_dsa_65_sign(priv_bytes, transcript_hash.as_slice())
446                    .map_err(|e| SessionError::Generic(e.to_string()))?,
447            ))
448        } else {
449            None
450        };
451        let hello_r = HelloR {
452            signature: b64encode(&sig),
453            signature_mldsa: pq_sig,
454            ..hello_r_unsigned
455        };
456
457        self.state = ResponderState::AwaitingAuth {
458            hello_i: msg,
459            hello_r: hello_r.clone(),
460            shared,
461        };
462        Ok(hello_r)
463    }
464
465    pub fn process_auth(&mut self, msg: Auth) -> Result<SessionState, SessionError> {
466        let (hello_i, hello_r, shared) =
467            match std::mem::replace(&mut self.state, ResponderState::Fresh) {
468                ResponderState::AwaitingAuth {
469                    hello_i,
470                    hello_r,
471                    shared,
472                } => (hello_i, hello_r, shared),
473                _ => return Err(SessionError::Generic("not awaiting auth".into())),
474            };
475
476        let auth_unsigned = Auth {
477            signature: String::new(),
478            signature_mldsa: None,
479            ..msg.clone()
480        };
481        let full_transcript = canonical_concat(&[
482            &serde_json::to_value(&hello_i).map_err(|e| SessionError::Generic(e.to_string()))?,
483            &serde_json::to_value(&hello_r).map_err(|e| SessionError::Generic(e.to_string()))?,
484            &serde_json::to_value(&auth_unsigned)
485                .map_err(|e| SessionError::Generic(e.to_string()))?,
486        ])?;
487        let full_hash = Sha256::digest(full_transcript.as_bytes());
488
489        let ident_pub = b64decode(&msg.ident_pub)?;
490        let sig = b64decode(&msg.signature)?;
491        ed25519_verify(&ident_pub, &full_hash, &sig)
492            .map_err(|_| SessionError::Generic("initiator identity signature invalid".into()))?;
493
494        let negotiated_suite = hello_r
495            .selected_suite
496            .clone()
497            .unwrap_or_else(|| hello_i.suite.clone());
498        if is_hybrid_suite(&negotiated_suite) {
499            let pq_sig_b64 = msg.signature_mldsa.as_deref().ok_or_else(|| {
500                SessionError::Generic(format!(
501                    "negotiated hybrid suite {} but Auth missing signature_mldsa",
502                    negotiated_suite
503                ))
504            })?;
505            let pq_pub_b64 = msg.ident_pub_mldsa.as_deref().ok_or_else(|| {
506                SessionError::Generic(format!(
507                    "negotiated hybrid suite {} but Auth missing ident_pub_mldsa",
508                    negotiated_suite
509                ))
510            })?;
511            let pq_sig = b64decode(pq_sig_b64)?;
512            let pq_pub = b64decode(pq_pub_b64)?;
513            if !ml_dsa_65_verify(&pq_pub, full_hash.as_slice(), &pq_sig) {
514                return Err(SessionError::Generic(
515                    "initiator ml-dsa-65 signature invalid".into(),
516                ));
517            }
518        }
519
520        let session_id_bytes = b64decode(&hello_i.session_id)?;
521        let peer_actor = derive_peer_actor(&ident_pub)
522            .map_err(|e| SessionError::Generic(format!("derive_peer_actor: {e}")))?;
523        // Initiator's self-claimed actor URI travels in HelloI.self_hint.
524        let peer_claim = hello_i.self_hint.clone().filter(|s| !s.is_empty());
525        let session = SessionState::derive_with_claim(
526            Role::Responder,
527            &shared,
528            &session_id_bytes,
529            &full_hash,
530            &self.cfg.self_actor,
531            &peer_actor,
532            peer_claim,
533        );
534        self.state = ResponderState::Established(session.clone());
535        Ok(session)
536    }
537}
538
539#[derive(Clone, Debug, PartialEq, Eq)]
540pub enum Role {
541    Initiator,
542    Responder,
543}
544
545#[derive(Clone, Debug)]
546pub struct SessionState {
547    pub self_actor: String,
548    /// Key-derived canonical peer actor URI. Authoritative.
549    pub peer_actor: String,
550    /// Self-claimed peer actor URI from `peer_hint`. Advisory only.
551    pub peer_actor_claim: Option<String>,
552    pub session_id: Vec<u8>,
553    pub generation: u32,
554    pub send_key: [u8; 32],
555    pub recv_key: [u8; 32],
556    pub send_seq: u64,
557    pub recv_seq: u64,
558    pub closed: bool,
559    pending_rekey_priv: Option<[u8; 32]>,
560}
561
562impl SessionState {
563    pub fn derive(
564        role: Role,
565        shared_secret: &[u8; 32],
566        session_id: &[u8],
567        transcript_hash: &[u8],
568        self_actor: &str,
569        peer_actor: &str,
570    ) -> Self {
571        Self::derive_with_claim(
572            role,
573            shared_secret,
574            session_id,
575            transcript_hash,
576            self_actor,
577            peer_actor,
578            None,
579        )
580    }
581
582    pub fn derive_with_claim(
583        role: Role,
584        shared_secret: &[u8; 32],
585        session_id: &[u8],
586        transcript_hash: &[u8],
587        self_actor: &str,
588        peer_actor: &str,
589        peer_actor_claim: Option<String>,
590    ) -> Self {
591        let mut info = b"tf-session/v0/keys".to_vec();
592        info.extend_from_slice(transcript_hash);
593        let ikm = hkdf_sha256(shared_secret, session_id, &info, 64);
594        let i_to_r: [u8; 32] = ikm[0..32].try_into().unwrap();
595        let r_to_i: [u8; 32] = ikm[32..64].try_into().unwrap();
596        let (send_key, recv_key) = match role {
597            Role::Initiator => (i_to_r, r_to_i),
598            Role::Responder => (r_to_i, i_to_r),
599        };
600        SessionState {
601            self_actor: self_actor.to_owned(),
602            peer_actor: peer_actor.to_owned(),
603            peer_actor_claim,
604            session_id: session_id.to_vec(),
605            generation: 0,
606            send_key,
607            recv_key,
608            send_seq: 0,
609            recv_seq: 0,
610            closed: false,
611            pending_rekey_priv: None,
612        }
613    }
614
615    pub fn encrypt(&mut self, frame: &SessionFrame) -> Result<Vec<u8>, SessionError> {
616        if self.closed {
617            return Err(SessionError::Generic("session is closed".into()));
618        }
619        let body_value =
620            serde_json::to_value(frame).map_err(|e| SessionError::Generic(e.to_string()))?;
621        let body = canonicalize(&body_value).map_err(|e| SessionError::Generic(e.to_string()))?;
622        let plaintext = body.into_bytes();
623        let seq = self.send_seq;
624        let nonce = nonce_for(seq);
625        let length = 8 + plaintext.len() + 16;
626        if length > u32::MAX as usize {
627            return Err(SessionError::Generic("frame too long".into()));
628        }
629        let aad = make_aad(length as u32, seq);
630        let ct = chacha20poly1305_encrypt(&self.send_key, &nonce, &aad, &plaintext);
631        let mut out = Vec::with_capacity(4 + length);
632        out.extend_from_slice(&(length as u32).to_be_bytes());
633        out.extend_from_slice(&seq.to_be_bytes());
634        out.extend_from_slice(&ct);
635        self.send_seq = seq.wrapping_add(1);
636        Ok(out)
637    }
638
639    pub fn decrypt(&mut self, bytes: &[u8]) -> Result<SessionFrame, SessionError> {
640        if self.closed {
641            return Err(SessionError::Generic("session is closed".into()));
642        }
643        if bytes.len() < 12 + 16 {
644            return Err(SessionError::Generic("frame too short".into()));
645        }
646        let length = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize;
647        if 4 + length != bytes.len() {
648            return Err(SessionError::Generic("length mismatch".into()));
649        }
650        let seq = u64::from_be_bytes(bytes[4..12].try_into().unwrap());
651        if seq != self.recv_seq {
652            return Err(SessionError::Generic(format!(
653                "out-of-order frame: got {}, expected {}",
654                seq, self.recv_seq
655            )));
656        }
657        let aad = make_aad(length as u32, seq);
658        let nonce = nonce_for(seq);
659        let pt = chacha20poly1305_decrypt(&self.recv_key, &nonce, &aad, &bytes[12..])
660            .map_err(|_: AeadError| SessionError::Aead(seq))?;
661        let value: serde_json::Value =
662            serde_json::from_slice(&pt).map_err(|e| SessionError::Generic(e.to_string()))?;
663        let frame: SessionFrame =
664            serde_json::from_value(value).map_err(|e| SessionError::Generic(e.to_string()))?;
665        self.recv_seq = seq.wrapping_add(1);
666        Ok(frame)
667    }
668
669    pub fn request_rekey(&mut self, seed: Option<[u8; 32]>) -> Result<Vec<u8>, SessionError> {
670        let eph = make_ephemeral(&seed);
671        self.pending_rekey_priv = Some(eph.private);
672        self.encrypt(&SessionFrame::RekeyReq {
673            eph_pub: b64encode(&eph.public),
674        })
675    }
676
677    pub fn process_rekey_req(
678        &mut self,
679        peer_eph_pub_b64: &str,
680        seed: Option<[u8; 32]>,
681    ) -> Result<Vec<u8>, SessionError> {
682        let eph = make_ephemeral(&seed);
683        let peer_eph: [u8; 32] = b64decode(peer_eph_pub_b64)?
684            .try_into()
685            .map_err(|_| SessionError::Generic("eph_pub not 32 bytes".into()))?;
686        let shared = x25519_diffie_hellman(&eph.private, &peer_eph);
687        let ack = self.encrypt(&SessionFrame::RekeyAck {
688            eph_pub: b64encode(&eph.public),
689        })?;
690        self.rotate_keys(&shared);
691        Ok(ack)
692    }
693
694    pub fn process_rekey_ack(&mut self, peer_eph_pub_b64: &str) -> Result<(), SessionError> {
695        let pending = self
696            .pending_rekey_priv
697            .take()
698            .ok_or_else(|| SessionError::Generic("no pending rekey".into()))?;
699        let peer_eph: [u8; 32] = b64decode(peer_eph_pub_b64)?
700            .try_into()
701            .map_err(|_| SessionError::Generic("eph_pub not 32 bytes".into()))?;
702        let shared = x25519_diffie_hellman(&pending, &peer_eph);
703        self.rotate_keys(&shared);
704        Ok(())
705    }
706
707    fn rotate_keys(&mut self, shared: &[u8; 32]) {
708        // Canonical concat: lower-hex first.
709        let send_hex = hex_lower_32(&self.send_key);
710        let recv_hex = hex_lower_32(&self.recv_key);
711        let send_is_lower = send_hex < recv_hex;
712        let lo = if send_is_lower {
713            &self.send_key
714        } else {
715            &self.recv_key
716        };
717        let hi = if send_is_lower {
718            &self.recv_key
719        } else {
720            &self.send_key
721        };
722        let mut concat = Vec::with_capacity(64);
723        concat.extend_from_slice(lo);
724        concat.extend_from_slice(hi);
725        let prev_hash = Sha256::digest(&concat);
726
727        let info_label = format!("tf-session/v0/keys/g{}", self.generation + 1);
728        let mut info = info_label.into_bytes();
729        info.extend_from_slice(&prev_hash);
730        let ikm = hkdf_sha256(shared, &self.session_id, &info, 64);
731        let k1: [u8; 32] = ikm[0..32].try_into().unwrap();
732        let k2: [u8; 32] = ikm[32..64].try_into().unwrap();
733        if send_is_lower {
734            self.send_key = k1;
735            self.recv_key = k2;
736        } else {
737            self.send_key = k2;
738            self.recv_key = k1;
739        }
740        self.send_seq = 0;
741        self.recv_seq = 0;
742        self.generation += 1;
743    }
744}
745
746fn nonce_for(seq: u64) -> [u8; 12] {
747    let mut out = [0u8; 12];
748    out[4..].copy_from_slice(&seq.to_be_bytes());
749    out
750}
751
752fn make_aad(length: u32, seq: u64) -> [u8; 12] {
753    let mut out = [0u8; 12];
754    out[0..4].copy_from_slice(&length.to_be_bytes());
755    out[4..].copy_from_slice(&seq.to_be_bytes());
756    out
757}
758
759fn make_ephemeral(seed: &Option<[u8; 32]>) -> X25519KeyPair {
760    match seed {
761        Some(s) => x25519_from_bytes(s),
762        None => {
763            let mut rng = rand::thread_rng();
764            x25519_generate(&mut rng)
765        }
766    }
767}
768
769fn canonical_concat(values: &[&serde_json::Value]) -> Result<String, SessionError> {
770    let mut out = String::new();
771    for v in values {
772        let s = canonicalize(v).map_err(|e| SessionError::Generic(e.to_string()))?;
773        out.push_str(&s);
774    }
775    Ok(out)
776}
777
778fn hex_lower_32(bytes: &[u8; 32]) -> String {
779    let mut s = String::with_capacity(64);
780    for b in bytes {
781        s.push_str(&format!("{:02x}", b));
782    }
783    s
784}