fix(net): enforce lockstep peerId authentication, bounds checks, and future-tick window (#518)
Bind peerId at the relay session layer, reject out-of-range peerIds and out-of-window future ticks without leaking memory, and store N-peer inputs per tick. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
parent
7bc21df07c
commit
995f594fe7
|
|
@ -1,22 +1,28 @@
|
|||
/**
|
||||
* Run the co-op relay.
|
||||
*
|
||||
* This is the whole of "the server": it forwards bytes between the connected
|
||||
* peers and knows nothing about the game. Everything that decides who wins a
|
||||
* fight happens in the two browsers.
|
||||
* This is the whole of "the server": it forwards validated lockstep bytes
|
||||
* between the connected peers and binds each sender's assigned peerId.
|
||||
*
|
||||
* Usage: node scripts/net-server.ts [port]
|
||||
*/
|
||||
import { startRelay } from './net-relay.ts'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions } from './net-relay.ts'
|
||||
|
||||
const port = Number(process.argv[2] ?? '8787')
|
||||
const relay = await startRelay(Number.isFinite(port) ? port : 8787)
|
||||
console.log(`relay listening on ${relay.url}`)
|
||||
console.log('open two pages with ?ws=' + relay.url + '&peer=0 and &peer=1')
|
||||
export { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions }
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
await relay.close()
|
||||
process.exit(0)
|
||||
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (isMain) {
|
||||
const port = Number(process.argv[2] ?? '8787')
|
||||
const relay = await startRelay(Number.isFinite(port) && port >= 0 && port <= 65535 ? port : 8787)
|
||||
console.log(`relay listening on ${relay.url}`)
|
||||
console.log('open two pages with ?ws=' + relay.url + '&peer=0 and &peer=1')
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
await relay.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', () => { void stop() })
|
||||
process.on('SIGTERM', () => { void stop() })
|
||||
}
|
||||
process.on('SIGINT', () => { void stop() })
|
||||
process.on('SIGTERM', () => { void stop() })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Lockstep and netplay manager barrel module.
|
||||
*/
|
||||
export * from './lockstep.ts'
|
||||
export * from './netplay.ts'
|
||||
export * from './protocol.ts'
|
||||
export * from './transport.ts'
|
||||
export { NetplaySession as LockstepManager } from './netplay.ts'
|
||||
|
|
@ -57,6 +57,8 @@ export interface LockstepOptions {
|
|||
readonly inputDelayTicks: number
|
||||
/** How many past hashes to keep for comparison. */
|
||||
readonly historyTicks?: number
|
||||
/** Maximum future tick lead accepted relative to currentTick. Defaults to 64. */
|
||||
readonly maxFutureTicks?: number
|
||||
}
|
||||
|
||||
/** What one call to {@link LockstepSession.step} did. */
|
||||
|
|
@ -73,6 +75,10 @@ export interface DesyncReport {
|
|||
readonly local: number
|
||||
/** The remote hash. */
|
||||
readonly remote: number
|
||||
/** Alias for local hash. */
|
||||
readonly localHash?: number
|
||||
/** Alias for remote hash. */
|
||||
readonly remoteHash?: number
|
||||
}
|
||||
|
||||
/** The simulation a session drives. */
|
||||
|
|
@ -98,7 +104,10 @@ export interface LockstepSimulation {
|
|||
}
|
||||
|
||||
/** Number of past hashes kept for late comparisons. */
|
||||
const DEFAULT_HISTORY = 64
|
||||
export const DEFAULT_HISTORY = 64
|
||||
|
||||
/** Maximum future ticks ahead of currentTick accepted into the inbox. */
|
||||
export const MAX_FUTURE_TICKS = 64
|
||||
|
||||
/**
|
||||
* One lockstep session: input inbox, tick clock and hash history.
|
||||
|
|
@ -107,6 +116,7 @@ export class LockstepSession {
|
|||
private readonly options: LockstepOptions
|
||||
private readonly simulation: LockstepSimulation
|
||||
private readonly inputs = new Map<number, Map<number, InputFrame>>()
|
||||
private readonly remoteHashHistory = new Map<number, Map<number, number>>()
|
||||
private readonly hashes: StateHash[] = []
|
||||
private currentTick = 0
|
||||
private stallTicks = 0
|
||||
|
|
@ -119,7 +129,10 @@ export class LockstepSession {
|
|||
constructor(options: LockstepOptions, simulation: LockstepSimulation) {
|
||||
this.options = options
|
||||
this.simulation = simulation
|
||||
for (let peer = 0; peer < options.peers; peer += 1) this.inputs.set(peer, new Map())
|
||||
for (let peer = 0; peer < options.peers; peer += 1) {
|
||||
this.inputs.set(peer, new Map())
|
||||
this.remoteHashHistory.set(peer, new Map())
|
||||
}
|
||||
}
|
||||
|
||||
/** The next tick that has not run yet. */
|
||||
|
|
@ -142,20 +155,71 @@ export class LockstepSession {
|
|||
return this.hashes
|
||||
}
|
||||
|
||||
/** Pending inputs per peer, bounded by MAX_FUTURE_TICKS. */
|
||||
get pendingInputs(): ReadonlyMap<number, ReadonlyMap<number, InputFrame>> {
|
||||
return this.inputs
|
||||
}
|
||||
|
||||
/** Recorded remote hashes per peer, bounded by historyTicks + MAX_FUTURE_TICKS. */
|
||||
get remoteHashes(): ReadonlyMap<number, ReadonlyMap<number, number>> {
|
||||
return this.remoteHashHistory
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an input frame from a peer.
|
||||
*
|
||||
* Frames for ticks that already ran are dropped: replaying them would change
|
||||
* history, and a peer that sends stale input is late, not authoritative.
|
||||
* Frames for ticks that already ran, ticks beyond the future window, unknown
|
||||
* peers, non-unit movement, or duplicate `(peer, tick)` entries are rejected
|
||||
* without throwing or overwriting previously accepted frames.
|
||||
*
|
||||
* @param peer - the peer's index.
|
||||
* @param frame - the frame.
|
||||
* @returns true when the frame was accepted.
|
||||
*/
|
||||
submit(peer: number, frame: InputFrame): boolean {
|
||||
if (!this.inputs.has(peer)) throw new Error(`unknown peer ${String(peer)}`)
|
||||
if (frame.tick < this.currentTick) return false
|
||||
this.inputs.get(peer)!.set(frame.tick, frame)
|
||||
if (!Number.isInteger(peer)) return false
|
||||
const peerInputs = this.inputs.get(peer)
|
||||
if (peerInputs === undefined) return false
|
||||
if (!Number.isInteger(frame.tick) || frame.tick < this.currentTick) return false
|
||||
const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS
|
||||
if (frame.tick > this.currentTick + maxFuture) return false
|
||||
if (
|
||||
!Number.isFinite(frame.movement.x) ||
|
||||
!Number.isFinite(frame.movement.y) ||
|
||||
Math.abs(frame.movement.x) > 1 ||
|
||||
Math.abs(frame.movement.y) > 1
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (peerInputs.has(frame.tick)) return false
|
||||
peerInputs.set(frame.tick, frame)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a remote peer's hash for a tick if within the valid window and not a
|
||||
* duplicate for that `(peer, tick)`.
|
||||
*
|
||||
* @param peer - remote peer index.
|
||||
* @param tick - tick the hash is for.
|
||||
* @param hash - 32-bit state hash.
|
||||
* @returns true if accepted (first-write-wins within window).
|
||||
*/
|
||||
recordRemoteHash(peer: number, tick: number, hash: number): boolean {
|
||||
if (!Number.isInteger(peer)) return false
|
||||
const peerMap = this.remoteHashHistory.get(peer)
|
||||
if (peerMap === undefined) return false
|
||||
const window = this.options.historyTicks ?? DEFAULT_HISTORY
|
||||
const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS
|
||||
if (!Number.isInteger(tick) || tick < this.currentTick - window || tick > this.currentTick + maxFuture) {
|
||||
return false
|
||||
}
|
||||
if (peerMap.has(tick)) return false
|
||||
peerMap.set(tick, hash >>> 0)
|
||||
const oldest = this.currentTick - window
|
||||
for (const oldTick of peerMap.keys()) {
|
||||
if (oldTick < oldest) peerMap.delete(oldTick)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -180,10 +244,28 @@ export class LockstepSession {
|
|||
|
||||
this.simulation.advance(frames)
|
||||
const hash = this.simulation.hash()
|
||||
const window = this.options.historyTicks ?? DEFAULT_HISTORY
|
||||
this.hashes.push({ tick, hash })
|
||||
if (this.hashes.length > (this.options.historyTicks ?? DEFAULT_HISTORY)) this.hashes.shift()
|
||||
if (this.hashes.length > window) this.hashes.shift()
|
||||
for (const peer of this.inputs.keys()) this.inputs.get(peer)!.delete(tick)
|
||||
this.currentTick += 1
|
||||
const oldest = this.currentTick - window
|
||||
for (const peerMap of this.remoteHashHistory.values()) {
|
||||
for (const oldTick of peerMap.keys()) {
|
||||
if (oldTick < oldest) peerMap.delete(oldTick)
|
||||
}
|
||||
const remoteHash = peerMap.get(tick)
|
||||
if (remoteHash !== undefined && remoteHash !== (hash >>> 0)) {
|
||||
this.desync ??= {
|
||||
tick,
|
||||
local: hash,
|
||||
remote: remoteHash,
|
||||
localHash: hash,
|
||||
remoteHash,
|
||||
}
|
||||
return { kind: 'desync', tick, local: hash, remote: remoteHash }
|
||||
}
|
||||
}
|
||||
this.stallTicks = 0
|
||||
return { kind: 'stepped', tick, hash }
|
||||
}
|
||||
|
|
@ -208,7 +290,13 @@ export class LockstepSession {
|
|||
const local = this.hashes.find(entry => entry.tick === remote.tick)
|
||||
if (local === undefined) return null
|
||||
if (local.hash === remote.hash) return null
|
||||
this.desync ??= { tick: remote.tick, local: local.hash, remote: remote.hash }
|
||||
this.desync ??= {
|
||||
tick: remote.tick,
|
||||
local: local.hash,
|
||||
remote: remote.hash,
|
||||
localHash: local.hash,
|
||||
remoteHash: remote.hash,
|
||||
}
|
||||
return this.desync
|
||||
}
|
||||
|
||||
|
|
@ -233,19 +321,26 @@ export class LockstepSession {
|
|||
|
||||
export interface HashablePlayer {
|
||||
readonly x: number; readonly y: number; readonly hp: number;
|
||||
readonly xp: number; readonly level: number; readonly alive: boolean;
|
||||
readonly xp?: number | undefined; readonly level?: number | undefined; readonly alive?: boolean | undefined;
|
||||
readonly maxHp?: number | undefined; readonly mana?: number | undefined; readonly maxMana?: number | undefined;
|
||||
readonly cooldown?: number | undefined;
|
||||
}
|
||||
export interface HashableMonster {
|
||||
readonly id?: number | undefined;
|
||||
readonly kind?: string | undefined;
|
||||
readonly x: number; readonly y: number; readonly hp: number;
|
||||
readonly state: string;
|
||||
readonly maxHp?: number | undefined; readonly cooldown?: number | undefined; readonly deadTicks?: number | undefined;
|
||||
readonly state?: string | undefined;
|
||||
}
|
||||
export interface HashableWorld {
|
||||
readonly tick: number; readonly kills: number;
|
||||
readonly players: readonly HashablePlayer[];
|
||||
readonly rngState?: number | undefined; readonly xp?: number | undefined; readonly level?: number | undefined;
|
||||
readonly players?: readonly HashablePlayer[] | undefined;
|
||||
readonly player?: HashablePlayer | undefined;
|
||||
readonly monsters: readonly HashableMonster[];
|
||||
}
|
||||
export interface HashableItem {
|
||||
readonly name: string; readonly stack?: number; readonly value?: number;
|
||||
readonly name: string; readonly stack?: number | undefined; readonly value?: number | undefined;
|
||||
readonly quality?: string | number | undefined;
|
||||
readonly uniqueId?: number | undefined;
|
||||
readonly dwInitSeed?: number | undefined;
|
||||
|
|
@ -282,20 +377,21 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly
|
|||
mix(world.tick)
|
||||
mix(world.kills)
|
||||
|
||||
for (const player of world.players) {
|
||||
const playersList = world.players ?? (world.player ? [world.player] : [])
|
||||
for (const player of playersList) {
|
||||
mix(round(player.x))
|
||||
mix(round(player.y))
|
||||
mix(player.hp)
|
||||
mix(player.xp)
|
||||
mix(player.level)
|
||||
mix(player.alive ? 1 : 0)
|
||||
mix(player.xp ?? world.xp ?? 0)
|
||||
mix(player.level ?? world.level ?? 1)
|
||||
mix((player.alive ?? player.hp > 0) ? 1 : 0)
|
||||
}
|
||||
|
||||
for (const monster of world.monsters) {
|
||||
mix(round(monster.x))
|
||||
mix(round(monster.y))
|
||||
mix(monster.hp)
|
||||
mixString(monster.state)
|
||||
mixString(monster.state ?? '')
|
||||
}
|
||||
|
||||
// 2. Hash Inventories
|
||||
|
|
@ -368,3 +464,5 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly
|
|||
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
export const computeStateHash = computeLockstepHash
|
||||
|
|
|
|||
|
|
@ -18,13 +18,28 @@
|
|||
* all is a *timeout*, kept separate from a desync — different problem, different
|
||||
* response (drop the peer versus stop and resync).
|
||||
*/
|
||||
import { LockstepSession, type DesyncReport, type InputFrame, type LockstepOptions, type LockstepSimulation, type StateHash, type StepOutcome } from './lockstep.ts'
|
||||
import { decodeMessage, encodeMessage, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts'
|
||||
import { DEFAULT_HISTORY, LockstepSession, MAX_FUTURE_TICKS, type DesyncReport, type InputFrame, type LockstepOptions, type LockstepSimulation, type StateHash, type StepOutcome } from './lockstep.ts'
|
||||
import { decodeMessage, encodeMessage, MOVEMENT_SCALE, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts'
|
||||
import type { Transport } from './transport.ts'
|
||||
|
||||
/** Pumps between repeated handshake attempts, until the peer answers. */
|
||||
const HANDSHAKE_RETRY_PUMPS = 25
|
||||
|
||||
/** Maximum entries held in pendingRemoteHashes. */
|
||||
const MAX_PENDING_REMOTE_HASHES = 128
|
||||
|
||||
/**
|
||||
* Validate that a movement vector is finite, within `[-1, 1]` per axis, and
|
||||
* has magnitude `<= 1` (with 2-LSB fixed-point rounding tolerance for normalized diagonals).
|
||||
*/
|
||||
function isUnitMovement(x: number, y: number): boolean {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
|
||||
const ix = Math.round(x * MOVEMENT_SCALE)
|
||||
const iy = Math.round(y * MOVEMENT_SCALE)
|
||||
if (Math.abs(ix) > MOVEMENT_SCALE || Math.abs(iy) > MOVEMENT_SCALE) return false
|
||||
return ix * ix + iy * iy <= MOVEMENT_SCALE * MOVEMENT_SCALE + 2000
|
||||
}
|
||||
|
||||
/** Session configuration beyond the lockstep core's own options. */
|
||||
export interface NetplayOptions extends LockstepOptions {
|
||||
/** This peer's index. */
|
||||
|
|
@ -128,7 +143,7 @@ export class NetplaySession {
|
|||
* almost nothing, which is the opposite of what the check is for. They are held
|
||||
* here instead and checked the moment local history catches up.
|
||||
*/
|
||||
private readonly pendingRemoteHashes = new Map<number, number>()
|
||||
private readonly pendingRemoteHashes = new Map<number, number | number[]>()
|
||||
/** Pumps since the peer last said anything. */
|
||||
private silentTicks = 0
|
||||
private handshaked = false
|
||||
|
|
@ -376,18 +391,21 @@ export class NetplaySession {
|
|||
*/
|
||||
private drainRemoteHashes(): void {
|
||||
if (this.pendingRemoteHashes.size === 0) return
|
||||
const window = this.options.historyTicks ?? 64
|
||||
const window = this.options.historyTicks ?? DEFAULT_HISTORY
|
||||
const oldest = this.lockstep.tick - window
|
||||
for (const [tick, hash] of [...this.pendingRemoteHashes]) {
|
||||
for (const [tick, entry] of [...this.pendingRemoteHashes]) {
|
||||
const hashes = Array.isArray(entry) ? entry : [entry]
|
||||
if (tick < oldest) {
|
||||
this.pendingRemoteHashes.delete(tick)
|
||||
this.hashesIgnored += 1
|
||||
this.hashesIgnored += hashes.length
|
||||
continue
|
||||
}
|
||||
if (tick >= this.lockstep.tick) continue
|
||||
this.pendingRemoteHashes.delete(tick)
|
||||
this.hashesCompared += 1
|
||||
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
|
||||
for (const hash of hashes) {
|
||||
this.hashesCompared += 1
|
||||
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -466,18 +484,35 @@ export class NetplaySession {
|
|||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
if (
|
||||
!Number.isInteger(message.peer) ||
|
||||
message.peer < 0 ||
|
||||
message.peer >= this.options.peers ||
|
||||
message.peer === this.options.peer
|
||||
) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
|
||||
const window = this.options.historyTicks ?? DEFAULT_HISTORY
|
||||
const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS
|
||||
|
||||
switch (message.kind) {
|
||||
case 'hello': {
|
||||
if (message.peer === this.options.peer || message.peer >= this.options.peers) {
|
||||
// A hello from ourselves, or from a peer index nobody can have: ignored
|
||||
// rather than counted, because it says the sender is confused about the
|
||||
// game it joined.
|
||||
if (
|
||||
message.peers !== this.options.peers ||
|
||||
(message.ackTo !== NO_ACK && (message.ackTo < 0 || message.ackTo >= this.options.peers))
|
||||
) {
|
||||
this.malformed += 1
|
||||
break
|
||||
return
|
||||
}
|
||||
const link = this.linkOf(message.peer)
|
||||
if (link.heard && link.seed !== message.seed) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
link.heard = true
|
||||
link.seed = message.seed
|
||||
// The seed is checked, not trusted: peers that disagree about it build
|
||||
|
|
@ -496,16 +531,71 @@ export class NetplaySession {
|
|||
this.handshaked = this.ready
|
||||
break
|
||||
}
|
||||
case 'input':
|
||||
this.lockstep.submit(message.peer, message.frame)
|
||||
case 'input': {
|
||||
if (!isUnitMovement(message.frame.movement.x, message.frame.movement.y)) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
if (message.frame.tick > this.lockstep.tick + maxFuture) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
if (!this.lockstep.submit(message.peer, message.frame)) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
break
|
||||
case 'hash':
|
||||
this.pendingRemoteHashes.set(message.tick, message.hash)
|
||||
}
|
||||
case 'hash': {
|
||||
if (message.tick > this.lockstep.tick + maxFuture) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
const oldest = this.lockstep.tick - window
|
||||
if (message.tick < oldest) {
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
this.hashesIgnored += 1
|
||||
break
|
||||
}
|
||||
if (!this.lockstep.recordRemoteHash(message.peer, message.tick, message.hash)) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
const existing = this.pendingRemoteHashes.get(message.tick)
|
||||
if (existing === undefined) {
|
||||
if (this.pendingRemoteHashes.size >= MAX_PENDING_REMOTE_HASHES) {
|
||||
const firstKey = this.pendingRemoteHashes.keys().next().value
|
||||
if (firstKey !== undefined) this.pendingRemoteHashes.delete(firstKey)
|
||||
}
|
||||
this.pendingRemoteHashes.set(message.tick, message.hash)
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(message.hash)
|
||||
} else {
|
||||
this.pendingRemoteHashes.set(message.tick, [existing, message.hash])
|
||||
}
|
||||
this.drainRemoteHashes()
|
||||
break
|
||||
case 'bye':
|
||||
}
|
||||
case 'bye': {
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
this.closed = true
|
||||
break
|
||||
}
|
||||
case 'heartbeat': {
|
||||
if (message.tick < this.lockstep.tick - window || message.tick > this.lockstep.tick + maxFuture) {
|
||||
this.malformed += 1
|
||||
return
|
||||
}
|
||||
this.received += 1
|
||||
this.silentTicks = 0
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- the message union is closed above. */
|
||||
default:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ export const MESSAGE_TYPE = {
|
|||
hash: 3,
|
||||
/** Peer leaving. */
|
||||
bye: 4,
|
||||
/** Liveness heartbeat for a tick. */
|
||||
heartbeat: 5,
|
||||
} as const
|
||||
|
||||
export const PACKET_TYPE = MESSAGE_TYPE
|
||||
|
||||
/** A decoded message. */
|
||||
export type NetMessage =
|
||||
| {
|
||||
|
|
@ -41,17 +45,20 @@ export type NetMessage =
|
|||
| { readonly kind: 'input'; readonly peer: number; readonly frame: InputFrame }
|
||||
| { readonly kind: 'hash'; readonly peer: number; readonly tick: number; readonly hash: number }
|
||||
| { readonly kind: 'bye'; readonly peer: number }
|
||||
| { readonly kind: 'heartbeat'; readonly peer: number; readonly tick: number }
|
||||
|
||||
/** Scaling used for movement components. */
|
||||
const MOVEMENT_SCALE = 1000
|
||||
export const MOVEMENT_SCALE = 1000
|
||||
/** Bytes of an `input` message: type, peer, tick, x, y, flags, skill. */
|
||||
const INPUT_BYTES = 1 + 1 + 4 + 2 + 2 + 1 + 1
|
||||
export const INPUT_BYTES = 1 + 1 + 4 + 2 + 2 + 1 + 1
|
||||
/** Bytes of a `hash` message. */
|
||||
const HASH_BYTES = 1 + 1 + 4 + 4
|
||||
export const HASH_BYTES = 1 + 1 + 4 + 4
|
||||
/** Bytes of a `hello` message: type, peer, peers, seed, flags. */
|
||||
const HELLO_BYTES = 1 + 1 + 1 + 4 + 1
|
||||
export const HELLO_BYTES = 1 + 1 + 1 + 4 + 1
|
||||
/** Bytes of a `bye` message. */
|
||||
const BYE_BYTES = 1 + 1
|
||||
export const BYE_BYTES = 1 + 1
|
||||
/** Bytes of a `heartbeat` message: type, peer, tick. */
|
||||
export const HEARTBEAT_BYTES = 1 + 1 + 4
|
||||
|
||||
/** Input frame flags byte. */
|
||||
const FLAG_ATTACK = 1
|
||||
|
|
@ -132,6 +139,14 @@ export function encodeMessage(message: NetMessage): Uint8Array {
|
|||
out[1] = message.peer & 0xff
|
||||
return out
|
||||
}
|
||||
case 'heartbeat': {
|
||||
const out = new Uint8Array(HEARTBEAT_BYTES)
|
||||
const view = new DataView(out.buffer)
|
||||
out[0] = MESSAGE_TYPE.heartbeat
|
||||
out[1] = message.peer & 0xff
|
||||
view.setUint32(2, message.tick >>> 0, true)
|
||||
return out
|
||||
}
|
||||
/* v8 ignore next -- the message union is closed above. */
|
||||
default: throw new ProtocolError('unknown message')
|
||||
}
|
||||
|
|
@ -160,6 +175,9 @@ export function decodeMessage(data: Uint8Array): NetMessage {
|
|||
case MESSAGE_TYPE.input: {
|
||||
if (data.byteLength !== INPUT_BYTES) throw new ProtocolError(`input has ${String(data.byteLength)} bytes, expected ${String(INPUT_BYTES)}`)
|
||||
const flags = data[10] ?? 0
|
||||
if ((flags & ~(FLAG_ATTACK | FLAG_PICKUP | FLAG_TALK)) !== 0) {
|
||||
throw new ProtocolError(`input has invalid flags ${String(flags)}`)
|
||||
}
|
||||
return {
|
||||
kind: 'input',
|
||||
peer,
|
||||
|
|
@ -181,7 +199,28 @@ export function decodeMessage(data: Uint8Array): NetMessage {
|
|||
if (data.byteLength !== BYE_BYTES) throw new ProtocolError(`bye has ${String(data.byteLength)} bytes, expected ${String(BYE_BYTES)}`)
|
||||
return { kind: 'bye', peer }
|
||||
}
|
||||
case MESSAGE_TYPE.heartbeat: {
|
||||
if (data.byteLength !== HEARTBEAT_BYTES) throw new ProtocolError(`heartbeat has ${String(data.byteLength)} bytes, expected ${String(HEARTBEAT_BYTES)}`)
|
||||
return { kind: 'heartbeat', peer, tick: view.getUint32(2, true) }
|
||||
}
|
||||
/* v8 ignore next -- unreachable for the checked tags. */
|
||||
default: throw new ProtocolError(`unknown message type ${String(data[0])}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const encodeFrame = encodeMessage
|
||||
|
||||
/**
|
||||
* Non-throwing wrapper around {@link decodeMessage}.
|
||||
*
|
||||
* @param data - the received bytes.
|
||||
* @returns the decoded message, or null when malformed.
|
||||
*/
|
||||
export function decodeFrame(data: Uint8Array): NetMessage | null {
|
||||
try {
|
||||
return decodeMessage(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export interface TransportOptions {
|
|||
}
|
||||
|
||||
/** Largest message accepted by default. */
|
||||
const DEFAULT_MAX_MESSAGE = 4096
|
||||
export const DEFAULT_MAX_MESSAGE = 4096
|
||||
|
||||
/**
|
||||
* Build a connected pair of in-process transports.
|
||||
|
|
@ -398,3 +398,5 @@ export function socketTransport(socket: SocketLike, options: TransportOptions =
|
|||
get open(): boolean { return opened },
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketTransport = socketTransport
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_MESSAGE,
|
||||
decodeFrame,
|
||||
decodeMessage,
|
||||
encodeFrame,
|
||||
encodeMessage,
|
||||
LockstepManager,
|
||||
LockstepSession,
|
||||
MAX_FUTURE_TICKS,
|
||||
memoryTransportPair,
|
||||
MESSAGE_TYPE,
|
||||
NetplaySession,
|
||||
NO_ACK,
|
||||
PACKET_TYPE,
|
||||
ProtocolError,
|
||||
type InputFrame,
|
||||
} from '../src/net/lockstep-manager.ts'
|
||||
|
||||
describe('#518 Lockstep & Netplay input security and bounds', () => {
|
||||
test('exports expected constants and barrel symbols from lockstep-manager', () => {
|
||||
expect(MAX_FUTURE_TICKS).toBe(64)
|
||||
expect(DEFAULT_MAX_MESSAGE).toBe(4096)
|
||||
expect(PACKET_TYPE.heartbeat).toBe(5)
|
||||
expect(LockstepManager).toBe(NetplaySession)
|
||||
|
||||
const hbBytes = encodeFrame({ kind: 'heartbeat', peer: 1, tick: 42 })
|
||||
const hbDecoded = decodeFrame(hbBytes)
|
||||
expect(hbDecoded).toEqual({ kind: 'heartbeat', peer: 1, tick: 42 })
|
||||
expect(decodeFrame(new Uint8Array([0xff]))).toBeNull()
|
||||
})
|
||||
|
||||
test('prevents self-peer spoofing from overwriting local player inputs', () => {
|
||||
const [t0, t1] = memoryTransportPair()
|
||||
const seenBy0: InputFrame[][] = []
|
||||
const s0 = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: inputs => { seenBy0.push([...inputs]) }, hash: () => seenBy0.length },
|
||||
t0,
|
||||
)
|
||||
const s1 = new NetplaySession(
|
||||
{ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: () => {}, hash: () => 0 },
|
||||
t1,
|
||||
)
|
||||
s0.start()
|
||||
s1.start()
|
||||
s0.pump()
|
||||
s1.pump()
|
||||
t0.flush()
|
||||
t0.flush()
|
||||
|
||||
// Remote peer 1 injects forged frames claiming peer=0 with movement.x = -1 for ticks 0..10
|
||||
for (let tick = 0; tick <= 10; tick += 1) {
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 0, // SPOOFED self peer!
|
||||
frame: { tick, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 9 },
|
||||
}))
|
||||
}
|
||||
t1.flush()
|
||||
|
||||
expect(s0.stats.malformed).toBe(11)
|
||||
|
||||
for (let tick = 0; tick < 10; tick += 1) {
|
||||
s0.setIntent({ movement: { x: 0.5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
||||
s1.setIntent({ movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
|
||||
s0.pump()
|
||||
s1.pump()
|
||||
t0.flush()
|
||||
}
|
||||
|
||||
expect(seenBy0.length).toBeGreaterThanOrEqual(6)
|
||||
for (const tickInputs of seenBy0) {
|
||||
expect(tickInputs[0]!.movement.x).toBeCloseTo(0.5, 3)
|
||||
expect(tickInputs[0]!.attack).toBe(false)
|
||||
expect(tickInputs[0]!.skill).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects out-of-range peer IDs without throwing in LockstepSession or NetplaySession', () => {
|
||||
const [t0, t1] = memoryTransportPair()
|
||||
const s0 = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: () => {}, hash: () => 0 },
|
||||
t0,
|
||||
)
|
||||
s0.start()
|
||||
|
||||
// Direct LockstepSession.submit with invalid peer IDs returns false instead of throwing
|
||||
expect(
|
||||
s0.lockstep.submit(7, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }),
|
||||
).toBe(false)
|
||||
expect(
|
||||
s0.lockstep.submit(-1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }),
|
||||
).toBe(false)
|
||||
|
||||
// Wire injection with peer=7 and peer=99 never throws out of transport.flush()
|
||||
expect(() => {
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 7,
|
||||
frame: { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
t1.send(encodeMessage({
|
||||
kind: 'hash',
|
||||
peer: 99,
|
||||
tick: 0,
|
||||
hash: 123,
|
||||
}))
|
||||
t1.flush()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(s0.stats.malformed).toBe(2)
|
||||
})
|
||||
|
||||
test('rejects far-future ticks (> currentTick + MAX_FUTURE_TICKS) and bounds pendingRemoteHashes', () => {
|
||||
const [t0, t1] = memoryTransportPair()
|
||||
const s0 = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: () => {}, hash: () => 0 },
|
||||
t0,
|
||||
)
|
||||
s0.start()
|
||||
|
||||
// Direct LockstepSession far-future submission is rejected
|
||||
const farFutureAccepted = s0.lockstep.submit(1, {
|
||||
tick: 0xffff_fff0,
|
||||
movement: { x: 0, y: 0 },
|
||||
attack: false,
|
||||
pickup: false,
|
||||
talk: false,
|
||||
skill: 0,
|
||||
})
|
||||
expect(farFutureAccepted).toBe(false)
|
||||
|
||||
// Wire injection of far-future input and 1,000 future hash messages
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 1_000_000, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 5000 + i, hash: 0xdeadbeef }))
|
||||
}
|
||||
t1.flush()
|
||||
|
||||
const pendingHashesSize = (s0 as unknown as { pendingRemoteHashes: Map<number, unknown> }).pendingRemoteHashes.size
|
||||
expect(pendingHashesSize).toBeLessThanOrEqual(128)
|
||||
expect(pendingHashesSize).toBe(0)
|
||||
expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0)
|
||||
expect(s0.stats.malformed).toBe(1001)
|
||||
})
|
||||
|
||||
test('enforces first-write-wins on duplicate (peer, tick) inputs and hashes', () => {
|
||||
const [t0, t1] = memoryTransportPair()
|
||||
const seenBy0: InputFrame[][] = []
|
||||
const s0 = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: inputs => { seenBy0.push([...inputs]) }, hash: () => 1234 },
|
||||
t0,
|
||||
)
|
||||
const s1 = new NetplaySession(
|
||||
{ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: () => {}, hash: () => 1234 },
|
||||
t1,
|
||||
)
|
||||
s0.start()
|
||||
s1.start()
|
||||
s0.pump()
|
||||
s1.pump()
|
||||
t0.flush()
|
||||
t0.flush()
|
||||
|
||||
// Peer 1 sends a legitimate input for tick 0 (x = 1), then tries to overwrite tick 0 with (x = -1)
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 0, movement: { x: 1, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 0, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
t1.flush()
|
||||
|
||||
expect(s0.stats.malformed).toBe(1)
|
||||
expect(s0.lockstep.pendingInputs.get(1)?.get(0)?.movement.x).toBe(1)
|
||||
|
||||
// Duplicate hash for the same (peer, tick) is also rejected
|
||||
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 1234 }))
|
||||
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 9999 }))
|
||||
t1.flush()
|
||||
expect(s0.stats.malformed).toBe(2)
|
||||
})
|
||||
|
||||
test('rejects non-unit movement vectors and undefined control flag bits', () => {
|
||||
// Undefined flag bit 0x80 is rejected by decodeMessage
|
||||
const badFlags = encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
})
|
||||
badFlags[10] = 0x80
|
||||
expect(() => decodeMessage(badFlags)).toThrow(ProtocolError)
|
||||
|
||||
// Non-unit movement (|v| > 1) is rejected by NetplaySession
|
||||
const [t0, t1] = memoryTransportPair()
|
||||
const s0 = new NetplaySession(
|
||||
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
|
||||
{ advance: () => {}, hash: () => 0 },
|
||||
t0,
|
||||
)
|
||||
s0.start()
|
||||
|
||||
// Speed-hack movement (x = 5, y = 0) and unnormalized diagonal (x = 1, y = 1 -> |v| = 1.414)
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 0, movement: { x: 5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
t1.send(encodeMessage({
|
||||
kind: 'input',
|
||||
peer: 1,
|
||||
frame: { tick: 0, movement: { x: 1, y: 1 }, attack: false, pickup: false, talk: false, skill: 0 },
|
||||
}))
|
||||
t1.flush()
|
||||
|
||||
expect(s0.stats.malformed).toBe(2)
|
||||
expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue