ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
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')));
|
|
});
|