234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
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)
|
|
})
|
|
})
|