anthonykirby / lora-packet

LoRa radio packet decoder
MIT License
258 stars 83 forks source link

Join Accept details cannot be shown without decrypting using the AppKey #10

Open avbentem opened 7 years ago

avbentem commented 7 years ago

lora-packet shows incorrect details for a Join Accept:

  1. Without first decrypting the message, the following lines in _initialiseFromWireformat are not quite valid: https://github.com/anthonykirby/lora-packet/blob/53190fa4b9b2920c31187f8e39adb7d72c8cc6b0/lib/packet.js#L138-L149

  2. I guess toString should only print the message type, without any of the erroneous details: https://github.com/anthonykirby/lora-packet/blob/53190fa4b9b2920c31187f8e39adb7d72c8cc6b0/lib/packet.js#L408-L414

Background

For a not-encrypted Join Request like 00DC0000D07ED5B3701E6FEDF57CEEAF0085CC587FE913 lora-packet correctly shows:

Message Type = Join Request
      AppEUI = 70B3D57ED00000DC
      DevEUI = 00AFEE7CF5ED6F1E
    DevNonce = CC85
         MIC = 587FE913

For a matching response, 204DD85AE608B87FC4889970B7D2042C9E72959B0057AED6094B16003DF12DE145, it currently erroneously suggests:

Message Type = Join Accept
    AppNonce = 5AD84D
       NetID = B808E6
     DevAddr = 9988C47F
         MIC = F12DE145

This is wrong as the Join Accept payload (including its MIC) is encrypted using the secret AppKey (not to be confused with the session AppSKey, which is actually derived from the Join Accept). When decrypted using AppKey B6B53F4A168A7A88BDF7EA135CE9CFCA, the above Join Accept would yield:

    AppNonce = E5063A
       NetID = 000013
     DevAddr = 26012E43
  DLSettings = 03
     RXDelay = 01
      CFList = 184F84E85684B85E84886684586E8400
             = decimal 8671000, 8673000, 8675000, 8677000, 8679000
         MIC = 55121DE0

(The Things Network has been assigned a 7-bits "device address prefix" a.k.a. NwkID %0010011. Using that, TTN currently sends NetID 0x000013, and a TTN DevAddr always starts with 0x26 or 0x27.)

When the DevNonce from the Join Request is known as well, then the session keys can be derived:

     NwkSKey = 2C96F7028184BB0BE8AA49275290D4FC
     AppSKey = F3A5C8F0232A38C144029C165865802C

Example to derive the values

The following working example can also be seen at https://runkit.com/avbentem/deciphering-a-lorawan-otaa-join-accept

/*
 * Shows how to decode a LoRaWAN OTAA Join Accept message, and derive the session keys.
 */

var reverse = require('buffer-reverse');
'use strict';
var CryptoJS = require('crypto-js');
var aesCmac = require('node-aes-cmac').aesCmac;

// Secret AppKey as programmed in the device
var appKey = Buffer.from('B6B53F4A168A7A88BDF7EA135CE9CFCA', 'hex');

// DevNonce as generated in Join Request
var devNonce = Buffer.from('CC85', 'hex');

// Full packet: 0x20 MHDR, Join Accept (12 bytes, 16 bytes optional CFList, 4 bytes MIC)
var phyPayload = Buffer.from(
    '204dd85ae608b87fc4889970b7d2042c9e72959b0057aed6094b16003df12de145', 'hex');

// Initialization vector is always zero
var LORA_IV = CryptoJS.enc.Hex.parse('00000000000000000000000000000000');

// Encrypts the given buffer, returning another buffer.
function encrypt(buffer, key) {
    var ciphertext = CryptoJS.AES.encrypt(
        CryptoJS.lib.WordArray.create(buffer),
        CryptoJS.lib.WordArray.create(key),
        {
            mode: CryptoJS.mode.ECB,
            iv: LORA_IV,
            padding: CryptoJS.pad.NoPadding
        }
    ).ciphertext.toString(CryptoJS.enc.Hex);
    return new Buffer(ciphertext, 'hex');
}

// ## Decrypt payload, including MIC
//
// The network server uses an AES decrypt operation in ECB mode to encrypt the join-accept
// message so that the end-device can use an AES encrypt operation to decrypt the message.
// This way an end-device only has to implement AES encrypt but not AES decrypt.
var mhdr = phyPayload.slice(0, 1);
var joinAccept = encrypt(phyPayload.slice(1), appKey);

