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

- 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:
2026-06-26 08:53:21 +02:00
parent 1ae9c33f00
commit 6270d8c956
28 changed files with 981 additions and 297 deletions
@@ -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`.