Add authenticated protocol 2 and TypeScript WASM client

This commit is contained in:
sergeych committed 2026-10-19 04:17:34 +04:00
1 parent 88ceeb2ae9
commit 237d404bcd
52 files changed
+3249 -150

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Client, LocalInterface, command } from '../dist/client.js';
import { generateSigningKey, createKeyExchange, verifySignature } from '../dist/crypto.js';
import { bytes, string, unit } from '../dist/bipack.js';
import { blockCodec, handshakeCodec, sealedDataCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage } from '../dist/wire.js';
const echo = command('echo',string,string);
class PeerSocket extends EventTarget {
readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer';
constructor(peer) { super(); this.peer = peer; }
send(data) { queueMicrotask(() => { void this.peer.receive(blockCodec.decode(data)).catch(error => { this.peer.error = error; this.close(); }); }); }
deliver(block) { const data = blockCodec.encode(block); this.dispatchEvent(new MessageEvent('message',{data:data.buffer})); }
close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); }
}
async function makePeer(options = {}) {
const signing = await generateSigningKey();
const exchange = await createKeyExchange();
const peer = { signing, exchange, crypto:null, nonce:0n, error:null, calls:0, ...options };
peer.socket = new PeerSocket(peer);
peer.encrypt = clear => encryptedPackageCodec.encode({nonce:peer.nonce,encryptedMessage:peer.crypto.encrypt(peer.nonce++,clear)});
peer.decrypt = packed => { const p=encryptedPackageCodec.decode(packed); return peer.crypto.decrypt(p.nonce,p.encryptedMessage); };
peer.respond = (id,result) => peer.socket.deliver({kind:'response',forId:id,packedResult:result});
peer.receive = async b => {
if (b.kind !== 'call') { peer.callbackResult?.(b); return; }
if (b.name === 'L0Request') {
const h=handshakeCodec.decode(b.packedArgs), publicKey=exchange.publicKey;
peer.crypto=exchange.serverSession(h.publicKey);
const createdAt=BigInt(Date.now()), nonce=null, expiresAt=null;
const signature=signing.sign(sealedDataCodec.encode({message:authenticationMessage('server',peer.crypto.token),nonce,createdAt,validUntil:expiresAt}));
peer.respond(b.id,handshakeCodec.encode({version:2,publicKey,signature:{publicKey:signing.publicKey,signature,nonce,createdAt,expiresAt}}));
} else if (b.name === 'L0ClientId') {
const identityBytes=peer.decrypt(bytes.decode(b.packedArgs));
peer.identity=clientIdentityCodec.decode(identityBytes);
peer.respond(b.id,bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0,payload:identityBytes}))));
} else if (b.name === 'L0Call') {
peer.calls++; peer.lastPriority=b.priority ?? 0;
const message=protectedMessageCodec.decode(peer.decrypt(bytes.decode(b.packedArgs)));
assert.equal(message.correlation,BigInt(b.id)); assert.equal(message.priority,b.priority ?? 0);
const call=commandCallCodec.decode(message.payload);
if (peer.hang) return;
const payload=blockCodec.encode({kind:'response',forId:0,packedResult:string.encode(string.decode(call.serializedArgs))});
const encrypted=peer.encrypt(protectedMessageCodec.encode({kind:'response',correlation:BigInt(b.id),priority:message.priority,payload}));
if(peer.tamper) encrypted[encrypted.length-1]^=1;
peer.respond(b.id,bytes.encode(encrypted));
}
};
peer.connect = extra => Client.connect('ws://test.invalid',{expectedServerKey:signing.publicKey,session:{},heartbeatMs:0,timeoutMs:500,socketFactory:()=>peer.socket,...extra});
peer.dispose = () => { peer.socket.close(); peer.crypto?.destroy(); exchange.destroy(); signing.destroy(); };
return peer;
}
test('Client authenticates peer and performs encrypted typed calls', async () => {
const peer=await makePeer();
try { const client=await peer.connect(); assert.equal(await client.call(echo,'hello'),'hello'); client.close(); await client.closed; assert.equal(peer.error,null); }
finally { peer.dispose(); }
});
test('Wrong pinned key rejects connection before sending identity', async () => {
const peer=await makePeer(), wrong=await generateSigningKey();
try { await assert.rejects(peer.connect({expectedServerKey:wrong.publicKey}), e=>e.code==='authentication'); assert.equal(peer.identity,undefined); }
finally { peer.dispose(); wrong.destroy(); }
});
test('Call timeout never automatically replays work', async () => {
const peer=await makePeer({hang:true});
try { const client=await peer.connect(); await assert.rejects(client.call(echo,'once',{timeoutMs:20}),e=>e.code==='timeout'); assert.equal(peer.calls,1); client.close(); }
finally { peer.dispose(); }
});
test('Corrupt encrypted response closes connection', async () => {
const peer=await makePeer({tamper:true});
try { const client=await peer.connect(); await assert.rejects(client.call(echo,'x'),e=>e.code==='protocol'); await client.closed; await assert.rejects(client.call(echo,'x'),e=>e.code==='closed'); }
finally { peer.dispose(); }
});
test('Incoming handler can await outbound call: full duplex has no dispatch deadlock', async () => {
const peer=await makePeer();
const callback=command('callback',string,string);
const local=new LocalInterface().on(callback,async (scope,arg)=>await scope.remote.call(echo,arg+'!'));
try {
const client=await peer.connect({local});
const reply=new Promise(resolve=>{peer.callbackResult=resolve;});
peer.socket.deliver({kind:'call',id:80,name:'L0Call',packedArgs:bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'call',correlation:80n,priority:0,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('nested')})})))});
const response=await reply;
const inner=blockCodec.decode(protectedMessageCodec.decode(peer.decrypt(bytes.decode(response.packedResult))).payload);
assert.equal(inner.kind,'response'); assert.equal(string.decode(inner.packedResult),'nested!'); client.close();
} finally { peer.dispose(); }
});
test('Abort cleans pending slot and preserves remote execution uncertainty', async () => {
const peer=await makePeer({hang:true});
try {
const client=await peer.connect({maxPendingCalls:1});
const abort=new AbortController();
const first=client.call(echo,'first',{signal:abort.signal}); abort.abort();
await assert.rejects(first,e=>e.code==='aborted');
peer.hang=false;
assert.equal(await client.call(echo,'second',{priority:7}),'second');
assert.equal(peer.lastPriority,7); assert.equal(peer.calls,2); client.close();
} finally { peer.dispose(); }
});
test('Explicit close rejects all pending calls and repeated close is harmless', async () => {
const peer=await makePeer({hang:true});
try {
const client=await peer.connect();
const first=client.call(echo,'a'), second=client.call(echo,'b');
client.close(); client.close();
await Promise.all([assert.rejects(first,e=>e.code==='closed'),assert.rejects(second,e=>e.code==='closed')]);
await client.closed;
} finally { peer.dispose(); }
});
test('client identity signs the v2 client domain, distinct from server and legacy proofs',async()=>{
const peer=await makePeer(), identity=await generateSigningKey();
try {
const client=await peer.connect({identity});
assert.deepEqual(peer.identity.clientIdKey,identity.publicKey);
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('client',peer.crypto.token)),true);
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('server',peer.crypto.token)),false);
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,peer.crypto.token),false);
client.close();
} finally {peer.dispose();identity.destroy();}
});
+73
View File
@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { array, nullable, BipackReader, BipackWriter, uint64, int64, int32, uint32, string, bytes, boolean, unit } from '../dist/bipack.js';
import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, sealedDataCodec, sealCodec, commandCallCodec } from '../dist/wire.js';
const hex = bytes => Buffer.from(bytes).toString('hex');
test('Smartint boundaries and sign-magnitude vectors match Kotlin source', () => {
const vectors = [[0n,'00'],[1n,'04'],[63n,'fc'],[64n,'0101'],[16383n,'fdff'],[16384n,'020001'],[4194303n,'feffff'],[4194304n,'03000001'],[(1n<<64n)-1n,'ffffffffffffffff7f']];
for (const [value, expected] of vectors) { assert.equal(hex(uint64.encode(value)), expected); assert.equal(uint64.decode(Buffer.from(expected,'hex')),value); }
assert.equal(hex(int64.encode(-1n)), '0c');
assert.equal(hex(int64.encode(1n)), '08');
for (const value of [-(1n<<63n)+1n,-16384n,-32n,0n,31n,32n, (1n<<63n)-1n]) assert.equal(int64.decode(int64.encode(value)),value);
for (const value of [-2147483648,2147483647]) assert.equal(int32.decode(int32.encode(value)),value);
assert.equal(uint32.decode(uint32.encode(0xffffffff)),0xffffffff);
assert.throws(() => int64.encode(-(1n<<63n)), /overflow/);
assert.throws(() => uint64.encode(1n<<64n), /overflow/);
});
test('Bounded decoding rejects malformed values without allocation from untrusted lengths', () => {
for (const value of ['01','0201','03000080','03000080808080808000','0100','020000','03000000']) assert.throws(() => uint64.decode(Buffer.from(value,'hex')));
assert.throws(() => int64.decode(Uint8Array.of(4)), /Negative zero/);
assert.throws(() => bytes.decode(uint32.encode(0xffffffff)), /Truncated/);
assert.throws(() => string.decode(Uint8Array.of(4,255)), /UTF-8/);
assert.throws(() => boolean.decode(Uint8Array.of(2)), /boolean/);
assert.throws(() => unit.decode(Uint8Array.of(0)), /Trailing/);
assert.throws(() => new BipackReader(new Uint8Array(4),3), /limit/);
assert.throws(() => new BipackWriter(2).bytes(new Uint8Array(2)), /limit/);
assert.equal(string.decode(string.encode('Привет 🌌')), 'Привет 🌌');
});
test('Transport legacy vectors and optional signed priority trailer', () => {
const call = {kind:'call',id:1,name:'x',packedArgs:Uint8Array.of(42)};
assert.equal(hex(blockCodec.encode(call)), '00040478042a');
assert.deepEqual(blockCodec.decode(blockCodec.encode(call)),call);
const response = {kind:'response',forId:1,packedResult:new Uint8Array()};
assert.equal(hex(blockCodec.encode(response)), '020400');
const error = {kind:'error',forId:1,code:'E',text:null,extra:null};
assert.equal(hex(blockCodec.encode(error)), '010404450000');
for (const p of [-2147483648,-1,1,2147483647]) assert.deepEqual(blockCodec.decode(blockCodec.encode({...call,priority:p})), {...call,priority:p});
assert.equal(hex(blockCodec.encode({...call,priority:0})), hex(blockCodec.encode(call)));
const encoded = blockCodec.encode({...call,priority:1});
for (let trim=1;trim<=3;trim++) assert.throws(() => blockCodec.decode(encoded.slice(0,-trim)), /trailer/);
assert.throws(() => blockCodec.decode(Uint8Array.of(...encoded,0)), /trailer/);
assert.throws(() => blockCodec.decode(Uint8Array.of(4)), /Unknown/);
});
test('Handshake, Seal and nullable Instant exact field order', () => {
const publicKey = new Uint8Array(32);
const seal = {publicKey,signature:new Uint8Array(64),nonce:null,createdAt:1n,expiresAt:null};
assert.equal(hex(sealCodec.encode(seal)), '80'+'00'.repeat(32)+'0101'+'00'.repeat(64)+'000800');
assert.deepEqual(sealCodec.decode(sealCodec.encode(seal)),seal);
const hs = {version:1,publicKey,signature:null};
assert.equal(hex(handshakeCodec.encode(hs)),'0880'+'00'.repeat(32)+'00');
assert.deepEqual(handshakeCodec.decode(handshakeCodec.encode({...hs,signature:seal})),{...hs,signature:seal});
assert.equal(hex(clientIdentityCodec.encode({clientIdKey:null,signature:null})),'0000');
assert.equal(hex(sealedDataCodec.encode({message:Uint8Array.of(7),nonce:null,createdAt:1n,validUntil:null})),'040700010800');
assert.throws(() => handshakeCodec.encode({...hs,publicKey:new Uint8Array(31)}), /32 bytes/);
});
test('Encrypted envelope and command call roundtrip, complete consumption enforced', () => {
const p = {nonce:(1n<<64n)-1n,encryptedMessage:Uint8Array.of(0,128,255)};
assert.deepEqual(encryptedPackageCodec.decode(encryptedPackageCodec.encode(p)),p);
const c = {name:'echo',serializedArgs:string.encode('hello')};
assert.deepEqual(commandCallCodec.decode(commandCallCodec.encode(c)),c);
assert.throws(() => commandCallCodec.decode(Uint8Array.of(...commandCallCodec.encode(c),0)),/Trailing/);
});
test('Typed lists and nullable values match unframed Kotlin collection encoding', () => {
const ints = array(int32);
assert.equal(hex(ints.encode([1,-1,0])), '0c080c00');
assert.deepEqual(ints.decode(ints.encode([1,-1,0])),[1,-1,0]);
const nested = array(nullable(array(string)));
assert.deepEqual(nested.decode(nested.encode([null,[],['x','ы']])),[null,[],['x','ы']]);
assert.throws(() => array(unit,3).decode(uint32.encode(4)), /Array limit/);
assert.throws(() => array(unit,3).encode([undefined,undefined,undefined,undefined]), /Array limit/);
assert.throws(() => ints.decode(Uint8Array.of(8,8)), /Truncated/);
});
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {cryptoReady, generateSigningKey, restoreSigningKey, createKeyExchange,
verifySignature, numericNonce, encodeWithFill, decodeWithFill} from '../dist/crypto.js';
await cryptoReady();
const fromHex = s => new Uint8Array(Buffer.from(s, 'hex'));
const hex = b => Buffer.from(b).toString('hex');
test('Ed25519 RFC8032 vector; identity restore and secret lifecycle', async () => {
const key = await generateSigningKey(fromHex('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'));
assert.equal(hex(key.publicKey), 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a');
const signature = key.sign(new Uint8Array());
assert.equal(hex(signature), 'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b');
assert.ok(verifySignature(key.publicKey, signature, new Uint8Array()));
assert.equal(verifySignature(key.publicKey, signature, Uint8Array.of(1)), false);
assert.equal(key.keyId.length, 34); assert.equal(key.keyId[32], 4);
const saved = key.exportSecret(); const restored = await restoreSigningKey(saved);
assert.deepEqual(restored.keyId, key.keyId);
saved[63] ^= 1; await assert.rejects(restoreSigningKey(saved), /Inconsistent/);
key.destroy(); assert.throws(() => key.sign(new Uint8Array()), /destroyed/);
assert.throws(() => key.exportSecret(), /destroyed/); restored.destroy();
});
test('directional KX agrees on token and authenticates both directions', async () => {
const client = await createKeyExchange(new Uint8Array(32).fill(1));
const server = await createKeyExchange(new Uint8Array(32).fill(2));
const c = client.clientSession(server.publicKey), s = server.serverSession(client.publicKey);
assert.deepEqual(c.token, s.token);
const message = new TextEncoder().encode('full Kiloparsec');
const cipher = c.encrypt(0n, message);
assert.deepEqual(s.decrypt(0n, cipher), message);
assert.deepEqual(c.decrypt(7n, s.encrypt(7n, message)), message);
assert.throws(() => c.decrypt(0n, cipher)); // wrong direction
assert.throws(() => s.decrypt(1n, cipher)); // wrong nonce
cipher[0] ^= 1; assert.throws(() => s.decrypt(0n, cipher));
assert.throws(() => client.clientSession(server.publicKey), /consumed/);
c.destroy(); s.destroy(); assert.throws(() => c.encrypt(0n, message), /destroyed/);
});
test('numeric nonce uses unsigned little-endian XOR, validates overflow', () => {
assert.deepEqual(numericNonce(Uint8Array.of(0xaa, 0xbb, 0xcc), 0x1234n), Uint8Array.of(0x9e, 0xa9, 0xcc));
assert.throws(() => numericNonce(new Uint8Array(24), -1n), RangeError);
assert.throws(() => numericNonce(new Uint8Array(24), 1n << 64n), RangeError);
assert.throws(() => numericNonce(new Uint8Array(1), 256n), RangeError);
});
test('WithFill preserves arbitrary bytes and rejects trailing input', () => {
const payload = Uint8Array.of(0, 128, 255);
assert.deepEqual(decodeWithFill(encodeWithFill(payload)), payload);
assert.deepEqual(decodeWithFill(encodeWithFill(payload, Uint8Array.of(4, 5))), payload);
assert.throws(() => decodeWithFill(new Uint8Array([...encodeWithFill(payload), 0])));
});
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Client, LocalInterface, command, string, int32, array, unit, generateSigningKey, RemoteError } from '../dist/index.js';
const url = process.env.KILOPARSEC_TEST_URL;
const pin = Uint8Array.from(Buffer.from('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8', 'hex'));
const echo = command('echo', string, string);
const callback = command('callback', string, string);
const clientEcho = command('clientEcho', string, string);
const notice = command('notice', string, unit);
test('source Kotlin protocol 2 server: pinned DH, identity, codecs, duplex, push, errors, parallel calls, reconnect', { skip: !url, timeout: 20_000 }, async () => {
const identity = await generateSigningKey();
const session = { calls: 0 };
let notify;
const noticed = new Promise(resolve => { notify = resolve; });
const local = new LocalInterface()
.on(clientEcho, async scope => {
assert.equal(scope.session, session);
assert.deepEqual(scope.remoteIdentity, pin);
scope.session.calls++;
// Server is waiting for this callback; client can call back into the server.
return await scope.remote.call(echo, 'nested ✓');
})
.on(notice, (scope, text) => { notify(text); });
const options = { session, local, identity, expectedServerKey: pin, heartbeatMs: 0 };
let client;
try {
client = await Client.connect(url, options);
assert.equal(await client.call(echo, 'Привет, Kotlin 👋'), 'Привет, Kotlin 👋');
assert.equal(await client.call(command('identity', unit, string), undefined), Buffer.from(identity.publicKey).toString('hex'));
assert.equal(await client.call(command('sum', array(int32), int32), [-1000, 500, 507]), 7);
assert.equal(await client.call(callback, 'request callback'), 'nested ✓');
assert.equal(session.calls, 1);
client.push(command('push', string, unit), 'notification from TS');
assert.equal(await noticed, 'notification from TS');
await assert.rejects(client.call(command('fail', string, string), 'expected rejection'), e => e instanceof RemoteError && e.code === 'IllegalArgumentException');
await assert.rejects(client.call(command('missing-command', unit, unit), undefined), e => e instanceof RemoteError && e.code === 'UnknownCommand');
const calls = Array.from({ length: 64 }, (_, i) => `parallel:${i}`);
assert.deepEqual(await Promise.all(calls.map(x => client.call(echo, x))), calls);
const large = 'я'.repeat(65536);
assert.equal(await client.call(echo, large), large);
await client.ping();
client.close(); await client.closed;
client = await Client.connect(url, options);
assert.equal(await client.call(echo, 'new session'), 'new session');
} finally { client?.close(); identity.destroy(); }
});
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {Client, LocalInterface, command} from '../dist/client.js';
import {generateSigningKey, createKeyExchange} from '../dist/crypto.js';
import {bytes, string} from '../dist/bipack.js';
import {blockCodec, handshakeCodec, sealedDataCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage} from '../dist/wire.js';
/** In-memory hostile peer: no TLS or socket timing assumptions in these tests. */
async function peer(mode = 'normal') {
const key = await generateSigningKey();
const exchange = await createKeyExchange();
let session, nonce = 0n, previous, calls = [], peerError;
class Socket extends EventTarget {
readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer';
send(data) { queueMicrotask(() => { void receive(blockCodec.decode(data)).catch(e => { peerError = e; this.close(); }); }); }
close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); }
}
const socket = new Socket();
const emit = block => {
const data = blockCodec.encode(block);
socket.dispatchEvent(new MessageEvent('message', {data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)}));
};
const encrypted = value => {
const n = nonce++;
return bytes.encode(encryptedPackageCodec.encode({nonce:n, encryptedMessage:session.encrypt(n,value)}));
};
async function receive(block) {
if (block.name === 'L0Request') {
const request = handshakeCodec.decode(block.packedArgs);
session = exchange.serverSession(request.publicKey);
const now = BigInt(Date.now());
const createdAt = mode === 'future' ? now + 100000n : now;
const expiresAt = mode === 'expired' ? now - 1000n : null;
const signature = key.sign(sealedDataCodec.encode({message:mode === 'legacy-signature' ? session.token : authenticationMessage('server',session.token), nonce:null, createdAt, validUntil:expiresAt}));
if (mode === 'tamper') signature[0] ^= 1;
emit({kind:'response', forId:block.id, packedResult:handshakeCodec.encode({version:mode === 'downgrade' ? 1 : 2,publicKey:exchange.publicKey,
signature:mode === 'unsigned' ? null : {publicKey:key.publicKey,signature,nonce:null,createdAt,expiresAt}})});
} else if (block.name === 'L0ClientId') {
const p=encryptedPackageCodec.decode(bytes.decode(block.packedArgs));
const identity=session.decrypt(p.nonce,p.encryptedMessage);
const packedResult=mode === 'forged-finished' ? new Uint8Array() : encrypted(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0,
payload:mode === 'wrong-finished' ? new Uint8Array() : identity}));
emit({kind:'response', forId:block.id, packedResult});
} else {
const pkg = encryptedPackageCodec.decode(bytes.decode(block.packedArgs));
const message=protectedMessageCodec.decode(session.decrypt(pkg.nonce,pkg.encryptedMessage));
const call = commandCallCodec.decode(message.payload);
let packedResult = encrypted(protectedMessageCodec.encode({kind:'response',correlation:message.correlation,priority:message.priority,payload:blockCodec.encode({kind:'response',forId:0,packedResult:call.serializedArgs})}));
if (mode === 'damage') packedResult[packedResult.length - 1] ^= 1;
if (mode === 'replay') { const saved = packedResult; packedResult = previous ?? saved; previous = saved; }
if (mode === 'swap') {
calls.push({id:block.id, packedResult});
if (calls.length === 2) {
emit({kind:'response',forId:calls[1].id,packedResult:calls[0].packedResult});
emit({kind:'response',forId:calls[0].id,packedResult:calls[1].packedResult});
}
} else emit({kind:'response',forId:block.id,packedResult});
}
}
return {key, socket, injectCall({id=90, signedId=90n, priority=0, signedPriority=0, kind='call'}={}) {
emit({kind:'call', id, priority, name:'L0Call', packedArgs:encrypted(protectedMessageCodec.encode({kind,correlation:signedId,priority:signedPriority,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('payload')})}))});
}, connect: (pin, extra={}) => Client.connect('ws://test.invalid/', {expectedServerKey:pin ?? key.publicKey,session:null,
heartbeatMs:0,timeoutMs:1000,socketFactory:() => socket,...extra}), cleanup() {socket.close();session?.destroy();key.destroy();exchange.destroy(); if(peerError) throw peerError;} };
}
const echo = command('echo',string,string);
test('server pin is mandatory before creating a socket', async () => {
let created = false;
await assert.rejects(Client.connect('ws://test.invalid/',{session:null,socketFactory:() => {created=true;}}), /trusted server key/);
assert.equal(created,false);
});
for (const mode of ['wrong-pin','unsigned','tamper','expired','future','downgrade','legacy-signature']) {
test(`handshake fails closed: ${mode}`,async () => {
const p = await peer(mode);
try {
await assert.rejects(p.connect(mode === 'wrong-pin' ? new Uint8Array(32) : undefined), e => e.code === 'authentication');
assert.equal(p.socket.readyState,3);
} finally {p.cleanup();}
});
}
test('damaged encrypted response closes connection', async () => {
const p=await peer('damage');
try {
const client=await p.connect();
await assert.rejects(client.call(echo,'hello'),e=>e.code==='protocol');
await client.closed;
await assert.rejects(client.call(echo,'again'),e=>e.code==='closed');
} finally {p.cleanup();}
});
test('replayed encrypted response closes connection', async () => {
const p=await peer('replay');
try {
const client=await p.connect(); assert.equal(await client.call(echo,'first'),'first');
await assert.rejects(client.call(echo,'second'),e=>e.code==='protocol'); await client.closed;
} finally {p.cleanup();}
});
test('outer forId substitution fails closed without delivering swapped responses', async () => {
const p=await peer('swap');
try {
const client=await p.connect();
const results=await Promise.allSettled([client.call(echo,'first'),client.call(echo,'second')]);
assert.ok(results.every(r=>r.status==='rejected'));
assert.ok(results.some(r=>r.reason.code==='protocol'));
await client.closed;
client.close();
} finally {p.cleanup();}
});
for (const mode of ['forged-finished','wrong-finished']) {
test(`connection requires authenticated identity confirmation: ${mode}`,async()=>{
const p=await peer(mode);
try { await assert.rejects(p.connect()); assert.equal(p.socket.readyState,3); }
finally {p.cleanup();}
});
}
for (const [name, metadata] of [
['outer call ID substitution',{id:91}],
['call rewritten as push',{id:0}],
['outer priority substitution',{priority:8}],
['protected response rewritten as call',{kind:'response'}],
]) {
test(`incoming metadata is authenticated before dispatch: ${name}`,async()=>{
const p=await peer(); let invoked=0;
const local=new LocalInterface().on(command('callback',string,string),()=>{invoked++;return 'response';});
try {
const client=await p.connect(undefined,{local});
p.injectCall(metadata);
await client.closed;
assert.equal(invoked,0);
} finally {p.cleanup();}
});
}