feat: add remote trust and server identity pinning
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
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.
This commit is contained in:
@@ -355,8 +355,8 @@ export class BrowserCli implements INodeType {
|
||||
name: 'pattern',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '*.github.com/*',
|
||||
description: 'URL glob pattern. Required for Filter; optional for Count (omit to count all).',
|
||||
placeholder: 'twitch.tv/* or twitch.tv',
|
||||
description: 'Matched against the full tab URL. A plain string is a case-sensitive substring match ("twitch.tv"); a pattern with "*" or "?" is a glob ("twitch.tv/*", "*.twitch.tv"). Glob needs the serve-side extension at 0.16.4+; older extensions treat the whole pattern as a literal substring. Required for Filter; optional for Count (omit to count all).',
|
||||
displayOptions: { show: showFor('tab', ['filter', 'count']) },
|
||||
},
|
||||
{
|
||||
@@ -727,6 +727,8 @@ function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions
|
||||
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
||||
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
||||
route: creds.browser ? String(creds.browser) : null,
|
||||
serverIdentity: creds.serverIdentity ? String(creds.serverIdentity) : null,
|
||||
allowUnknownServerIdentity: Boolean(creds.allowUnknownServerIdentity),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createHash,
|
||||
verify as nodeVerify,
|
||||
createHmac,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
@@ -234,6 +235,8 @@ export interface Challenge {
|
||||
nonce?: string;
|
||||
min_client_version?: string;
|
||||
pq_kex?: { alg?: string; public_key?: string };
|
||||
server_pubkey?: string;
|
||||
server_sig?: string;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
@@ -247,6 +250,81 @@ function pqPublicKey(challenge: Challenge): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function ed25519PublicKeyFromHex(pubkeyHex: string): KeyObject {
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(pubkeyHex)) throw new Error('server public key must be 32-byte hex');
|
||||
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: base64Url(Buffer.from(pubkeyHex, 'hex')) }, format: 'jwk' });
|
||||
}
|
||||
|
||||
function signedChallenge(challenge: Challenge): Record<string, unknown> {
|
||||
const { server_sig: _serverSig, ...rest } = challenge;
|
||||
return rest as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function serverFingerprint(pubkeyHex: string): string {
|
||||
return 'SHA256:' + createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('base64').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function verifyServerChallengeSignature(challenge: Challenge): boolean {
|
||||
const pubkey = challenge.server_pubkey;
|
||||
const sig = challenge.server_sig;
|
||||
if (!pubkey || !sig) return false;
|
||||
try {
|
||||
return nodeVerify(
|
||||
null,
|
||||
Buffer.from(canonicalJson(signedChallenge(challenge)), 'utf8'),
|
||||
ed25519PublicKeyFromHex(pubkey),
|
||||
Buffer.from(sig, 'hex'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyServerIdentity(
|
||||
challenge: Challenge,
|
||||
expectedServerIdentity: string | null | undefined,
|
||||
endpoint: string,
|
||||
allowUnknown: boolean,
|
||||
): void {
|
||||
const pubkey = challenge.server_pubkey;
|
||||
const expected = (expectedServerIdentity || '').trim();
|
||||
|
||||
if (!pubkey) {
|
||||
if (expected) throw new Error(`server ${endpoint} did not advertise a server identity key`);
|
||||
return;
|
||||
}
|
||||
if (!verifyServerChallengeSignature(challenge)) {
|
||||
throw new Error(`server ${endpoint} identity signature is invalid`);
|
||||
}
|
||||
|
||||
const seenFingerprint = serverFingerprint(pubkey);
|
||||
if (!expected) {
|
||||
if (allowUnknown) return;
|
||||
throw new Error(
|
||||
`Unknown browser-cli server identity for ${endpoint} (${seenFingerprint}). ` +
|
||||
'Set the expected Server Public Key/Fingerprint in the Browser CLI credential.',
|
||||
);
|
||||
}
|
||||
|
||||
if (expected.startsWith('SHA256:')) {
|
||||
if (expected !== seenFingerprint) {
|
||||
throw new Error(`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${expected}, seen ${seenFingerprint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedExpected = expected.toLowerCase();
|
||||
if (normalizedExpected !== pubkey.toLowerCase()) {
|
||||
throw new Error(
|
||||
`REMOTE SERVER IDENTITY CHANGED for ${endpoint}: expected ${serverFingerprint(normalizedExpected)}, seen ${seenFingerprint}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single framed message a client sends in response to the challenge.
|
||||
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { connect as tlsConnect } from 'node:tls';
|
||||
import type { Socket } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
|
||||
import { buildAuthPayload, decodeResponse, frame, verifyServerIdentity, type Challenge } from './protocol';
|
||||
|
||||
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
|
||||
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
||||
@@ -33,6 +33,10 @@ export interface ServeConnectOptions {
|
||||
privateKeyPem?: string | null;
|
||||
/** Optional `_route` target for a multi-browser serve. */
|
||||
route?: string | null;
|
||||
/** Expected server identity: raw Ed25519 public key hex or SHA256 fingerprint. */
|
||||
serverIdentity?: string | null;
|
||||
/** Allow unknown server identities (TOFU disabled). Intended for loopback/dev only. */
|
||||
allowUnknownServerIdentity?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -121,6 +125,12 @@ export async function sendServeCommand(
|
||||
const run = async () => {
|
||||
const challengeRaw = await reader.next();
|
||||
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
||||
verifyServerIdentity(
|
||||
challenge,
|
||||
opts.serverIdentity,
|
||||
`${opts.host}:${opts.port}`,
|
||||
Boolean(opts.allowUnknownServerIdentity),
|
||||
);
|
||||
|
||||
const baseMsg: Record<string, unknown> = {
|
||||
id: randomUUID(),
|
||||
|
||||
Reference in New Issue
Block a user