// ## Decode fields
//
// Size (bytes):     3       3       4         1          1     (16) Optional   4
// Join Accept:  AppNonce  NetID  DevAddr  DLSettings  RxDelay      CFList     MIC
var i = 0;
var appNonce = joinAccept.slice(i, i += 3);
var netID = joinAccept.slice(i, i += 3);
var devAddr = joinAccept.slice(i, i += 4);
var dlSettings = joinAccept.slice(i, i += 1);
var rxDelay = joinAccept.slice(i, i += 1);
if (i + 4 < joinAccept.length) {
    // We need the complete little-endian list (including its RFU byte) for the MIC
    var cfList = joinAccept.slice(i, i += 16);
    // Decode the 5 additional channel frequencies
    var frequencies = [];
    for (var c = 0; c < 5; c++) {
        frequencies.push(cfList.readUIntLE(3 * c, 3));
    }
    var rfu = cfList.slice(15, 15 + 1);
}
var mic = joinAccept.slice(i, i += 4);

// ## Validate MIC
//
// Below, the AppNonce, NetID and all should be added in little-endian format.
// cmac = aes128_cmac(AppKey, MHDR|AppNonce|NetID|DevAddr|DLSettings|RxDelay|CFList)
// MIC = cmac[0..3]
var micVerify = aesCmac(
    appKey,
    Buffer.concat([
        mhdr,
        appNonce,
        netID,
        devAddr,
        dlSettings,
        rxDelay,
        cfList
    ]),
    {returnAsBuffer: true}
).slice(0, 4);

// ## Derive session keys
//
// NwkSKey = aes128_encrypt(AppKey, 0x01|AppNonce|NetID|DevNonce|pad16)
// AppSKey = aes128_encrypt(AppKey, 0x02|AppNonce|NetID|DevNonce|pad16)
var sKey = Buffer.concat([
    appNonce,
    netID,
    reverse(devNonce),
    Buffer.from('00000000000000', 'hex')
]);
var nwkSKey = encrypt(Buffer.concat([Buffer.from('01', 'hex'), sKey]), appKey);
var appSKey = encrypt(Buffer.concat([Buffer.from('02', 'hex'), sKey]), appKey);

var r = '     Payload = ' + phyPayload.toString('hex')
    + '\n        MHDR = ' + mhdr.toString('hex')
    + '\n Join Accept = ' + joinAccept.toString('hex')
    + '\n    AppNonce = ' + (reverse(appNonce)).toString('hex')
    + '\n       NetID = ' + (reverse(netID)).toString('hex')
    + '\n     DevAddr = ' + (reverse(devAddr)).toString('hex')
    + '\n  DLSettings = ' + dlSettings.toString('hex')
    + '\n     RXDelay = ' + rxDelay.toString('hex')
    + '\n      CFList = ' + cfList.toString('hex')
    + '\n             = decimal ' + frequencies.join(', ')
    + '\n message MIC = ' + mic.toString('hex')
    + '\nverified MIC = ' + micVerify.toString('hex')
    + '\n     NwkSKey = ' + nwkSKey.toString('hex')
    + '\n     AppSKey = ' + appSKey.toString('hex');

console.log('<pre>\n' + r + '\n</pre>');
anthonykirby commented 7 years ago

(thank you for the detailed report; I'm scheduling time in September to work on this; apologies for the delay)

avbentem commented 7 years ago

(I wish I could downvote for unneeded apologies! ;-) )

avbentem commented 5 years ago

Note that the above example is for EU868 in LoRaWAN 1.0.x; other regions and versions might need a different decoding.

Like US915 (which support a whopping 64 channels) does not support CFList in 1.0.x, but does in 1.1; see page 15 of https://lora-alliance.org/sites/default/files/2018-05/lorawan-regional-parameters-v1.1ra.pdf

nvdak commented 4 years ago

Can I have code for ABP, I need this because I am using ABP.

avbentem commented 4 years ago

@nvdak, this issue does not apply to ABP.

(For ABP, the DevAddr and the secret AppSKey and NwkSKey are fixed, and are simply copied/programmed into the device after registering/activating it on the network. Like for www.thethingsnetwork.org such registration/activation would use the TTN Console website, or the ttnctl command line interface. After copying/programming the fixed details into the device, there are no join messages to be decrypted/decoded at all.)