#!/usr/bin/env python3 import json import argparse from binascii import unhexlify from Crypto.Cipher import AES # Requires pycryptodome import maccommands # JoinRequest: ./lora-decode.py -d 0013000001005f63204e000009015f63209df0b5ab08ba # JoinRequest: ./lora-decode.py -d 0013000001005f63204e000009015f6320f94b7f8d2805 # JoinAccept: ./lora-decode.py -d 20100000020000503802040201a0af8c70b78c40bf8c10c78ce0ce8c00cf671b6d --clear (unciphered) # JoinAccept: ./lora-decode.py -d 20c74d265d1cc3f3a96f3d2adb2d731ef6fbecf6a854b845f98531706f06b064c6 (ciphered) #20110000020000503802040201a0af8c70b78c40bf8c10c78ce0ce8c0070cbe839 #207abde18dd2a63bf98b87d654899c5091368b23be326e4a66b9822fab06b064c6 # Uplink: ./lora-decode.py -d 4050380204820200070311f0ed825f95378b6434a6c6e633bcd01a91e4198fe4c32dfaf740d57bca3cae472bc9c40b9b0b def decode_mhdr(mhdr_byte): mtype = (mhdr_byte >> 5) & 0x07 mtype_map = { 0: "Join Request", 1: "Join Accept", 2: "Unconfirmed Data Up", 3: "Unconfirmed Data Down", 4: "Confirmed Data Up", 5: "Confirmed Data Down", 6: "RFU (Reserved)", 7: "Proprietary" } major = mhdr_byte & 0x03 return { 'MHDR Byte': f"{mhdr_byte:#02x}", 'MType': mtype_map.get(mtype, "Unknown"), 'MTypeValue': mtype, 'Major': f"{major} (LoRaWAN v1.0)" if major == 0 else f"{major} (Unknown)" } def parse_join_request(payload): if len(payload) != 23: raise ValueError("Invalid Join Request length (expecting 23 bytes including MIC)") app_eui = payload[1:9][::-1].hex() dev_eui = payload[9:17][::-1].hex() dev_nonce = payload[17:19][::-1].hex() mic = payload[19:23].hex() return { 'AppEUI': app_eui, 'DevEUI': dev_eui, 'DevNonce': dev_nonce, 'MIC': mic } def decrypt_join_accept(payload, appkey_hex): if len(appkey_hex) != 32: raise ValueError("AppKey must be 16 bytes (32 hex characters)") appkey = unhexlify(appkey_hex) cipher = AES.new(appkey, AES.MODE_ECB) encrypted = payload[1:-4] if len(encrypted) % 16 != 0: raise ValueError(f"Encrypted Join Accept length {len(encrypted)} is not a multiple of 16") decrypted = cipher.decrypt(encrypted) return bytearray([payload[0]]) + decrypted + payload[-4:] def parse_join_accept(payload): data = payload[1:-4] mic = payload[-4:].hex() if len(data) < 12: raise ValueError("Join Accept payload too short") app_nonce = data[0:3][::-1].hex() net_id = data[3:6][::-1].hex() dev_addr = data[6:10][::-1].hex() dl_settings = data[10] rx_delay = data[11] cf_list = data[12:].hex() if len(data) > 12 else None return { 'AppNonce': app_nonce, 'NetID': net_id, 'DevAddr': dev_addr, 'DLSettings': f"{dl_settings:#02x}", 'RxDelay': rx_delay, 'CFList': cf_list, 'MIC': mic } def parse_fopts(fopts_bytes): # ~ print(fopts_bytes) # ~ mac_cmds = { # ~ 0x02: "LinkCheckReq", # ~ 0x03: "LinkCheckAns", # ~ 0x04: "LinkADRReq", # ~ 0x05: "LinkADRAns", # ~ 0x06: "DutyCycleReq", # ~ 0x07: "DutyCycleAns", # ~ 0x08: "RXParamSetupReq", # ~ 0x09: "RXParamSetupAns", # ~ 0x0A: "DevStatusReq", # ~ 0x0B: "DevStatusAns", # ~ 0x0C: "NewChannelReq", # ~ 0x0D: "NewChannelAns", # ~ 0x0E: "RXTimingSetupReq", # ~ 0x0F: "RXTimingSetupAns" # ~ } # ~ decoded = [] # ~ i = 0 # ~ while i < len(fopts_bytes): # ~ cid = fopts_bytes[i] # ~ cmd_name = mac_cmds.get(cid, f"Unknown (CID: {cid:#02x})") # ~ decoded.append(cmd_name) # ~ i += 1 decoded=maccommands.mac_decode(0,fopts_bytes.hex()) return decoded def parse_data_frame(payload): frame = {} mac_payload = payload[1:-4] mic = payload[-4:].hex() frame['MIC'] = mic frame['DevAddr'] = mac_payload[0:4][::-1].hex() fctrl = mac_payload[4] frame['FCtrl'] = fctrl fopts_len = fctrl & 0x0F frame['FOptsLen'] = fopts_len fcnt = int.from_bytes(mac_payload[5:7], byteorder='little') frame['FCnt'] = fcnt fopts_start = 7 fopts_end = fopts_start + fopts_len fopts_raw = mac_payload[fopts_start:fopts_end] frame['FOpts (raw)'] = fopts_raw.hex() frame['FOpts (decoded)'] = json.dumps(parse_fopts(fopts_raw),indent=2) if fopts_end < len(mac_payload): fport = mac_payload[fopts_end] frame['FPort'] = fport frmpayload_start = fopts_end + 1 frame['FRMPayload'] = mac_payload[frmpayload_start:].hex() else: frame['FPort'] = None frame['FRMPayload'] = None return frame def parse_lorawan_frame(hex_payload, appkey_hex=None, clear_join=False): payload = bytearray(unhexlify(hex_payload)) if len(payload) < 5: raise ValueError("Payload too short") mhdr = payload[0] mhdr_info = decode_mhdr(mhdr) mtype = mhdr_info['MTypeValue'] result = {'MHDR': mhdr_info} if mtype == 0: result['Type'] = 'Join Request' result['Fields'] = parse_join_request(payload) elif mtype == 1: result['Type'] = 'Join Accept' if clear_join: result['Info'] = 'Processing Join Accept as cleartext' result['Fields'] = parse_join_accept(payload) elif appkey_hex: decrypted_payload = decrypt_join_accept(payload, appkey_hex) result['Decrypted'] = decrypted_payload.hex() result['Fields'] = parse_join_accept(decrypted_payload) elif mtype in (2, 3, 4, 5): result['Type'] = 'Data Frame' result['Fields'] = parse_data_frame(payload) else: result['Type'] = 'Unknown/Unsupported' result['Fields'] = {} return result def main(): parser = argparse.ArgumentParser(description="Parse a LoRaWAN PHYPayload.") parser.add_argument('-d', '--data', required=True, help='LoRaWAN payload in hex string') parser.add_argument('--appkey', help='AppKey (hex) to decrypt Join Accept') parser.add_argument('--clear', action='store_true',help='Treat Join Accept as already decrypted (cleartext)') args = parser.parse_args() maccommands.mac_direction() try: result = parse_lorawan_frame(args.data, args.appkey, args.clear) print("MHDR:") for k, v in result['MHDR'].items(): if k != 'MTypeValue': print(f" {k}: {v}") print(f"\nFrame Type: {result['Type']}") if 'Warning' in result: print(f" Warning: {result['Warning']}") if 'Decrypted' in result: print(f" Decrypted Payload: {result['Decrypted']}") print(f" Should be : 20110000020000503802040201a0af8c70b78c40bf8c10c78ce0ce8c0070cbe839") print("Fields:") for key, value in result['Fields'].items(): if isinstance(value, list): print(f" {key}:") for item in value: print(f" - {item}") else: print(f" {key}: {value}") except Exception as e: print(f"Error: {e}") if __name__ == '__main__': main()