Skip to main content

pamoja_lorawan/
join.rs

1//! Over-the-air activation: the join exchange that turns root keys into a session.
2
3use crate::crypto::Cipher;
4use crate::error::LorawanError;
5use crate::frame::{PhyPayload, MTYPE_JOIN_ACCEPT, MTYPE_JOIN_REQUEST, MTYPE_MASK};
6use crate::session::Session;
7
8// A join-request is a fixed 23 bytes: MHDR, AppEUI, DevEUI, DevNonce, and MIC.
9pub(crate) const JOIN_REQUEST_LEN: usize = 1 + 8 + 8 + 2 + 4;
10
11/// An end device's root credentials for over-the-air activation.
12///
13/// Where a [`Session`] is the state of an already-activated device, a `Device` holds what
14/// it takes to activate: the device and application identifiers and the application root
15/// key. It builds the [`join_request`](Device::join_request) a device broadcasts and turns
16/// the network's reply into a ready [`Session`] with [`accept_join`](Device::accept_join),
17/// deriving the session keys the spec prescribes.
18///
19/// The 8-byte identifiers are given most-significant byte first, as they are written; the
20/// join-request transmits them little-endian, as the spec requires.
21pub struct Device {
22    dev_eui: [u8; 8],
23    app_eui: [u8; 8],
24    app_key: [u8; 16],
25}
26
27impl Device {
28    /// Creates a device from its identifiers and application root key.
29    ///
30    /// # Arguments
31    ///
32    /// * `dev_eui` - the device identifier, most-significant byte first.
33    /// * `app_eui` - the application (join) identifier, most-significant byte first.
34    /// * `app_key` - the application root key.
35    ///
36    /// # Returns
37    ///
38    /// The device.
39    pub fn new(dev_eui: [u8; 8], app_eui: [u8; 8], app_key: [u8; 16]) -> Self {
40        Device {
41            dev_eui,
42            app_eui,
43            app_key,
44        }
45    }
46
47    /// Builds a join-request to broadcast.
48    ///
49    /// # Arguments
50    ///
51    /// * `dev_nonce` - a nonce the device must not reuse; keep it for the matching
52    ///   [`accept_join`](Device::accept_join), which needs it to derive the keys.
53    ///
54    /// # Returns
55    ///
56    /// The join-request frame.
57    pub fn join_request(&self, dev_nonce: u16) -> PhyPayload {
58        let mut buf = [0u8; JOIN_REQUEST_LEN];
59        buf[0] = MTYPE_JOIN_REQUEST;
60        copy_reversed(&mut buf[1..9], &self.app_eui);
61        copy_reversed(&mut buf[9..17], &self.dev_eui);
62        buf[17..19].copy_from_slice(&dev_nonce.to_le_bytes());
63        let tag = Cipher::new(&self.app_key).cmac(&buf[..19]);
64        buf[19..23].copy_from_slice(&tag[..4]);
65        PhyPayload::new(&buf).expect("a join-request always fits a frame")
66    }
67
68    /// Accepts a join-accept, deriving the activated session.
69    ///
70    /// Decrypts the reply, verifies its MIC against the application root key, and derives
71    /// the network and application session keys from the nonces it carries.
72    ///
73    /// # Arguments
74    ///
75    /// * `bytes` - the raw join-accept as it came off the radio.
76    /// * `dev_nonce` - the same nonce passed to the [`join_request`](Device::join_request)
77    ///   this reply answers.
78    ///
79    /// # Returns
80    ///
81    /// The activation, including the ready-to-use [`Session`].
82    ///
83    /// # Errors
84    ///
85    /// Returns [`LorawanError::FrameTooShort`] or [`LorawanError::MalformedFrame`] if the
86    /// reply is not a valid join-accept shape, [`LorawanError::UnsupportedMType`] if it is
87    /// not a join-accept, or [`LorawanError::MicMismatch`] if its MIC does not verify.
88    pub fn accept_join(&self, bytes: &[u8], dev_nonce: u16) -> Result<JoinAccept, LorawanError> {
89        if bytes.is_empty() {
90            return Err(LorawanError::FrameTooShort);
91        }
92        if bytes[0] & MTYPE_MASK != MTYPE_JOIN_ACCEPT {
93            return Err(LorawanError::UnsupportedMType(bytes[0] & MTYPE_MASK));
94        }
95        let encrypted = &bytes[1..];
96        // The encrypted part is one block, or two when a channel list is attached.
97        if encrypted.len() != 16 && encrypted.len() != 32 {
98            return Err(LorawanError::MalformedFrame);
99        }
100
101        let cipher = Cipher::new(&self.app_key);
102        // The network "encrypts" with AES decryption, so the device decrypts by encrypting.
103        let mut clear = [0u8; 32];
104        for (i, chunk) in encrypted.chunks(16).enumerate() {
105            let block: [u8; 16] = chunk.try_into().map_err(|_| LorawanError::MalformedFrame)?;
106            clear[i * 16..i * 16 + 16].copy_from_slice(&cipher.encrypt_block(&block));
107        }
108        let clear = &clear[..encrypted.len()];
109
110        // The MIC covers the MHDR and the decrypted body up to the MIC itself.
111        let mic_at = clear.len() - 4;
112        let mut signed = [0u8; 1 + 28];
113        signed[0] = bytes[0];
114        signed[1..1 + mic_at].copy_from_slice(&clear[..mic_at]);
115        let tag = cipher.cmac(&signed[..1 + mic_at]);
116        if clear[mic_at..] != tag[..4] {
117            return Err(LorawanError::MicMismatch);
118        }
119
120        let app_nonce = &clear[0..3];
121        let net_id_bytes = &clear[3..6];
122        let dev_addr = u32::from_le_bytes([clear[6], clear[7], clear[8], clear[9]]);
123        let dl_settings = clear[10];
124        let rx_delay = clear[11];
125        let net_id = u32::from_le_bytes([net_id_bytes[0], net_id_bytes[1], net_id_bytes[2], 0]);
126
127        let nwk_skey = derive_key(&cipher, 0x01, app_nonce, net_id_bytes, dev_nonce);
128        let app_skey = derive_key(&cipher, 0x02, app_nonce, net_id_bytes, dev_nonce);
129
130        Ok(JoinAccept {
131            session: Session::new(dev_addr, nwk_skey, app_skey),
132            net_id,
133            dev_addr,
134            dl_settings,
135            rx_delay,
136        })
137    }
138}
139
140/// A successful activation: the session to use, plus the network parameters the accept
141/// carried.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct JoinAccept {
144    session: Session,
145    net_id: u32,
146    dev_addr: u32,
147    dl_settings: u8,
148    rx_delay: u8,
149}
150
151impl JoinAccept {
152    /// Returns the activated session, ready to secure data frames.
153    ///
154    /// # Returns
155    ///
156    /// The [`Session`].
157    pub fn session(&self) -> Session {
158        self.session
159    }
160
161    /// Returns the device address the network assigned.
162    ///
163    /// # Returns
164    ///
165    /// The device address.
166    pub fn dev_addr(&self) -> u32 {
167        self.dev_addr
168    }
169
170    /// Returns the network identifier, a 24-bit value.
171    ///
172    /// # Returns
173    ///
174    /// The NetID.
175    pub fn net_id(&self) -> u32 {
176        self.net_id
177    }
178
179    /// Returns the downlink settings byte, which selects the downlink data rates.
180    ///
181    /// # Returns
182    ///
183    /// The DLSettings byte.
184    pub fn dl_settings(&self) -> u8 {
185        self.dl_settings
186    }
187
188    /// Returns the delay, in seconds, before the first receive window.
189    ///
190    /// # Returns
191    ///
192    /// The RxDelay value.
193    pub fn rx_delay(&self) -> u8 {
194        self.rx_delay
195    }
196}
197
198// Copies `src` into `dst` reversed, turning a most-significant-byte-first identifier into
199// the little-endian order the air interface uses.
200pub(crate) fn copy_reversed(dst: &mut [u8], src: &[u8]) {
201    for (d, s) in dst.iter_mut().zip(src.iter().rev()) {
202        *d = *s;
203    }
204}
205
206// Derives a session key by encrypting the spec's key-derivation block with the root key.
207pub(crate) fn derive_key(
208    cipher: &Cipher,
209    kind: u8,
210    app_nonce: &[u8],
211    net_id: &[u8],
212    dev_nonce: u16,
213) -> [u8; 16] {
214    let mut block = [0u8; 16];
215    block[0] = kind;
216    block[1..4].copy_from_slice(app_nonce);
217    block[4..7].copy_from_slice(net_id);
218    block[7..9].copy_from_slice(&dev_nonce.to_le_bytes());
219    cipher.encrypt_block(&block)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::Uplink;
226
227    const APP_KEY: [u8; 16] = [0xAB; 16];
228    const DEV_EUI: [u8; 8] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
229    const APP_EUI: [u8; 8] = [0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
230    const DEV_NONCE: u16 = 0x1234;
231
232    // Builds a join-accept the way a network server would, so a device can accept it.
233    fn make_join_accept(
234        app_key: &[u8; 16],
235        app_nonce: [u8; 3],
236        net_id: [u8; 3],
237        dev_addr: u32,
238        dl_settings: u8,
239        rx_delay: u8,
240    ) -> [u8; 17] {
241        let cipher = Cipher::new(app_key);
242        let mut clear = [0u8; 16];
243        clear[0..3].copy_from_slice(&app_nonce);
244        clear[3..6].copy_from_slice(&net_id);
245        clear[6..10].copy_from_slice(&dev_addr.to_le_bytes());
246        clear[10] = dl_settings;
247        clear[11] = rx_delay;
248        let mut signed = [0u8; 13];
249        signed[0] = MTYPE_JOIN_ACCEPT;
250        signed[1..13].copy_from_slice(&clear[..12]);
251        let tag = cipher.cmac(&signed);
252        clear[12..16].copy_from_slice(&tag[..4]);
253
254        let mut frame = [0u8; 17];
255        frame[0] = MTYPE_JOIN_ACCEPT;
256        frame[1..17].copy_from_slice(&cipher.decrypt_block(&clear));
257        frame
258    }
259
260    #[test]
261    fn a_join_request_is_well_formed() {
262        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
263        let request = device.join_request(DEV_NONCE);
264        let bytes = request.as_bytes();
265        assert_eq!(bytes.len(), JOIN_REQUEST_LEN);
266        assert_eq!(bytes[0], MTYPE_JOIN_REQUEST);
267        // EUIs are transmitted little-endian, so reversed from how they are written.
268        assert_eq!(
269            &bytes[1..9],
270            &[0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88]
271        );
272        assert_eq!(&bytes[17..19], &DEV_NONCE.to_le_bytes());
273    }
274
275    #[test]
276    fn a_join_activates_a_session_that_secures_data() {
277        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
278        let frame = make_join_accept(
279            &APP_KEY,
280            [0x01, 0x02, 0x03],
281            [0x04, 0x05, 0x06],
282            0x2601_1BDA,
283            0x00,
284            0x01,
285        );
286
287        let accepted = device.accept_join(&frame, DEV_NONCE).unwrap();
288        assert_eq!(accepted.dev_addr(), 0x2601_1BDA);
289        assert_eq!(accepted.net_id(), 0x0006_0504);
290        assert_eq!(accepted.rx_delay(), 0x01);
291
292        // The derived session secures a data-frame round-trip.
293        let session = accepted.session();
294        let uplink = session
295            .encode_uplink(&Uplink::new(1, 1, b"joined"))
296            .unwrap();
297        let rx = session.decode(uplink.as_bytes(), 1).unwrap();
298        assert_eq!(rx.payload(), b"joined");
299    }
300
301    #[test]
302    fn a_tampered_join_accept_fails_the_mic() {
303        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
304        let mut frame = make_join_accept(
305            &APP_KEY,
306            [0x01, 0x02, 0x03],
307            [0x04, 0x05, 0x06],
308            0x2601_1BDA,
309            0x00,
310            0x01,
311        );
312        frame[5] ^= 0xff;
313        assert_eq!(
314            device.accept_join(&frame, DEV_NONCE),
315            Err(LorawanError::MicMismatch)
316        );
317    }
318
319    #[test]
320    fn the_wrong_root_key_rejects_the_join() {
321        let device = Device::new(DEV_EUI, APP_EUI, [0x00; 16]);
322        let frame = make_join_accept(
323            &APP_KEY,
324            [0x01, 0x02, 0x03],
325            [0x04, 0x05, 0x06],
326            0x2601_1BDA,
327            0x00,
328            0x01,
329        );
330        assert_eq!(
331            device.accept_join(&frame, DEV_NONCE),
332            Err(LorawanError::MicMismatch)
333        );
334    }
335
336    #[test]
337    fn a_join_accept_of_the_wrong_length_is_malformed() {
338        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
339        assert_eq!(
340            device.accept_join(&[MTYPE_JOIN_ACCEPT; 20], DEV_NONCE),
341            Err(LorawanError::MalformedFrame)
342        );
343    }
344
345    #[test]
346    fn a_non_join_frame_is_rejected() {
347        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
348        let mut frame = [0u8; 17];
349        frame[0] = MTYPE_JOIN_REQUEST; // 0x00, not a join-accept
350        assert_eq!(
351            device.accept_join(&frame, DEV_NONCE),
352            Err(LorawanError::UnsupportedMType(0x00))
353        );
354    }
355
356    // Builds a 33-byte join-accept carrying a 16-byte channel list, the two-block form.
357    fn make_join_accept_with_cflist(app_key: &[u8; 16], cflist: [u8; 16]) -> [u8; 33] {
358        let cipher = Cipher::new(app_key);
359        let mut clear = [0u8; 32];
360        clear[0..3].copy_from_slice(&[0x01, 0x02, 0x03]); // AppNonce
361        clear[3..6].copy_from_slice(&[0x04, 0x05, 0x06]); // NetID
362        clear[6..10].copy_from_slice(&0x2601_1BDAu32.to_le_bytes()); // DevAddr
363        clear[10] = 0x00; // DLSettings
364        clear[11] = 0x01; // RxDelay
365        clear[12..28].copy_from_slice(&cflist);
366        let mut signed = [0u8; 29];
367        signed[0] = MTYPE_JOIN_ACCEPT;
368        signed[1..29].copy_from_slice(&clear[..28]);
369        let tag = cipher.cmac(&signed);
370        clear[28..32].copy_from_slice(&tag[..4]);
371
372        let mut frame = [0u8; 33];
373        frame[0] = MTYPE_JOIN_ACCEPT;
374        let first: [u8; 16] = clear[0..16].try_into().unwrap();
375        let second: [u8; 16] = clear[16..32].try_into().unwrap();
376        frame[1..17].copy_from_slice(&cipher.decrypt_block(&first));
377        frame[17..33].copy_from_slice(&cipher.decrypt_block(&second));
378        frame
379    }
380
381    #[test]
382    fn a_join_accept_with_a_channel_list_activates() {
383        let device = Device::new(DEV_EUI, APP_EUI, APP_KEY);
384        let frame = make_join_accept_with_cflist(&APP_KEY, [0x11; 16]);
385        let accepted = device.accept_join(&frame, DEV_NONCE).unwrap();
386        assert_eq!(accepted.dev_addr(), 0x2601_1BDA);
387        // The derived session still secures a data frame.
388        let session = accepted.session();
389        let uplink = session.encode_uplink(&Uplink::new(1, 1, b"cf")).unwrap();
390        assert_eq!(
391            session.decode(uplink.as_bytes(), 1).unwrap().payload(),
392            b"cf"
393        );
394    }
395}