Skip to main content

osdp/secure/
session.rs

1//! Type-state secure-channel state machine.
2//!
3//! # Spec: Annex D.4
4//!
5//! States are phantom-typed so that calling out-of-order is a *compile* error
6//! rather than a runtime fault. Calls from any state may transition back to
7//! [`Disconnected`] on error.
8//!
9//! See [`Session`] for the rendered state diagram.
10
11use crate::error::SecureSessionError;
12use crate::reply::CCrypt;
13use crate::secure::crypto::{SessionKeys, client_cryptogram, initial_rmac, server_cryptogram};
14use crate::secure::mac::cbc_mac;
15use core::marker::PhantomData;
16use subtle::ConstantTimeEq;
17use zeroize::{Zeroize, ZeroizeOnDrop};
18
19/// Phantom: session not started.
20#[derive(Debug, Clone, Copy)]
21pub struct Disconnected;
22/// Phantom: ACU has sent CHLNG and is awaiting CCRYPT.
23#[derive(Debug, Clone, Copy)]
24pub struct Challenged;
25/// Phantom: ACU has verified CCRYPT and is awaiting RMAC_I.
26#[derive(Debug, Clone, Copy)]
27pub struct Cryptogrammed;
28/// Phantom: SC fully established.
29#[derive(Debug, Clone, Copy)]
30pub struct Secure;
31
32/// Secure-channel session state.
33///
34/// The phantom parameter `S` tracks which step of the Annex D.4 handshake
35/// the session is in. Each transition consumes the old `Session` and yields
36/// a new one in the next state — calling methods out of order is therefore a
37/// *compile* error rather than a runtime fault.
38///
39#[cfg_attr(feature = "_docs", aquamarine::aquamarine)]
40/// ```mermaid
41/// stateDiagram-v2
42///     [*] --> Disconnected: Session::new(scbk)
43///     Disconnected --> Challenged: challenge(RND.A)
44///     Challenged --> Cryptogrammed: receive_ccrypt(ccrypt) ✔
45///     Challenged --> Disconnected: receive_ccrypt(ccrypt) ✘<br/>BadCryptogram
46///     Cryptogrammed --> Secure: confirm_rmac_i(mac) ✔
47///     Cryptogrammed --> Disconnected: confirm_rmac_i(mac) ✘<br/>BadCryptogram
48///     Secure --> Secure: mac() / verify()<br/>seal_data() / open_data()
49/// ```
50///
51/// All fields are zeroized when the session is dropped (regardless of which
52/// state it is in), so cancelled or panicking flows do not leave the SCBK or
53/// derived material in memory. Note that `Clone` is preserved for ergonomic
54/// fork-and-mirror flows: cloning duplicates the key material, and only the
55/// dropped clone is zeroized — the surviving clone still holds keys until it
56/// is dropped in turn.
57#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)]
58pub struct Session<S> {
59    scbk: [u8; 16],
60    rnd_a: [u8; 8],
61    rnd_b: [u8; 8],
62    cuid: [u8; 8],
63    keys: SessionKeys,
64    /// Last MAC received from the *other* device — used as ICV for the next
65    /// outgoing MAC, per Annex D.5.
66    last_their_mac: [u8; 16],
67    #[zeroize(skip)]
68    _state: PhantomData<S>,
69}
70
71impl<S> Session<S> {
72    /// Carry every field forward into a new phantom state. Used for
73    /// state transitions that do not mutate cryptographic material.
74    ///
75    /// Uses `core::mem::take` per field rather than direct moves because
76    /// `Session` derives `ZeroizeOnDrop`, which adds a `Drop` impl and
77    /// forbids moving out of fields. Each `take` leaves a zero in `self`,
78    /// the new `Session` carries the original value, and the old `self` is
79    /// then dropped (zeroizing already-zero memory — a no-op).
80    fn transition<T>(mut self) -> Session<T> {
81        Session {
82            scbk: core::mem::take(&mut self.scbk),
83            rnd_a: core::mem::take(&mut self.rnd_a),
84            rnd_b: core::mem::take(&mut self.rnd_b),
85            cuid: core::mem::take(&mut self.cuid),
86            keys: core::mem::take(&mut self.keys),
87            last_their_mac: core::mem::take(&mut self.last_their_mac),
88            _state: PhantomData,
89        }
90    }
91
92    /// Drop back to [`Disconnected`], wiping all derived material but keeping
93    /// the SCBK so the caller can immediately re-handshake.
94    fn reset(mut self) -> Session<Disconnected> {
95        Session {
96            scbk: core::mem::take(&mut self.scbk),
97            rnd_a: [0; 8],
98            rnd_b: [0; 8],
99            cuid: [0; 8],
100            keys: SessionKeys::default(),
101            last_their_mac: [0; 16],
102            _state: PhantomData,
103        }
104    }
105}
106
107impl Session<Disconnected> {
108    /// Begin a new session, holding the SCBK we will use.
109    ///
110    /// # Example
111    ///
112    /// ```
113    /// use osdp::secure::{SCBK_D, Session};
114    /// use osdp::secure::session::Disconnected;
115    /// let session = Session::<Disconnected>::new(SCBK_D);
116    /// // The next step is `session.challenge(rnd_a)` once we've sent osdp_CHLNG.
117    /// # let _ = session;
118    /// ```
119    pub fn new(scbk: [u8; 16]) -> Self {
120        Self {
121            scbk,
122            rnd_a: [0; 8],
123            rnd_b: [0; 8],
124            cuid: [0; 8],
125            keys: SessionKeys::default(),
126            last_their_mac: [0; 16],
127            _state: PhantomData,
128        }
129    }
130
131    /// Move to [`Challenged`] by capturing the `RND.A` we are about to send.
132    pub fn challenge(mut self, rnd_a: [u8; 8]) -> Session<Challenged> {
133        self.rnd_a = rnd_a;
134        self.transition()
135    }
136}
137
138impl Session<Challenged> {
139    /// Verify the PD's [`CCrypt`] and move to [`Cryptogrammed`].
140    pub fn receive_ccrypt(
141        mut self,
142        ccrypt: &CCrypt,
143    ) -> Result<Session<Cryptogrammed>, (Session<Disconnected>, SecureSessionError)> {
144        self.cuid = ccrypt.cuid;
145        self.rnd_b = ccrypt.rnd_b;
146        self.keys = SessionKeys::derive(&self.scbk, &self.rnd_a);
147        let expected = client_cryptogram(&self.keys.s_enc, &self.rnd_a, &self.rnd_b);
148        if expected.ct_eq(&ccrypt.client_cryptogram).unwrap_u8() == 0 {
149            return Err((self.reset(), SecureSessionError::BadCryptogram));
150        }
151        Ok(self.transition())
152    }
153}
154
155impl Session<Cryptogrammed> {
156    /// Compute the server cryptogram to ship in `osdp_SCRYPT`.
157    pub fn server_cryptogram(&self) -> [u8; 16] {
158        server_cryptogram(&self.keys.s_enc, &self.rnd_a, &self.rnd_b)
159    }
160
161    /// Initial R-MAC, computed from `S-MAC1`/`S-MAC2` over the server cryptogram.
162    pub fn initial_rmac(&self) -> [u8; 16] {
163        initial_rmac(
164            &self.keys.s_mac1,
165            &self.keys.s_mac2,
166            &self.server_cryptogram(),
167        )
168    }
169
170    /// PD echoed back our initial R-MAC; advance to fully [`Secure`].
171    pub fn confirm_rmac_i(
172        mut self,
173        their_rmac_i: &[u8; 16],
174    ) -> Result<Session<Secure>, (Session<Disconnected>, SecureSessionError)> {
175        let mine = self.initial_rmac();
176        if mine.ct_eq(their_rmac_i).unwrap_u8() == 0 {
177            return Err((self.reset(), SecureSessionError::BadCryptogram));
178        }
179        self.last_their_mac = mine;
180        Ok(self.transition())
181    }
182}
183
184impl Session<Secure> {
185    /// Compute the MAC trailer for an outgoing packet body.
186    ///
187    /// `bytes` is the SOM..end-of-DATA region (as per [`super::mac::cbc_mac`]).
188    pub fn mac(&mut self, bytes: &[u8]) -> [u8; 16] {
189        let mac = cbc_mac(
190            bytes,
191            &self.last_their_mac,
192            &self.keys.s_mac1,
193            &self.keys.s_mac2,
194        );
195        self.last_their_mac = mac;
196        mac
197    }
198
199    /// Verify a received MAC against our locally-computed value.
200    pub fn verify(
201        &mut self,
202        bytes: &[u8],
203        wire_mac: &[u8; crate::packet::MAC_LEN],
204    ) -> Result<(), SecureSessionError> {
205        let computed = cbc_mac(
206            bytes,
207            &self.last_their_mac,
208            &self.keys.s_mac1,
209            &self.keys.s_mac2,
210        );
211        if computed[..crate::packet::MAC_LEN]
212            .ct_eq(wire_mac)
213            .unwrap_u8()
214            == 0
215        {
216            return Err(SecureSessionError::BadCryptogram);
217        }
218        self.last_their_mac = computed;
219        Ok(())
220    }
221
222    /// Borrow the derived session keys (for AES-CBC DATA encryption).
223    pub fn keys(&self) -> &SessionKeys {
224        &self.keys
225    }
226
227    /// Last MAC the other end produced (used as encryption ICV per Annex D).
228    pub fn last_other_mac(&self) -> &[u8; 16] {
229        &self.last_their_mac
230    }
231
232    /// Encrypt `data` for an `SCS_17`/`SCS_18` payload using the current ICV
233    /// (`!last_other_mac`). Returns the ciphertext (always padded to a 16-byte
234    /// multiple).
235    ///
236    /// # Spec: Annex D.5
237    pub fn seal_data(&self, data: &[u8]) -> alloc::vec::Vec<u8> {
238        let iv = crate::secure::cipher::complement_icv(&self.last_their_mac);
239        crate::secure::cipher::encrypt_data(&self.keys.s_enc, &iv, data)
240    }
241
242    /// Decrypt a payload received in an `SCS_17`/`SCS_18` packet, validating
243    /// the 0x80 padding.
244    ///
245    /// # Spec: Annex D.5
246    pub fn open_data(&self, ct: &[u8]) -> Result<alloc::vec::Vec<u8>, SecureSessionError> {
247        let iv = crate::secure::cipher::complement_icv(&self.last_their_mac);
248        crate::secure::cipher::decrypt_data(&self.keys.s_enc, &iv, ct)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::reply::CCrypt;
256
257    #[test]
258    fn handshake_happy_path() {
259        let scbk = crate::secure::SCBK_D;
260        let session = Session::<Disconnected>::new(scbk);
261
262        let rnd_a = [1u8; 8];
263        let session = session.challenge(rnd_a);
264
265        let rnd_b = [2u8; 8];
266        let cuid = [3u8; 8];
267        let keys = crate::secure::crypto::SessionKeys::derive(&scbk, &rnd_a);
268        let cc = crate::secure::crypto::client_cryptogram(&keys.s_enc, &rnd_a, &rnd_b);
269        let ccrypt = CCrypt {
270            cuid,
271            rnd_b,
272            client_cryptogram: cc,
273        };
274        let session = session.receive_ccrypt(&ccrypt).unwrap();
275
276        let rmac = session.initial_rmac();
277        let session = session.confirm_rmac_i(&rmac).unwrap();
278        let _ = session.last_other_mac();
279    }
280
281    #[test]
282    fn bad_ccrypt_rejected() {
283        let scbk = crate::secure::SCBK_D;
284        let session = Session::<Disconnected>::new(scbk).challenge([0u8; 8]);
285        let ccrypt = CCrypt {
286            cuid: [0; 8],
287            rnd_b: [0; 8],
288            client_cryptogram: [0; 16],
289        };
290        let err = session.receive_ccrypt(&ccrypt).unwrap_err();
291        assert_eq!(err.1, SecureSessionError::BadCryptogram);
292    }
293}