Release 0.8.0 with replay-safe prioritized transport
This commit is contained in:
1 parent
237d404bcd
commit
009d62a155
31 files changed
+1188
-130
No files matched your search
@@ -90,7 +90,7 @@ Long.MIN_VALUE correctly in this baseline; TS rejects it explicitly.
|
||||
|
||||
Packet sizes, concurrent incoming work, pending calls and send buffers are bounded.
|
||||
Receive replay tracking permits out-of-order counters within 4096 positions and
|
||||
fails closed outside the window. Authenticated envelopes additionally bind packet kind, RPC ID and priority
|
||||
silently discards duplicates and packets outside the window without closing the session. Authenticated envelopes additionally bind packet kind, RPC ID and priority
|
||||
to the encrypted payload. Priority trailers
|
||||
are preserved, but this first client uses WebSocket FIFO rather than a priority
|
||||
scheduler. No TCP/UDP adapters, carrier cycling, automatic reconnection, or code
|
||||
|
||||
@@ -32,7 +32,11 @@ is not accepted as an authenticated application result.
|
||||
|
||||
Session nonces are replay-checked on both peers, including pushes. A bounded
|
||||
4096-position window supports responses completed out of order and rejects replay
|
||||
or excessively stale traffic. Send nonces and transport IDs do not wrap. Nonce
|
||||
or excessively stale traffic by silently discarding those packets, without closing
|
||||
the session. A discarded response leaves the pending call waiting for a valid reply
|
||||
or its timeout/cancellation. Replay state uses a circular bitmap; unauthenticated
|
||||
packets never advance it. Kotlin bounds local priority overtaking to 2730 nonce
|
||||
positions and serializes nonce allocation, sealing and queue admission. Send nonces and transport IDs do not wrap. Nonce
|
||||
state used during identity exchange is preserved when the identity is completed.
|
||||
|
||||
The routing envelope still allows denial of service by an active carrier: packets
|
||||
@@ -79,7 +83,8 @@ silently invent a key provisioning or trust-on-first-use policy.
|
||||
|
||||
- `test/security.test.mjs`: swapped responses reject; wrong pin, version/role/token
|
||||
signature substitution, forged/mismatched finished, expired/tampered server Seal,
|
||||
damaged/replayed packets, altered call IDs/kinds/priorities fail closed.
|
||||
damaged packets and altered call IDs/kinds/priorities fail closed; duplicates
|
||||
and stale packets are discarded without executing again or closing the session.
|
||||
- Kotlin `AuthenticatedEnvelopeTest`: normal signed duplex and session identities,
|
||||
response-ID swap, call-to-push rewrite, priority rewrite, replay, forged Unit
|
||||
acknowledgement and protocol downgrade.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
|
||||
kotlin {
|
||||
js { nodejs(); binaries.executable(); generateTypeScriptDefinitions() }
|
||||
sourceSets { jsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } }
|
||||
sourceSets { jsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0") } }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins { kotlin("jvm"); kotlin("plugin.serialization"); application }
|
||||
kotlin { jvmToolchain(21); compilerOptions { optIn.add("kotlin.ExperimentalUnsignedTypes") } }
|
||||
dependencies {
|
||||
implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT")
|
||||
implementation("net.sergeych:kiloparsec:0.8.0")
|
||||
implementation("io.ktor:ktor-server-netty:3.1.1")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.17")
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
|
||||
kotlin {
|
||||
@OptIn(ExperimentalWasmDsl::class)
|
||||
wasmJs { nodejs(); binaries.executable() }
|
||||
sourceSets { wasmJsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } }
|
||||
sourceSets { wasmJsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0") } }
|
||||
}
|
||||
+20
-20
@@ -1,3 +1,4 @@
|
||||
import { ReplayWindow, DiscardPacket } from './replay.js';
|
||||
import { bytes, int64, type Codec } from './bipack.js';
|
||||
import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, sealedDataCodec, protectedMessageCodec, authenticationMessage, type Block } from './wire.js';
|
||||
import { createKeyExchange, verifySignature, type SessionCrypto } from './crypto.js';
|
||||
@@ -51,7 +52,7 @@ export interface ConnectOptions<S> {
|
||||
/** For a host-provided WebSocket implementation. Must implement the standard binary WebSocket API. */
|
||||
readonly socketFactory?: (url: string) => WebSocket;
|
||||
}
|
||||
type Pending = { resolve(value: Uint8Array): void; reject(error: unknown): void; cleanup(): void };
|
||||
type Pending = { resolve(value: Uint8Array): void; reject(error: unknown): void; cleanup(): void; readResponse?: (raw: Uint8Array) => Uint8Array };
|
||||
const pingCommand = command('@kiloparsec/ping', int64, int64);
|
||||
const empty = new Uint8Array();
|
||||
function bound(value: number, label: string, min = 1): number {
|
||||
@@ -74,8 +75,7 @@ export class Client<S> {
|
||||
#token: Uint8Array = empty;
|
||||
#remoteIdentity: Uint8Array = empty;
|
||||
#nonce = 0n;
|
||||
#highestReceived = -1n;
|
||||
#received = new Set<bigint>();
|
||||
#received = new ReplayWindow();
|
||||
#nextId = 1;
|
||||
#pending = new Map<number, Pending>();
|
||||
#incoming = new Set<number>();
|
||||
@@ -99,7 +99,7 @@ export class Client<S> {
|
||||
if (!(event.data instanceof ArrayBuffer)) throw new ConnectionError('protocol', 'Expected binary WebSocket message');
|
||||
if (event.data.byteLength > this.maxPacketBytes) throw new ConnectionError('protocol', 'Packet too large');
|
||||
this.#receive(blockCodec.decode(new Uint8Array(event.data)));
|
||||
} catch { this.#fail(new ConnectionError('protocol', 'Invalid peer packet')); }
|
||||
} catch (error) { if (!(error instanceof DiscardPacket)) this.#fail(new ConnectionError('protocol', 'Invalid peer packet')); }
|
||||
};
|
||||
#onClose = () => this.#fail(new ConnectionError('closed'));
|
||||
#onError = () => this.#fail(new ConnectionError('closed', 'WebSocket failed'));
|
||||
@@ -163,8 +163,8 @@ export class Client<S> {
|
||||
// The trusted server may call us from its onConnected handler before replying.
|
||||
client.#ready = true;
|
||||
const identityBytes = clientIdentityCodec.encode({ clientIdKey: publicKey, signature });
|
||||
const result = await client.#rpc('L0ClientId', bytes.encode(client.#encrypt(identityBytes)), { signal: options.signal });
|
||||
const finished = protectedMessageCodec.decode(client.#decrypt(bytes.decode(result)));
|
||||
const result = await client.#rpc('L0ClientId', bytes.encode(client.#encrypt(identityBytes)), { signal: options.signal }, raw => client.#decrypt(bytes.decode(raw)));
|
||||
const finished = protectedMessageCodec.decode(result);
|
||||
if (finished.kind !== 'finished' || finished.correlation !== 0n || finished.priority !== 0 || !same(finished.payload, identityBytes)) {
|
||||
throw new ConnectionError('authentication', 'Invalid server identity confirmation');
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export class Client<S> {
|
||||
if (packet.length > this.maxPacketBytes || this.#socket.bufferedAmount + packet.length > this.maxPacketBytes * 2) throw new ConnectionError('overloaded', 'Send buffer limit');
|
||||
this.#socket.send(packet);
|
||||
}
|
||||
#rpc(name: string, packedArgs: Uint8Array | ((id: number) => Uint8Array), options: CallOptions = {}): Promise<Uint8Array> {
|
||||
#rpc(name: string, packedArgs: Uint8Array | ((id: number) => Uint8Array), options: CallOptions = {}, readResponse?: (raw: Uint8Array) => Uint8Array): Promise<Uint8Array> {
|
||||
if (this.#closed) return Promise.reject(new ConnectionError('closed'));
|
||||
if (options.signal?.aborted) return Promise.reject(new ConnectionError('aborted'));
|
||||
if (this.#pending.size >= this.maxPendingCalls) return Promise.reject(new ConnectionError('overloaded'));
|
||||
@@ -210,7 +210,7 @@ export class Client<S> {
|
||||
const abort = () => finish(new ConnectionError('aborted'));
|
||||
const timer = setTimeout(() => finish(new ConnectionError('timeout')), timeout);
|
||||
const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); };
|
||||
this.#pending.set(id, { resolve, reject, cleanup });
|
||||
this.#pending.set(id, { resolve, reject, cleanup, readResponse });
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
try { this.#send({ kind: 'call', id, name, packedArgs: typeof packedArgs === 'function' ? packedArgs(id) : packedArgs, priority: options.priority ?? 0 }); }
|
||||
catch (error) { finish(error); }
|
||||
@@ -221,20 +221,25 @@ export class Client<S> {
|
||||
if (block.kind !== 'call') {
|
||||
const pending = this.#pending.get(block.forId);
|
||||
if (!pending) return; // Late response after timeout/abort; no implicit retry.
|
||||
this.#pending.delete(block.forId); pending.cleanup();
|
||||
if (block.kind === 'response') pending.resolve(block.packedResult);
|
||||
// Decode/authenticate before consuming the waiter: duplicates and stale replies
|
||||
// must leave it available for a later valid response (or timeout/abort).
|
||||
if (block.kind === 'response') {
|
||||
const result = pending.readResponse ? pending.readResponse(block.packedResult) : block.packedResult;
|
||||
this.#pending.delete(block.forId); pending.cleanup(); pending.resolve(result);
|
||||
}
|
||||
else {
|
||||
this.#pending.delete(block.forId); pending.cleanup();
|
||||
const error = new ConnectionError('protocol', 'Unencrypted outer RPC error');
|
||||
pending.reject(error); this.#fail(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (block.name !== 'L0Call' || !this.#crypto || !this.#remoteIdentity.length) throw new ConnectionError('protocol');
|
||||
if (this.#incomingCount >= this.maxIncomingCalls || (block.id !== 0 && this.#incoming.has(block.id))) throw new ConnectionError('overloaded');
|
||||
const message = protectedMessageCodec.decode(this.#decrypt(bytes.decode(block.packedArgs)));
|
||||
if (message.kind !== (block.id === 0 ? 'push' : 'call') || message.correlation !== BigInt(block.id) || message.priority !== (block.priority ?? 0)) {
|
||||
throw new ConnectionError('protocol', 'Unauthenticated call metadata');
|
||||
}
|
||||
if (this.#incomingCount >= this.maxIncomingCalls || (block.id !== 0 && this.#incoming.has(block.id))) throw new ConnectionError('overloaded');
|
||||
const call = commandCallCodec.decode(message.payload);
|
||||
this.#incomingCount++;
|
||||
if (block.id) this.#incoming.add(block.id);
|
||||
@@ -265,14 +270,9 @@ export class Client<S> {
|
||||
#decrypt(payload: Uint8Array): Uint8Array {
|
||||
if (!this.#crypto || this.#closed) throw new ConnectionError('closed');
|
||||
const pkg = encryptedPackageCodec.decode(payload);
|
||||
// Responses may finish out of order. Bounded replay window fails closed when too old.
|
||||
if (this.#received.has(pkg.nonce) || pkg.nonce <= this.#highestReceived - 4096n) throw new ConnectionError('protocol', 'Replayed or stale encrypted packet');
|
||||
if (this.#received.containsOrStale(pkg.nonce)) throw new DiscardPacket();
|
||||
const clear = this.#crypto.decrypt(pkg.nonce, pkg.encryptedMessage);
|
||||
this.#received.add(pkg.nonce);
|
||||
if (pkg.nonce > this.#highestReceived) {
|
||||
this.#highestReceived = pkg.nonce;
|
||||
for (const n of this.#received) if (n <= pkg.nonce - 4096n) this.#received.delete(n);
|
||||
}
|
||||
this.#received.record(pkg.nonce);
|
||||
return clear;
|
||||
}
|
||||
/** Execute a typed command. Timeout/abort stops waiting, never cancels remote work. */
|
||||
@@ -285,10 +285,10 @@ export class Client<S> {
|
||||
requestId = id;
|
||||
return bytes.encode(this.#encrypt(protectedMessageCodec.encode({kind: 'call', correlation: BigInt(id), priority,
|
||||
payload: commandCallCodec.encode({ name: cmd.name, serializedArgs: cmd.args.encode(args) }) })));
|
||||
}, options);
|
||||
}, options, raw => this.#decrypt(bytes.decode(raw)));
|
||||
let reply: Block;
|
||||
try {
|
||||
const message = protectedMessageCodec.decode(this.#decrypt(bytes.decode(raw)));
|
||||
const message = protectedMessageCodec.decode(raw);
|
||||
if ((message.kind !== 'response' && message.kind !== 'error') || message.correlation !== BigInt(requestId) || message.priority !== priority) {
|
||||
throw new ConnectionError('protocol', 'Response metadata does not match request');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Bounded circular bitmap; only authenticated messages may call record(). */
|
||||
export class ReplayWindow {
|
||||
#highest = -1n;
|
||||
#bits = new Uint32Array(128);
|
||||
containsOrStale(n: bigint): boolean {
|
||||
if (n > this.#highest) return false;
|
||||
if (this.#highest - n >= 4096n) return true;
|
||||
const i = Number(n % 4096n);
|
||||
return (this.#bits[i >>> 5]! & (1 << (i & 31))) !== 0;
|
||||
}
|
||||
record(n: bigint): void {
|
||||
if (n > this.#highest) {
|
||||
if (this.#highest < 0n || n - this.#highest >= 4096n) this.#bits.fill(0);
|
||||
else for (let next = this.#highest + 1n; next <= n; next++) {
|
||||
const i = Number(next % 4096n);
|
||||
this.#bits[i >>> 5] = this.#bits[i >>> 5]! & ~(1 << (i & 31));
|
||||
}
|
||||
this.#highest = n;
|
||||
}
|
||||
const i = Number(n % 4096n);
|
||||
this.#bits[i >>> 5] = this.#bits[i >>> 5]! | (1 << (i & 31));
|
||||
}
|
||||
clear(): void { this.#highest = -1n; this.#bits.fill(0); }
|
||||
}
|
||||
export class DiscardPacket extends Error {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {ReplayWindow} from '../dist/replay.js';
|
||||
|
||||
test('circular replay bitmap agrees with a reference across wraparound and jumps', () => {
|
||||
const window = new ReplayWindow(), seen = new Set();
|
||||
let high = -1n, seed = 17;
|
||||
const inputs = [4096n, 1n, 2n, 0n, 1n, 8191n, 4096n, 8192n];
|
||||
for (let i=0;i<15000;i++) { seed = (Math.imul(seed,1664525)+1013904223)>>>0; inputs.push(BigInt(seed % 30000)); }
|
||||
inputs.push((1n<<64n)-1n, (1n<<64n)-4096n, (1n<<64n)-4097n);
|
||||
for (const n of inputs) {
|
||||
const reject = seen.has(n) || (n <= high && high-n >= 4096n);
|
||||
assert.equal(window.containsOrStale(n), reject);
|
||||
if (!reject) { window.record(n); seen.add(n); if(n>high) high=n; assert.equal(window.containsOrStale(n),true); }
|
||||
}
|
||||
window.clear(); assert.equal(window.containsOrStale(0n),false);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ async function peer(mode = 'normal') {
|
||||
return bytes.encode(encryptedPackageCodec.encode({nonce:n, encryptedMessage:session.encrypt(n,value)}));
|
||||
};
|
||||
async function receive(block) {
|
||||
if (block.kind !== 'call') return;
|
||||
if (block.name === 'L0Request') {
|
||||
const request = handshakeCodec.decode(block.packedArgs);
|
||||
session = exchange.serverSession(request.publicKey);
|
||||
@@ -47,6 +48,10 @@ async function peer(mode = 'normal') {
|
||||
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-then-valid') {
|
||||
if (previous) emit({kind:'response',forId:block.id,packedResult:previous});
|
||||
previous = packedResult;
|
||||
}
|
||||
if (mode === 'replay') { const saved = packedResult; packedResult = previous ?? saved; previous = saved; }
|
||||
if (mode === 'swap') {
|
||||
calls.push({id:block.id, packedResult});
|
||||
@@ -58,8 +63,10 @@ async function peer(mode = 'normal') {
|
||||
}
|
||||
}
|
||||
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,
|
||||
const packet = {kind:'call', id, priority, name:'L0Call', packedArgs:encrypted(protectedMessageCodec.encode({kind,correlation:signedId,priority:signedPriority,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('payload')})}))};
|
||||
emit(packet);
|
||||
return () => emit(packet);
|
||||
}, skipNonces(count) { nonce += BigInt(count); }, 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);
|
||||
@@ -86,11 +93,12 @@ test('damaged encrypted response closes connection', async () => {
|
||||
await assert.rejects(client.call(echo,'again'),e=>e.code==='closed');
|
||||
} finally {p.cleanup();}
|
||||
});
|
||||
test('replayed encrypted response closes connection', async () => {
|
||||
test('replayed encrypted response is discarded without closing 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;
|
||||
await assert.rejects(client.call(echo,'second',{timeoutMs:20}),e=>e.code==='timeout');
|
||||
assert.equal(p.socket.readyState,1);
|
||||
} finally {p.cleanup();}
|
||||
});
|
||||
test('outer forId substitution fails closed without delivering swapped responses', async () => {
|
||||
@@ -130,3 +138,31 @@ for (const [name, metadata] of [
|
||||
} finally {p.cleanup();}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test('a replayed response does not consume the waiter for the valid reply', async () => {
|
||||
const p = await peer('replay-then-valid');
|
||||
try {
|
||||
const client = await p.connect();
|
||||
assert.equal(await client.call(echo, 'first'), 'first');
|
||||
assert.equal(await client.call(echo, 'second'), 'second');
|
||||
assert.equal(p.socket.readyState, 1);
|
||||
} finally { p.cleanup(); }
|
||||
});
|
||||
|
||||
test('duplicate and stale incoming pushes are discarded without closing', async () => {
|
||||
const p = await peer();
|
||||
let calls = 0;
|
||||
const local = new LocalInterface().on(command('callback', string, string), () => { calls++; return 'ok'; });
|
||||
try {
|
||||
const client = await p.connect(undefined, {local});
|
||||
const replay = p.injectCall({id:0, signedId:0n, kind:'push'});
|
||||
replay();
|
||||
assert.equal(calls, 1);
|
||||
p.skipNonces(4096);
|
||||
p.injectCall({id:0, signedId:0n, kind:'push'});
|
||||
replay();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(await client.call(echo, 'still alive'), 'still alive');
|
||||
} finally { p.cleanup(); }
|
||||
});
|
||||
Reference in new issue
Block a user