Skip to main content

lora_packet/
crypto.rs

1//! AES-ECB primitives, `FRMPayload` and `FOpts` crypt, Join Accept crypt,
2//! and OTAA / Join Server / WOR key derivation.
3//!
4//! Three layers in this module:
5//!
6//! 1. **Low-level AES-128 block primitive**: [`aes_ecb_encrypt`]. Use this
7//!    when you need raw AES (testing, protocol experiments). Most code
8//!    should reach for a higher-level helper instead.
9//! 2. **Per-frame crypt**: [`crate::Data::decrypt_payload`] /
10//!    [`crate::Data::encrypt_payload`] for `FRMPayload`,
11//!    [`crate::Data::decrypt_fopts`] / [`crate::Data::encrypt_fopts`] for
12//!    1.1 MAC commands in `FOpts`, and [`crate::JoinAccept::decrypt_from_wire`]
13//!    / [`crate::JoinAccept::encrypt_for_wire`] for Join Accept frames.
14//! 3. **Key derivation**: [`SessionKeys10::derive`] /
15//!    [`SessionKeys11::derive`] for OTAA session keys, [`JoinServerKeys::derive`]
16//!    for 1.1 JS keys, and [`WorKeys::root`] / [`WorKeys::session`] for
17//!    Relay (WOR) keys.
18
19use aes::Aes128;
20use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
21
22use crate::types::{
23  AppEui, AppKey, AppNonce, AppSKey, DevAddr, DevEui, DevNonce, FNwkSIntKey, JSEncKey, JSIntKey, NetId, NwkKey,
24  NwkSEncKey, NwkSKey, RootWorSKey, SNwkSIntKey, WorSEncKey, WorSIntKey,
25};
26
27/// Encrypt one 16-byte block under AES-128 ECB.
28///
29/// The low-level primitive every other crypto helper in this crate is
30/// built on. Exposed for raw protocol work and for parity with the TS
31/// reference's `encrypt(buffer, key)` helper.
32///
33/// # Examples
34///
35/// NIST FIPS-197 Appendix B test vector:
36///
37/// ```
38/// use lora_packet::aes_ecb_encrypt;
39///
40/// let key = [
41///   0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
42///   0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c,
43/// ];
44/// let plain = [
45///   0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d,
46///   0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34,
47/// ];
48/// assert_eq!(
49///   aes_ecb_encrypt(&plain, &key),
50///   [
51///     0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb,
52///     0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, 0x32,
53///   ],
54/// );
55/// ```
56pub fn aes_ecb_encrypt(block: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
57  let cipher = Aes128::new(&Array::from(*key));
58  let mut buf = Array::from(*block);
59  cipher.encrypt_block(&mut buf);
60  buf.into()
61}
62
63/// Decrypt one 16-byte block under AES-128 ECB.
64///
65/// Counterpart to [`aes_ecb_encrypt`]. Used internally by
66/// [`crate::JoinAccept::encrypt_for_wire`] (which inverts the AES-ECB
67/// transform applied on-air) and exposed for completeness.
68pub fn aes_ecb_decrypt(block: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
69  let cipher = Aes128::new(&Array::from(*key));
70  let mut buf = Array::from(*block);
71  cipher.decrypt_block(&mut buf);
72  buf.into()
73}
74
75/// `LoRaWAN` 1.0 session keys derived during OTAA.
76///
77/// The two keys cover all 1.0 session needs:
78/// - [`AppSKey`] encrypts `FRMPayload` when `FPort > 0`.
79/// - [`NwkSKey`] computes the Data MIC and encrypts `FRMPayload` when
80///   `FPort == 0`.
81///
82/// Build with [`SessionKeys10::derive`] given the device's `AppKey` and the
83/// join exchange's `NetId`, `AppNonce`, and `DevNonce`.
84#[derive(Debug, Clone)]
85pub struct SessionKeys10 {
86  /// Application session key.
87  pub app_s_key: AppSKey,
88  /// Network session key.
89  pub nwk_s_key: NwkSKey,
90}
91
92impl SessionKeys10 {
93  /// Derive `AppSKey` and `NwkSKey` from the OTAA root key and join nonces.
94  ///
95  /// The derivation is `AES-ECB-encrypt(AppKey, 0x0?  || AppNonce_LE ||
96  /// NetID_LE || DevNonce_LE || padding)` with `0x01` for `NwkSKey` and
97  /// `0x02` for `AppSKey`.
98  ///
99  /// # Examples
100  ///
101  /// ```
102  /// use lora_packet::{SessionKeys10, AppKey, NetId, AppNonce, DevNonce};
103  ///
104  /// let app_key = AppKey::new([0u8; 16]);
105  /// let keys = SessionKeys10::derive(
106  ///   &app_key,
107  ///   &NetId::new([0, 0, 0]),
108  ///   &AppNonce::new([0, 0, 0]),
109  ///   &DevNonce::new([0, 0]),
110  /// );
111  /// assert_ne!(keys.app_s_key.as_bytes(), keys.nwk_s_key.as_bytes());
112  /// ```
113  // All key-derivation helpers take inputs by reference for a uniform public API
114  // even though the small identifier types are `Copy`.
115  #[allow(clippy::trivially_copy_pass_by_ref)]
116  pub fn derive(app_key: &AppKey, net_id: &NetId, app_nonce: &AppNonce, dev_nonce: &DevNonce) -> Self {
117    let app_s_key = AppSKey::new(derive_session_key_10(0x02, app_key, net_id, app_nonce, dev_nonce));
118    let nwk_s_key = NwkSKey::new(derive_session_key_10(0x01, app_key, net_id, app_nonce, dev_nonce));
119    Self { app_s_key, nwk_s_key }
120  }
121}
122
123#[allow(clippy::trivially_copy_pass_by_ref)]
124fn derive_session_key_10(
125  prefix: u8,
126  app_key: &AppKey,
127  net_id: &NetId,
128  app_nonce: &AppNonce,
129  dev_nonce: &DevNonce,
130) -> [u8; 16] {
131  let mut block = [0u8; 16];
132  block[0] = prefix;
133  let mut n = *app_nonce.as_bytes();
134  n.reverse();
135  block[1..4].copy_from_slice(&n);
136  let mut id = *net_id.as_bytes();
137  id.reverse();
138  block[4..7].copy_from_slice(&id);
139  let mut dn = *dev_nonce.as_bytes();
140  dn.reverse();
141  block[7..9].copy_from_slice(&dn);
142  aes_ecb_encrypt(&block, app_key.as_bytes())
143}
144
145/// `LoRaWAN` 1.1 session keys derived during OTAA.
146///
147/// 1.1 splits the 1.0 `NwkSKey` into three role-specific keys
148/// (`FNwkSIntKey`, `SNwkSIntKey`, `NwkSEncKey`) plus keeps the application
149/// session key. See [`crate::V1_1MicKeys`] for how they slot into MIC
150/// computation.
151#[derive(Debug, Clone)]
152pub struct SessionKeys11 {
153  /// Application session key (`FRMPayload` crypt with `FPort > 0`).
154  pub app_s_key: AppSKey,
155  /// Forwarding network session integrity key (lower 2 MIC bytes for
156  /// uplink Data frames).
157  pub f_nwk_s_int_key: FNwkSIntKey,
158  /// Serving network session integrity key (upper 2 MIC bytes for uplinks,
159  /// full MIC for downlinks).
160  pub s_nwk_s_int_key: SNwkSIntKey,
161  /// Network session encryption key (`FOpts` MAC commands, plus
162  /// `FRMPayload` when `FPort == 0`).
163  pub nwk_s_enc_key: NwkSEncKey,
164}
165
166impl SessionKeys11 {
167  /// Derive all four 1.1 session keys.
168  ///
169  /// `AppSKey` is derived from `AppKey`; the three network keys are
170  /// derived from `NwkKey`. Inputs include `JoinEUI` because 1.1 binds the
171  /// session to the join server identity.
172  ///
173  /// # Examples
174  ///
175  /// ```
176  /// use lora_packet::{SessionKeys11, AppKey, NwkKey, AppEui, AppNonce, DevNonce};
177  ///
178  /// let keys = SessionKeys11::derive(
179  ///   &AppKey::new([0u8; 16]),
180  ///   &NwkKey::new([0u8; 16]),
181  ///   &AppEui::new([0u8; 8]),
182  ///   &AppNonce::new([0, 0, 0]),
183  ///   &DevNonce::new([0, 0]),
184  /// );
185  /// assert_ne!(keys.app_s_key.as_bytes(), keys.f_nwk_s_int_key.as_bytes());
186  /// ```
187  #[allow(clippy::trivially_copy_pass_by_ref)]
188  pub fn derive(
189    app_key: &AppKey,
190    nwk_key: &NwkKey,
191    join_eui: &AppEui,
192    app_nonce: &AppNonce,
193    dev_nonce: &DevNonce,
194  ) -> Self {
195    let app_s_key = AppSKey::new(derive_session_key_11(
196      0x02,
197      app_key.as_bytes(),
198      join_eui,
199      app_nonce,
200      dev_nonce,
201    ));
202    let f_nwk_s_int_key = FNwkSIntKey::new(derive_session_key_11(
203      0x01,
204      nwk_key.as_bytes(),
205      join_eui,
206      app_nonce,
207      dev_nonce,
208    ));
209    let s_nwk_s_int_key = SNwkSIntKey::new(derive_session_key_11(
210      0x03,
211      nwk_key.as_bytes(),
212      join_eui,
213      app_nonce,
214      dev_nonce,
215    ));
216    let nwk_s_enc_key = NwkSEncKey::new(derive_session_key_11(
217      0x04,
218      nwk_key.as_bytes(),
219      join_eui,
220      app_nonce,
221      dev_nonce,
222    ));
223    Self {
224      app_s_key,
225      f_nwk_s_int_key,
226      s_nwk_s_int_key,
227      nwk_s_enc_key,
228    }
229  }
230}
231
232#[allow(clippy::trivially_copy_pass_by_ref)]
233fn derive_session_key_11(
234  prefix: u8,
235  key: &[u8; 16],
236  join_eui: &AppEui,
237  app_nonce: &AppNonce,
238  dev_nonce: &DevNonce,
239) -> [u8; 16] {
240  let mut block = [0u8; 16];
241  block[0] = prefix;
242  let mut n = *app_nonce.as_bytes();
243  n.reverse();
244  block[1..4].copy_from_slice(&n);
245  let mut e = *join_eui.as_bytes();
246  e.reverse();
247  block[4..12].copy_from_slice(&e);
248  let mut dn = *dev_nonce.as_bytes();
249  dn.reverse();
250  block[12..14].copy_from_slice(&dn);
251  aes_ecb_encrypt(&block, key)
252}
253
254/// Join Server keys (`LoRaWAN` 1.1) derived from `NwkKey` and `DevEUI`.
255///
256/// Used by the Join Server (not the device) for Rejoin-aware Join Accept
257/// signing and re-encryption. Pair of keys; see [`JoinServerKeys::derive`].
258#[derive(Debug, Clone)]
259pub struct JoinServerKeys {
260  /// Integrity key for Join Accept and Rejoin Type 1 MIC.
261  pub js_int_key: JSIntKey,
262  /// Encryption key for re-encrypting Join Accept bodies sent to rejoining
263  /// devices.
264  pub js_enc_key: JSEncKey,
265}
266
267impl JoinServerKeys {
268  /// Derive both JS keys from `NwkKey` and `DevEUI`.
269  ///
270  /// # Examples
271  ///
272  /// ```
273  /// use lora_packet::{JoinServerKeys, NwkKey, DevEui};
274  ///
275  /// let nwk_key = NwkKey::new([0x42u8; 16]);
276  /// let dev_eui = DevEui::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
277  /// let js = JoinServerKeys::derive(&nwk_key, &dev_eui);
278  /// assert_ne!(js.js_int_key.as_bytes(), js.js_enc_key.as_bytes());
279  /// ```
280  #[allow(clippy::trivially_copy_pass_by_ref)]
281  pub fn derive(nwk_key: &NwkKey, dev_eui: &DevEui) -> Self {
282    let mut block = [0u8; 16];
283    block[0] = 0x06;
284    let mut e = *dev_eui.as_bytes();
285    e.reverse();
286    block[1..9].copy_from_slice(&e);
287    let js_int_key = JSIntKey::new(aes_ecb_encrypt(&block, nwk_key.as_bytes()));
288    block[0] = 0x05;
289    let js_enc_key = JSEncKey::new(aes_ecb_encrypt(&block, nwk_key.as_bytes()));
290    Self { js_int_key, js_enc_key }
291  }
292}
293
294/// Relay (Wake-On-Radio) session keys derived from a `RootWorSKey` and
295/// `DevAddr`.
296///
297/// Two keys per relay session: integrity (MIC) and encryption. Build with
298/// [`WorKeys::session`].
299#[derive(Debug, Clone)]
300pub struct WorSessionKeys {
301  /// WOR session integrity key.
302  pub wor_s_int_key: WorSIntKey,
303  /// WOR session encryption key.
304  pub wor_s_enc_key: WorSEncKey,
305}
306
307/// Namespace for Relay / Wake-On-Radio key derivation.
308///
309/// A unit struct that groups [`WorKeys::root`] and [`WorKeys::session`]
310/// without polluting the crate root.
311///
312/// # Examples
313///
314/// Derive a root WOR key then a session pair:
315///
316/// ```
317/// use lora_packet::{WorKeys, NwkSKey, DevAddr};
318///
319/// let nwk_s_key = NwkSKey::new([0u8; 16]);
320/// let root = WorKeys::root(&nwk_s_key);
321/// let session = WorKeys::session(&root, &DevAddr::new([0x01, 0x02, 0x03, 0x04]));
322/// assert_ne!(session.wor_s_int_key.as_bytes(), session.wor_s_enc_key.as_bytes());
323/// ```
324pub struct WorKeys;
325
326impl WorKeys {
327  /// Derive `RootWorSKey` from `NwkSKey`.
328  ///
329  /// `RootWorSKey = AES-ECB-encrypt(NwkSKey, 0x01 || 0x00..)`.
330  pub fn root(nwk_s_key: &NwkSKey) -> RootWorSKey {
331    let mut block = [0u8; 16];
332    block[0] = 0x01;
333    RootWorSKey::new(aes_ecb_encrypt(&block, nwk_s_key.as_bytes()))
334  }
335
336  /// Derive WOR session keys from a root key and `DevAddr`.
337  ///
338  /// Two AES-ECB blocks under the root, with the first byte set to 0x01
339  /// for the integrity key and 0x02 for the encryption key. The remainder
340  /// is `DevAddr_LE` padded with zeros.
341  #[allow(clippy::trivially_copy_pass_by_ref)]
342  pub fn session(root: &RootWorSKey, dev_addr: &DevAddr) -> WorSessionKeys {
343    let mut block = [0u8; 16];
344    block[0] = 0x01;
345    let mut a = *dev_addr.as_bytes();
346    a.reverse();
347    block[1..5].copy_from_slice(&a);
348    let wor_s_int_key = WorSIntKey::new(aes_ecb_encrypt(&block, root.as_bytes()));
349    block[0] = 0x02;
350    let wor_s_enc_key = WorSEncKey::new(aes_ecb_encrypt(&block, root.as_bytes()));
351    WorSessionKeys {
352      wor_s_int_key,
353      wor_s_enc_key,
354    }
355  }
356}
357
358impl crate::codec::Data {
359  /// Decrypt `FRMPayload`.
360  ///
361  /// `LoRaWAN` uses an AES-CTR-like keystream so the same operation works
362  /// in both directions; this method is named for the typical use (receiver
363  /// side). The key is selected by `FPort`:
364  /// - `FPort == 0`: `NwkSKey` (MAC commands in `FRMPayload`).
365  /// - `FPort  > 0`: `AppSKey` (application data).
366  ///
367  /// `f_cnt_msb` is the upper 16 bits of the 32-bit `FCnt`; pass `0` if
368  /// frame counters never wrap. See [`crate::Data::f_cnt_32`].
369  ///
370  /// # Errors
371  /// [`crate::Error::PayloadTooLarge`] if the ciphertext exceeds the
372  /// AES-CTR block-index limit (255 blocks = 4080 bytes).
373  ///
374  /// # Examples
375  ///
376  /// ```
377  /// use lora_packet::{LoraPacket, AppSKey, NwkSKey};
378  ///
379  /// let bytes = hex::decode("40f17dbe4900020001954378762b11ff0d")?;
380  /// let packet = LoraPacket::from_wire(&bytes)?;
381  /// let app_s_key = AppSKey::from_slice(&hex::decode("ec925802ae430ca77fd3dd73cb2cc588")?)?;
382  /// let nwk_s_key = NwkSKey::from_slice(&hex::decode("44024241ed4ce9a68c6a8bc055233fd3")?)?;
383  /// let plain = packet.as_data().unwrap().decrypt_payload(&app_s_key, &nwk_s_key, 0)?;
384  /// assert_eq!(&plain, b"test");
385  /// # Ok::<(), Box<dyn std::error::Error>>(())
386  /// ```
387  pub fn decrypt_payload(
388    &self,
389    app_s_key: &AppSKey,
390    nwk_s_key: &NwkSKey,
391    f_cnt_msb: u16,
392  ) -> crate::Result<alloc::vec::Vec<u8>> {
393    let cipher = self.frm_payload.as_deref().unwrap_or(&[]);
394    let key = if self.f_port == Some(0) {
395      nwk_s_key.as_bytes()
396    } else {
397      app_s_key.as_bytes()
398    };
399    payload_crypt(cipher, key, self.direction, self.dev_addr, self.f_cnt_32(f_cnt_msb))
400  }
401
402  /// Encrypt the given plaintext under the `FRMPayload` keystream.
403  ///
404  /// Same primitive as [`Self::decrypt_payload`]; named differently for
405  /// clarity at call sites. Used by
406  /// [`crate::LoraPacketBuilder::sign_and_encrypt`] for downlink building.
407  ///
408  /// Selects `NwkSKey` when `FPort == 0`, `AppSKey` otherwise.
409  ///
410  /// # Errors
411  /// [`crate::Error::PayloadTooLarge`] if the plaintext exceeds 4080 bytes
412  /// (255 AES blocks). Beyond this, the 1-byte block counter in the `Ai`
413  /// keystream block overflows and silently produces ciphertext no other
414  /// `LoRaWAN` stack can decrypt.
415  pub fn encrypt_payload(
416    &self,
417    plaintext: &[u8],
418    app_s_key: &AppSKey,
419    nwk_s_key: &NwkSKey,
420    f_cnt_msb: u16,
421  ) -> crate::Result<alloc::vec::Vec<u8>> {
422    let key = if self.f_port == Some(0) {
423      nwk_s_key.as_bytes()
424    } else {
425      app_s_key.as_bytes()
426    };
427    payload_crypt(plaintext, key, self.direction, self.dev_addr, self.f_cnt_32(f_cnt_msb))
428  }
429}
430
431fn payload_crypt(
432  input: &[u8],
433  key: &[u8; 16],
434  direction: crate::types::Direction,
435  dev_addr: DevAddr,
436  f_cnt_32: u32,
437) -> crate::Result<alloc::vec::Vec<u8>> {
438  // The block index byte (Ai[15]) is 1-based and only one byte wide. Anything
439  // beyond 255 blocks would overflow the index and silently produce ciphertext
440  // that no other LoRaWAN stack can decrypt.
441  let block_count = input.len().div_ceil(16);
442  if block_count > 255 {
443    return Err(crate::Error::PayloadTooLarge(input.len()));
444  }
445  let dir_byte = u8::from(!matches!(direction, crate::types::Direction::Uplink));
446  let mut out = alloc::vec::Vec::with_capacity(input.len());
447  let mut addr = *dev_addr.as_bytes();
448  addr.reverse();
449  for (i_chunk, chunk) in input.chunks(16).enumerate() {
450    let mut ai = [0u8; 16];
451    ai[0] = 0x01;
452    ai[5] = dir_byte;
453    ai[6..10].copy_from_slice(&addr);
454    ai[10..14].copy_from_slice(&f_cnt_32.to_le_bytes());
455    // Safe: block_count <= 255 guarded above, so i_chunk + 1 fits in a u8.
456    ai[15] = u8::try_from(i_chunk + 1).map_err(|_| crate::Error::PayloadTooLarge(input.len()))?;
457    let s = aes_ecb_encrypt(&ai, key);
458    for (j, b) in chunk.iter().enumerate() {
459      out.push(b ^ s[j]);
460    }
461  }
462  Ok(out)
463}
464
465impl crate::codec::Data {
466  /// Decrypt `FOpts` MAC commands (`LoRaWAN` 1.1 only).
467  ///
468  /// In 1.0, `FOpts` is plaintext on the wire; this method is a no-op
469  /// (but still callable). In 1.1, `FOpts` is encrypted under
470  /// `NwkSEncKey` with a single AES-ECB block.
471  ///
472  /// Uses the keystream layout from the `LoRa` Alliance errata
473  /// "`FCntDwn` Usage in `FOpts` Encryption" (CR v2 r1): when the frame is
474  /// a downlink with `FPort > 0` the `aFCntDown` flag selects byte 4 =
475  /// 0x02; otherwise it is 0x01.
476  ///
477  /// # Errors
478  /// Currently infallible; returns `Result` for forward compatibility.
479  pub fn decrypt_fopts(
480    &self,
481    nwk_s_enc_key: &crate::types::NwkSEncKey,
482    f_cnt_msb: u16,
483  ) -> crate::Result<alloc::vec::Vec<u8>> {
484    Ok(fopts_crypt(
485      &self.f_opts,
486      nwk_s_enc_key.as_bytes(),
487      self.direction,
488      self.dev_addr,
489      self.f_port,
490      self.f_cnt_32(f_cnt_msb),
491    ))
492  }
493
494  /// Encrypt `FOpts` MAC commands (`LoRaWAN` 1.1 only).
495  ///
496  /// Symmetric to [`Self::decrypt_fopts`]; same primitive in both
497  /// directions. Use when building a 1.1 frame that carries MAC commands
498  /// in `FOpts`.
499  ///
500  /// # Errors
501  /// Currently infallible; returns `Result` for forward compatibility.
502  pub fn encrypt_fopts(
503    &self,
504    nwk_s_enc_key: &crate::types::NwkSEncKey,
505    f_cnt_msb: u16,
506  ) -> crate::Result<alloc::vec::Vec<u8>> {
507    Ok(fopts_crypt(
508      &self.f_opts,
509      nwk_s_enc_key.as_bytes(),
510      self.direction,
511      self.dev_addr,
512      self.f_port,
513      self.f_cnt_32(f_cnt_msb),
514    ))
515  }
516}
517
518impl crate::codec::JoinAccept {
519  /// Decrypt a wire-format Join Accept (`MHDR` + ciphertext body + MIC) on
520  /// the device side.
521  ///
522  /// `LoRaWAN` Join Accept uses an unusual trick: the server applies
523  /// AES-ECB-*decrypt* to the body so that the device can use only the
524  /// AES-ECB-*encrypt* primitive (smaller code on constrained MCUs). This
525  /// helper inverts that: encrypt on the device side gives back the
526  /// plaintext bytes.
527  ///
528  /// The MHDR (first byte) passes through unchanged. The total length
529  /// must be 17 (one block, no `CFList`) or 33 (two blocks, with `CFList`).
530  ///
531  /// # Errors
532  /// [`crate::Error::InvalidJoinAcceptLength`] when the total length is
533  /// outside `{17, 33}`.
534  ///
535  /// # Examples
536  ///
537  /// ```
538  /// use lora_packet::{JoinAccept, AppKey};
539  ///
540  /// let app_key = AppKey::new([0u8; 16]);
541  /// let encrypted = hex::decode("20e3de108795f776b8037610ef7869b5b3")?;
542  /// let plaintext = JoinAccept::decrypt_from_wire(&encrypted, &app_key)?;
543  /// // plaintext is now MHDR || JoinAcceptBody || MIC
544  /// assert_eq!(plaintext.len(), 17);
545  /// # Ok::<(), Box<dyn std::error::Error>>(())
546  /// ```
547  pub fn decrypt_from_wire(ciphertext: &[u8], app_key: &AppKey) -> crate::Result<alloc::vec::Vec<u8>> {
548    join_accept_transform(ciphertext, app_key, aes_ecb_encrypt)
549  }
550
551  /// Encrypt a plaintext Join Accept on the server side.
552  ///
553  /// Applies AES-ECB-decrypt to the body (the inverse of what
554  /// [`Self::decrypt_from_wire`] does on the device). The MHDR is left
555  /// as-is. Use when assembling a Join Accept to send to a device.
556  ///
557  /// The total length must be 17 (one block) or 33 (two blocks).
558  ///
559  /// # Errors
560  /// [`crate::Error::InvalidJoinAcceptLength`] when the total length is
561  /// outside `{17, 33}`.
562  pub fn encrypt_for_wire(plaintext: &[u8], app_key: &AppKey) -> crate::Result<alloc::vec::Vec<u8>> {
563    join_accept_transform(plaintext, app_key, aes_ecb_decrypt)
564  }
565}
566
567fn join_accept_transform(
568  input: &[u8],
569  app_key: &AppKey,
570  op: fn(&[u8; 16], &[u8; 16]) -> [u8; 16],
571) -> crate::Result<alloc::vec::Vec<u8>> {
572  if input.len() != 17 && input.len() != 33 {
573    return Err(crate::Error::InvalidJoinAcceptLength(input.len()));
574  }
575  let mut out = alloc::vec::Vec::with_capacity(input.len());
576  out.push(input[0]);
577  for chunk in input[1..].chunks(16) {
578    let mut block = [0u8; 16];
579    block.copy_from_slice(chunk);
580    out.extend_from_slice(&op(&block, app_key.as_bytes()));
581  }
582  Ok(out)
583}
584
585fn fopts_crypt(
586  input: &[u8],
587  key: &[u8; 16],
588  direction: crate::types::Direction,
589  dev_addr: DevAddr,
590  f_port: Option<u8>,
591  f_cnt_32: u32,
592) -> alloc::vec::Vec<u8> {
593  let is_downlink = matches!(direction, crate::types::Direction::Downlink);
594  let dir_byte = u8::from(is_downlink);
595  let a_f_cnt_down = is_downlink && f_port.is_some_and(|p| p > 0);
596
597  let mut ai = [0u8; 16];
598  ai[0] = 0x01;
599  ai[4] = if a_f_cnt_down { 0x02 } else { 0x01 };
600  ai[5] = dir_byte;
601  let mut addr = *dev_addr.as_bytes();
602  addr.reverse();
603  ai[6..10].copy_from_slice(&addr);
604  ai[10..14].copy_from_slice(&f_cnt_32.to_le_bytes());
605  ai[15] = 0x01;
606  let s = aes_ecb_encrypt(&ai, key);
607
608  input.iter().enumerate().map(|(i, b)| b ^ s[i]).collect()
609}
610
611#[cfg(test)]
612mod tests {
613  use super::*;
614
615  /// NIST AES-128 test vector from FIPS-197 Appendix B.
616  #[test]
617  fn aes_ecb_encrypt_nist_vector() {
618    let key = [
619      0x2bu8, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c,
620    ];
621    let plaintext = [
622      0x32u8, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34,
623    ];
624    let expected = [
625      0x39u8, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, 0x32,
626    ];
627    assert_eq!(aes_ecb_encrypt(&plaintext, &key), expected);
628  }
629
630  #[test]
631  fn session_keys_10_distinct() {
632    let app_key = AppKey::new([
633      0x2bu8, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c,
634    ]);
635    let net_id = NetId::new([0x00, 0x00, 0x01]);
636    let app_nonce = AppNonce::new([0xC1, 0xD5, 0xEC]);
637    let dev_nonce = DevNonce::new([0xC8, 0xF8]);
638    let keys = SessionKeys10::derive(&app_key, &net_id, &app_nonce, &dev_nonce);
639    assert_ne!(keys.app_s_key.as_bytes(), keys.nwk_s_key.as_bytes());
640    // Same inputs -> same outputs (deterministic)
641    let keys2 = SessionKeys10::derive(&app_key, &net_id, &app_nonce, &dev_nonce);
642    assert_eq!(keys.app_s_key.as_bytes(), keys2.app_s_key.as_bytes());
643    assert_eq!(keys.nwk_s_key.as_bytes(), keys2.nwk_s_key.as_bytes());
644  }
645
646  #[test]
647  fn session_keys_11_distinct() {
648    let app_key = AppKey::new([0x11u8; 16]);
649    let nwk_key = NwkKey::new([0x22u8; 16]);
650    let join_eui = AppEui::new([0x33u8; 8]);
651    let app_nonce = AppNonce::new([0x44, 0x55, 0x66]);
652    let dev_nonce = DevNonce::new([0x77, 0x88]);
653    let k = SessionKeys11::derive(&app_key, &nwk_key, &join_eui, &app_nonce, &dev_nonce);
654    assert_ne!(k.app_s_key.as_bytes(), k.f_nwk_s_int_key.as_bytes());
655    assert_ne!(k.f_nwk_s_int_key.as_bytes(), k.s_nwk_s_int_key.as_bytes());
656    assert_ne!(k.s_nwk_s_int_key.as_bytes(), k.nwk_s_enc_key.as_bytes());
657  }
658
659  #[test]
660  fn js_keys_distinct() {
661    let nwk_key = NwkKey::new([0x42u8; 16]);
662    let dev_eui = DevEui::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
663    let k = JoinServerKeys::derive(&nwk_key, &dev_eui);
664    assert_ne!(k.js_int_key.as_bytes(), k.js_enc_key.as_bytes());
665  }
666
667  #[test]
668  fn wor_root_key_deterministic() {
669    let nwk = NwkSKey::new([0x00u8; 16]);
670    let r1 = WorKeys::root(&nwk);
671    let r2 = WorKeys::root(&nwk);
672    assert_eq!(r1.as_bytes(), r2.as_bytes());
673  }
674
675  #[test]
676  fn wor_session_keys_distinct() {
677    let nwk = NwkSKey::new([0x33u8; 16]);
678    let root = WorKeys::root(&nwk);
679    let dev = DevAddr::new([0x01, 0x02, 0x03, 0x04]);
680    let s = WorKeys::session(&root, &dev);
681    assert_ne!(s.wor_s_int_key.as_bytes(), s.wor_s_enc_key.as_bytes());
682  }
683
684  use crate::codec::LoraPacket;
685  use alloc::vec::Vec;
686
687  fn hex_to_vec(s: &str) -> Vec<u8> {
688    (0..s.len())
689      .step_by(2)
690      .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
691      .collect()
692  }
693
694  fn hex_to_arr_16(s: &str) -> [u8; 16] {
695    let mut arr = [0u8; 16];
696    for (i, byte) in (0..s.len()).step_by(2).enumerate() {
697      arr[i] = u8::from_str_radix(&s[byte..byte + 2], 16).unwrap();
698    }
699    arr
700  }
701
702  /// Mirror of `__tests__/decrypt_test.ts`: "should decrypt test payload".
703  #[test]
704  fn decrypt_payload_test_text() {
705    let bytes = hex_to_vec("40f17dbe4900020001954378762b11ff0d");
706    let packet = LoraPacket::from_wire(&bytes).unwrap();
707    let data = packet.as_data().unwrap();
708    let app_s_key = AppSKey::new(hex_to_arr_16("ec925802ae430ca77fd3dd73cb2cc588"));
709    let nwk_s_key = NwkSKey::new([0u8; 16]);
710    let plain = data.decrypt_payload(&app_s_key, &nwk_s_key, 0).unwrap();
711    assert_eq!(plain, b"test");
712  }
713
714  /// Round-trip: encrypt -> decrypt produces original.
715  #[test]
716  fn encrypt_then_decrypt_round_trip() {
717    let bytes = hex_to_vec("40f17dbe4900020001954378762b11ff0d");
718    let packet = LoraPacket::from_wire(&bytes).unwrap();
719    let data = packet.as_data().unwrap();
720    let app_s_key = AppSKey::new(hex_to_arr_16("ec925802ae430ca77fd3dd73cb2cc588"));
721    let nwk_s_key = NwkSKey::new([0u8; 16]);
722    let plain = b"hello world!";
723    let ct = data.encrypt_payload(plain, &app_s_key, &nwk_s_key, 0).unwrap();
724    assert_ne!(ct, plain);
725    let mut clone = data.clone();
726    clone.frm_payload = Some(ct);
727    let decrypted = clone.decrypt_payload(&app_s_key, &nwk_s_key, 0).unwrap();
728    assert_eq!(decrypted, plain);
729  }
730
731  /// Vector from <https://pkg.go.dev/github.com/brocaar/lorawan>, mirrored in
732  /// `__tests__/fopts_test.ts`: "should encode packet with Lorawan11
733  /// Encrypted Fopts". Downlink with `FPort` > 0 means `aFCntDown` is true.
734  #[test]
735  fn encrypt_fopts_1_1_vector() {
736    use crate::codec::Data;
737    use crate::types::{Direction, FCtrl, NwkSEncKey};
738
739    let data = Data {
740      direction: Direction::Downlink,
741      confirmed: false,
742      dev_addr: DevAddr::new([0x01, 0x02, 0x03, 0x04]),
743      f_ctrl: FCtrl(0x03),
744      f_cnt: [0x00, 0x00],
745      f_opts: alloc::vec![0x02, 0x07, 0x01],
746      f_port: Some(1),
747      frm_payload: Some(alloc::vec![0x01, 0x02, 0x03, 0x04]),
748    };
749    let nwk_s_enc_key = NwkSEncKey::new([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 0]);
750    let encrypted = data.encrypt_fopts(&nwk_s_enc_key, 0).unwrap();
751    assert_eq!(encrypted, [0x22, 0xac, 0x0a]);
752
753    let mut clone = data;
754    clone.f_opts = encrypted;
755    let decrypted = clone.decrypt_fopts(&nwk_s_enc_key, 0).unwrap();
756    assert_eq!(decrypted, [0x02, 0x07, 0x01]);
757  }
758
759  /// Mirror of `__tests__/join_accept_encrypt.ts`: "should create join
760  /// accept packet with zero value" (server-side encrypt produces wire form).
761  #[test]
762  fn join_accept_encrypt_zero_app_key() {
763    let app_key = AppKey::new([0u8; 16]);
764    let plaintext = hex_to_vec("20000000000000000000000000f86f0a91");
765    let encrypted = crate::codec::JoinAccept::encrypt_for_wire(&plaintext, &app_key).unwrap();
766    let expected = hex_to_vec("20e3de108795f776b8037610ef7869b5b3");
767    assert_eq!(encrypted, expected);
768  }
769
770  #[test]
771  fn payload_crypt_rejects_oversize() {
772    // 256 AES-128 blocks: i_chunk + 1 would reach 256, overflowing the
773    // 1-byte block index in Ai[15]. The crypt must refuse rather than
774    // silently truncate the index.
775    use crate::codec::Data;
776    use crate::types::{Direction, FCtrl};
777
778    let huge_plaintext = alloc::vec![0u8; 16 * 256];
779    let data = Data {
780      direction: Direction::Uplink,
781      confirmed: false,
782      dev_addr: DevAddr::new([0u8; 4]),
783      f_ctrl: FCtrl(0),
784      f_cnt: [0, 0],
785      f_opts: alloc::vec![],
786      f_port: Some(1),
787      frm_payload: None,
788    };
789    let app_s_key = AppSKey::new([0u8; 16]);
790    let nwk_s_key = NwkSKey::new([0u8; 16]);
791    let err = data
792      .encrypt_payload(&huge_plaintext, &app_s_key, &nwk_s_key, 0)
793      .unwrap_err();
794    assert!(matches!(err, crate::Error::PayloadTooLarge(n) if n == 16 * 256));
795
796    // The boundary case: exactly 255 blocks (= 4080 bytes) must still succeed.
797    let max_plaintext = alloc::vec![0u8; 16 * 255];
798    let ok = data.encrypt_payload(&max_plaintext, &app_s_key, &nwk_s_key, 0).unwrap();
799    assert_eq!(ok.len(), 16 * 255);
800  }
801
802  /// `encrypt_for_wire` and `decrypt_from_wire` are inverses (the on-air
803  /// AES-ECB trick).
804  #[test]
805  fn join_accept_decrypt_round_trip() {
806    let app_key = AppKey::new([0u8; 16]);
807    let plaintext = hex_to_vec("20000000000000000000000000f86f0a91");
808    let encrypted = crate::codec::JoinAccept::encrypt_for_wire(&plaintext, &app_key).unwrap();
809    let decrypted = crate::codec::JoinAccept::decrypt_from_wire(&encrypted, &app_key).unwrap();
810    assert_eq!(decrypted, plaintext);
811  }
812}