-
Notifications
You must be signed in to change notification settings - Fork 2
fix: prevent nonce poisoning (WAPI-1121) #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,19 @@ type TransportState = "disconnected" | "connecting" | "connected"; | |
|
|
||
| /** The maximum number of messages to fetch from history upon a new subscription. */ | ||
| const HISTORY_FETCH_LIMIT = 50; | ||
| /** | ||
| * Maximum allowed nonce jump from a known sender. Messages with a nonce that | ||
| * jumps more than this from the last confirmed nonce are rejected as suspicious. | ||
| * Does not apply to the first message from a new sender (baseline is 0). This | ||
| * is safe because a spoofed first message would fail decryption and its nonce | ||
| * would never be confirmed to storage. | ||
| * | ||
| * Trade-off: if the receiver goes offline and misses more than this many | ||
| * messages from a known sender, legitimate messages will be permanently | ||
| * blocked. In practice this is unlikely given low message rates in MWP | ||
| * sessions, but worth noting. | ||
| */ | ||
| const MAX_NONCE_JUMP = 100; | ||
chakra-guy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** The maximum number of retry attempts for publishing a message. */ | ||
| const MAX_RETRY_ATTEMPTS = 5; | ||
| /** The base delay in milliseconds for exponential backoff between publish retries. */ | ||
|
|
@@ -65,6 +78,7 @@ export class WebSocketTransport extends EventEmitter implements ITransport { | |
| private readonly centrifuge: Centrifuge | SharedCentrifuge; | ||
| private readonly storage: WebSocketTransportStorage; | ||
| private readonly queue: QueuedItem[] = []; | ||
| private readonly pendingNonces = new Map<string, Set<number>>(); | ||
| private isProcessingQueue = false; | ||
| private state: TransportState = "disconnected"; | ||
|
|
||
|
|
@@ -214,6 +228,9 @@ export class WebSocketTransport extends EventEmitter implements ITransport { | |
| */ | ||
| public async clear(channel: string): Promise<void> { | ||
| await this.storage.clear(channel); | ||
| for (const key of this.pendingNonces.keys()) { | ||
| if (key.startsWith(`${channel}:`)) this.pendingNonces.delete(key); | ||
| } | ||
| const sub = this.centrifuge.getSubscription(channel); | ||
| if (sub) this.centrifuge.removeSubscription(sub as Subscription); | ||
| } | ||
|
|
@@ -229,6 +246,11 @@ export class WebSocketTransport extends EventEmitter implements ITransport { | |
|
|
||
| /** | ||
| * Parses an incoming raw message, checks for duplicates, and emits it. | ||
| * | ||
| * The nonce is checked for deduplication but NOT persisted here. The emitted | ||
| * payload includes a `confirmNonce` callback that the consumer (BaseClient) | ||
| * must call after successful decryption. This prevents an attacker from | ||
| * poisoning the nonce tracker with high-nonce messages that fail decryption. | ||
| */ | ||
| private async _handleIncomingMessage(channel: string, rawData: string): Promise<void> { | ||
| try { | ||
|
|
@@ -246,13 +268,44 @@ export class WebSocketTransport extends EventEmitter implements ITransport { | |
| const latestNonces = await this.storage.getLatestNonces(channel); | ||
| const latestNonce = latestNonces.get(message.clientId) || 0; | ||
|
|
||
| if (message.nonce > latestNonce) { | ||
| // This is a new message, update the latest nonce and emit the message. | ||
| latestNonces.set(message.clientId, message.nonce); | ||
| await this.storage.setLatestNonces(channel, latestNonces); | ||
| this.emit("message", { channel, data: message.payload }); | ||
| if (message.nonce <= latestNonce) { | ||
| return; | ||
| } | ||
| // If message.nonce <= latestNonce, it's a duplicate and we ignore it. | ||
|
|
||
| // Reject suspiciously large nonce jumps (but allow first message from a new sender). | ||
| if (latestNonce > 0 && message.nonce - latestNonce > MAX_NONCE_JUMP) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't quite understand what the max nonce jump is supposed to guard against. Is there a scenario where we think an attacker would send single messages with artificially high nonces? If so, what's the difference between than and simply spamming small incremental nonce bumps?
Thank you! sorry if these are obvious |
||
| this.emit("error", new TransportError(ErrorCode.TRANSPORT_PARSE_FAILED, `Nonce jump too large: ${latestNonce} -> ${message.nonce}`)); | ||
| return; | ||
| } | ||
|
|
||
| // Guard against duplicate processing between emit and confirm. | ||
| // Without this, a message arriving via both live publication and | ||
| // _fetchHistory could pass the storage-based dedup check twice | ||
|
Comment on lines
+282
to
+283
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should handleIncoming message be behind a mutex instead of the pendingNonces guarding logic? |
||
| // because the nonce hasn't been confirmed yet. | ||
| const pendingKey = `${channel}:${message.clientId}`; | ||
| const pending = this.pendingNonces.get(pendingKey); | ||
| if (pending?.has(message.nonce)) { | ||
| return; | ||
| } | ||
| if (!pending) { | ||
| this.pendingNonces.set(pendingKey, new Set([message.nonce])); | ||
chakra-guy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } else { | ||
| pending.add(message.nonce); | ||
| } | ||
|
|
||
| const confirmNonce = async () => { | ||
| try { | ||
| await this.storage.confirmNonce(channel, message.clientId, message.nonce); | ||
| } catch (error) { | ||
| this.emit("error", new TransportError(ErrorCode.UNKNOWN, `Failed to confirm nonce: ${error instanceof Error ? error.message : String(error)}`)); | ||
| } | ||
| const p = this.pendingNonces.get(pendingKey); | ||
| if (p) { | ||
| p.delete(message.nonce); | ||
| if (p.size === 0) this.pendingNonces.delete(pendingKey); | ||
| } | ||
| }; | ||
| this.emit("message", { channel, data: message.payload, confirmNonce }); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (error) { | ||
| this.emit("error", new TransportError(ErrorCode.TRANSPORT_PARSE_FAILED, `Failed to parse incoming message: ${error instanceof Error ? error.message : "Unknown error"}`)); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { Mutex } from "async-mutex"; | ||
| import { v4 as uuid } from "uuid"; | ||
| import type { IKVStore } from "../../domain/kv-store"; | ||
|
|
||
|
|
@@ -8,6 +9,7 @@ import type { IKVStore } from "../../domain/kv-store"; | |
| export class WebSocketTransportStorage { | ||
| private readonly kvstore: IKVStore; | ||
| private readonly clientId: string; | ||
| private readonly nonceMutex = new Mutex(); | ||
|
|
||
| /** | ||
| * Creates a new WebSocketTransportStorage instance with a persistent client ID. | ||
|
|
@@ -42,12 +44,28 @@ export class WebSocketTransportStorage { | |
| async getNextNonce(channel: string): Promise<number> { | ||
| const key = this.getNonceKey(channel); | ||
| const value = await this.kvstore.get(key); | ||
| const currentNonce = value ? parseInt(value, 10) : 0; | ||
| let currentNonce = value ? parseInt(value, 10) : 0; | ||
| if (Number.isNaN(currentNonce)) currentNonce = 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this needed now? |
||
| const nextNonce = currentNonce + 1; | ||
| await this.kvstore.set(key, nextNonce.toString()); | ||
| return nextNonce; | ||
| } | ||
|
|
||
| /** | ||
| * Confirms a received nonce after the message has been successfully processed | ||
| * (e.g., decrypted). Only updates if the nonce is higher than the current value. | ||
| */ | ||
| async confirmNonce(channel: string, clientId: string, nonce: number): Promise<void> { | ||
| await this.nonceMutex.runExclusive(async () => { | ||
| const latestNonces = await this.getLatestNonces(channel); | ||
| const current = latestNonces.get(clientId) || 0; | ||
| if (nonce > current) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if this isn't true, hasn't something gone very wrong? |
||
| latestNonces.set(clientId, nonce); | ||
| await this.setLatestNonces(channel, latestNonces); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves the latest received nonces from all senders on the specified channel. | ||
| * Used for message deduplication - only messages with nonces greater than the | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.