Commit 4c1667e4 authored by Dan Walters's avatar Dan Walters

refactor: Separate class for secure lock session, lots of API cleanup.

parent 5aabd3b6
......@@ -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();
});
}
});
});
'use strict';
var Lock = require('./lib/lock');
var scan = require('./lib/scan');
module.exports = {
Lock: Lock
Lock: Lock,
scan: scan
};
'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;
'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));
......
'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;
'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;
{
"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"
}
}
}
......@@ -4,8 +4,11 @@
"description": "Controller for August Smart Lock",
"author": "Dan Walters <dan@walters.io>",
"license": "MIT",
"main": "august.js",
"bin": "./cli.js",