Add authenticated protocol 2 and TypeScript WASM client
This commit is contained in:
1 parent
88ceeb2ae9
commit
237d404bcd
52 files changed
+3249
-150
No files matched your search
@@ -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();}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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])));
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user