From 4c1667e4d0c73ee68e5c08cd348e9f84697f874b Mon Sep 17 00:00:00 2001 From: Dan Walters <dan@walters.io> Date: Mon, 8 Dec 2014 09:34:19 -0600 Subject: [PATCH] refactor: Separate class for secure lock session, lots of API cleanup. --- cli.js | 63 +++++----------- index.js | 4 +- lib/lock.js | 146 +++++++++++++++++++------------------ lib/lock_session.js | 126 ++++++++++++++++---------------- lib/scan.js | 33 +++++++++ lib/secure_lock_session.js | 46 ++++++++++++ npm-shrinkwrap.json | 45 ------------ package.json | 13 ++-- 8 files changed, 248 insertions(+), 228 deletions(-) mode change 100644 => 100755 cli.js create mode 100644 lib/scan.js create mode 100644 lib/secure_lock_session.js delete mode 100644 npm-shrinkwrap.json diff --git a/cli.js b/cli.js old mode 100644 new mode 100755 index 241df45..2bad60f --- a/cli.js +++ b/cli.js @@ -2,53 +2,28 @@ 'use strict'; -var Lock = require('./lib/lock'); -var noble = require('noble'); - -var argv = require('yargs') - .usage('Control an August Smart Lock.\nUsage: $0 [command]') - .example('$0 lock', 'closes the lock') - .example('$0 unlock', 'opens the lock') - .check(function(argv) { - if (argv._.length !== 1) { - return 'must specify an operation to perform'; - } - - var op = argv._[0]; - if (typeof Lock.prototype[op] !== 'function') { - return 'invalid operation: ' + op; - } - }) - .argv; +var augustctl = require('./index'); var config = require(process.env.AUGUSTCTL_CONFIG || './config.json'); -noble.on('stateChange', function(state) { - if (state === 'poweredOn') { - noble.startScanning([ Lock.BLE_COMMAND_SERVICE ]); - } else { - noble.stopScanning(); - } -}); - -noble.on('discover', function(peripheral) { - if (config.uuid === undefined || peripheral.uuid === config.uuid) { - noble.stopScanning(); - - peripheral.on('disconnect', function() { +var op = process.argv[2]; +if (typeof augustctl.Lock.prototype[op] !== 'function') { + throw new Error('invalid operation: ' + op); +} + +augustctl.scan(config.lockUuid).then(function(peripheral) { + var lock = new augustctl.Lock( + peripheral, + config.offlineKey, + config.offlineKeyOffset + ); + lock.connect().then(function() { + return lock[op](); + }).catch(function(e) { + console.error(e.toString()); + }).finally(function() { + return lock.disconnect().finally(function() { process.exit(0); }); - - var lock = new Lock( - peripheral, - config.offlineKey, - config.offlineKeyOffset - ); - lock.connect().then(function() { - var op = argv._[0]; - return lock[op](); - }).finally(function() { - return lock.disconnect(); - }); - } + }); }); diff --git a/index.js b/index.js index 0c671a8..1a83747 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ 'use strict'; var Lock = require('./lib/lock'); +var scan = require('./lib/scan'); module.exports = { - Lock: Lock + Lock: Lock, + scan: scan }; diff --git a/lib/lock.js b/lib/lock.js index ee2c88e..6fb7050 100644 --- a/lib/lock.js +++ b/lib/lock.js @@ -1,62 +1,70 @@ 'use strict'; -var LockSession = require('./lock_session'); +var debug = require('debug')('august:lock'); -// relevant UUIDs - w/ this library, must be lowercase and without hyphens -const BLE_COMMAND_SERVICE = "bd4ac6100b4511e38ffd0800200c9a66"; -const BLE_COMMAND_WRITE_CHARACTERISTIC = "bd4ac6110b4511e38ffd0800200c9a66"; -const BLE_COMMAND_READ_CHARACTERISTIC = "bd4ac6120b4511e38ffd0800200c9a66"; -const BLE_COMMAND_SECURE_WRITE_CHARACTERISTIC = "bd4ac6130b4511e38ffd0800200c9a66"; -const BLE_COMMAND_SECURE_READ_CHARACTERISTIC = "bd4ac6140b4511e38ffd0800200c9a66"; - -/// -// LockCommand -// basically, a zero initialized 18 byte Buffer - -function LockCommand() { - var cmd = new Buffer(0x12); - cmd.fill(0x00); - return cmd; -} +var Promise = require('bluebird'); +var crypto = require('crypto'); +var util = require('util'); -/// -// Lock object. +var LockSession = require('./lock_session'); +var SecureLockSession = require('./secure_lock_session'); function Lock(peripheral, offlineKey, offlineKeyOffset) { + if (!offlineKey) { + throw new Error('offlineKey must be specified when creating lock'); + } + if (!offlineKeyOffset) { + throw new Error('offlineKeyOffset must be specified when creating lock'); + } + this._peripheral = peripheral; - this._offlineKey = offlineKey; + this._offlineKey = new Buffer(offlineKey, 'hex'); this._offlineKeyOffset = offlineKeyOffset; debug('peripheral: ' + util.inspect(peripheral)); } +// service uuid, exposed for scanning +Lock.BLE_COMMAND_SERVICE = "bd4ac6100b4511e38ffd0800200c9a66"; + Lock.prototype.connect = function() { - var handshakeKeys; + var handshakeKeys = crypto.randomBytes(16); + this._isSecure = false; return this._peripheral.connectAsync().then(function() { debug('connected.'); - return this._peripheral.discoverServicesAsync([ BLE_COMMAND_SERVICE ]); - }.bind(this)).then(function(services) { + + // run discovery; would be quicker if we could skip this step, and on linux writing + // directly to the appropriate handles seems to work, but unfortunately not on mac. + // a better approach may very well be some sort of OS level caching (ala Android), or + // maybe caching services in the noble library. + return this._peripheral.discoverSomeServicesAndCharacteristicsAsync([ Lock.BLE_COMMAND_SERVICE ], []); + }.bind(this)).spread(function(services, characteristics) { debug('services: ' + util.inspect(services)); - if (services.length !== 1) { - throw new Error("expected exactly one service"); - } - return services[0].discoverCharacteristicsAsync([]); - }).then(function(characteristics) { debug('characteristics: ' + util.inspect(characteristics)); + function characteristicByUuid(uuid) { + for (var i = 0; i < characteristics.length; i++) { + if (characteristics[i].uuid === uuid) { + return characteristics[i]; + } + } + throw new Error("could not find required characteristic with uuid: " + uuid); + } + // initialize the secure session - this._secureSession = new LockSession( - _.findWhere(characteristics, {uuid: BLE_COMMAND_SECURE_WRITE_CHARACTERISTIC}), - _.findWhere(characteristics, {uuid: BLE_COMMAND_SECURE_READ_CHARACTERISTIC}), - true + this._secureSession = new SecureLockSession( + this._peripheral, + characteristicByUuid("bd4ac6130b4511e38ffd0800200c9a66"), + characteristicByUuid("bd4ac6140b4511e38ffd0800200c9a66"), + this._offlineKeyOffset ); - this._secureSession.setKey(new Buffer(this._offlineKey, 'hex')); + this._secureSession.setKey(this._offlineKey); // intialize the session this._session = new LockSession( - _.findWhere(characteristics, {uuid: BLE_COMMAND_WRITE_CHARACTERISTIC}), - _.findWhere(characteristics, {uuid: BLE_COMMAND_READ_CHARACTERISTIC}), - false + this._peripheral, + characteristicByUuid("bd4ac6110b4511e38ffd0800200c9a66"), + characteristicByUuid("bd4ac6120b4511e38ffd0800200c9a66") ); // start the sessions @@ -65,17 +73,18 @@ Lock.prototype.connect = function() { this._session.start() ); }.bind(this)).then(function() { - // generate handshake keys - handshakeKeys = crypto.randomBytes(16); - // send SEC_LOCK_TO_MOBILE_KEY_EXCHANGE - var cmd = new LockCommand(); - cmd.writeUInt8(0x01, 0x00); // cmdSecuritySendMobileKeyWithIndex + var cmd = this._secureSession.buildCommand(0x01); handshakeKeys.copy(cmd, 0x04, 0x00, 0x08); - cmd.writeUInt8(0x0f, 0x10); - cmd.writeUInt8(this._offlineKeyOffset, 0x11); return this._secureSession.execute(cmd); }.bind(this)).then(function(response) { + if (response[0] !== 0x02) { + throw new Error("unexpected response to SEC_LOCK_TO_MOBILE_KEY_EXCHANGE: " + response.toString('hex')); + } + + // secure session established + this._isSecure = true; + // setup the session key var sessionKey = new Buffer(16); handshakeKeys.copy(sessionKey, 0x00, 0x00, 0x08); @@ -86,49 +95,48 @@ Lock.prototype.connect = function() { this._secureSession.setKey(sessionKey); // send SEC_INITIALIZATION_COMMAND - var cmd = new LockCommand(); - cmd.writeUInt8(0x03, 0x00); // cmdSecurityInitializationCommandWithIndex + var cmd = this._secureSession.buildCommand(0x03); handshakeKeys.copy(cmd, 0x04, 0x08, 0x10); - cmd.writeUInt8(0x0f, 0x10); - cmd.writeUInt8(this._offlineKeyOffset, 0x11); return this._secureSession.execute(cmd); - }.bind(this)); + }.bind(this)).then(function(response) { + if (response[0] !== 0x04) { + throw new Error("unexpected response to SEC_INITIALIZATION_COMMAND: " + response.toString('hex')); + } + return true; + }); }; Lock.prototype.lock = function() { debug('locking...'); - - var cmd = new LockCommand(); - cmd.writeUInt8(0xee, 0x00); // magic - cmd.writeUInt8(0x0b, 0x01); // cmdLock - cmd.writeUInt8(0x05, 0x03); // simpleChecksum - cmd.writeUInt8(0x02, 0x10); + var cmd = this._session.buildCommand(0x0b); return this._session.execute(cmd); }; Lock.prototype.unlock = function() { debug('unlocking...'); - - var cmd = new LockCommand(); - cmd.writeUInt8(0xee, 0x00); // magic - cmd.writeUInt8(0x0a, 0x01); // cmdUnlock - cmd.writeUInt8(0x06, 0x03); // simpleChecksum - cmd.writeUInt8(0x02, 0x10); + var cmd = this._session.buildCommand(0x0a); return this._session.execute(cmd); }; Lock.prototype.disconnect = function() { debug('disconnecting...'); - var cmd = new LockCommand(); - cmd.writeUInt8(0x05, 0x00); // cmdSecurityTerminate - cmd.writeUInt8(0x0f, 0x10); - return this._secureSession.execute(cmd).finally(function() { - this._peripheral.disconnect(); - }.bind(this)); + var disconnect = function() { + return this._peripheral.disconnectAsync(); + }.bind(this); + + if (this._isSecure) { + var cmd = this._secureSession.buildCommand(0x05); + cmd.writeUInt8(0x00, 0x11); // zero offline key for security terminate - not sure if necessary + return this._secureSession.execute(cmd).then(function(response) { + if (response[0] !== 0x8b) { + throw new Error("unexpected response to DISCONNECT: " + response.toString('hex')); + } + return true; + }).finally(disconnect); + } else { + return disconnect(); + } }; -// expose the service uuid -Lock.BLE_COMMAND_SERVICE = BLE_COMMAND_SERVICE; - module.exports = Lock; diff --git a/lib/lock_session.js b/lib/lock_session.js index c81b42e..6531f09 100644 --- a/lib/lock_session.js +++ b/lib/lock_session.js @@ -1,62 +1,43 @@ 'use strict'; +var debug = require('debug')('august:lock_session'); + var Promise = require('bluebird'); var crypto = require('crypto'); -var debug = require('debug')('august'); var events = require('events'); -var noble = require('noble'); var util = require('util'); -var _ = require('underscore'); -// promisification of noble -Promise.promisifyAll(require('noble/lib/characteristic').prototype); -Promise.promisifyAll(require('noble/lib/peripheral').prototype); +// promisify noble Promise.promisifyAll(require('noble/lib/service').prototype); +Promise.promisifyAll(require('noble/lib/peripheral').prototype); +Promise.promisifyAll(require('noble/lib/characteristic').prototype); -// Calculates the security checksum of a command buffer. -function securityChecksum(buffer) { - return (0 - (buffer.readUInt32LE(0x00) + buffer.readUInt32LE(0x04) + buffer.readUInt32LE(0x08))) >>> 0; -} - -/// -// LockSession - -function LockSession(writeCharacteristic, readCharacteristic, isSecure) { - if (!writeCharacteristic || !readCharacteristic) { - throw new Error('write and/or read characteristic not found'); - } +function LockSession(peripheral, writeCharacteristic, readCharacteristic) { + this._peripheral = peripheral; this._writeCharacteristic = writeCharacteristic; this._readCharacteristic = readCharacteristic; - this._isSecure = isSecure; return this; } util.inherits(LockSession, events.EventEmitter); -LockSession.prototype.setKey = function(key) { - var cipherSuite, iv; - if (this._isSecure) { - cipherSuite = 'aes-128-ecb'; - iv = ''; - } else { - cipherSuite = 'aes-128-cbc'; - iv = new Buffer(0x10); - iv.fill(0); - } +LockSession.prototype._cipherSuite = 'aes-128-cbc'; +LockSession.prototype._iv = (function() { + var buf = new Buffer(0x10); + buf.fill(0); + return buf; +})(); - this._encryptCipher = crypto.createCipheriv(cipherSuite, key, iv); +LockSession.prototype.setKey = function(key) { + this._encryptCipher = crypto.createCipheriv(this._cipherSuite, key, this._iv); this._encryptCipher.setAutoPadding(false); - this._decryptCipher = crypto.createDecipheriv(cipherSuite, key, iv); + this._decryptCipher = crypto.createDecipheriv(this._cipherSuite, key, this._iv); this._decryptCipher.setAutoPadding(false); }; LockSession.prototype.start = function() { // decrypt all reads, modifying the buffer in place - this._readCharacteristic.on('read', function(data, isNotify) { - if (!data) { - throw new Error('read returned no data'); - } - + this._readCharacteristic.on('read', function(data) { debug('read data: ' + data.toString('hex')); if (this._decryptCipher) { @@ -67,60 +48,77 @@ LockSession.prototype.start = function() { debug('decrypted data: ' + data.toString('hex')); } - // the notification flag is not being set properly on OSX Yosemite, so just - // forcing it to true. - if (process.platform === 'darwin') { - isNotify = true; - } - - if (isNotify) { - this.emit('notification', data); - } + this.emit('notification', data); }.bind(this)); - // enable notifications on the read characterestic - debug('enabling notifications on ' + this._readCharacteristic); + // enable indications + debug('enabling indications on ' + this._readCharacteristic); return this._readCharacteristic.notifyAsync(true); }; -LockSession.prototype.execute = function(command) { - // write the security checksum if on the secure channel - if (this._isSecure) { - var checksum = securityChecksum(command); - command.writeUInt32LE(checksum, 0x0c); +LockSession.prototype.buildCommand = function(opcode) { + var cmd = new Buffer(0x12); + cmd.fill(0); + cmd.writeUInt8(0xee, 0x00); // magic + cmd.writeUInt8(opcode, 0x01); + cmd.writeUInt8(0x02, 0x10); // unknown? + return cmd; +}; + +// Calculates the simple checksum of a command buffer. +function simpleChecksum(buf) { + var cs = 0; + for (var i = 0; i < 0x12; i++) { + cs = (cs + buf[i]) & 0xff; } + return (-cs) & 0xff; +} + +LockSession.prototype._writeChecksum = function(command) { + var checksum = simpleChecksum(command); + command.writeUInt8(checksum, 0x03); +}; - debug((this._isSecure ? 'secure ' : '') + 'execute command: ' + command.toString('hex')); +LockSession.prototype._validateResponse = function(response) { + if (simpleChecksum(response) !== 0) { + throw new Error("simple checksum mismatch"); + } + if (response[0] !== 0xbb && response[0] !== 0xaa) { + throw new Error("unexpected magic in response"); + } +}; +LockSession.prototype._write = function(command) { // NOTE: the last two bytes are not encrypted // general idea seems to be that if the last byte of the command indicates an offline key offset (is non-zero), the command is "secure" and encrypted with the offline key if (this._encryptCipher) { var plainText = command.slice(0x00, 0x10); var cipherText = this._encryptCipher.update(plainText); cipherText.copy(plainText); - debug('execute command (encrypted): ' + command.toString('hex')); + debug('write (encrypted): ' + command.toString('hex')); } + // write the command to the write characteristic + return this._writeCharacteristic.writeAsync(command, false); +}; + +LockSession.prototype.execute = function(command) { + this._writeChecksum(command); + + debug('execute command: ' + command.toString('hex')); + // register the notification event listener here, before issuing the write, as the // response notification arrives before the write response. var waitForNotification = new Promise(function(resolve) { this.once('notification', resolve); }.bind(this)); - return this._writeCharacteristic.writeAsync(command, false).then(function() { + return this._write(command).then(function() { debug('write successful, waiting for notification...'); return waitForNotification; }).then(function(data) { // perform some basic validation before passing it on - if (this._isSecure) { - if (securityChecksum(data) !== data.readUInt32LE(0x0c)) { - throw new Error("security checksum mismatch"); - } - } else { - if (data[0] !== 0xbb && data[0] !== 0xaa) { - throw new Error("unexpected magic in response"); - } - } + this._validateResponse(data); return data; }.bind(this)); diff --git a/lib/scan.js b/lib/scan.js new file mode 100644 index 0000000..e2247d8 --- /dev/null +++ b/lib/scan.js @@ -0,0 +1,33 @@ +'use strict'; + +var Promise = require('bluebird'); +var noble = require('noble'); + +var Lock = require('./lock'); + +var firstRun = true; + +function scan(uuid) { + if (firstRun) { + firstRun = false; + + noble.on('stateChange', function(state) { + if (state === 'poweredOn') { + noble.startScanning([ Lock.BLE_COMMAND_SERVICE ]); + } else { + noble.stopScanning(); + } + }); + } + + return new Promise(function(resolve) { + noble.on('discover', function(peripheral) { + if (uuid === undefined || peripheral.uuid === uuid) { + noble.stopScanning(); + resolve(peripheral); + } + }); + }); +} + +module.exports = scan; diff --git a/lib/secure_lock_session.js b/lib/secure_lock_session.js new file mode 100644 index 0000000..3a88dda --- /dev/null +++ b/lib/secure_lock_session.js @@ -0,0 +1,46 @@ +'use strict'; + +var util = require('util'); + +var LockSession = require('./lock_session'); + +function SecureLockSession(peripheral, writeCharacteristic, readCharacteristic, offlineKeyOffset) { + SecureLockSession.super_.call(this, peripheral, writeCharacteristic, readCharacteristic); + if (!offlineKeyOffset) { + throw new Error("offline key offset not specified"); + } + this._offlineKeyOffset = offlineKeyOffset; + return this; +} + +util.inherits(SecureLockSession, LockSession); + +SecureLockSession.prototype._cipherSuite = 'aes-128-ecb'; +SecureLockSession.prototype._iv = ''; + +SecureLockSession.prototype.buildCommand = function(opcode) { + var cmd = new Buffer(0x12); + cmd.fill(0); + cmd.writeUInt8(opcode, 0x00); + cmd.writeUInt8(0x0f, 0x10); // unknown + cmd.writeUInt8(this._offlineKeyOffset, 0x11); + return cmd; +}; + +// Calculates the security checksum of a command buffer. +function securityChecksum(buffer) { + return (0 - (buffer.readUInt32LE(0x00) + buffer.readUInt32LE(0x04) + buffer.readUInt32LE(0x08))) >>> 0; +} + +SecureLockSession.prototype._writeChecksum = function(command) { + var checksum = securityChecksum(command); + command.writeUInt32LE(checksum, 0x0c); +}; + +SecureLockSession.prototype._validateResponse = function(data) { + if (securityChecksum(data) !== data.readUInt32LE(0x0c)) { + throw new Error("security checksum mismatch"); + } +}; + +module.exports = SecureLockSession; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json deleted file mode 100644 index 7ca3dd5..0000000 --- a/npm-shrinkwrap.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "augustctl", - "version": "0.0.1", - "dependencies": { - "bluebird": { - "version": "2.3.11", - "from": "bluebird@>=2.3.10 <3.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.3.11.tgz" - }, - "debug": { - "version": "2.1.0", - "from": "debug@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.1.0.tgz", - "dependencies": { - "ms": { - "version": "0.6.2", - "from": "ms@0.6.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz" - } - } - }, - "noble": { - "version": "0.3.6", - "from": "noble@>=0.3.6 <0.4.0", - "resolved": "https://registry.npmjs.org/noble/-/noble-0.3.6.tgz", - "dependencies": { - "debug": { - "version": "0.7.4", - "from": "debug@>=0.7.2 <0.8.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" - } - } - }, - "underscore": { - "version": "1.7.0", - "from": "underscore@>=1.7.0 <2.0.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz" - }, - "yargs": { - "version": "1.3.3", - "from": "yargs@>=1.3.3 <2.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz" - } - } -} diff --git a/package.json b/package.json index bb689cf..7af3d5e 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,11 @@ "description": "Controller for August Smart Lock", "author": "Dan Walters <dan@walters.io>", "license": "MIT", - "main": "august.js", - "bin": "./cli.js", + "main": "index.js", + "bin": { + "augustctl": "./cli.js", + "augustctld": "./server.js" + }, "keywords": [ "august", "smartlock", @@ -14,9 +17,9 @@ "dependencies": { "bluebird": "^2.3.10", "debug": "^2.1.0", - "noble": "^0.3.6", - "underscore": "^1.7.0", - "yargs": "^1.3.3" + "express": "^4.10.2", + "morgan": "^1.5.0", + "noble": "^0.3.6" }, "repository": { "type": "git", -- GitLab