...
 
Commits (5)
node-acp
===
A Node.js implementation of the management protocol of Apple's AirPort devices.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -4,7 +4,7 @@
"author": {
"name": "Samuel Elliott"
},
"version": "0.2.1",
"version": "0.3.0",
"homepage": "https://gitlab.fancy.org.uk/samuel/node-acp",
"bugs": "https://gitlab.fancy.org.uk/samuel/node-acp/issues",
"repository": {
......@@ -13,7 +13,7 @@
},
"main": "dist/client.js",
"bin": {
"acp": "bin/acp.js"
"acp": "bin/acp"
},
"files": [
"dist",
......@@ -22,17 +22,18 @@
"dependencies": {
"adler32": "^0.1.7",
"bonjour": "^3.5.0",
"yargs": "^12.0.2"
"fast-srp-hap": "^1.0.1",
"yargs": "^12.0.5"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-preset-env": "^1.6.1",
"babel-preset-env": "^1.7.0",
"babel-preset-es2015": "^6.24.1",
"eslint": "^4.19.1",
"eslint-config-google": "^0.9.1",
"gulp": "^4.0.0",
"gulp-babel": "^7.0.1",
"pump": "^3.0.0",
"qunit": "^2.8.0"
"qunit": "^2.9.2"
}
}
......@@ -46,7 +46,9 @@ yargs.command('server', 'Start the ACP server', yargs => {
default: '00-00-00-00-00-00',
});
}, async argv => {
const server = new Server(argv.host || '::', argv.port, argv.password);
const server = new Server(argv.host || '::', argv.port);
await server.addUser('admin', argv.password);
try {
await server.listen();
......@@ -89,6 +91,12 @@ const commandHandler = handler => async argv => {
try {
await client.connect();
if (argv.encryption) {
console.log('Authenticating');
await client.authenticate();
console.log('Authenticated!');
}
await handler.call(undefined, client, argv);
} catch (err) {
console.error(err);
......@@ -101,13 +109,23 @@ const commandHandler = handler => async argv => {
yargs.command('authenticate', 'Authenticate', yargs => {}, commandHandler(async (client, argv) => {
const data = await client.authenticate();
console.log(data);
console.log('Authenticated!', data);
console.log('Getting syNm prop');
const props = await client.getProperties(['syNm']);
console.log(props[0].format());
}));
yargs.command('getprop <prop>', 'Get an ACP property', yargs => {
yargs.positional('prop', {
describe: 'The name of the ACP property',
});
yargs.option('encryption', {
describe: 'Whether to encrypt connections to the AirPort device',
default: true,
type: 'boolean',
});
}, commandHandler(async (client, argv) => {
const props = await client.getProperties([argv.prop]);
......@@ -120,13 +138,24 @@ yargs.command('setprop <prop> <value>', 'Set an ACP property', yargs => {
}).positional('value', {
describe: 'The new value',
});
yargs.option('encryption', {
describe: 'Whether to encrypt connections to the AirPort device',
default: true,
type: 'boolean',
});
}, commandHandler(async (client, argv) => {
const props = await client.setProperties([new Property(argv.prop, argv.value)]);
console.log(props);
}));
yargs.command('features', 'Get supported features', yargs => {}, commandHandler(async (client, argv) => {
yargs.command('features', 'Get supported features', yargs => {
yargs.option('encryption', {
describe: 'Whether to encrypt connections to the AirPort device',
default: true,
type: 'boolean',
});
}, commandHandler(async (client, argv) => {
const features = await client.getFeatures();
console.log(features);
......
......@@ -4,6 +4,11 @@ import Message, {HEADER_SIZE as MESSAGE_HEADER_SIZE} from './message';
import Property, {HEADER_SIZE as ELEMENT_HEADER_SIZE} from './property';
import CFLBinaryPList from './cflbinary';
import crypto from 'crypto';
import srp from 'fast-srp-hap';
import BigInteger from 'fast-srp-hap/lib/jsbn';
export default class Client {
/**
* Creates an ACP Client.
......@@ -101,6 +106,8 @@ export default class Client {
const reply = await this.receiveMessageHeader();
const reply_header = await Message.parseRaw(reply);
console.log('Get prop response', reply_header);
if (reply_header.error_code !== 0) {
throw new Error('Error ' . reply_header.error_code);
}
......@@ -110,7 +117,7 @@ export default class Client {
while (true) {
const prop_header = await this.receivePropertyElementHeader();
console.debug('Received property element header:', prop_header);
const data = await Property.parseRawElementHeader(prop_header);
const data = await Property.unpackHeader(prop_header);
console.debug(data);
const {name, flags, size} = data;
......@@ -160,7 +167,7 @@ export default class Client {
}
const prop_header = await this.receivePropertyElementHeader();
const {name, flags, size} = await Property.parseRawElementHeader(prop_header);
const {name, flags, size} = await Property.unpackHeader(prop_header);
const value = await this.receive(size);
......@@ -196,22 +203,154 @@ export default class Client {
}
async authenticate() {
let payload = {
if (this.session.encryption) {
throw new Error('Encryption is already enabled.');
}
if (this.authenticating) return this.authenticating;
try {
await (this.authenticating = this.authenticateStageOne());
} finally {
this.authenticating = null;
}
}
async authenticateStageOne() {
/**
* Stage 1 (client)
*
* Request SRP params, the server's public key (B) and the user's salt.
*/
const payload = {
state: 1,
username: 'admin',
};
const message = Message.composeAuthCommand(4, this.password, CFLBinaryPList.compose(payload));
console.log('Authentication stage one data', payload);
const message = Message.composeAuthCommand(4, CFLBinaryPList.compose(payload));
await this.send(message);
/**
* Stage 2 (server)
*
* Return SRP params, the server's public key (B) and the user's salt.
*/
const response = await this.session.receiveMessage();
if (response.error_code !== 0) {
throw new Error('Authenticate stage two error code ' + response.error_code);
}
const data = CFLBinaryPList.parse(response.body);
console.log('Authentication stage two data', data);
return this.authenticateStageThree(data);
}
async authenticateStageThree(data) {
/**
* Stage 3 (client)
*
* Generate a public key (A) and use the password and salt to generate proof we know the password (M1), then send it to the server.
*/
// data === {
// salt: Buffer, // .length === 16
// generator: Buffer, // .toString('hex') === '02'
// publicKey: Buffer, // .length === 192
// modulus: Buffer, // === srp.params[1536].N
// }
const salt = data.salt; // salt.length === 16
// eslint-disable-next-line no-unused-vars
const B = data.publicKey; // B.length === 192 (not 384)
const params = {
// 1536
N_length_bits: 1536,
N: new BigInteger(data.modulus),
g: new BigInteger(data.generator),
hash: 'sha1',
};
const key = crypto.randomBytes(24); // .length === 192
const srpc = new srp.Client(params, salt, Buffer.from('admin'), Buffer.from(this.password), key);
srpc.setB(data.publicKey);
const A = srpc.computeA(); // === key
const M1 = srpc.computeM1(); // .length should === 20
const iv = crypto.randomBytes(16);
const payload = {
iv,
publicKey: A,
state: 3,
response: M1,
};
// payload === {
// iv: Buffer, // .length === 16
// publicKey: Buffer, // .length === 192
// state: Number, // === 3
// response: Buffer, // .length === 20
// }
console.log('Authentication stage 3 data', payload);
const request = Message.composeAuthCommand(4, CFLBinaryPList.compose(payload));
await this.send(request);
/**
* Stage 4 (server)
*
* Use the client's public key (A) to verify the client's proof it knows the password (M1) and generate proof the server knows the password (M2).
*/
const response = await this.session.receiveMessage();
if (response.error_code !== 0) {
console.log('Authenticate error code', response.error_code);
return;
throw new Error('Authenticate stage 4 error code ' + response.error_code);
}
const data_2 = CFLBinaryPList.parse(response.body);
console.log('Authentication stage 4 data', data_2);
return this.authenticateStageFive(srpc, iv, data_2);
}
async authenticateStageFive(srpc, client_iv, data) {
/**
* Stage 5 (client)
*
* Verify the server's proof it knows the password (M2), and if valid enable session encryption.
*/
// data === {
// response: Buffer, // .length === 20
// iv: Buffer, // .length === 16
// }
try {
srpc.checkM2(data.response);
} catch (err) {
// Probably wrong password
throw new Error('Error verifying response (M2)');
}
return data;
// We now have a key, client iv and server iv
// Enable encryption
const key = srpc.computeK();
const server_iv = data.iv;
console.log('Enabling encryption...');
this.session.enableEncryption(key, client_iv, server_iv);
}
}
......@@ -237,8 +237,8 @@ export default class Message {
return new Message(0x00030001, flags, 0, 0x19, 0, generateACPHeaderKey(password), payload);
}
static composeAuthCommand(flags, password, payload) {
return new Message(0x00030001, flags, 0, 0x1a, 0, generateACPHeaderKey(password), payload);
static composeAuthCommand(flags, payload) {
return new Message(0x00030001, flags, 0, 0x1a, 0, generateACPHeaderKey(''), payload);
}
static composeFeatCommand(flags, payload) {
......
......@@ -2,19 +2,44 @@
import Session from './session';
import Message, {HEADER_SIZE as MESSAGE_HEADER_SIZE} from './message';
import Property, {HEADER_SIZE as ELEMENT_HEADER_SIZE} from './property';
// import CFLBinaryPList from './cflbinary';
import CFLBinaryPList from './cflbinary';
import net from 'net';
import crypto from 'crypto';
import srp from 'fast-srp-hap';
export default class Server {
constructor(host, port, password) {
constructor(host, port) {
this.host = host;
this.port = port;
this.password = password;
this.password = null;
this.users = new Map();
this.socket = undefined;
}
/**
* Adds a user.
*
* @param {string} username
* @param {Buffer|string} password (or verifier)
* @param {Buffer} [salt] Must be 16 bytes
* @param {object} [params] srp.params[1536]
*/
async addUser(username, password, salt, params) {
if (username === 'admin') this.password = password;
// The verifier isn't actually used as fast-srp-hap doesn't accept verifiers
// const salt = await new Promise((rs, rj) => srp.genKey(16, (err, secret2) => err ? rj(err) : rs(secret2)));
if (!params) params = srp.params[1536];
if (!salt) salt = crypto.randomBytes(16); // .length === 128
const verifier = password instanceof Buffer ? password :
srp.computeVerifier(params, salt, Buffer.from(username), Buffer.from(password));
this.users.set(username, {params, salt, verifier, password});
}
listen(_timeout) {
return new Promise((resolve, reject) => {
this.socket = new net.Server();
......@@ -25,7 +50,7 @@ export default class Server {
}, _timeout);
this.socket.listen(this.port, this.host, err => {
console.log('Connected', err);
console.log('Listening', this.host, this.port, err);
if (err) reject(err);
else resolve();
});
......@@ -60,11 +85,22 @@ export default class Server {
console.debug(0, 'Receiving data', data, 'on connection', session.host + ' port ' + session.port);
if (session.reading) return;
session.emit('raw-data', data);
if (session.encryption) {
data = session.encryption.decrypt(data);
console.debug(0, 'Decrypted', data);
}
session.emit('data', data);
session.buffer += data.toString('binary');
// Try decoding the data as a message
this.handleData(session);
});
return session;
}
async handleData(session) {
......@@ -79,7 +115,7 @@ export default class Server {
session.buffer = data;
if (message.body.length !== message.body_size) {
if (!message.body || message.body.length !== message.body_size) {
// Haven't received the message body yet
return;
}
......@@ -89,26 +125,105 @@ export default class Server {
this.handleMessage(session, message);
} catch (err) {
console.error('Error handling message from', session.host, session.port, err);
session.buffer = '';
}
}
async handleMessage(session, message) {
console.log('Received message', message);
// console.log('Received message', message);
switch (message.command) {
// Authenticate
case 0x1a: {
const data = CFLBinaryPList.parse(message.body);
console.log('Authenticate request from', session.host, session.port, data);
if (data.state === 1) {
console.log('Authenticate stage one');
const user = this.users.get(data.username);
session.authenticating_user = data.username;
// console.log('Authenticating user', user);
const key = crypto.randomBytes(24); // .length === 192
const params = user.params || srp.params[1536];
const salt = user.salt || Buffer.from(crypto.randomBytes(16));
// Why doesn't fast-srp-hap allow using a verifier instead of storing the plain text password?
// const verifier = srp.computeVerifier(params, salt, Buffer.from(username), Buffer.from(password));
const srps = new srp.Server(params, salt, Buffer.from(data.username), Buffer.from(user.password), key);
session.srp = srps;
const payload = {
salt,
generator: params.g.toBuffer(true),
publicKey: srps.computeB(),
modulus: params.N.toBuffer(true),
};
console.log('Stage one response payload', payload);
await session.send(Message.composeAuthCommand(5, CFLBinaryPList.compose(payload)));
} else if (data.state === 3) {
console.log('Authenticate stage three');
const user = this.users.get(session.authenticating_user); // eslint-disable-line no-unused-vars
const srps = session.srp;
srps.setA(data.publicKey);
try {
srps.checkM1(data.response); // throws error if wrong
} catch (err) {
console.error('Error checking password', err.message);
session.socket.destroy();
return;
}
const M2 = srps.computeM2();
const iv = crypto.randomBytes(16);
const payload = {
response: M2,
iv,
};
console.log('Stage three response payload', payload);
await session.send(Message.composeAuthCommand(5, CFLBinaryPList.compose(payload)));
const key = srps.computeK();
const client_iv = data.iv;
// Enable session encryption
console.log('Enabling session encryption');
session.enableServerEncryption(key, client_iv, iv);
} else {
console.error('Unknown auth stage', data.state, message, data);
}
// console.log(payload, CFLBinaryPList.parse(payload));
return;
}
// Get prop
case 0x14: {
console.log('Received get prop command');
let data = message.body;
const props = [];
// Read the requested props into an array of Propertys
while (data.length) {
const prop_header = data.substr(0, ELEMENT_HEADER_SIZE);
const prop_data = await Property.parseRawElementHeader(prop_header);
console.debug(prop_data);
const prop_data = await Property.unpackHeader(prop_header);
const {name, size} = prop_data;
const value = data.substr(0, ELEMENT_HEADER_SIZE + size);
const value = data.substr(ELEMENT_HEADER_SIZE, size);
data = data.substr(ELEMENT_HEADER_SIZE + size);
const prop = new Property(name, value);
......@@ -121,26 +236,27 @@ export default class Server {
}
// Send back an array of Propertys
const ret = this.getProperties(props);
const ret = await this.getProperties(props);
let payload = '';
let i = 0;
await session.send(Message.composeGetPropCommand(5, ''));
let i = 0;
for (let prop of ret) {
payload += Property.composeRawElement(0, prop instanceof Property ? prop : new Property(props[i], prop));
await session.send(Property.composeRawElement(0, prop instanceof Property ? prop : new Property(props[i].name, prop)));
i++;
}
// eslint-disable-next-line no-unused-vars
const response = Message.composeGetPropCommand(4, this.password, payload);
await session.send(Property.composeRawElement(0, new Property()));
return;
}
}
console.error('Unknown command', message.command, message);
}
getProperties(props) {
return props.map(prop => this.getProperty(prop));
return Promise.all(props.map(prop => this.getProperty(prop)));
}
getProperty(prop) {
......
......@@ -4,8 +4,10 @@ import {HEADER_SIZE as ELEMENT_HEADER_SIZE} from './property';
// import {ClientEncryption, ServerEncryption} from './encryption';
import net from 'net';
import crypto from 'crypto';
import EventEmitter from 'events';
export default class Session {
export default class Session extends EventEmitter {
/**
* Creates a Session.
*
......@@ -15,6 +17,8 @@ export default class Session {
* @return {undefined}
*/
constructor(host, port, password) {
super();
this.host = host;
this.port = port;
this.password = password;
......@@ -43,18 +47,29 @@ export default class Session {
this.socket.connect(this.port, this.host, err => {
console.log('Connected', err);
this.emit('connected');
if (err) reject(err);
else resolve();
});
this.socket.on('close', had_error => {
this.socket = undefined;
this.emit('disconnected');
});
this.socket.on('data', data => {
console.debug(0, 'Receiving data', data);
if (this.reading) return;
this.emit('raw-data', data);
if (this.encryption) {
data = this.encryption.decrypt(data);
console.debug(0, 'Decrypted', data);
}
this.buffer += data.toString('binary');
this.emit('data', data);
});
});
}
......@@ -72,6 +87,7 @@ export default class Session {
return new Promise((resolve, reject) => {
this.socket.on('close', () => {
this.socket = undefined;
this.emit('disconnected');
resolve();
});
});
......@@ -144,6 +160,7 @@ export default class Session {
}
if (this.encryption) {
console.debug(0, 'Before encryption', data);
data = this.encryption.encrypt(data);
}
......@@ -162,62 +179,39 @@ export default class Session {
* Receives raw data from the ACP server.
*
* @param {number} size
* @param {number} timeout
* @param {number} timeout (default is 10000 ms / 10 seconds)
* @return {Promise<string>}
*/
receiveSize(size, timeout = 10000) {
const receivedChunks = [this.buffer.substr(0, size)];
this.buffer = this.buffer.substr(size);
async receiveSize(size, timeout = 10000) {
this.reading++;
let receivedSize = receivedChunks[0].length;
let waitingFor = size - receivedSize;
if (waitingFor <= 0) {
return Promise.resolve(receivedChunks.join(''));
}
let _timeout = timeout;
try {
const received_chunks = [this.buffer.substr(0, size)];
this.buffer = this.buffer.substr(size);
let waiting_for = size - received_chunks[0].length;
return new Promise((resolve, reject) => {
const defer = () => {
this.reading -= 1;
reject('Timeout');
};
let last_received_at = Date.now();
let timeout = setTimeout(defer, _timeout);
const listener = data => {
data = data.toString('binary');
if (data.length > waitingFor) {
this.buffer += data.substr(waitingFor);
data = data.substr(0, waitingFor);
while (waiting_for > 0) {
if (last_received_at > Date.now() + timeout) {
throw new Error('Timeout');
}
receivedChunks.push(data);
receivedSize += data.length;
waitingFor = waitingFor - data.length;
clearTimeout(timeout);
// console.debug('Receiving data', {
// data: data.toString(),
// received: receivedChunks,
// receivedSize,
// waitingFor
// });
if (waitingFor <= 0) {
this.socket.removeListener('data', listener);
this.reading -= 1;
resolve(receivedChunks.join(''));
} else {
timeout = setTimeout(defer, _timeout);
await new Promise(r => setTimeout(r, 1));
if (this.buffer) {
const received = this.buffer.substr(0, waiting_for);
waiting_for = waiting_for - received.length;
received_chunks.push(received);
this.buffer = this.buffer.substr(received.length);
last_received_at = Date.now();
}
};
}
this.socket.on('data', listener);
});
return received_chunks.join('');
} finally {
this.reading -= 1;
}
}
/**
......@@ -230,18 +224,77 @@ export default class Session {
async receive(size, timeout = 10000) {
let data = await this.receiveSize(size, timeout);
if (this.encryption) {
data = this.encryption.decrypt(data);
}
return data;
}
enableEncryption(key, client_iv, server_iv) {
this.encryption_context = new ClientEncryption(key, client_iv, server_iv);
this.encryption = new ClientEncryption(key, client_iv, server_iv);
}
enableServerEncryption(key, client_iv, server_iv) {
this.encryption_context = new ServerEncryption(key, client_iv, server_iv);
this.encryption = new ServerEncryption(key, client_iv, server_iv);
}
}
export class Encryption {
constructor(key, client_iv, server_iv) {
this.key = key;
this.client_iv = client_iv;
this.server_iv = server_iv;
const derived_client_key = this.derived_client_key =
crypto.pbkdf2Sync(key, PBKDF_salt0, 5, 16, 'sha1'); // KDF.PBKDF2(key, PBKDF_salt0, 16, 5)
const derived_server_key = this.derived_server_key =
crypto.pbkdf2Sync(key, PBKDF_salt1, 7, 16, 'sha1'); // KDF.PBKDF2(key, PBKDF_salt1, 16, 7);
// PBKDF2(password, salt, dkLen=16, count=1000, prf=None)
this.client_context = this.constructor.createEncryptionContext(derived_client_key, client_iv);
this.server_context = this.constructor.createEncryptionContext(derived_server_key, server_iv);
}
static createEncryptionContext(key, iv) {
return {
cipher: crypto.createCipheriv('aes-128-ctr', key, iv),
decipher: crypto.createDecipheriv('aes-128-ctr', key, iv),
};
}
clientEncrypt(data) {
return this.client_context.cipher.update(data);
}
clientDecrypt(data) {
return this.client_context.decipher.update(data);
}
serverEncrypt(data) {
return this.server_context.cipher.update(data);
}
serverDecrypt(data) {
return this.server_context.decipher.update(data);
}
}
const PBKDF_salt0 = Buffer.from('F072FA3F66B410A135FAE8E6D1D43D5F', 'hex');
const PBKDF_salt1 = Buffer.from('BD0682C9FE79325BC73655F4174B996C', 'hex');
export class ClientEncryption extends Encryption {
encrypt(data) {
return this.clientEncrypt(data);
}
decrypt(data) {
return this.serverDecrypt(data);
}
}
export class ServerEncryption extends Encryption {
encrypt(data) {
return this.serverEncrypt(data);
}
decrypt(data) {
return this.clientDecrypt(data);
}
}