70 lines
5.3 KiB
JavaScript
70 lines
5.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|||
|
|
import { test } from 'node:test';
|
||
|
|
import { createHash, createCipheriv, createDecipheriv } from 'node:crypto';
|
||
|
|
import { readFileSync } from 'node:fs';
|
||
|
|
import ts from 'typescript';
|
||
|
|
const compiled = ts.transpileModule(readFileSync(new URL('../src/ble/security2.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||
|
|
const { Security2 } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
|
||
|
|
// Independent server side, based on ESP-IDF esp_srp.c and RFC 3526 group 15.
|
||
|
|
const { getDiffieHellman } = await import('node:crypto');
|
||
|
|
const N = BigInt('0x' + getDiffieHellman('modp15').getPrime('hex'));
|
||
|
|
const b = 0xa1386433d535c175643fb90493801529327587213648974398127398478937289477n;
|
||
|
|
const buf = (n, width = 0) => { let h = n.toString(16); if (h.length % 2) h = '0' + h; return Buffer.from(h.padStart(width * 2, '0'), 'hex'); };
|
||
|
|
const int = b => BigInt('0x' + Buffer.from(b).toString('hex'));
|
||
|
|
const hash = (...args) => createHash('sha512').update(Buffer.concat(args.map(x => Buffer.from(x)))).digest();
|
||
|
|
function pow(a, b) { let r = 1n; a = (a % N + N) % N; for (; b; b >>= 1n, a = a * a % N) if (b & 1n) r = r * a % N; return r; }
|
||
|
|
const varint = n => { const a = []; do { a.push(n & 127 | (n > 127 ? 128 : 0)); n >>>= 7; } while (n); return Buffer.from(a); };
|
||
|
|
const field = (id, x) => typeof x === 'number' ? Buffer.concat([varint(id * 8), varint(x)]) : Buffer.concat([varint(id * 8 + 2), varint(x.length), x]);
|
||
|
|
function parse(raw) { let i = 0; const o = {}; const read = () => { let n = 0, s = 0, x; do { x = raw[i++]; n |= (x & 127) << s; s += 7; } while (x & 128); return n; }; while (i < raw.length) { const tag = read(); if ((tag & 7) === 0) o[tag >> 3] = read(); else { const len = read(); o[tag >> 3] = raw.slice(i, i += len); } } return o; }
|
||
|
|
const packet = (kind, id, data) => Buffer.concat([field(2, 2), field(12, Buffer.concat([field(1, kind), field(id, data)]))]);
|
||
|
|
function server(username, password, { badProof = false, salt = Buffer.alloc(16, 3) } = {}) {
|
||
|
|
const v = pow(5n, int(hash(salt, hash(Buffer.from(username + ':' + password)))));
|
||
|
|
const k = int(hash(buf(N, 384), buf(5n, 384)));
|
||
|
|
const B = (k * v + pow(5n, b)) % N;
|
||
|
|
let key, Araw, proof;
|
||
|
|
const iv = Buffer.from('010203040506070800000001', 'hex');
|
||
|
|
return {
|
||
|
|
async exchange(input) {
|
||
|
|
const payload = parse(parse(input)[12]);
|
||
|
|
if ((payload[1] ?? 0) === 0) {
|
||
|
|
const command = parse(payload[20]); Araw = command[2];
|
||
|
|
assert.equal(Buffer.from(command[1]).toString(), username); assert.equal(Araw.length, 384);
|
||
|
|
const A = int(Araw), u = int(hash(Araw, buf(B, 384)));
|
||
|
|
key = hash(buf(pow(A * pow(v, u), b)));
|
||
|
|
const hn = hash(buf(N)), hg = hash(buf(5n, 384));
|
||
|
|
proof = hash(hn.map((v, i) => v ^ hg[i]), hash(Buffer.from(username)), salt, Araw, buf(B), key);
|
||
|
|
return packet(1, 21, Buffer.concat([field(2, buf(B)), field(3, salt)]));
|
||
|
|
}
|
||
|
|
assert.equal(payload[1], 2); assert.deepEqual(Buffer.from(parse(payload[22])[1]), proof);
|
||
|
|
const serverProof = hash(Araw, proof, key); if (badProof) serverProof[0] ^= 1;
|
||
|
|
return packet(3, 23, Buffer.concat([field(2, serverProof), field(3, iv)]));
|
||
|
|
},
|
||
|
|
decrypt(data) { const d = createDecipheriv('aes-256-gcm', key.subarray(0, 32), iv); d.setAuthTag(data.slice(-16)); const raw = Buffer.concat([d.update(data.slice(0, -16)), d.final()]); iv.writeUInt32BE(iv.readUInt32BE(8) + 1, 8); return raw; },
|
||
|
|
encrypt(data) { const e = createCipheriv('aes-256-gcm', key.subarray(0, 32), iv); const raw = Buffer.concat([e.update(data), e.final(), e.getAuthTag()]); iv.writeUInt32BE(iv.readUInt32BE(8) + 1, 8); return raw; },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
test('Security2 matches the C server and shares an ordered GCM counter', async () => {
|
||
|
|
const s = server('KP01-B43A451634A8', 'test-setup-only'), c = new Security2();
|
||
|
|
await c.handshake('KP01-B43A451634A8', 'test-setup-only', s.exchange);
|
||
|
|
for (let i = 0; i < 4; i++) {
|
||
|
|
assert.equal(s.decrypt(await c.encrypt(Buffer.from('request-' + i))).toString(), 'request-' + i);
|
||
|
|
assert.equal(Buffer.from(await c.decrypt(s.encrypt(Buffer.from('response-' + i)))).toString(), 'response-' + i);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
test('leading zero salt/digest bytes are preserved (C firmware compatibility)', async () => {
|
||
|
|
const username = 'KP01-B43A451634A8'; let password = '', i = 0;
|
||
|
|
do { password = 'leading-zero-test-' + i++; } while (hash(Buffer.from(username + ':' + password))[0] !== 0);
|
||
|
|
const s = server(username, password, { salt: Buffer.alloc(16) }), c = new Security2();
|
||
|
|
await c.handshake(username, password, s.exchange);
|
||
|
|
assert.equal(s.decrypt(await c.encrypt(Buffer.from('verified'))).toString(), 'verified');
|
||
|
|
});
|
||
|
|
test('wrong device proof and damaged ciphertext fail closed', async () => {
|
||
|
|
const c = new Security2(), bad = server('device', 'password', { badProof: true });
|
||
|
|
await assert.rejects(c.handshake('device', 'password', bad.exchange));
|
||
|
|
await assert.rejects(c.encrypt(Buffer.from('secret')));
|
||
|
|
const s = server('device', 'password'); await c.handshake('device', 'password', s.exchange);
|
||
|
|
s.decrypt(await c.encrypt(Buffer.from('hello')));
|
||
|
|
const damaged = s.encrypt(Buffer.from('reply')); damaged[0] ^= 1;
|
||
|
|
await assert.rejects(c.decrypt(damaged)); await assert.rejects(c.encrypt(Buffer.from('secret')));
|
||
|
|
});
|