Release 0.8.0 with replay-safe prioritized transport

This commit is contained in:
sergeych committed 2026-10-21 16:35:20 +04:00
1 parent 237d404bcd
commit 009d62a155
31 files changed
+1188 -130

No files matched your search

+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -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');
}
+25
View File
@@ -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 {}
+17
View File
@@ -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);
});
+40 -4
View File
@@ -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(); }
});