Commit af23f2ea authored by Dan Walters's avatar Dan Walters

Initial release.

parents
node_modules
The MIT License (MIT)
Copyright (c) 2014 Dan Walters
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.
augustctl
=========
A node.js module to operate an [August Smart Lock](http://www.august.com/), via BLE.
Prerequisties
=============
Same as for [noble](https://github.com/sandeepmistry/noble).
On Linux, you will need [bluez 5](http://www.bluez.org/).
Also tested and working on OSX Yosemite.
Install
=======
npm install augustctl
Configuration
=============
It's necessary to have an `offlineKey` and corresponding `offlineKeyOffset` that are recognized by your lock. These can be sourced from an Android phone that is already associated with the lock. The phone needs to be rooted.
You'll need to copy the `/data/data/com.august.app/shared_prefs/LockSettingsPreferences.xml` file from your phone to your computer. Many file manager apps, or an adb shell, will let you access it, as long as your phone is rooted.
Run this file through the [tools/decrypt_preferences.js](tools/decrypt_preferences.js) script to extract the necessary key.
mkdir $HOME/.config
node tools/decrypt_preferences.js LockSettingsPreferences.xml > $HOME/.config/augustctl.json
The decrypted JSON object itself works fine as a configuration file, as long as you only have one lock.
Usage
=====
Assuming you've extracted your offline key and offset into a configuration file, as above, just:
augustctl unlock
augustctl lock
var Lock = require('./lock');
module.exports = {
Lock: Lock
};
#!/usr/bin/env node
var Lock = require('./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')
.describe('config', 'configuration file (default is $HOME/.config/augustctl.json)')
.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 config = require('./config')(argv.config);
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() {
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();
});
}
});
var fs = require('fs');
function loadConfig(filename) {
if (!filename) {
var configDir = process.env.XDG_CONFIG_HOME || (process.env.HOME + '/.config');
filename = configDir + '/augustctl.json';
}
var config = JSON.parse(fs.readFileSync(filename));
if (!config.offlineKey || !config.offlineKeyOffset) {
throw new Error("config file must specify offlineKey and offlineKeyOffset");
}
return config;
}
module.exports = loadConfig;
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);
Promise.promisifyAll(require('noble/lib/service').prototype);
// 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;
}
// 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');
}
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);
}
this._encryptCipher = crypto.createCipheriv(cipherSuite, key, iv);
this._encryptCipher.setAutoPadding(false);
this._decryptCipher = crypto.createDecipheriv(cipherSuite, key, 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');
}
debug('read data: ' + data.toString('hex'));
if (this._decryptCipher) {
var cipherText = data.slice(0x00, 0x10);
var plainText = this._decryptCipher.update(cipherText);
plainText.copy(cipherText);
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);
}
}.bind(this));
// enable notifications on the read characterestic
debug('enabling notifications 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);
}
debug((this._isSecure ? 'secure ' : '') + 'execute command: ' + command.toString('hex'));
// 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'));
}
// 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() {
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");
}
}
return data;
}.bind(this));
};
///
// Lock object.
function Lock(peripheral, offlineKey, offlineKeyOffset) {
this._peripheral = peripheral;
this._offlineKey = offlineKey;
this._offlineKeyOffset = offlineKeyOffset;
debug('peripheral: ' + util.inspect(peripheral));
}
Lock.prototype.connect = function() {
var handshakeKeys;
return this._peripheral.connectAsync().then(function() {
debug('connected.');
return this._peripheral.discoverServicesAsync([ BLE_COMMAND_SERVICE ]);
}.bind(this)).then(function(services) {
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));
// 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.setKey(new Buffer(this._offlineKey, 'hex'));
// intialize the session
this._session = new LockSession(
_.findWhere(characteristics, {uuid: BLE_COMMAND_WRITE_CHARACTERISTIC}),
_.findWhere(characteristics, {uuid: BLE_COMMAND_READ_CHARACTERISTIC}),
false
);
// start the sessions
return Promise.join(
this._secureSession.start(),
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
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) {
// setup the session key
var sessionKey = new Buffer(16);
handshakeKeys.copy(sessionKey, 0x00, 0x00, 0x08);
response.copy(sessionKey, 0x08, 0x04, 0x0c);
this._session.setKey(sessionKey);
// rekey the secure session as well
this._secureSession.setKey(sessionKey);
// send SEC_INITIALIZATION_COMMAND
var cmd = new LockCommand();
cmd.writeUInt8(0x03, 0x00); // cmdSecurityInitializationCommandWithIndex
handshakeKeys.copy(cmd, 0x04, 0x08, 0x10);
cmd.writeUInt8(0x0f, 0x10);
cmd.writeUInt8(this._offlineKeyOffset, 0x11);
return this._secureSession.execute(cmd);
}.bind(this));
};
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);
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);
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));
};
// expose the service uuid
Lock.BLE_COMMAND_SERVICE = BLE_COMMAND_SERVICE;
module.exports = Lock;
{
"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"
}
}
}
{
"name": "augustctl",
"version": "0.0.1",
"description": "Controller for August Smart Lock",
"author": "Dan Walters <dan@walters.io>",
"license": "MIT",
"main": "august.js",
"bin": "./cli.js",
"keywords": [
"august",
"smartlock",
"bluetooth"
],
"dependencies": {
"bluebird": "^2.3.10",
"debug": "^2.1.0",
"noble": "^0.3.6",
"underscore": "^1.7.0",
"yargs": "^1.3.3"
},
"repository": {
"type": "git",
"url": "http://github.com/sretlawd/augustctl/augustctl.git"
}
}
// Decrypts a btsnoop_hci.log from an Android device (enabled in developer options.)
// First use tshark to create a text log from a hci capture:
// tshark -r btsnoop_hci.log -Y 'btatt.opcode == 0x12 || btatt.opcode == 0x1d' -Tfields -e frame.number -e btatt.opcode -e btatt.handle -e btatt.value >capture.log
// Then, assuming you already have a config file with your offline key, decrypt it:
// node tools/decode_capture.js capture.log >command.log
var crypto = require('crypto');
var fs = require('fs');
var ZERO_BYTES = new Buffer(16);
ZERO_BYTES.fill(0);
var cryptoKey, sessionKey, txCipherSec, rxCipherSec, txCipher, rxCipher;
function isSecurityChecksumValid(buf) {
var cs = (0 - (buf.readUInt32LE(0x00) + buf.readUInt32LE(0x04) + buf.readUInt32LE(0x08))) >>> 0;
return cs === buf.readUInt32LE(0x0c);
}
function decode(frameNumber, opcode, handle, data) {
var isSecure = (handle === 38 || handle === 41);
var cipher = (opcode === 18) ? (isSecure ? txCipherSec : txCipher) : (isSecure ? rxCipherSec : rxCipher);
var ct = data.slice(0x00, 0x10);
var pt = cipher.update(ct);
pt.copy(ct);
var op = (opcode == 18 ? 'WRITE' : 'READ');
if (isSecure) {
op = 'S' + op;
if (!isSecurityChecksumValid(data)) {
op = op + '*';
}
}
console.log([frameNumber, op, data.toString('hex')].join('\t'));
if (isSecure) {
switch (data[0]) {
case 0x01:
sessionKey = new Buffer(0x10);
data.copy(sessionKey, 0x00, 0x04, 0x0c);
break;
case 0x02:
data.copy(sessionKey, 0x08, 0x04, 0x0c);
txCipher = crypto.createDecipheriv('aes-128-cbc', sessionKey, ZERO_BYTES); txCipher.setAutoPadding(false);
rxCipher = crypto.createDecipheriv('aes-128-cbc', sessionKey, ZERO_BYTES); rxCipher.setAutoPadding(false);
txCipherSec = crypto.createDecipheriv('aes-128-ecb', sessionKey, ''); txCipherSec.setAutoPadding(false);
rxCipherSec = crypto.createDecipheriv('aes-128-ecb', sessionKey, ''); rxCipherSec.setAutoPadding(false);
break;
}
}
}
function decodeLog(offlineKey, filename) {
cryptoKey = new Buffer(offlineKey, 'hex');
txCipherSec = crypto.createDecipheriv('aes-128-ecb', cryptoKey, ''); txCipherSec.setAutoPadding(false);
rxCipherSec = crypto.createDecipheriv('aes-128-ecb', cryptoKey, ''); rxCipherSec.setAutoPadding(false);
var records = fs.readFileSync(filename, 'ascii').split(/\n/);
records.forEach(function(record) {
var fields = record.split(/\t/);
if (fields.length !== 4) {
return;
}
var buf = new Buffer(fields[3].replace(/:/g, ''), 'hex');
if (buf.length === 18) {
decode(+fields[0], +fields[1], +fields[2], buf);
}
});
}
var config = require('../config')();
decodeLog(config.offlineKey, process.argv[2]);
var crypto = require('crypto');
var fs = require('fs');
// extract the encoded, encrypted data
var prefs = fs.readFileSync(process.argv[2] || 'LockSettingsPreferences.xml', 'utf8');
var hexEncoded = /[0-9A-F]+(?=\<\/string\>)/.exec(prefs)[0];
var cipherText = new Buffer(hexEncoded, 'hex');
// decrypt
const key = new Buffer('August#@3417r\0\0\0', 'utf8');
var cipher = crypto.createDecipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false);
var plaintext = cipher.update(cipherText) + cipher.final();
// remove trailing nulls
plaintext = plaintext.replace(/\0+$/, '');
process.stdout.write(plaintext);
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment