Skip to main content

osdp/secure/
frame.rs

1//! Helpers for constructing and parsing fully-secured frames.
2//!
3//! These tie together [`super::session::Session<Secure>`], the
4//! [`crate::packet::PacketBuilder`], and the AES-CBC payload codec into a
5//! single ergonomic call.
6//!
7//! # Spec: Annex D.5
8
9use crate::error::{Error, SecureSessionError};
10use crate::packet::{Address, ControlByte, CtrlFlags, PacketBuilder, ParsedPacket, Scb, ScsType};
11use crate::secure::session::{Secure, Session};
12use alloc::vec::Vec;
13
14/// What kind of secured frame we're building.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Direction {
17    /// ACU → PD: produces SCS_15 (mac-only) or SCS_17 (encrypted+mac).
18    AcuToPd,
19    /// PD → ACU: produces SCS_16 (mac-only) or SCS_18 (encrypted+mac).
20    PdToAcu,
21}
22
23impl Direction {
24    /// Pick the SCS type based on whether the payload should be encrypted.
25    pub const fn scs_for(self, encrypt: bool) -> ScsType {
26        match (self, encrypt) {
27            (Self::AcuToPd, false) => ScsType::Scs15,
28            (Self::AcuToPd, true) => ScsType::Scs17,
29            (Self::PdToAcu, false) => ScsType::Scs16,
30            (Self::PdToAcu, true) => ScsType::Scs18,
31        }
32    }
33}
34
35/// Build a fully-secured packet (SCB + optional encryption + MAC + trailer).
36///
37/// `data` is the *plaintext* DATA payload — it will be AES-CBC encrypted with
38/// the current ICV if `encrypt` is true. The `code` byte is *not* encrypted
39/// (it sits in front of DATA in the wire layout).
40pub fn seal(
41    session: &mut Session<Secure>,
42    addr: Address,
43    sqn: crate::packet::Sqn,
44    direction: Direction,
45    encrypt: bool,
46    code: u8,
47    data: &[u8],
48) -> Result<Vec<u8>, Error> {
49    let ty = direction.scs_for(encrypt);
50    let scb = Scb::new(ty, []);
51    let payload: Vec<u8> = if encrypt {
52        session.seal_data(data)
53    } else {
54        data.to_vec()
55    };
56    let builder = PacketBuilder {
57        addr,
58        ctrl: ControlByte::new(sqn, CtrlFlags::USE_CRC | CtrlFlags::HAS_SCB),
59        scb: Some(scb),
60        code,
61        data: payload,
62    };
63    builder.encode_with_mac(|bytes| {
64        let full = session.mac(bytes);
65        let mut tag = [0u8; crate::packet::MAC_LEN];
66        tag.copy_from_slice(&full[..crate::packet::MAC_LEN]);
67        tag
68    })
69}
70
71/// Verify the MAC on `parsed` and (if SCS_17/18) decrypt the DATA payload.
72///
73/// The caller is expected to first run [`ParsedPacket::parse`] and verify
74/// that a [`crate::packet::ScsType`] is present and is one of `SCS_15..=18`.
75pub fn unseal(
76    session: &mut Session<Secure>,
77    parsed: &ParsedPacket<'_>,
78    raw: &[u8],
79) -> Result<Vec<u8>, Error> {
80    let scb = parsed
81        .scb
82        .ok_or(Error::SecureSession(SecureSessionError::NotSecure))?;
83    if !scb.ty.has_mac() {
84        return Err(Error::SecureSession(SecureSessionError::NotSecure));
85    }
86    let trailer_len = if parsed.ctrl.use_crc() { 2 } else { 1 };
87    let mac_offset = raw
88        .len()
89        .checked_sub(trailer_len + crate::packet::MAC_LEN)
90        .ok_or(Error::ShortMac)?;
91    let mac_bytes = parsed.mac.ok_or(Error::ShortMac)?;
92
93    // Decrypt DATA first, while the rolling-ICV still reflects the *prior*
94    // MAC. `verify` will then advance the chain.
95    let plaintext = if scb.ty.is_encrypted() {
96        session.open_data(parsed.data).map_err(Error::from)?
97    } else {
98        parsed.data.to_vec()
99    };
100    session
101        .verify(&raw[..mac_offset], &mac_bytes)
102        .map_err(Error::from)?;
103    Ok(plaintext)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::packet::Sqn;
110    use crate::reply::CCrypt;
111    use crate::secure::SCBK_D;
112    use crate::secure::crypto::{SessionKeys, client_cryptogram};
113    use crate::secure::session::{Disconnected, Session};
114
115    fn handshake_pair() -> (Session<Secure>, Session<Secure>) {
116        // ACU side
117        let acu = Session::<Disconnected>::new(SCBK_D);
118        let rnd_a = [0xAAu8; 8];
119        let acu = acu.challenge(rnd_a);
120
121        // PD side computes the same things to mirror state.
122        let rnd_b = [0xBBu8; 8];
123        let cuid = [0xCCu8; 8];
124        let keys = SessionKeys::derive(&SCBK_D, &rnd_a);
125        let cc = client_cryptogram(&keys.s_enc, &rnd_a, &rnd_b);
126
127        let acu = acu
128            .receive_ccrypt(&CCrypt {
129                cuid,
130                rnd_b,
131                client_cryptogram: cc,
132            })
133            .unwrap();
134        let rmac = acu.initial_rmac();
135        let acu = acu.confirm_rmac_i(&rmac).unwrap();
136
137        // PD side mirror: Disconnected → Challenged → Cryptogrammed → Secure
138        let pd = Session::<Disconnected>::new(SCBK_D).challenge(rnd_a);
139        let pd = pd
140            .receive_ccrypt(&CCrypt {
141                cuid,
142                rnd_b,
143                client_cryptogram: cc,
144            })
145            .unwrap();
146        let pd_rmac = pd.initial_rmac();
147        let pd = pd.confirm_rmac_i(&pd_rmac).unwrap();
148
149        // Both sides agree on the same initial R-MAC seed.
150        assert_eq!(rmac, pd_rmac);
151        (acu, pd)
152    }
153
154    #[test]
155    fn seal_then_unseal_mac_only() {
156        let (mut acu, mut pd) = handshake_pair();
157        let bytes = seal(
158            &mut acu,
159            Address::pd(0x05).unwrap(),
160            Sqn::new(1).unwrap(),
161            Direction::AcuToPd,
162            false,
163            0x60, // POLL
164            &[],
165        )
166        .unwrap();
167        let (parsed, _used) = ParsedPacket::parse(&bytes).unwrap();
168        let plain = unseal(&mut pd, &parsed, &bytes).unwrap();
169        assert!(plain.is_empty());
170    }
171
172    #[test]
173    fn seal_then_unseal_encrypted() {
174        let (mut acu, mut pd) = handshake_pair();
175        let payload = b"sensitive command data";
176        let bytes = seal(
177            &mut acu,
178            Address::pd(0x05).unwrap(),
179            Sqn::new(2).unwrap(),
180            Direction::AcuToPd,
181            true,
182            0x6E, // COMSET
183            payload,
184        )
185        .unwrap();
186        let (parsed, _used) = ParsedPacket::parse(&bytes).unwrap();
187        // The on-wire DATA is the ciphertext, padded to 16-byte multiple.
188        assert_eq!(parsed.data.len() % 16, 0);
189        assert_ne!(parsed.data, payload);
190
191        let plain = unseal(&mut pd, &parsed, &bytes).unwrap();
192        assert_eq!(plain, payload);
193    }
194
195    #[test]
196    fn tampered_mac_rejected() {
197        let (mut acu, mut pd) = handshake_pair();
198        let mut bytes = seal(
199            &mut acu,
200            Address::pd(0x05).unwrap(),
201            Sqn::new(1).unwrap(),
202            Direction::AcuToPd,
203            false, // mac-only so we don't have the larger encrypt-then-mac surface
204            0x60,
205            &[],
206        )
207        .unwrap();
208        // Flip a MAC byte and re-CRC so the parser accepts the frame.
209        let n = bytes.len();
210        let mac_byte = n - 2 - 4; // skip CRC (2) and target the last MAC byte
211        bytes[mac_byte] ^= 0x01;
212        let crc = crate::packet::crc16(&bytes[..n - 2]);
213        bytes[n - 2..].copy_from_slice(&crc.to_le_bytes());
214
215        let (parsed, _used) = ParsedPacket::parse(&bytes).unwrap();
216        let err = unseal(&mut pd, &parsed, &bytes).unwrap_err();
217        assert!(matches!(err, Error::SecureSession(_)));
218    }
219}