cea8a7e994
- Add the n8n community node package with credentials, command mapping, direct serve TCP client, and browser-cli protocol crypto helpers. - Cover Ed25519 signing, canonical JSON, PQ transport encryption, request mapping, and security behavior with unit tests. - Harden serve-http with per-address rate limiting, an 8 MB request body cap, and clear warnings when binding plain HTTP beyond loopback. - Stop one-shot --key overrides from being persisted automatically; document explicit remote trust and keep key-management behind the keys policy tier. - Make HTML-to-Markdown conversion safer by bounding tree depth and dropping unsafe link/image URL schemes. - Bump package and extension release metadata to 0.16.3.
142 lines
5.8 KiB
TypeScript
142 lines
5.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
|
|
import {
|
|
authMessage,
|
|
buildAuthPayload,
|
|
canonicalJson,
|
|
decodeResponse,
|
|
ed25519PublicKeyHex,
|
|
frame,
|
|
pqDecrypt,
|
|
pqEncrypt,
|
|
pqTransportKey,
|
|
signAuth,
|
|
} from '../nodes/BrowserCli/protocol';
|
|
|
|
// Known-answer vectors produced by the real Python implementation
|
|
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
|
|
// Regenerate with the snippet in the PR description if the protocol changes.
|
|
const PEM =
|
|
'-----BEGIN PRIVATE KEY-----\n' +
|
|
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
|
|
'-----END PRIVATE KEY-----\n';
|
|
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
|
|
const MSG = {
|
|
id: 'abc-123',
|
|
command: 'dom.type',
|
|
args: { selector: '#q', text: 'héllo ☃ "x"' },
|
|
user_agent: 'browser-cli/0.15.4',
|
|
accept_encoding: { ser: ['json'], comp: [] },
|
|
};
|
|
const CANON =
|
|
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
|
|
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
|
|
'"user_agent":"browser-cli/0.15.4"}';
|
|
const NONCE_HEX = '11'.repeat(32);
|
|
const SIG_NOPQ =
|
|
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
|
|
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
|
|
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
|
|
const SIG_PQ =
|
|
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
|
|
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
|
|
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
|
|
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
|
|
const DECRYPT_ENV = {
|
|
alg: 'ML-KEM-768+ChaCha20Poly1305',
|
|
nonce: 'a77e8a72e13f49aab713e0dc',
|
|
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
|
};
|
|
const DECRYPT_PLAIN = 'hello world payload';
|
|
|
|
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
|
|
assert.equal(canonicalJson(MSG), CANON);
|
|
});
|
|
|
|
test('canonicalJson sorts nested keys and omits undefined', () => {
|
|
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
|
|
});
|
|
|
|
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
|
|
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
|
|
});
|
|
|
|
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
|
|
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
|
|
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
|
|
});
|
|
|
|
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
|
|
// What a single-line/password credential field does to a multi-line PEM.
|
|
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
|
|
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
|
|
});
|
|
|
|
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
|
|
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
|
|
});
|
|
|
|
test('signAuth reproduces the Python signature without PQ', () => {
|
|
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
|
|
});
|
|
|
|
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
|
|
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
|
|
});
|
|
|
|
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
|
|
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
|
|
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
|
|
});
|
|
|
|
test('pqTransportKey matches Python HKDF for both directions', () => {
|
|
const secret = Buffer.from(SECRET_HEX, 'hex');
|
|
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
|
|
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
|
|
});
|
|
|
|
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
|
|
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
|
|
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
|
|
});
|
|
|
|
test('pqEncrypt/pqDecrypt round-trip', () => {
|
|
const secret = Buffer.from(SECRET_HEX, 'hex');
|
|
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
|
|
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
|
|
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
|
|
});
|
|
|
|
test('pqDecrypt rejects an unknown envelope', () => {
|
|
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
|
|
});
|
|
|
|
test('frame prepends a 4-byte little-endian length', () => {
|
|
const out = frame(Buffer.from('abc'));
|
|
assert.equal(out.readUInt32LE(0), 3);
|
|
assert.equal(out.subarray(4).toString(), 'abc');
|
|
});
|
|
|
|
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
|
|
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
|
|
assert.equal(pqSecret, null);
|
|
assert.equal((payload as any).pubkey, PUB_HEX);
|
|
assert.equal((payload as any).sig, SIG_NOPQ);
|
|
});
|
|
|
|
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
|
|
const base = { ...MSG };
|
|
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
|
|
assert.equal(pqSecret, null);
|
|
assert.equal(payload, base);
|
|
});
|
|
|
|
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
|
|
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
|
|
const secret = Buffer.from(SECRET_HEX, 'hex');
|
|
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
|
|
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
|
|
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
|
|
});
|