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