diff --git a/dist/crc16.js b/dist/crc16.js new file mode 100644 index 0000000..7be2075 --- /dev/null +++ b/dist/crc16.js @@ -0,0 +1,64 @@ +/* @license + * + * CRC-CCITT (0xFFFF) library. + * + * The MIT License (MIT) + * + * Copyright (c) 2016 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + // https://github.com/umdjs/umd +(function(global, factory) { + if (typeof exports === 'object') { + // CommonJS (Node) + module.exports = factory(); + } else if (typeof define === 'function' && define.amd) { + // AMD + define(factory); + } else { + // Browser global (with support for web workers) + global.crc16 = factory(); + } +}(this, function() { + 'use strict'; + + /** + * Copied from Nordic's command line tool nrf.exe. + * https://github.com/NordicSemiconductor/pc-nrfutil/blob/master/nordicsemi/dfu/crc16.py + * CRC-CCITT (0xFFFF). + */ + return function (binaryData) { + var crc = 0xFFFF; + var view = new Uint8Array(binaryData); + + var i; + + for (i = 0; i < view.byteLength; i++) { + crc = (crc >> 8 & 0x00FF) | (crc << 8 & 0xFF00); + crc ^= view[i]; + crc ^= (crc & 0x00FF) >> 4; + crc ^= (crc << 8) << 4; + crc ^= ((crc & 0x00FF) << 4) << 1; + } + + return (crc & 0xFFFF); + }; +})); diff --git a/dist/dfu.js b/dist/dfu.js index c74448c..5913c99 100644 --- a/dist/dfu.js +++ b/dist/dfu.js @@ -1,9 +1,9 @@ /* @license * - * Secure device firmware update with Web Bluetooth + * Device firmware update with Web Bluetooth * * Protocol from: - * http://infocenter.nordicsemi.com/topic/com.nordic.infocenter.sdk5.v13.0.0/lib_dfu_transport_ble.html + * http://infocenter.nordicsemi.com/topic/com.nordic.infocenter.sdk5.v11.0.0/bledfu_transport.html * * The MIT License (MIT) * @@ -32,288 +32,439 @@ (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. - define(['bleat'], factory); + define(['es6-promise', 'bleat', 'crc16'], factory); } else if (typeof exports === 'object') { // Node. Does not work with strict CommonJS - module.exports = factory(require('bleat').webbluetooth); + module.exports = factory(Promise, require('bleat').webbluetooth, require('./crc16')); } else { // Browser globals with support for web workers (root is window) - root.SecureDfu = factory(root.navigator.bluetooth); + root.dfu = factory(Promise, root.navigator.bluetooth, root.crc16); } -}(this, function(bluetooth) { +}(this, function(Promise, bluetooth, crc16) { "use strict"; - const SERVICE_UUID = 0xFE59; - const CONTROL_UUID = "8ec90001-f315-4f60-9fb8-838830daea50"; - const PACKET_UUID = "8ec90002-f315-4f60-9fb8-838830daea50"; + // Make server a global variable (initialized in connect(). + // This fixes a bug in BlueZ that causes transfers to stall. + var currentServer = null; - const LITTLE_ENDIAN = true; - const PACKET_SIZE = 20; + var LITTLE_ENDIAN = true; - const OPERATIONS = { - CREATE_COMMAND: [0x01, 0x01], - CREATE_DATA: [0x01, 0x02], - RECEIPT_NOTIFICATIONS: [0x02], - CACULATE_CHECKSUM: [0x03], - EXECUTE: [0x04], - SELECT_COMMAND: [0x06, 0x01], - SELECT_DATA: [0x06, 0x02], - RESPONSE: [0x60] + var packetSize = 20; + var notifySteps = 40; + + var serviceUUID = "00001530-1212-efde-1523-785feabcd123"; + var controlUUID = "00001531-1212-efde-1523-785feabcd123"; + var packetUUID = "00001532-1212-efde-1523-785feabcd123"; + var versionUUID = "00001534-1212-efde-1523-785feabcd123"; + + var ImageType = { + None: 0, + SoftDevice: 1, + Bootloader: 2, + SoftDevice_Bootloader: 3, // Will not work right now + Application: 4 }; - const RESPONSE = { - 0x00: "Invalid code", - 0x01: "Success", - 0x02: "Opcode not supported", - 0x03: "Invalid parameter", - 0x04: "Insufficient resources", - 0x05: "Invalid object", - 0x07: "Unsupported type", - 0x08: "Operation not permitted", - 0x0A: "Operation failed", - 0x0B: "Extended error" + // TODO: This should be configurable by the user. For now this will work with any of Nordic's SDK examples. + var initPacket = { + device_type: 0xFFFF, + device_rev: 0xFFFF, + app_version: 0xFFFFFFFF, + softdevice_len: 0x0001, + softdevice: 0xFFFE, + crc: 0x0000 }; - function secureDfu(crc32) { - this.crc32 = crc32; - this.events = {}; - this.notifyFns = {}; - this.connected = false; - this.controlChar = null; - this.packetChar = null; - this.buffer = null; - } - - function createListenerFn(eventTypes) { - return function(type, callback, capture) { - if (eventTypes.indexOf(type) < 0) return; - if (!this.events[type]) this.events[type] = []; - this.events[type].push(callback); - }; - } - function removeEventListener(type, callback, capture) { - if (!this.events[type]) return; - let i = this.events[type].indexOf(callback); - if (i >= 0) this.events[type].splice(i, 1); - if (this.events[type].length === 0) delete this.events[type]; - } - function dispatchEvent(event) { - if (!this.events[event.type]) return; - event.target = this; - this.events[event.type].forEach(callback => { - if (typeof callback === "function") callback(event); - }); - } - - secureDfu.prototype.log = function(message) { - this.dispatchEvent({ - type: "log", - message: message - }); + var OPCODE = { + RESERVED: 0, + START_DFU: 1, + INITIALIZE_DFU_PARAMETERS: 2, + RECEIVE_FIRMWARE_IMAGE: 3, + VALIDATE_FIRMWARE: 4, + ACTIVATE_IMAGE_AND_RESET: 5, + RESET_SYSTEM: 6, + REPORT_RECEIVED_IMAGE_SIZE: 7, + PACKET_RECEIPT_NOTIFICATION_REQUEST: 8, + RESPONSE_CODE: 16, + PACKET_RECEIPT_NOTIFICATION: 17 }; - secureDfu.prototype.requestDevice = function(filters) { - if (!filters) { - filters = [{ - services: [SERVICE_UUID] - }]; + var loggers = []; + function addLogger(loggerFn) { + if (typeof loggerFn === "function") { + loggers.push(loggerFn); } + } + function log(message) { + loggers.forEach(function(logger) { + logger(message); + }); + } + function findDevice(filters) { return bluetooth.requestDevice({ - filters: filters, - optionalServices: [SERVICE_UUID] - }); - }; - - secureDfu.prototype.connect = function(device) { - let service = null; - - device.addEventListener("gattserverdisconnected", event => { - this.connected = false; - this.controlChar = null; - this.packetChar = null; - this.buffer = null; - this.log("disconnected"); - }); - - return device.gatt.connect() - .then(gattServer => { - this.log("connected to gatt server"); - return gattServer.getPrimaryService(SERVICE_UUID); - }) - .then(primaryService => { - this.log("found DFU service"); - service = primaryService; - return service.getCharacteristic(CONTROL_UUID); - }) - .then(characteristic => { - this.log("found control characteristic"); - if (!characteristic.properties.notify) { - throw new Error("control characterisitc does not allow notifications"); - } - this.controlChar = characteristic; - return characteristic.startNotifications(); - }) - .then(() => { - this.log("enabled control notifications"); - this.controlChar.addEventListener("characteristicvaluechanged", this.handleNotification.bind(this)); - return service.getCharacteristic(PACKET_UUID); - }) - .then(characteristic => { - this.log("found packet characteristic"); - this.packetChar = characteristic; - this.connected = true; - }); - }; - - secureDfu.prototype.handleNotification = function(event) { - let view = event.target.value; - - if (view.getUint8(0) !== OPERATIONS.RESPONSE[0]) { - throw new Error("unrecognised control characteristic response notification"); - } - - let operation = view.getUint8(1); - if (this.notifyFns[operation]) { - let result = view.getUint8(2); - let response = RESPONSE[result]; - - if (result === 0x01) { - let data = new DataView(view.buffer, 3); - this.notifyFns[operation].resolve(data); - } else { - this.log(`notify: ${response}`); - this.notifyFns[operation].reject(response); - } - - delete this.notifyFns[operation]; - } - }; - - secureDfu.prototype.sendOperation = function(operation, buffer) { - return new Promise((resolve, reject) => { - if (!this.connected) throw new Error("device not connected"); - if (!this.controlChar) throw new Error("control characteristic not found"); - if (!this.packetChar) throw new Error("packet characteristic not found"); - - let size = operation.length; - if (buffer) size += buffer.byteLength; - - let value = new Uint8Array(size); - value.set(operation); - if (buffer) { - let data = new Uint8Array(buffer); - value.set(data, operation.length); - } - - this.notifyFns[operation[0]] = { - resolve: resolve, - reject: reject - }; - - this.controlChar.writeValue(value); + filters: [ filters ], + optionalServices: [serviceUUID] }); } - secureDfu.prototype.transferInit = function(buffer) { - return this.sendOperation(OPERATIONS.SELECT_COMMAND) - .then(response => { + /** + * Switch to bootloader/DFU mode by writing to the control point of the DFU Service. + * The DFU Controller is not responsible for disconnecting from the application (DFU Target) after the write. + * The application (DFU Target) will issue a GAP Disconnect and reset into bootloader/DFU mode. + * + * https://infocenter.nordicsemi.com/topic/com.nordic.infocenter.sdk5.v11.0.0/bledfu_appswitching.html?cp=4_0_0_4_1_3_2_2 + */ + function writeMode(device) { + return new Promise(function(resolve, reject) { - let maxSize = response.getUint32(0, LITTLE_ENDIAN); - let offset = response.getUint32(4, LITTLE_ENDIAN); - let crc = response.getInt32(8, LITTLE_ENDIAN); - - if (offset === buffer.byteLength && this.checkCrc(buffer, crc)) { - this.log("init packet already available, skipping transfer"); - return; + var resolved = false; + function disconnectHandler() { + if (!resolved) { + resolved = true; + log("DFU target issued GAP disconnect and reset into bootloader/DFU mode"); + resolve(device); + } } + device.addEventListener("gattserverdisconnected", disconnectHandler); - this.buffer = buffer; - return this.transferObject(OPERATIONS.CREATE_COMMAND, maxSize, offset); + var characteristics = null; + + connect(device) + .then(function(chars) { + log("enabling notifications"); + characteristics = chars; + return characteristics.controlChar.startNotifications(); + }) + .then(function() { + log("writing modeData"); + return characteristics.controlChar.writeValue(new Uint8Array([OPCODE.START_DFU, ImageType.Application])); + }) + .then(function() { + log("modeData written"); + // TODO: Remove this when gattserverdisconnected event is implemented and possibly put a timeout in that event handler before resolving + setTimeout(function() { + if (currentServer && currentServer.connected === true) { + currentServer.disconnect(); + } + disconnectHandler(); + }, 5000); + }) + .catch(function(error) { + error = "writeMode error: " + error; + log(error); + reject(error); + }); }); } - secureDfu.prototype.transferFirmware = function(buffer) { - return this.sendOperation(OPERATIONS.SELECT_DATA) - .then(response => { + /** + * Contains basic functionality for performing safety checks on software updates for nRF5 based devices. + * Init packet used for pre-checking to ensure the following image is compatible with the device. + * Contains information on device type, revision, and supported SoftDevices along with a CRC or hash of firmware image. + * + * Not used in mbed bootloader (init packet was optional in SDK v6.x). + */ + function generateInitPacket() { + var buffer = new ArrayBuffer(14); + var view = new DataView(buffer); + view.setUint16(0, initPacket.device_type, LITTLE_ENDIAN); + view.setUint16(2, initPacket.device_rev, LITTLE_ENDIAN); + view.setUint32(4, initPacket.app_version, LITTLE_ENDIAN); // Application version for the image software. This field allows for additional checking, for example ensuring that a downgrade is not allowed. + view.setUint16(8, initPacket.softdevice_len, LITTLE_ENDIAN); // Number of different SoftDevice revisions compatible with this application. + view.setUint16(10, initPacket.softdevice, LITTLE_ENDIAN); // Variable length array of SoftDevices compatible with this application. The length of the array is specified in the length (softdevice_len) field. 0xFFFE indicates any SoftDevice. + view.setUint16(12, initPacket.crc, LITTLE_ENDIAN); + return view; + } - let maxSize = response.getUint32(0, LITTLE_ENDIAN); - let offset = response.getUint32(4, LITTLE_ENDIAN); - let crc = response.getInt32(8, LITTLE_ENDIAN); + function provision(device, arrayBuffer, imageType, crc) { + return new Promise(function(resolve, reject) { + imageType = imageType || ImageType.Application; + initPacket.crc = crc || crc16(arrayBuffer); + var versionChar = null; - this.buffer = buffer; - return this.transferObject(OPERATIONS.CREATE_DATA, maxSize, offset); + connect(device) + .then(function(chars) { + versionChar = chars.versionChar; + // Older DFU implementations (from older Nordic SDKs < 7.0) have no DFU Version characteristic. + if (versionChar) { + return versionChar.readValue() + .then(function(data) { + log('read versionChar'); + var major = data.getUint8(0); + var minor = data.getUint8(1); + return transfer(chars, arrayBuffer, imageType, major, minor); + }); + } else { + // Default to version 6.0 (mbed). + return transfer(chars, arrayBuffer, imageType, 6, 0); + } + }) + .then(function() { + resolve(); + }) + .catch(function(error) { + log(error); + reject(error); + }); }); } - secureDfu.prototype.transferObject = function(createType, maxSize, offset) { - let start = offset - offset % maxSize; - let end = Math.min(start + maxSize, this.buffer.byteLength); + function connect(device) { + return new Promise(function(resolve, reject) { + var service = null; + var controlChar = null; + var packetChar = null; + var versionChar = null; - let view = new DataView(new ArrayBuffer(4)); - view.setUint32(0, end - start, LITTLE_ENDIAN); - - return this.sendOperation(createType, view.buffer) - .then(response => { - let data = this.buffer.slice(start, end); - return this.transferData(data, start); - }) - .then(() => { - return this.sendOperation(OPERATIONS.CACULATE_CHECKSUM); - }) - .then(response => { - let crc = response.getInt32(4, LITTLE_ENDIAN); - let transferred = response.getUint32(0, LITTLE_ENDIAN); - let data = this.buffer.slice(0, transferred); - - if (this.checkCrc(data, crc)) { - this.log(`written ${transferred} bytes`); - offset = transferred; - return this.sendOperation(OPERATIONS.EXECUTE); - } else { - this.log("object failed to validate"); - } - }) - .then(() => { - if (end < this.buffer.byteLength) { - return this.transferObject(createType, maxSize, offset); - } else { - this.log("transfer complete"); + function complete() { + resolve({ + controlChar: controlChar, + packetChar: packetChar, + versionChar: versionChar + }); } + + device.gatt.connect() + .then(function(gattServer) { + log("connected to device"); + currentServer = gattServer; + // This delay is needed because BlueZ needs time to update it's cache. + return new Promise(function(resolve, reject) { + setTimeout(resolve, 2000); + }); + }) + .then(function() { + return currentServer.getPrimaryService(serviceUUID); + }) + .then(function(primaryService) { + log("found DFU service"); + service = primaryService; + return service.getCharacteristic(controlUUID); + }) + .then(function(characteristic) { + log("found control characteristic"); + controlChar = characteristic; + return service.getCharacteristic(packetUUID); + }) + .then(function(characteristic) { + log("found packet characteristic"); + packetChar = characteristic; + service.getCharacteristic(versionUUID) + // Older DFU implementations (from older Nordic SDKs) have no DFU Version characteristic. So this may fail. + .then(function(characteristic) { + log("found version characteristic"); + versionChar = characteristic; + complete(); + }) + .catch(function(error) { + log("info: no version characteristic found"); + complete(); + }); + }) + .catch(function(error) { + error = "connect error: " + error; + log(error); + reject(error); + }); }); } - secureDfu.prototype.transferData = function(data, offset, start) { - start = start || 0; - let end = start + PACKET_SIZE; - let packet = data.slice(start, end); + var interval; + var offset; + function transfer(chars, arrayBuffer, imageType, majorVersion, minorVersion) { + return new Promise(function(resolve, reject) { + var controlChar = chars.controlChar; + var packetChar = chars.packetChar; + log('using dfu version ' + majorVersion + "." + minorVersion); - return this.packetChar.writeValue(packet) - .then(() => { - this.dispatchEvent({ - type: "progress", - currentBytes: offset + end, - totalBytes: this.buffer.byteLength + var resolved = false; + function disconnectHandler() { + if (!resolved) { + resolved = true; + log('disconnected and completed the DFU transfer'); + resolve(); + } + } + currentServer.device.addEventListener("gattserverdisconnected", disconnectHandler); + + // Set up receipts + interval = Math.floor(arrayBuffer.byteLength / (packetSize * notifySteps)); + offset = 0; + + if (!controlChar.properties.notify) { + var err = "controlChar missing notify property"; + log(err); + return reject(err); + } + + log("enabling notifications"); + controlChar.startNotifications() + .then(function() { + controlChar.addEventListener('characteristicvaluechanged', handleControl); + log("sending imagetype: " + imageType); + return controlChar.writeValue(new Uint8Array([OPCODE.START_DFU, imageType])); + }) + .then(function() { + log("sent start"); + + var softLength = (imageType === ImageType.SoftDevice) ? arrayBuffer.byteLength : 0; + var bootLength = (imageType === ImageType.Bootloader) ? arrayBuffer.byteLength : 0; + var appLength = (imageType === ImageType.Application) ? arrayBuffer.byteLength : 0; + + var buffer = new ArrayBuffer(12); + var view = new DataView(buffer); + view.setUint32(0, softLength, LITTLE_ENDIAN); + view.setUint32(4, bootLength, LITTLE_ENDIAN); + view.setUint32(8, appLength, LITTLE_ENDIAN); + + return packetChar.writeValue(view); + }) + .then(function() { + log("sent image size: " + arrayBuffer.byteLength); + }) + .catch(function(error) { + error = "start error: " + error; + log(error); + reject(error); }); - if (end < data.byteLength) { - return this.transferData(data, offset, end); + function handleControl(event) { + var view = event.target.value; + + var opCode = view.getUint8(0); + var req_opcode = view.getUint8(1); + var resp_code = view.getUint8(2); + + if (opCode === OPCODE.RESPONSE_CODE) { + if (resp_code !== 1) { + var err = "error from control point notification, resp_code: " + resp_code; + log(err); + return reject(err); + } + + switch(req_opcode) { + case OPCODE.START_DFU: + case OPCODE.INITIALIZE_DFU_PARAMETERS: + if(req_opcode === OPCODE.START_DFU && majorVersion > 6) { // init packet is not used in SDK v6 (so not used in mbed). + log('write init packet'); + controlChar.writeValue(new Uint8Array([OPCODE.INITIALIZE_DFU_PARAMETERS, 0])) + .then(function() { + return packetChar.writeValue(generateInitPacket()); + }) + .then(function() { + return controlChar.writeValue(new Uint8Array([OPCODE.INITIALIZE_DFU_PARAMETERS, 1])); + }) + .catch(function(error) { + error = "error writing dfu init parameters: " + error; + log(error); + reject(error); + }); + break; + } + + log('send packet count'); + + var buffer = new ArrayBuffer(3); + view = new DataView(buffer); + view.setUint8(0, OPCODE.PACKET_RECEIPT_NOTIFICATION_REQUEST); + view.setUint16(1, interval, LITTLE_ENDIAN); + + controlChar.writeValue(view) + .then(function() { + log("sent packet count: " + interval); + return controlChar.writeValue(new Uint8Array([OPCODE.RECEIVE_FIRMWARE_IMAGE])); + }) + .then(function() { + log("sent receive"); + return writePacket(packetChar, arrayBuffer, 0); + }) + .catch(function(error) { + error = "error sending packet count: " + error; + log(error); + reject(error); + }); + break; + case OPCODE.RECEIVE_FIRMWARE_IMAGE: + log('check length'); + + controlChar.writeValue(new Uint8Array([OPCODE.REPORT_RECEIVED_IMAGE_SIZE])) + .catch(function(error) { + error = "error checking length: " + error; + log(error); + reject(error); + }); + break; + case OPCODE.REPORT_RECEIVED_IMAGE_SIZE: + var bytesReceived = view.getUint32(3, LITTLE_ENDIAN); + log('length: ' + bytesReceived); + log('validate...'); + + controlChar.writeValue(new Uint8Array([OPCODE.VALIDATE_FIRMWARE])) + .catch(function(error) { + error = "error validating: " + error; + log(error); + reject(error); + }); + break; + case OPCODE.VALIDATE_FIRMWARE: + log('complete, reset...'); + + controlChar.writeValue(new Uint8Array([OPCODE.ACTIVATE_IMAGE_AND_RESET])) + .then(function() { + log('image activated and dfu target reset'); + // TODO: Remove this when gattserverdisconnected event is implemented and possibly put a timeout in that event handler before resolving + setTimeout(function() { + if (currentServer && currentServer.connected === true) { + currentServer.disconnect(); + } + disconnectHandler(); + }, 5000); + }) + .catch(function(error) { + error = "error resetting: " + error; + log(error); + reject(error); + }); + break; + default: + log('unexpected req opCode - ERROR'); + break; + } + + } else if (opCode === OPCODE.PACKET_RECEIPT_NOTIFICATION) { + var bytes = view.getUint32(1, LITTLE_ENDIAN); + log('transferred: ' + bytes); + writePacket(packetChar, arrayBuffer, 0); + } } }); } - secureDfu.prototype.checkCrc = function(buffer, crc) { - if (!this.crc32) { - this.log("crc32 not found, skipping CRC check"); - return true; - } + function writePacket(packetChar, arrayBuffer, count) { + var size = (offset + packetSize > arrayBuffer.byteLength) ? arrayBuffer.byteLength - offset : packetSize; + var packet = arrayBuffer.slice(offset, offset + size); + var view = new Uint8Array(packet); - return crc === this.crc32(new Uint8Array(buffer)); + packetChar.writeValue(view) + .then(function() { + count ++; + offset += packetSize; + if (count < interval && offset < arrayBuffer.byteLength) { + writePacket(packetChar, arrayBuffer, count); + } + }) + .catch(function(error) { + error = "writePacket error: " + error; + log(error); + }); } - secureDfu.prototype.addEventListener = createListenerFn([ "log", "progress" ]); - secureDfu.prototype.removeEventListener = removeEventListener; - secureDfu.prototype.dispatchEvent = dispatchEvent; - - return secureDfu; + return { + addLogger: addLogger, + ImageType: ImageType, + findDevice: findDevice, + writeMode: writeMode, + provision: provision + }; })); diff --git a/dist/hex2bin.js b/dist/hex2bin.js new file mode 100644 index 0000000..f840762 --- /dev/null +++ b/dist/hex2bin.js @@ -0,0 +1,191 @@ +/* @license + * + * Hex to Bin library + * + * The MIT License (MIT) + * + * Copyright (c) 2016 Rob Moran + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + // https://github.com/umdjs/umd +(function(global, factory) { + if (typeof exports === 'object') { + // CommonJS (Node) + module.exports = factory(); + } else if (typeof define === 'function' && define.amd) { + // AMD + define(factory); + } else { + // Browser global (with support for web workers) + global.hex2bin = factory(); + } +}(this, function() { + 'use strict'; + + var RECORD_TYPE = { // I32HEX files use only record types 00, 01, 04, 05. + DATA : '00', + END_OF_FILE : '01', + EXTENDED_SEGMENT_ADDRESS : '02', + START_SEGMENT_ADDRESS : '03', + EXTENDED_LINEAR_ADDRESS : '04', + START_LINEAR_ADDRESS: '05' + }; + + var loggers = []; + function addLogger(loggerFn) { + if (typeof loggerFn === "function") { + loggers.push(loggerFn); + } + } + function log(message) { + loggers.forEach(function(logger) { + logger(message); + }); + } + + /** + * The first record of type extended linear address in the provided hex file will store the start base address of the binary. + * Then the first data record's address offset will complete our start address. + */ + function helperGetBinaryStartAddress(hexLines) { + var record; + + do { + record = hexLines.shift(); + } while (record.substr(7, 2) != RECORD_TYPE.EXTENDED_LINEAR_ADDRESS); + + var firstBaseAddress = parseInt(record.substr(9, 4), 16) << 16; + + do { + record = hexLines.shift(); + } while (record.substr(7, 2) != RECORD_TYPE.DATA); + var firstDataRecordAddressOffset = parseInt(record.substr(3, 4), 16); + + var startAddress = firstBaseAddress + firstDataRecordAddressOffset; + log('start address of binary: ' + startAddress); + return startAddress; + } + + /** + * The last record of type extended linear address in the provided hex file will store the base address of the last data segment in the binary. + * Then the last record of type data will store the address offset and length of data to be stored here to complete the base address and obtain maxAddress. + */ + function helperGetBinaryEndAddress(hexLines, maxAddress) { + var record; + + do { + record = hexLines.pop(); + } while (record.substr(7, 2) != RECORD_TYPE.DATA); + + var lastDataRecordLength = parseInt(record.substr(1, 2), 16); + var lastDataRecordAddressOffset = parseInt(record.substr(3, 4), 16); + + do { + record = hexLines.pop(); + } while (record.substr(7, 2) != RECORD_TYPE.EXTENDED_LINEAR_ADDRESS); + + var lastBaseAddress = parseInt(record.substr(9, 4), 16) << 16; + + var endAddress = lastBaseAddress + lastDataRecordAddressOffset + lastDataRecordLength; + if (endAddress > maxAddress) { + return helperGetBinaryEndAddress(hexLines, maxAddress); + } + log('end address of binary: ' + endAddress); + return endAddress; + } + + /** + * Converts a hex file to a binary blob and returns the data as a buffer. + * @param hex - hex file of either SoftDevice, Application or Bootloader to be transferred OTA to the device. Must be a valid Intel 32 hex file. + * @param minAddress - the first address (i.e. when updating the SoftDevice we don't send the Master Boot Record). + * @param maxAddress - the last address in the devices flash. + * @return a buffer of bytes that corresponds to the inputs of this function and is padded with 0xFF in gaps between data segments in the hex file. + * Any data in addresses under minAddress will be cut off along with any data in addresses above maxAddress (required by Nordic's DFU protocol). + * This is because we are not to send the Master Boot Record (under minAddress) when updating the SoftDevice. + * And we are not to send UICR data (above maxAddress) when updating the bootloader or application. + */ + function convert(hex, minAddress, maxAddress) { + maxAddress = maxAddress || 0x80000; // This will always cut off the UICR and the user will not have to every specify this parameter. + minAddress = minAddress || 0x0; + + var startAddress = helperGetBinaryStartAddress(hex.split("\n"), minAddress); + var endAddress = helperGetBinaryEndAddress(hex.split("\n"), maxAddress); + + if (startAddress < minAddress) { + startAddress = minAddress; + log('trimmed start address of binary: ' + startAddress); + } + + var binarySizeBytes = endAddress - startAddress; + + var buffer = new ArrayBuffer(binarySizeBytes); + var view = new Uint8Array(buffer); + view.fill(0xFF); // Pad the binary blob with 0xFF as this corresponds to erased 'unwritten' flash. + + var baseAddress; + + var hexLines = hex.split("\n"); + hexLines.forEach(function(line) { + + switch (line.substr(7, 2)) { + + case RECORD_TYPE.DATA: + var length = parseInt(line.substr(1, 2), 16); + var addressOffset = parseInt(line.substr(3, 4), 16); + var data = line.substr(9, length * 2); + for (var i = 0; i < length * 2; i += 2) { + var index = (baseAddress + addressOffset) - startAddress + (i / 2); + if (index >= 0 && index < binarySizeBytes) { // This cuts off any data below minAddress and above maxAddress. + view[index] = parseInt(data.substr(i, 2), 16); + } + } + break; + case RECORD_TYPE.END_OF_FILE: + log('done converting hex file to binary'); + break; + case RECORD_TYPE.EXTENDED_SEGMENT_ADDRESS: + throw 'ERROR - invalid hex file - extended segment address is not handled'; + case RECORD_TYPE.START_SEGMENT_ADDRESS: + throw 'ERROR - invalid hex file - start segment address is not handled'; + case RECORD_TYPE.EXTENDED_LINEAR_ADDRESS: + baseAddress = parseInt(line.substr(9, 4), 16) << 16; + break; + case RECORD_TYPE.START_LINEAR_ADDRESS: + log('ignore records of type start linear address'); + break; + default: + if (line === '') { + break; + } else { + throw 'ERROR - invalid hex file - unexpected record type in provided hex file'; + } + + } + + }); + return buffer; + } + + return { + addLogger: addLogger, + convert: convert + }; +})); diff --git a/dist/secure-dfu.js b/dist/secure-dfu.js new file mode 100644 index 0000000..a397bf7 --- /dev/null +++ b/dist/secure-dfu.js @@ -0,0 +1,342 @@ +/* @license + * + * Secure device firmware update with Web Bluetooth + * + * Protocol from: + * http://infocenter.nordicsemi.com/topic/com.nordic.infocenter.sdk5.v13.0.0/lib_dfu_transport_ble.html + * + * The MIT License (MIT) + * + * Copyright (c) 2017 Rob Moran + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// https://github.com/umdjs/umd +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['bleat'], factory); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS + module.exports = factory(require('bleat').webbluetooth); + } else { + // Browser globals with support for web workers (root is window) + root.SecureDfu = factory(root.navigator.bluetooth); + } +}(this, function(bluetooth) { + "use strict"; + + const SERVICE_UUID = 0xFE59; + const CONTROL_UUID = "8ec90001-f315-4f60-9fb8-838830daea50"; + const PACKET_UUID = "8ec90002-f315-4f60-9fb8-838830daea50"; + + const LITTLE_ENDIAN = true; + const PACKET_SIZE = 20; + + const OPERATIONS = { + CREATE_COMMAND: [0x01, 0x01], + CREATE_DATA: [0x01, 0x02], + RECEIPT_NOTIFICATIONS: [0x02], + CACULATE_CHECKSUM: [0x03], + EXECUTE: [0x04], + SELECT_COMMAND: [0x06, 0x01], + SELECT_DATA: [0x06, 0x02], + RESPONSE: [0x60] + }; + + const RESPONSE = { + 0x00: "Invalid code", // Invalid opcode. + 0x01: "Success", // Operation successful. + 0x02: "Opcode not supported", // Opcode not supported. + 0x03: "Invalid parameter", // Missing or invalid parameter value. + 0x04: "Insufficient resources", // Not enough memory for the data object. + 0x05: "Invalid object", // Data object does not match the firmware and hardware requirements, the signature is wrong, or parsing the command failed. + 0x07: "Unsupported type", // Not a valid object type for a Create request. + 0x08: "Operation not permitted", // The state of the DFU process does not allow this operation. + 0x0A: "Operation failed", // Operation failed. + 0x0B: "Extended error" // Extended error. + }; + + const EXTENDED_ERROR = { + 0x00: "No error", // No extended error code has been set. This error indicates an implementation problem. + 0x01: "Invalid error code", // Invalid error code. This error code should never be used outside of development. + 0x02: "Wrong command format", // The format of the command was incorrect. + 0x03: "Unknown command", // The command was successfully parsed, but it is not supported or unknown. + 0x04: "Init command invalid", // The init command is invalid. The init packet either has an invalid update type or it is missing required fields for the update type. + 0x05: "Firmware version failure", // The firmware version is too low. For an application, the version must be greater than the current application. For a bootloader, it must be greater than or equal to the current version. + 0x06: "Hardware version failure", // The hardware version of the device does not match the required hardware version for the update. + 0x07: "Softdevice version failure", // The array of supported SoftDevices for the update does not contain the FWID of the current SoftDevice. + 0x08: "Signature missing", // The init packet does not contain a signature. + 0x09: "Wrong hash type", // The hash type that is specified by the init packet is not supported by the DFU bootloader. + 0x0A: "Hash failed", // The hash of the firmware image cannot be calculated. + 0x0B: "Wrong signature type", // The type of the signature is unknown or not supported by the DFU bootloader. + 0x0C: "Verification failed", // The hash of the received firmware image does not match the hash in the init packet. + 0x0D: "Insufficient space" // The available space on the device is insufficient to hold the firmware. + }; + + function secureDfu(crc32) { + this.crc32 = crc32; + this.events = {}; + this.notifyFns = {}; + this.connected = false; + this.controlChar = null; + this.packetChar = null; + this.buffer = null; + } + + function createListenerFn(eventTypes) { + return function(type, callback, capture) { + if (eventTypes.indexOf(type) < 0) return; + if (!this.events[type]) this.events[type] = []; + this.events[type].push(callback); + }; + } + function removeEventListener(type, callback, capture) { + if (!this.events[type]) return; + let i = this.events[type].indexOf(callback); + if (i >= 0) this.events[type].splice(i, 1); + if (this.events[type].length === 0) delete this.events[type]; + } + function dispatchEvent(event) { + if (!this.events[event.type]) return; + event.target = this; + this.events[event.type].forEach(callback => { + if (typeof callback === "function") callback(event); + }); + } + + secureDfu.prototype.log = function(message) { + this.dispatchEvent({ + type: "log", + message: message + }); + }; + + secureDfu.prototype.requestDevice = function(filters) { + if (!filters) { + filters = [{ + services: [SERVICE_UUID] + }]; + } + + return bluetooth.requestDevice({ + filters: filters, + optionalServices: [SERVICE_UUID] + }); + }; + + secureDfu.prototype.connect = function(device) { + let service = null; + + device.addEventListener("gattserverdisconnected", event => { + this.connected = false; + this.controlChar = null; + this.packetChar = null; + this.buffer = null; + this.log("disconnected"); + }); + + return device.gatt.connect() + .then(gattServer => { + this.log("connected to gatt server"); + return gattServer.getPrimaryService(SERVICE_UUID); + }) + .then(primaryService => { + this.log("found DFU service"); + service = primaryService; + return service.getCharacteristic(CONTROL_UUID); + }) + .then(characteristic => { + this.log("found control characteristic"); + if (!characteristic.properties.notify) { + throw new Error("control characterisitc does not allow notifications"); + } + this.controlChar = characteristic; + return characteristic.startNotifications(); + }) + .then(() => { + this.log("enabled control notifications"); + this.controlChar.addEventListener("characteristicvaluechanged", this.handleNotification.bind(this)); + return service.getCharacteristic(PACKET_UUID); + }) + .then(characteristic => { + this.log("found packet characteristic"); + this.packetChar = characteristic; + this.connected = true; + }); + }; + + secureDfu.prototype.handleNotification = function(event) { + let view = event.target.value; + + if (view.getUint8(0) !== OPERATIONS.RESPONSE[0]) { + throw new Error("unrecognised control characteristic response notification"); + } + + let operation = view.getUint8(1); + if (this.notifyFns[operation]) { + let result = view.getUint8(2); + let error = null; + + if (result === 0x01) { + let data = new DataView(view.buffer, 3); + this.notifyFns[operation].resolve(data); + } else if (result === 0x0B) { + let code = view.getUint8(3); + error = `Error: ${EXTENDED_ERROR[code]}`; + } else { + error = `Error: ${RESPONSE[result]}`; + } + + if (error) { + this.log(`notify: ${error}`); + this.notifyFns[operation].reject(error); + } + delete this.notifyFns[operation]; + } + }; + + secureDfu.prototype.sendOperation = function(operation, buffer) { + return new Promise((resolve, reject) => { + if (!this.connected) throw new Error("device not connected"); + if (!this.controlChar) throw new Error("control characteristic not found"); + if (!this.packetChar) throw new Error("packet characteristic not found"); + + let size = operation.length; + if (buffer) size += buffer.byteLength; + + let value = new Uint8Array(size); + value.set(operation); + if (buffer) { + let data = new Uint8Array(buffer); + value.set(data, operation.length); + } + + this.notifyFns[operation[0]] = { + resolve: resolve, + reject: reject + }; + + this.controlChar.writeValue(value); + }); + } + + secureDfu.prototype.transferInit = function(buffer) { + return this.sendOperation(OPERATIONS.SELECT_COMMAND) + .then(response => { + + let maxSize = response.getUint32(0, LITTLE_ENDIAN); + let offset = response.getUint32(4, LITTLE_ENDIAN); + let crc = response.getInt32(8, LITTLE_ENDIAN); + + if (offset === buffer.byteLength && this.checkCrc(buffer, crc)) { + this.log("init packet already available, skipping transfer"); + return; + } + + this.buffer = buffer; + return this.transferObject(OPERATIONS.CREATE_COMMAND, maxSize, offset); + }); + } + + secureDfu.prototype.transferFirmware = function(buffer) { + return this.sendOperation(OPERATIONS.SELECT_DATA) + .then(response => { + + let maxSize = response.getUint32(0, LITTLE_ENDIAN); + let offset = response.getUint32(4, LITTLE_ENDIAN); + let crc = response.getInt32(8, LITTLE_ENDIAN); + + this.buffer = buffer; + return this.transferObject(OPERATIONS.CREATE_DATA, maxSize, offset); + }); + } + + secureDfu.prototype.transferObject = function(createType, maxSize, offset) { + let start = offset - offset % maxSize; + let end = Math.min(start + maxSize, this.buffer.byteLength); + + let view = new DataView(new ArrayBuffer(4)); + view.setUint32(0, end - start, LITTLE_ENDIAN); + + return this.sendOperation(createType, view.buffer) + .then(response => { + let data = this.buffer.slice(start, end); + return this.transferData(data, start); + }) + .then(() => { + return this.sendOperation(OPERATIONS.CACULATE_CHECKSUM); + }) + .then(response => { + let crc = response.getInt32(4, LITTLE_ENDIAN); + let transferred = response.getUint32(0, LITTLE_ENDIAN); + let data = this.buffer.slice(0, transferred); + + if (this.checkCrc(data, crc)) { + this.log(`written ${transferred} bytes`); + offset = transferred; + return this.sendOperation(OPERATIONS.EXECUTE); + } else { + this.log("object failed to validate"); + } + }) + .then(() => { + if (end < this.buffer.byteLength) { + return this.transferObject(createType, maxSize, offset); + } else { + this.log("transfer complete"); + } + }); + } + + secureDfu.prototype.transferData = function(data, offset, start) { + start = start || 0; + let end = Math.min(start + PACKET_SIZE, data.byteLength); + let packet = data.slice(start, end); + + return this.packetChar.writeValue(packet) + .then(() => { + this.dispatchEvent({ + type: "progress", + currentBytes: offset + end, + totalBytes: this.buffer.byteLength + }); + + if (end < data.byteLength) { + return this.transferData(data, offset, end); + } + }); + } + + secureDfu.prototype.checkCrc = function(buffer, crc) { + if (!this.crc32) { + this.log("crc32 not found, skipping CRC check"); + return true; + } + + return crc === this.crc32(new Uint8Array(buffer)); + } + + secureDfu.prototype.addEventListener = createListenerFn([ "log", "progress" ]); + secureDfu.prototype.removeEventListener = removeEventListener; + secureDfu.prototype.dispatchEvent = dispatchEvent; + + return secureDfu; +})); diff --git a/examples/dfu_node.js b/examples/dfu_node.js new file mode 100644 index 0000000..426d3df --- /dev/null +++ b/examples/dfu_node.js @@ -0,0 +1,51 @@ +var dfu = require('../index').dfu; +var hex2bin = require('../index').hex2bin; +var fs = require('fs'); + +var log = console.log; +dfu.addLogger(log); +hex2bin.addLogger(log); + +var fileMask = ""; +var fileName = null; + +var deviceType = process.argv[2]; +if (!deviceType) { + deviceType = "nrf51"; + log("no device-type specified, defaulting to " + deviceType); +} + +switch(deviceType) { + case "nrf51": + fileMask = "firmware/nrf51_app_{0}.hex"; + break; + case "nrf52": + fileMask = "firmware/nrf52_app.hex"; + break; + default: + log("unknown device-type: " + deviceType); + process.exit(); +} + +dfu.findDevice({ services: [0x180D] }) +.then(device => { + fileName = fileMask.replace("{0}", device.name === "Hi_Rob" ? "bye" : "hi"); + log("found device: " + device.name); + log("using file name: " + fileName); + + return dfu.writeMode(device); +}) +.then(() => dfu.findDevice({ name: "DfuTarg" })) +.then(device => { + var file = fs.readFileSync(fileName); + var hex = file.toString(); + var buffer = hex2bin.convert(hex); + log("file length: " + buffer.byteLength); + + return dfu.provision(device, buffer); +}) +.then(() => process.exit()) +.catch(error => { + log(error); + process.exit(); +}); \ No newline at end of file diff --git a/example_web.html b/examples/dfu_web.html similarity index 96% rename from example_web.html rename to examples/dfu_web.html index 89d93ea..1ebf403 100644 --- a/example_web.html +++ b/examples/dfu_web.html @@ -26,9 +26,9 @@
- - - + + + + + + + +