LoRaWAN
LoRaWAN 1.0 frames read on your own server: PHYPayload parsed, the MIC checked with AES-CMAC, FRMPayload decrypted, a replay refused, the 16-bit counter widened past 65535, MAC commands named, OTAA joins with their session keys, frames built for simulators, and Cayenne LPP sensor payloads both ways.
Version 1.0.0 · 10 functions · 6 crates
Functions
What use lorawan.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use lorawan_parse, lorawan_verify from lorawan (Quillo 0.6).
lorawan_parse(packet: any) → anyor an errorReads a PHYPayload (hex, base64 or bytes) without keys: frame type, DevAddr, FCtrl's flags, frame counter, port, MAC commands in FOpts and the MIC; for a join request its EUIs and nonce.
lorawan_verify(packet: any, keys: any) → boolor an errorTrue when a data frame's MIC matches under keys.nwk_skey; keys.last_fcnt widens a counter past 65535.
lorawan_open(packet: any, keys: any) → anyor an errorChecks a data frame's MIC and decrypts its payload: keys holds nwk_skey, app_skey and optionally last_fcnt, the last counter seen, which widens this one to 32 bits and refuses a replay. A bad MIC is an error, never a payload.
lorawan_build(frame: any, keys: any) → stror an errorBuilds and signs a data frame, for simulators and downlinks: frame has dev_addr, fcnt, fport, payload (hex, or text), and type, adr, ack, fopts; keys has nwk_skey and app_skey. Answers hex.
lorawan_join_request_verify(packet: any, app_key: str) → boolor an errorTrue when a join request's MIC matches the device's app_key.
lorawan_join_request(request: any, app_key: str) → stror an errorBuilds a join request for a simulated device: request has join_eui, dev_eui and dev_nonce in hex as printed, most significant byte first. Answers hex.
lorawan_join_accept(join_request: any, accept: any, app_key: str) → anyor an errorMakes the network's answer to a join request: accept has dev_addr, net_id, and app_nonce (three bytes, hex; random when left out), rx1_dr_offset, rx2_dr, rx_delay. Answers the encrypted join accept in hex with the session keys derived, 1.0.x style.
lorawan_join_accept_read(packet: any, app_key: str, dev_nonce: str) → anyor an errorReads a join accept as a device does: decrypts it with app_key, checks its MIC and derives NwkSKey and AppSKey from the dev_nonce of the join request (hex, as printed).
lorawan_cayenne_decode(payload: any) → anyor an errorDecodes a Cayenne LPP payload (hex, base64 or bytes): { readings: [{ channel, type, value }], values: { temperature_3: 27.2 } }. A cut-off reading or an unknown type is an error naming the offset.
lorawan_cayenne_encode(readings: list) → stror an errorEncodes readings as Cayenne LPP: a list of { channel, type, value }, the shape the decoder gives. A value that does not fit its field is refused. Answers hex.
Get it
$ quillo install lorawan # lorawan.qwer into this folder$ quillo install lorawan --example # and its example, lorawan.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/lorawan.qwer$ curl -O https://quillolang.org/plugins/files/lorawan.qweThen, in your program, all of it or only what you call:
use lorawan.qweruse lorawan_parse, lorawan_verify from lorawan // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
aes = "0.8"cmac = "0.7"base64 = "0.22"hex = "0.4"getrandom = "0.2"serde_json = { version = "1", features = ["preserve_order"] }Example
// Sensors on a LoRaWAN network: what a gateway forwards here is checked and decrypted with the// device's session keys, a replay is refused, a Cayenne LPP payload becomes readings, and a device// joins by OTAA, both ends deriving the same keys. No radio needed: the first frame is the one the// lora-packet project publishes, and the others are made here.//// quillo run lorawan.qwe then open http://localhost:8080/uplink, /tampered, /cayenne and /join//// For your own devices, POST what the gateway's bridge gives you to /uplink as { "phy": "..." }:// hex or base64. Keep keys out of the program: LORAWAN_NWK_SKEY and LORAWAN_APP_SKEY.use "lorawan.qwer"let keys = { nwk_skey: env("LORAWAN_NWK_SKEY", "44024241ed4ce9a68c6a8bc055233fd3"), app_skey: env("LORAWAN_APP_SKEY", "ec925802ae430ca77fd3dd73cb2cc588"),}let frame = "40F17DBE4900020001954378762B11FF0D"route GET /uplink { let up = lorawan_open(frame, keys)? { device: up.dev_addr, count: up.fcnt, port: up.fport, text: up.text, mic: up.mic, parsed: lorawan_parse(frame)?.type }}route POST /uplink { json: { phy: str } } { let up = lorawan_open(phy, keys) if is_err(up) { reply 401 up.error } up}// One bit changed on the way, the wrong key, and an old frame sent again.route GET /tampered = { changed: lorawan_open("40F17DBE4900020001954378762B11FF0E", keys).error, wrong_key: lorawan_verify(frame, { nwk_skey: "00000000000000000000000000000000" })?, right_key: lorawan_verify(frame, keys)?, replay: lorawan_open(frame, { ...keys, last_fcnt: 2 }).error, cut_short: lorawan_parse("40F17DBE49").error,}// A counter past 65535: only its low half is sent, and the MIC needs all of it.route GET /rollover { let sent = lorawan_build({ dev_addr: "49BE7DF1", fcnt: 70000, fport: 2, text: "late", adr: true }, keys)? let up = lorawan_open(sent, { ...keys, last_fcnt: 69999 })? { on_the_wire: up.fcnt, widened: up.fcnt32, text: up.text, adr: up.adr, without_the_hint: lorawan_verify(sent, keys)? }}// MAC commands: in the clear in FOpts, or encrypted with the network key on port 0.route GET /mac { let plain = lorawan_open(lorawan_build({ type: "confirmed-up", dev_addr: "26011F22", fcnt: 9, fopts: "06FE0A" }, keys)?, keys)? let hidden = lorawan_open(lorawan_build({ dev_addr: "26011F22", fcnt: 10, fport: 0, payload: "02050106FE0A" }, keys)?, keys)? { type: plain.type, in_fopts: plain.mac_commands, on_port_0: hidden.mac_commands | map((c) => c.command), both: lorawan_build({ dev_addr: "26011F22", fcnt: 11, fport: 0, fopts: "02" }, keys).error, }}route GET /cayenne = { two_temperatures: lorawan_cayenne_decode("03670110056700FF")?.values, negative: lorawan_cayenne_decode("0167FFD7")?.readings, moving: lorawan_cayenne_decode("067104D2FB2E0000")?.values, gps: lorawan_cayenne_decode("018806765ff2960a0003e8")?.values, written: lorawan_cayenne_encode([{ channel: 3, type: "temperature", value: 27.2 }, { channel: 5, type: "temperature", value: 25.5 }])?, cut_off: lorawan_cayenne_decode("036701").error, too_big: lorawan_cayenne_encode([{ channel: 1, type: "humidity", value: 500 }]).error,}// Over-the-air activation: the device asks, the network answers, both hold the same session keys,// and the first uplink under those keys is read by the network.route GET /join { let app_key = "2b7e151628aed2a6abf7158809cf4f3c" let request = lorawan_join_request({ join_eui: "70B3D57ED0000000", dev_eui: "0004A30B001C0530", dev_nonce: "2C8A" }, app_key)? let asked = lorawan_parse(request)? let answer = lorawan_join_accept(request, { dev_addr: "260B1234", net_id: "000013", app_nonce: "C4A1F0", rx_delay: 1 }, app_key)? let device = lorawan_join_accept_read(answer.packet, app_key, asked.dev_nonce)? let session = { nwk_skey: device.nwk_skey, app_skey: device.app_skey } let first = lorawan_build({ dev_addr: device.dev_addr, fcnt: 1, fport: 10, text: "hello" }, session)? { device_eui: asked.dev_eui, request_ok: lorawan_join_request_verify(request, app_key)?, wrong_app_key: lorawan_join_accept(request, { dev_addr: "260B1234", net_id: "000013" }, "00000000000000000000000000000000").error, same_keys: device.nwk_skey == answer.nwk_skey && device.app_skey == answer.app_skey, keys_differ: device.nwk_skey != device.app_skey, addr: device.dev_addr, net: device.net_id, first: lorawan_open(first, { nwk_skey: answer.nwk_skey, app_skey: answer.app_skey })?.text, }}$ quillo run lorawan.qwe$ curl 'localhost:8080/uplink'$ curl 'localhost:8080/tampered'$ curl 'localhost:8080/rollover'$ curl 'localhost:8080/mac'$ curl 'localhost:8080/cayenne'$ curl 'localhost:8080/join'Source
//! LoRaWAN 1.0.x frames read on a network or application server: PHYPayload parsed, the MIC checked with AES-CMAC, FRMPayload decrypted with AES-128, the 16-bit frame counter widened, join requests and join accepts with their session keys, MAC commands named, frames built for simulators, and Cayenne LPP sensor payloads decoded and encoded.//! version: 1.0.0//!//! [dependencies]//! aes = "0.8"//! cmac = "0.7"//! base64 = "0.22"//! hex = "0.4"//! getrandom = "0.2"//! serde_json = { version = "1", features = ["preserve_order"] }use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};use aes::Aes128;use base64::Engine;use cmac::{Cmac, Mac};use serde_json::{json, Map, Value};// A packet is given as hex, as base64 (what gateways' JSON carries) or as a list of bytes. A key// is 32 hex digits.fn bytes_of(v: &Value) -> Result<Vec<u8>, String> { match v { Value::Array(a) => a .iter() .map(|x| x.as_u64().filter(|n| *n < 256).map(|n| n as u8).ok_or("a byte list holds numbers from 0 to 255".to_string())) .collect(), Value::String(s) => { let t: String = s.chars().filter(|c| !c.is_whitespace()).collect(); if t.len() % 2 == 0 && t.bytes().all(|c| c.is_ascii_hexdigit()) { hex::decode(&t).map_err(|e| e.to_string()) } else { base64::engine::general_purpose::STANDARD.decode(t.trim_end_matches('=').to_string() + &"=".repeat((4 - t.trim_end_matches('=').len() % 4) % 4)).map_err(|_| "not hex or base64".to_string()) } } _ => Err("give the bytes as hex, base64 or a list of numbers".into()), }}fn key_of(s: &str, what: &str) -> Result<[u8; 16], String> { let t = s.replace([' ', ':', '-'], ""); let b = hex::decode(&t).map_err(|_| format!("{} must be hex", what))?; <[u8; 16]>::try_from(b.as_slice()).map_err(|_| format!("{} must be 16 bytes (32 hex digits), not {}", what, t.len() / 2))}fn key_in(keys: &Value, name: &str) -> Result<[u8; 16], String> { match keys.get(name).and_then(|v| v.as_str()) { Some(s) => key_of(s, name), None => Err(format!("the keys need `{}`", name)), }}fn aes_enc(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { let c = Aes128::new(GenericArray::from_slice(key)); let mut b = GenericArray::clone_from_slice(block); c.encrypt_block(&mut b); b.into()}fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { let c = Aes128::new(GenericArray::from_slice(key)); let mut b = GenericArray::clone_from_slice(block); c.decrypt_block(&mut b); b.into()}fn cmac4(key: &[u8; 16], parts: &[&[u8]]) -> [u8; 4] { let mut m = <Cmac<Aes128> as Mac>::new_from_slice(key).expect("16 bytes"); for p in parts { m.update(p); } let t = m.finalize().into_bytes(); [t[0], t[1], t[2], t[3]]}fn be_hex(b: &[u8]) -> String { hex::encode_upper(b.iter().rev().cloned().collect::<Vec<u8>>())}const MTYPES: [&str; 8] = ["join-request", "join-accept", "unconfirmed-up", "unconfirmed-down", "confirmed-up", "confirmed-down", "rejoin-request", "proprietary"];struct Frame { mtype: u8, addr: [u8; 4], // on the wire, little-endian fctrl: u8, fcnt: u16, fopts: Vec<u8>, fport: Option<u8>, frm: Vec<u8>, mic: [u8; 4], body: Vec<u8>, // MHDR to the end of MACPayload}impl Frame { fn uplink(&self) -> bool { self.mtype % 2 == 0 }}fn frame(p: &[u8]) -> Result<Frame, String> { if p.len() < 12 { return Err(format!("{} bytes is too short for a data frame: MHDR, DevAddr, FCtrl, FCnt and MIC take 12", p.len())); } let mtype = p[0] >> 5; if !(2..=5).contains(&mtype) { return Err(format!("a {} is not a data frame", MTYPES[mtype as usize])); } let fctrl = p[5]; let opts = (fctrl & 0x0f) as usize; let end = p.len() - 4; if 8 + opts > end { return Err(format!("FCtrl says {} bytes of FOpts, which the frame does not hold", opts)); } let fport = if end > 8 + opts { Some(p[8 + opts]) } else { None }; let start = 9 + opts; Ok(Frame { mtype, addr: [p[1], p[2], p[3], p[4]], fctrl, fcnt: u16::from_le_bytes([p[6], p[7]]), fopts: p[8..8 + opts].to_vec(), fport, frm: if start <= end { p[start..end].to_vec() } else { vec![] }, mic: [p[end], p[end + 1], p[end + 2], p[end + 3]], body: p[..end].to_vec(), })}fn mic_of(key: &[u8; 16], body: &[u8], up: bool, addr: &[u8; 4], fcnt: u32) -> [u8; 4] { let mut b0 = [0u8; 16]; b0[0] = 0x49; b0[5] = if up { 0 } else { 1 }; b0[6..10].copy_from_slice(addr); b0[10..14].copy_from_slice(&fcnt.to_le_bytes()); b0[15] = body.len() as u8; cmac4(key, &[&b0, body])}fn crypt(key: &[u8; 16], data: &[u8], up: bool, addr: &[u8; 4], fcnt: u32) -> Vec<u8> { let mut out = Vec::with_capacity(data.len()); for (i, chunk) in data.chunks(16).enumerate() { let mut a = [0u8; 16]; a[0] = 1; a[5] = if up { 0 } else { 1 }; a[6..10].copy_from_slice(addr); a[10..14].copy_from_slice(&fcnt.to_le_bytes()); a[15] = (i + 1) as u8; let s = aes_enc(key, &a); out.extend(chunk.iter().zip(s.iter()).map(|(x, y)| x ^ y)); } out}// The counter's top half is not sent: the nearest value after the last one seen is the answer.fn widen(low: u16, last: Option<i64>) -> u32 { match last { None => low as u32, Some(l) => { let l = l.max(0) as u32; let mut c = (l & 0xffff_0000) | low as u32; if c < l && l - c > 0x8000 { c = c.wrapping_add(0x1_0000); } c } }}fn mac_commands(data: &[u8], up: bool) -> Vec<Value> { let table: &[(u8, &str, usize)] = if up { &[(0x02, "LinkCheckReq", 0), (0x03, "LinkADRAns", 1), (0x04, "DutyCycleAns", 0), (0x05, "RXParamSetupAns", 1), (0x06, "DevStatusAns", 2), (0x07, "NewChannelAns", 1), (0x08, "RXTimingSetupAns", 0), (0x09, "TxParamSetupAns", 0), (0x0a, "DlChannelAns", 1), (0x0d, "DeviceTimeReq", 0)] } else { &[(0x02, "LinkCheckAns", 2), (0x03, "LinkADRReq", 4), (0x04, "DutyCycleReq", 1), (0x05, "RXParamSetupReq", 4), (0x06, "DevStatusReq", 0), (0x07, "NewChannelReq", 5), (0x08, "RXTimingSetupReq", 1), (0x09, "TxParamSetupReq", 1), (0x0a, "DlChannelReq", 4), (0x0d, "DeviceTimeAns", 5)] }; let mut out = vec![]; let mut i = 0; while i < data.len() { let cid = data[i]; match table.iter().find(|t| t.0 == cid) { Some((_, name, n)) if i + 1 + n <= data.len() => { let args = &data[i + 1..i + 1 + n]; let mut m = json!({ "command": name, "hex": hex::encode(args) }); if *name == "DevStatusAns" { m["battery"] = json!(match args[0] { 0 => json!("external"), 255 => Value::Null, b => json!(b as f64 / 254.0 * 100.0) }); m["margin_db"] = json!((((args[1] & 0x3f) << 2) as i8) >> 2); } if *name == "LinkCheckAns" { m["margin_db"] = json!(args[0]); m["gateways"] = json!(args[1]); } out.push(m); i += 1 + n; } _ => { out.push(json!({ "command": format!("unknown 0x{:02x}", cid), "hex": hex::encode(&data[i + 1..]) })); break; } } } out}fn described(f: &Frame) -> Value { let up = f.uplink(); let mut m = json!({ "type": MTYPES[f.mtype as usize], "direction": if up { "up" } else { "down" }, "confirmed": f.mtype >= 4, "dev_addr": be_hex(&f.addr), "adr": f.fctrl & 0x80 != 0, "ack": f.fctrl & 0x20 != 0, "fcnt": f.fcnt, "fport": f.fport, "fopts": hex::encode(&f.fopts), "mac_commands": mac_commands(&f.fopts, up), "frm_payload": hex::encode(&f.frm), "mic": hex::encode_upper(f.mic), }); if up { m["adr_ack_req"] = json!(f.fctrl & 0x40 != 0); m["class_b"] = json!(f.fctrl & 0x10 != 0); } else { m["fpending"] = json!(f.fctrl & 0x10 != 0); } m}/// Reads a PHYPayload (hex, base64 or bytes) without keys: frame type, DevAddr, FCtrl's flags, frame counter, port, MAC commands in FOpts and the MIC; for a join request its EUIs and nonce.pub fn lorawan_parse(packet: serde_json::Value) -> Result<Value, String> { let p = bytes_of(&packet)?; if p.is_empty() { return Err("an empty packet".into()); } if p[0] & 3 != 0 && p[0] >> 5 != 7 { return Err(format!("LoRaWAN major version {} is not one this reads (R1 is 0)", p[0] & 3)); } match p[0] >> 5 { 0 => { if p.len() != 23 { return Err(format!("a join request is 23 bytes, not {}", p.len())); } Ok(json!({ "type": "join-request", "join_eui": be_hex(&p[1..9]), "dev_eui": be_hex(&p[9..17]), "dev_nonce": be_hex(&p[17..19]), "mic": hex::encode_upper(&p[19..23]) })) } 1 => Ok(json!({ "type": "join-accept", "encrypted": true, "length": p.len() })), 6 | 7 => Ok(json!({ "type": MTYPES[(p[0] >> 5) as usize], "length": p.len(), "hex": hex::encode(&p) })), _ => Ok(described(&frame(&p)?)), }}/// True when a data frame's MIC matches under `keys.nwk_skey`; `keys.last_fcnt` widens a counter past 65535.pub fn lorawan_verify(packet: serde_json::Value, keys: serde_json::Value) -> Result<bool, String> { let f = frame(&bytes_of(&packet)?)?; let k = key_in(&keys, "nwk_skey")?; let cnt = widen(f.fcnt, keys.get("last_fcnt").and_then(|v| v.as_i64())); Ok(mic_of(&k, &f.body, f.uplink(), &f.addr, cnt) == f.mic)}/// Checks a data frame's MIC and decrypts its payload: `keys` holds `nwk_skey`, `app_skey` and optionally `last_fcnt`, the last counter seen, which widens this one to 32 bits and refuses a replay. A bad MIC is an error, never a payload.pub fn lorawan_open(packet: serde_json::Value, keys: serde_json::Value) -> Result<Value, String> { let f = frame(&bytes_of(&packet)?)?; let nwk = key_in(&keys, "nwk_skey")?; let last = keys.get("last_fcnt").and_then(|v| v.as_i64()); let cnt = widen(f.fcnt, last); let up = f.uplink(); let want = mic_of(&nwk, &f.body, up, &f.addr, cnt); if want != f.mic { return Err(format!("MIC check failed for {}: the frame has {}, these keys give {}", be_hex(&f.addr), hex::encode_upper(f.mic), hex::encode_upper(want))); } if let Some(l) = last { if cnt as i64 <= l { return Err(format!("replayed frame: counter {} is not past {}", cnt, l)); } } // Port 0 carries MAC commands under the network key; the others, data under the application key. let key = if f.fport == Some(0) { nwk } else { key_in(&keys, "app_skey")? }; let plain = crypt(&key, &f.frm, up, &f.addr, cnt); let mut m = described(&f); m["fcnt32"] = json!(cnt); m["verified"] = json!(true); m["payload"] = json!(hex::encode(&plain)); m["base64"] = json!(base64::engine::general_purpose::STANDARD.encode(&plain)); m["text"] = match std::str::from_utf8(&plain) { Ok(t) if t.chars().all(|c| !c.is_control() || c == '\n') => json!(t), _ => Value::Null, }; if f.fport == Some(0) { m["mac_commands"] = json!(mac_commands(&plain, up)); } Ok(m)}fn hex_field(frame: &Value, k: &str) -> Result<Vec<u8>, String> { match frame.get(k) { None | Some(Value::Null) => Ok(vec![]), Some(v) => bytes_of(v), }}/// Builds and signs a data frame, for simulators and downlinks: `frame` has `dev_addr`, `fcnt`, `fport`, `payload` (hex, or `text`), and `type`, `adr`, `ack`, `fopts`; `keys` has `nwk_skey` and `app_skey`. Answers hex.pub fn lorawan_build(frame: serde_json::Value, keys: serde_json::Value) -> Result<String, String> { let ty = frame.get("type").and_then(|v| v.as_str()).unwrap_or("unconfirmed-up"); let mtype = MTYPES.iter().position(|t| *t == ty).filter(|i| (2..=5).contains(i)).ok_or(format!("`{}` is not a data frame type", ty))? as u8; let addr = hex::decode(frame.get("dev_addr").and_then(|v| v.as_str()).ok_or("the frame needs `dev_addr`")?).map_err(|_| "dev_addr must be hex")?; if addr.len() != 4 { return Err("dev_addr must be 4 bytes (8 hex digits)".into()); } let wire = [addr[3], addr[2], addr[1], addr[0]]; let cnt = frame.get("fcnt").and_then(|v| v.as_i64()).ok_or("the frame needs `fcnt`")?; if !(0..=0xffff_ffff).contains(&cnt) { return Err("fcnt must fit 32 bits".into()); } let cnt = cnt as u32; let fopts = hex_field(&frame, "fopts")?; if fopts.len() > 15 { return Err("FOpts holds at most 15 bytes".into()); } let plain = match frame.get("text").and_then(|v| v.as_str()) { Some(t) => t.as_bytes().to_vec(), None => hex_field(&frame, "payload")?, }; let port = frame.get("fport").and_then(|v| v.as_i64()); if !plain.is_empty() && port.is_none() { return Err("a payload needs `fport`".into()); } if let Some(p) = port { if !(0..=255).contains(&p) { return Err("fport must be 0 to 255".into()); } if p == 0 && !fopts.is_empty() { return Err("MAC commands go in FOpts or on port 0, never both".into()); } } let up = mtype % 2 == 0; let nwk = key_in(&keys, "nwk_skey")?; let mut b = vec![mtype << 5]; b.extend(wire); let flag = |k: &str| frame.get(k).and_then(|v| v.as_bool()).unwrap_or(false); b.push((flag("adr") as u8) << 7 | (flag("ack") as u8) << 5 | fopts.len() as u8); b.extend(((cnt & 0xffff) as u16).to_le_bytes()); b.extend(&fopts); if let Some(p) = port { b.push(p as u8); let key = if p == 0 { nwk } else { key_in(&keys, "app_skey")? }; b.extend(crypt(&key, &plain, up, &wire, cnt)); } let mic = mic_of(&nwk, &b, up, &wire, cnt); b.extend(mic); Ok(hex::encode(b))}/// True when a join request's MIC matches the device's `app_key`.pub fn lorawan_join_request_verify(packet: serde_json::Value, app_key: &str) -> Result<bool, String> { let p = bytes_of(&packet)?; if p.len() != 23 || p[0] >> 5 != 0 { return Err("not a join request (23 bytes, MType 0)".into()); } let k = key_of(app_key, "app_key")?; Ok(cmac4(&k, &[&p[..19]]) == p[19..23])}/// Builds a join request for a simulated device: `request` has `join_eui`, `dev_eui` and `dev_nonce` in hex as printed, most significant byte first. Answers hex.pub fn lorawan_join_request(request: serde_json::Value, app_key: &str) -> Result<String, String> { let field = |k: &str, n: usize| -> Result<Vec<u8>, String> { let v = request.get(k).and_then(|v| v.as_str()).ok_or(format!("the request needs `{}`", k))?; let mut b = hex::decode(v).map_err(|_| format!("{} must be hex", k))?; if b.len() != n { return Err(format!("{} must be {} bytes", k, n)); } b.reverse(); Ok(b) }; let mut b = vec![0u8]; b.extend(field("join_eui", 8)?); b.extend(field("dev_eui", 8)?); b.extend(field("dev_nonce", 2)?); let k = key_of(app_key, "app_key")?; let mic = cmac4(&k, &[&b]); b.extend(mic); Ok(hex::encode(b))}fn session_keys(k: &[u8; 16], plain: &[u8], dev_nonce: &[u8]) -> ([u8; 16], [u8; 16]) { let mut block = [0u8; 16]; block[1..7].copy_from_slice(&plain[..6]); // AppNonce and NetID, as sent block[7..9].copy_from_slice(dev_nonce); let mut a = block; a[0] = 1; let mut b = block; b[0] = 2; (aes_enc(k, &a), aes_enc(k, &b))}/// Makes the network's answer to a join request: `accept` has `dev_addr`, `net_id`, and `app_nonce` (three bytes, hex; random when left out), `rx1_dr_offset`, `rx2_dr`, `rx_delay`. Answers the encrypted join accept in hex with the session keys derived, 1.0.x style.pub fn lorawan_join_accept(join_request: serde_json::Value, accept: serde_json::Value, app_key: &str) -> Result<Value, String> { let req = bytes_of(&join_request)?; if req.len() != 23 || req[0] >> 5 != 0 { return Err("not a join request".into()); } let k = key_of(app_key, "app_key")?; if cmac4(&k, &[&req[..19]]) != req[19..23] { return Err("the join request's MIC does not match this app_key".into()); } let field = |n: &str, len: usize| -> Result<Option<Vec<u8>>, String> { match accept.get(n).and_then(|v| v.as_str()) { None => Ok(None), Some(s) => { let mut b = hex::decode(s).map_err(|_| format!("{} must be hex", n))?; if b.len() != len { return Err(format!("{} must be {} bytes", n, len)); } b.reverse(); Ok(Some(b)) } } }; let nonce = match field("app_nonce", 3)? { Some(n) => n, None => { let mut n = vec![0u8; 3]; getrandom::getrandom(&mut n).map_err(|e| e.to_string())?; n } }; let mut plain = nonce; plain.extend(field("net_id", 3)?.ok_or("the accept needs `net_id`")?); plain.extend(field("dev_addr", 4)?.ok_or("the accept needs `dev_addr`")?); let num = |n: &str| accept.get(n).and_then(|v| v.as_u64()).unwrap_or(0) as u8; plain.push((num("rx1_dr_offset") & 7) << 4 | (num("rx2_dr") & 15)); plain.push(num("rx_delay")); let mic = cmac4(&k, &[&[0x20u8], &plain]); let (nwk, app) = session_keys(&k, &plain, &req[17..19]); plain.extend(mic); // The network uses AES's decrypt here, so that a device needs only encrypt to read it. let mut out = vec![0x20u8]; for c in plain.chunks(16) { let b: [u8; 16] = c.try_into().map_err(|_| "a join accept is 16 or 32 bytes")?; out.extend(aes_dec(&k, &b)); } Ok(json!({ "packet": hex::encode(out), "nwk_skey": hex::encode(nwk), "app_skey": hex::encode(app) }))}/// Reads a join accept as a device does: decrypts it with `app_key`, checks its MIC and derives NwkSKey and AppSKey from the `dev_nonce` of the join request (hex, as printed).pub fn lorawan_join_accept_read(packet: serde_json::Value, app_key: &str, dev_nonce: &str) -> Result<Value, String> { let p = bytes_of(&packet)?; if p.first().map(|b| b >> 5) != Some(1) || !(p.len() == 17 || p.len() == 33) { return Err("not a join accept (17 or 33 bytes, MType 1)".into()); } let k = key_of(app_key, "app_key")?; let mut plain = vec![]; for c in p[1..].chunks(16) { let b: [u8; 16] = c.try_into().map_err(|_| "a join accept is whole blocks")?; plain.extend(aes_enc(&k, &b)); } let (body, mic) = plain.split_at(plain.len() - 4); if cmac4(&k, &[&p[..1], body]) != mic { return Err("the join accept's MIC does not match this app_key".into()); } let mut nonce = hex::decode(dev_nonce).map_err(|_| "dev_nonce must be hex")?; if nonce.len() != 2 { return Err("dev_nonce must be 2 bytes".into()); } nonce.reverse(); let (nwk, app) = session_keys(&k, body, &nonce); let mut m = json!({ "app_nonce": be_hex(&body[..3]), "net_id": be_hex(&body[3..6]), "dev_addr": be_hex(&body[6..10]), "rx1_dr_offset": (body[10] >> 4) & 7, "rx2_dr": body[10] & 15, "rx_delay": body[11], "nwk_skey": hex::encode(nwk), "app_skey": hex::encode(app), }); if body.len() > 12 { m["cf_list"] = json!(hex::encode(&body[12..])); } Ok(m)}// Cayenne LPP: (type, name, field names, bytes per field, divisor, signed).const LPP: [(u8, &str, &[&str], usize, f64, bool); 12] = [ (0, "digital_input", &[""], 1, 1.0, false), (1, "digital_output", &[""], 1, 1.0, false), (2, "analog_input", &[""], 2, 100.0, true), (3, "analog_output", &[""], 2, 100.0, true), (101, "illuminance", &[""], 2, 1.0, false), (102, "presence", &[""], 1, 1.0, false), (103, "temperature", &[""], 2, 10.0, true), (104, "humidity", &[""], 1, 2.0, false), (113, "accelerometer", &["x", "y", "z"], 2, 1000.0, true), (115, "barometer", &[""], 2, 10.0, false), (134, "gyrometer", &["x", "y", "z"], 2, 100.0, true), (136, "gps", &["latitude", "longitude", "altitude"], 3, 0.0, true),];fn lpp_int(b: &[u8], signed: bool) -> i64 { let v = b.iter().fold(0i64, |a, x| (a << 8) | *x as i64); if signed && b[0] & 0x80 != 0 { v - (1i64 << (8 * b.len())) } else { v }}fn round(x: f64) -> f64 { (x * 1e6).round() / 1e6}/// Decodes a Cayenne LPP payload (hex, base64 or bytes): `{ readings: [{ channel, type, value }], values: { temperature_3: 27.2 } }`. A cut-off reading or an unknown type is an error naming the offset.pub fn lorawan_cayenne_decode(payload: serde_json::Value) -> Result<Value, String> { let p = bytes_of(&payload)?; let mut readings = vec![]; let mut values = Map::new(); let mut i = 0; while i < p.len() { if i + 2 > p.len() { return Err(format!("the payload ends inside a reading's header at byte {}", i)); } let (channel, ty) = (p[i], p[i + 1]); let t = LPP.iter().find(|t| t.0 == ty).ok_or(format!("unknown Cayenne type {} on channel {} at byte {}", ty, channel, i + 1))?; let n = t.2.len() * t.3; if i + 2 + n > p.len() { return Err(format!("the {} reading on channel {} needs {} bytes; {} are left", t.1, channel, n, p.len() - i - 2)); } let d = &p[i + 2..i + 2 + n]; let value = if ty == 136 { json!({ "latitude": round(lpp_int(&d[0..3], true) as f64 / 10000.0), "longitude": round(lpp_int(&d[3..6], true) as f64 / 10000.0), "altitude": round(lpp_int(&d[6..9], true) as f64 / 100.0) }) } else if t.2.len() == 3 { let at = |k: usize| round(lpp_int(&d[k * t.3..(k + 1) * t.3], t.5) as f64 / t.4); json!({ "x": at(0), "y": at(1), "z": at(2) }) } else { json!(round(lpp_int(d, t.5) as f64 / t.4)) }; values.insert(format!("{}_{}", t.1, channel), value.clone()); readings.push(json!({ "channel": channel, "type": t.1, "value": value })); i += 2 + n; } Ok(json!({ "readings": readings, "values": values }))}/// Encodes readings as Cayenne LPP: a list of `{ channel, type, value }`, the shape the decoder gives. A value that does not fit its field is refused. Answers hex.pub fn lorawan_cayenne_encode(readings: Vec<serde_json::Value>) -> Result<String, String> { fn put(out: &mut Vec<u8>, x: f64, size: usize, signed: bool, what: &str) -> Result<(), String> { let v = x.round() as i64; let (lo, hi) = if signed { (-(1i64 << (8 * size - 1)), (1i64 << (8 * size - 1)) - 1) } else { (0, (1i64 << (8 * size)) - 1) }; if v < lo || v > hi { return Err(format!("{} does not fit a {} reading", x, what)); } out.extend(&v.to_be_bytes()[8 - size..]); Ok(()) } let mut out = vec![]; for r in &readings { let name = r.get("type").and_then(|v| v.as_str()).ok_or("a reading needs `type`")?; let t = LPP.iter().find(|t| t.1 == name).ok_or(format!("unknown Cayenne type `{}`", name))?; let channel = r.get("channel").and_then(|v| v.as_u64()).filter(|c| *c < 256).ok_or("a reading needs `channel`, 0 to 255")?; out.push(channel as u8); out.push(t.0); let v = r.get("value").ok_or("a reading needs `value`")?; if t.0 == 136 { for (k, d) in [("latitude", 10000.0), ("longitude", 10000.0), ("altitude", 100.0)] { put(&mut out, v.get(k).and_then(|x| x.as_f64()).ok_or(format!("a gps reading needs `{}`", k))? * d, 3, true, name)?; } } else if t.2.len() == 3 { for k in t.2 { put(&mut out, v.get(*k).and_then(|x| x.as_f64()).ok_or(format!("an {} reading needs `{}`", name, k))? * t.4, t.3, t.5, name)?; } } else { put(&mut out, v.as_f64().ok_or("a reading's value must be a number")? * t.4, t.3, t.5, name)?; } } Ok(hex::encode(out))}Related
can_dbc.qwer · 1.0.0CAN bus frames made readable with a DBC file: messages and signals read with Intel or Motorola layout, scale, units, value names and multiplexing; frames decoded, frames built from values, and candump logs decoded.8 views · 9 downloadsModbus frames modbus.qwer · 1.0.0Modbus RTU and TCP frames built and read without a device: register and coil requests, answers and exceptions explained, CRC-16 and MBAP headers, registers turned into integers, floats and text in any word order and back, and a TCP client for a real PLC or meter.8 views · 10 downloadsOBD-II obd2.qwer · 1.0.0OBD-II answers from an ELM327 adapter decoded: speed, RPM, temperatures, load, fuel level and voltage, trouble codes such as P0133, and the PIDs a car supports.8 views · 8 downloadsAircraft ADS-B adsb.qwer · 1.0.0Aircraft ADS-B and Mode S messages from a 1090 MHz receiver decoded: call signs, altitudes, positions from even and odd pairs or near the receiver, speeds, tracks, vertical rates and squawks, CRC-checked, and the aircraft seen put together.7 views · 7 downloadsGarmin FIT files fit.qwer · 1.0.0Garmin FIT files from watches and bike computers read: the activity's distance, time, pace, heart rate, cadence, power and ascent, its laps, the track's points, any message as data, and the track as GPX.5 views · 7 downloadsSerial ports serial.qwer · 1.0.0Scales, sensors and instruments on a serial port: ports listed with their USB details, opened with baud, parity, data and stop bits and flow control, lines read whatever pieces they arrive in, reads up to a marker, exact byte counts, question and answer, timeouts that keep what came, and scale weight lines and sensor fields read.2 views · 7 downloadsThread 0 posts
On LoRaWAN: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.