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'))); });