6270d8c956
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints. - Add remote add/list/remove commands for explicit endpoint persistence. - Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing. - Add URL glob matching for tabs filter and count with extension tests. - Add n8n credential pinning for server public keys or SHA256 fingerprints. - Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes. - Bump browser-cli to 0.16.4 and n8n node to 0.3.1. - Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
181 lines
7.6 KiB
TypeScript
181 lines
7.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
|
|
import {
|
|
authMessage,
|
|
buildAuthPayload,
|
|
canonicalJson,
|
|
decodeResponse,
|
|
ed25519PublicKeyHex,
|
|
frame,
|
|
pqDecrypt,
|
|
pqEncrypt,
|
|
pqTransportKey,
|
|
serverFingerprint,
|
|
signAuth,
|
|
verifyServerChallengeSignature,
|
|
verifyServerIdentity,
|
|
} 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';
|
|
const SERVER_PUB_HEX = '982c13bda72ef7b2bf4a8cd9756e4f283faaf4a34f9dec8e6c65585b64d9a902';
|
|
const SERVER_SIG =
|
|
'fa6897bb7f00f711ee8af151384eda736a503008f8b8e11b972cfea46a0366d5' +
|
|
'3127c2f1e9ba01e72805b267d023c552756645ae7d95fd00fa277ed20a43ee09';
|
|
const SERVER_FP = 'SHA256:wsYDqD4OnF/Sfvr3RKvVCOW8ET802H2qHSvWfnQwQrs';
|
|
const SERVER_CHALLENGE = {
|
|
type: 'challenge',
|
|
nonce: NONCE_HEX,
|
|
server_version: '0.16.4',
|
|
min_client_version: '0.9.0',
|
|
server_pubkey: SERVER_PUB_HEX,
|
|
server_sig: SERVER_SIG,
|
|
};
|
|
|
|
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 });
|
|
});
|
|
|
|
test('server identity fingerprint and signature match Python challenge signing', () => {
|
|
assert.equal(serverFingerprint(SERVER_PUB_HEX), SERVER_FP);
|
|
assert.equal(verifyServerChallengeSignature(SERVER_CHALLENGE), true);
|
|
assert.equal(verifyServerChallengeSignature({ ...SERVER_CHALLENGE, nonce: '22'.repeat(32) }), false);
|
|
});
|
|
|
|
test('verifyServerIdentity accepts pinned pubkey or fingerprint', () => {
|
|
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_PUB_HEX, 'browser-host.example:8765', false));
|
|
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, SERVER_FP, 'browser-host.example:8765', false));
|
|
});
|
|
|
|
test('verifyServerIdentity rejects unknown and changed identities', () => {
|
|
assert.throws(
|
|
() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', false),
|
|
/Unknown browser-cli server identity/,
|
|
);
|
|
assert.throws(
|
|
() => verifyServerIdentity(SERVER_CHALLENGE, '00'.repeat(32), 'browser-host.example:8765', false),
|
|
/REMOTE SERVER IDENTITY CHANGED/,
|
|
);
|
|
assert.doesNotThrow(() => verifyServerIdentity(SERVER_CHALLENGE, null, 'browser-host.example:8765', true));
|
|
});
|