From 80ca3f1175121d8e13d46374a603c0ad588e52d8 Mon Sep 17 00:00:00 2001 From: Nadeem Date: Sat, 12 Sep 2026 01:26:09 +0530 Subject: [PATCH] feat(wire-format): per-document XChaCha announce and lock (TEC-2936) Clients declare the wire ciphers they can read on auth. With WIRE_FORMAT_TARGET set to xchacha the server announces xchacha for a room once every socket in it can read it, locks the document (a DocumentWireFormat row, kept separate from DocumentMeta so that row's $setOnInsert fields are never stranded by an auth-time upsert), sweeps any socket that cannot read it, and tells the incumbents over /document/wire_format so a long-lived tab converts without a re-auth. A locked document refuses a non-capable client with 426 WIRE_FORMAT_UNSUPPORTED before any session bookkeeping, including on a rotation-cutover re-auth, where the prior room is left explicitly. The disconnect handler ratchets a room whose last ECIES-only socket just left. With the target unset the server announces ECIES everywhere and never locks; existing locks keep refusing. Infrastructure failures degrade instead of failing the auth: a rejected cross-instance enumeration or lock write announces ECIES and takes no lock, and a failed post-lock sweep is logged without suppressing the event. Refusals are logged once per document per minute with a capped view of the declared list. The ratchet is skipped while the process is draining, since io.close() fires disconnecting for every socket and the reconnect auth after the restart resolves the same state. --- .env.example | 5 +- src/config/index.ts | 3 + src/database/models/document-wire-format.ts | 22 ++ src/database/models/index.ts | 1 + src/index.ts | 2 + src/services/lifecycle.ts | 16 + src/services/mongodb-store.ts | 30 +- src/services/socket-handlers.ts | 199 ++++++++++- src/services/wire-format.ts | 41 +++ src/tests/services/mongodb-store.test.ts | 20 +- .../socket-handlers.handleAuth.test.ts | 334 +++++++++++++++++- ...ocket-handlers.handleDisconnecting.test.ts | 141 +++++++- src/tests/services/wire-format.test.ts | 61 ++++ src/types/index.ts | 16 + 14 files changed, 873 insertions(+), 18 deletions(-) create mode 100644 src/database/models/document-wire-format.ts create mode 100644 src/services/lifecycle.ts create mode 100644 src/services/wire-format.ts create mode 100644 src/tests/services/wire-format.test.ts diff --git a/.env.example b/.env.example index a9eb338..660f96a 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,7 @@ SERVER_DID=did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK # Rate Limiting RATE_LIMIT_WINDOW_MS=900000 -RATE_LIMIT_MAX=100 \ No newline at end of file +RATE_LIMIT_MAX=100 +# Collab wire format rollout: unset (ECIES everywhere, never locks) or "xchacha" +# (announce XChaCha and lock a document once every socket in its room can read it). +# WIRE_FORMAT_TARGET=xchacha diff --git a/src/config/index.ts b/src/config/index.ts index 0162232..da967ec 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -54,4 +54,7 @@ export const config: ServerConfig = { webhook: { apiKey: process.env.COLLAB_WEBHOOK_API_KEY, }, + wireFormat: { + target: process.env.WIRE_FORMAT_TARGET === "xchacha" ? "xchacha" : "ecies", + }, }; \ No newline at end of file diff --git a/src/database/models/document-wire-format.ts b/src/database/models/document-wire-format.ts new file mode 100644 index 0000000..7a8120d --- /dev/null +++ b/src/database/models/document-wire-format.ts @@ -0,0 +1,22 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +// Per-document wire-format lock. A row means the document is locked to XChaCha; no row +// means ECIES. Separate from DocumentMeta on purpose: that row is created by the owner's +// meta upload with $setOnInsert fields, and an auth-time upsert would strand them. +interface IDocumentWireFormat extends MongooseDocument { + _id: string; // documentId + wireFormat: "xchacha"; + lockedAt: number; +} + +const DocumentWireFormatSchema = new Schema({ + _id: { type: String, required: true }, + wireFormat: { type: String, enum: ["xchacha"], required: true }, + lockedAt: { type: Number, required: true }, +}); + +export const DocumentWireFormatModel = mongoose.model( + "DocumentWireFormat", + DocumentWireFormatSchema +); +export type { IDocumentWireFormat }; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 322c44b..d258614 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -5,3 +5,4 @@ export { CounterModel, type ICounter } from "./counter"; export { DocumentMetaModel, type IDocumentMeta } from "./document-meta"; export { DocumentMirrorModel, type IDocumentMirror } from "./document-mirror"; export { DocumentEditEpochModel, type IDocumentEditEpoch } from "./document-edit-epoch"; +export { DocumentWireFormatModel, type IDocumentWireFormat } from "./document-wire-format"; diff --git a/src/index.ts b/src/index.ts index f48f425..e7819aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { createRotateSessionHandler } from "./services/rotate-route"; import { rotationCoordinator } from "./services/rotation-coordinator"; import { createFlushHandler } from "./services/flush-route"; import { createDeletedFileWebhookHandler } from "./services/deleted-file-webhook"; +import { markDraining } from "./services/lifecycle"; import { createLightNode } from "@waku/sdk"; import protobuf from "protobufjs"; import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; @@ -253,6 +254,7 @@ class CollaborationServer { private shutdown(signal: string) { logger.info(`\n Received ${signal}. Shutting down gracefully...`); + markDraining(); if (this.orphanGcInterval) { clearInterval(this.orphanGcInterval); diff --git a/src/services/lifecycle.ts b/src/services/lifecycle.ts new file mode 100644 index 0000000..a1ad5db --- /dev/null +++ b/src/services/lifecycle.ts @@ -0,0 +1,16 @@ +// Set at the top of the graceful shutdown, before io.close() fires "disconnecting" for +// every socket on the instance. Per-socket work that only matters on a live server (the +// wire-format ratchet) checks it and returns: the reconnect auth after the restart +// resolves the same state, and running it here only burns a Mongo read and a +// cross-instance enumeration per socket against peers that are also going down. +let draining = false; + +export const markDraining = (): void => { + draining = true; +}; + +export const isDraining = (): boolean => draining; + +export const resetDrainingForTests = (): void => { + draining = false; +}; diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index afc12cc..e05e93a 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,6 +1,7 @@ import { DocumentUpdate, DocumentCommit, AppType } from "../types/index"; -import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel, DocumentEditEpochModel } from "../database/models"; +import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel, DocumentMirrorModel, DocumentEditEpochModel, DocumentWireFormatModel } from "../database/models"; import { logger, perfLogger } from "./logger"; +import type { WireFormat } from "./wire-format"; export class SessionTerminatedError extends Error { constructor() { super("session terminated"); this.name = "SessionTerminatedError"; } @@ -375,6 +376,31 @@ export class MongoDBStore { return { editLock: meta.editLock ?? null, title: meta.title ?? null }; } + // Wire-format lock. No row means ECIES. + async getWireFormat(documentId: string): Promise { + const row: any = await DocumentWireFormatModel.findById(documentId).select("wireFormat").lean(); + return row?.wireFormat === "xchacha" ? "xchacha" : "ecies"; + } + + // Idempotent under a race: two concurrent auths both end locked, exactly one sees true. + async lockWireFormat(documentId: string): Promise { + try { + const res = await DocumentWireFormatModel.updateOne( + { _id: documentId }, + { $setOnInsert: { wireFormat: "xchacha", lockedAt: Date.now() } }, + { upsert: true, writeConcern: { w: "majority", j: true } } + ); + return res.upsertedCount === 1; + } catch (err) { + // A concurrent upsert on the same _id can surface as a duplicate-key error instead + // of a clean loss of the upsertedCount race; the other caller took the lock. + if (typeof err === "object" && err !== null && "code" in err && (err as { code: unknown }).code === 11000) { + return false; + } + throw err; + } + } + async setMinEditEpoch(documentId: string, epoch: number): Promise { await DocumentEditEpochModel.findOneAndUpdate( { _id: documentId }, @@ -632,6 +658,7 @@ export class MongoDBStore { CounterModel.deleteOne({ _id: documentId }), DocumentMirrorModel.deleteMany({ documentId }), DocumentEditEpochModel.deleteOne({ _id: documentId }), + DocumentWireFormatModel.deleteOne({ _id: documentId }), ]); } @@ -694,6 +721,7 @@ export class MongoDBStore { await DocumentCommitModel.deleteMany({ documentId: s.documentId }); await SessionModel.deleteMany({ documentId: s.documentId }); await CounterModel.deleteOne({ _id: s.documentId }); + await DocumentWireFormatModel.deleteOne({ _id: s.documentId }); purged++; } return purged; diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index f7281dc..baff236 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -33,6 +33,9 @@ import type { SocketHandlerDeps } from "./socket-handlers.deps"; import { config } from "../config"; import { timeAck } from "./perf-metrics"; import { logger } from "./logger"; +import { normalizeWireFormats, resolveWireFormat, supportsXChaCha } from "./wire-format"; +import type { WireFormat, WireResolution } from "./wire-format"; +import { isDraining } from "./lifecycle"; const defaultDeps: SocketHandlerDeps = { authService, @@ -135,7 +138,7 @@ export function registerEventHandlers(io: AppServer): void { ); // Disconnection handling - socket.on("disconnecting", () => handleDisconnecting(defaultDeps, socket)); + socket.on("disconnecting", () => handleDisconnecting(defaultDeps, io, socket)); socket.on("disconnect", (reason) => { logger.info(`Socket disconnected: ${socket.id}, reason: ${reason}`); }); @@ -145,6 +148,81 @@ export function registerEventHandlers(io: AppServer): void { }); } +// After a lock, no socket without xchacha may remain in the room that was just locked. The +// disconnect makes an old bundle reconnect, re-auth and receive WIRE_FORMAT_UNSUPPORTED. +export async function kickNonCapableSockets( + io: AppServer, + roomName: string, + keepSocketId: string +): Promise { + let kicked = 0; + for (const s of await io.in(roomName).fetchSockets()) { + if (s.id === keepSocketId) continue; + if (supportsXChaCha(s.data.wireFormats ?? ["ecies"])) continue; + s.disconnect(true); + kicked += 1; + } + return kicked; +} + +// Old bundles predate the terminal refusal handling and can retry in a loop; one warn per +// document per minute keeps the rollout signal without flooding the log. +const REFUSAL_LOG_INTERVAL_MS = 60_000; +const lastRefusalLogAt = new Map(); +function logRefusal(documentId: string, declared: AuthArgs["wireFormats"], normalized: WireFormat[], trigger: "auth" | "post-join"): void { + const now = Date.now(); + const last = lastRefusalLogAt.get(documentId) ?? 0; + if (now - last < REFUSAL_LOG_INTERVAL_MS) return; + if (lastRefusalLogAt.size > 10_000) lastRefusalLogAt.clear(); + lastRefusalLogAt.set(documentId, now); + // The declared list is client-supplied and bounded only by the socket buffer: log a + // capped, truncated view of it, never the raw value. + const declaredSummary = Array.isArray(declared) + ? declared.slice(0, 8).map((v) => (typeof v === "string" ? v.slice(0, 32) : typeof v)) + : declared === undefined ? null : typeof declared; + logger.warn( + { + documentId, + declared: declaredSummary, + declaredCount: Array.isArray(declared) ? declared.length : 0, + normalized, + trigger, + }, + "wire-format-refused" + ); +} + +// A refused socket may already be authenticated in its pre-rotation room (cutover +// re-auth); drop that so the refusal is not advisory. +async function refuseWireFormat( + socket: AppSocket, + callback: (response: AckResponse) => void, + sessionManager: SocketHandlerDeps["sessionManager"], + documentId: string, + declared: AuthArgs["wireFormats"], + normalized: WireFormat[], + trigger: "auth" | "post-join", + priorRoom?: { documentId: string; sessionDid: string } +): Promise { + logRefusal(documentId, declared, normalized, trigger); + socket.data.authenticated = false; + callback({ + status: false, + statusCode: 426, + error: "This document requires a newer client", + errorCode: ErrorCode.WIRE_FORMAT_UNSUPPORTED, + }); + if (!priorRoom) return; + // Same cleanup the successful cutover re-auth performs: the socket never disconnects, + // so the disconnecting sweep would not run for the prior sessionDid. + try { + socket.leave(getRoomName(priorRoom.documentId, priorRoom.sessionDid)); + await sessionManager.removeClientFromSession(priorRoom.documentId, priorRoom.sessionDid, socket.id); + } catch (err) { + logger.error({ err, documentId: priorRoom.documentId }, "wire-format-refusal-cleanup-failed"); + } +} + export async function handleAuth( deps: SocketHandlerDeps, io: AppServer, @@ -184,6 +262,22 @@ export async function handleAuth( }); } + // Refuse before any session bookkeeping, so an old bundle opening a locked document + // never terminates live sessions or writes a session row it cannot join. + const wireFormats = normalizeWireFormats(args.wireFormats); + const locked = (await deps.mongodbStore.getWireFormat(documentId)) === "xchacha"; + if (locked && !supportsXChaCha(wireFormats)) { + // Capture before anything below overwrites socket.data: on a cutover re-auth this + // is still the prior room the socket is sitting in. + const priorRoom = + socket.data.authenticated && socket.data.documentId && socket.data.sessionDid + ? { documentId: socket.data.documentId, sessionDid: socket.data.sessionDid } + : undefined; + return await refuseWireFormat( + socket, callback, sessionManager, documentId, args.wireFormats, wireFormats, "auth", priorRoom + ); + } + let existingSession = await sessionManager.getSession(documentId, sessionDid); let role: "owner" | "editor"; @@ -590,6 +684,33 @@ export async function handleAuth( // BEFORE it's overwritten below, so the socket can be silently migrated off it. const priorSessionDid = socket.data.sessionDid; + const roomName = getRoomName(documentId, sessionDid); + // Rule 3 needs the room's declared formats. When the cross-instance enumeration fails + // (the Redis adapter times out while a dyno restarts), announce ECIES and take no lock + // rather than failing the auth: ECIES is always readable, and the next auth or leave + // resolves the format. + let roomFormats: WireFormat[][] = []; + let enumerationFailed = false; + if (!locked && config.wireFormat.target === "xchacha") { + try { + roomFormats = (await io.in(roomName).fetchSockets()) + .filter((s) => s.id !== socket.id) + .map((s) => s.data.wireFormats ?? ["ecies"]); + } catch (err) { + enumerationFailed = true; + logger.error({ err, documentId }, "wire-format-enumeration-failed"); + } + } + // The locked-and-incapable case was already refused above; wire.refuse cannot be true here. + const wire: WireResolution = enumerationFailed + ? { announce: "ecies", lock: false, refuse: false } + : resolveWireFormat({ + locked, + target: config.wireFormat.target, + roomFormats, + joiningFormats: wireFormats, + }); + // Set socket data socket.data.authenticated = true; socket.data.documentId = documentId; @@ -602,10 +723,9 @@ export async function handleAuth( socket.data.actorIdentityDid = provenIdentityDid ?? undefined; socket.data.editEpoch = admittedEditEpoch; socket.data.editPlaneEnforced = editPlaneEnforced; + socket.data.wireFormats = wireFormats; // Join the Socket.IO room - const roomName = getRoomName(documentId, sessionDid); - if (args.rotationCutover && priorSessionDid && priorSessionDid !== sessionDid) { // Silent migration — no user_left. The socket never disconnects, so the // `disconnecting` sweep never fires for the pre-rotation sessionDid; do that @@ -616,6 +736,47 @@ export async function handleAuth( socket.join(roomName); + // A lock committed between the read above and this join ran its kick before this + // socket was in the room. Re-read once and step out if it landed; only an ecies-only + // joiner can be caught by that window. An existing lock must keep refusing old bundles + // even if a rolling target change has this instance running with the target unset. + if ( + !locked && + !supportsXChaCha(wireFormats) && + (await deps.mongodbStore.getWireFormat(documentId)) === "xchacha" + ) { + socket.leave(roomName); + return await refuseWireFormat( + socket, callback, sessionManager, documentId, args.wireFormats, wireFormats, "post-join" + ); + } + + let announce: WireFormat = wire.announce; + if (wire.lock) { + let took: boolean | null = null; + try { + took = await deps.mongodbStore.lockWireFormat(documentId); + } catch (err) { + // The socket is already joined and authenticated; a 500 here would leave it in the + // room untracked. Announce ECIES instead (always readable) and let the next auth or + // leave take the lock. + logger.error({ err, documentId, trigger: "auth" }, "wire-format-lock-failed"); + announce = "ecies"; + } + if (took !== null) { + let kicked = 0; + try { + kicked = await kickNonCapableSockets(io, roomName, socket.id); + } catch (err) { + logger.error({ err, documentId, trigger: "auth" }, "wire-format-sweep-failed"); + } + logger.info({ documentId, trigger: "auth", took, kicked }, "wire-format-lock"); + // Every incumbent is capable by the lock's own precondition; tell them so their next + // batch is XChaCha instead of waiting for a re-auth. The joiner learns it from the ack. + socket.to(roomName).emit("/document/wire_format", { roomId: documentId, wireFormat: "xchacha" }); + } + } + // Track in session manager (for session lifecycle / deactivation logic) await sessionManager.addClientToSession(documentId, sessionDid, socket.id); @@ -647,6 +808,7 @@ export async function handleAuth( sessionType, roomInfo, title: documentMeta?.title ?? null, + wireFormat: announce, }, }); } catch (error) { @@ -1480,6 +1642,7 @@ export function handleEpochLoaded( export async function handleDisconnecting( deps: SocketHandlerDeps, + io: AppServer, socket: AppSocket ): Promise { try { @@ -1505,7 +1668,37 @@ export async function handleDisconnecting( socket.data.sessionDid, socket.id ); + + await ratchetWireFormatAfterLeave(deps, io, socket, roomName); } catch (error) { logger.error({ err: error }, `Error during disconnection cleanup for ${socket.id}`); } } + +// The leaving socket may have been the last one holding the room at ECIES. If everyone +// still connected can read XChaCha, lock now and tell them, so a long-lived session +// ratchets without waiting for a reconnect. +async function ratchetWireFormatAfterLeave( + deps: SocketHandlerDeps, + io: AppServer, + socket: AppSocket, + roomName: string +): Promise { + if (isDraining()) return; + if (config.wireFormat.target !== "xchacha") return; + const documentId = socket.data.documentId; + if ((await deps.mongodbStore.getWireFormat(documentId)) === "xchacha") return; + // "disconnecting" fires while the socket is still in its rooms; exclude it. + const remaining = (await io.in(roomName).fetchSockets()).filter((s) => s.id !== socket.id); + if (remaining.length === 0) return; + if (!remaining.every((s) => supportsXChaCha(s.data.wireFormats ?? ["ecies"]))) return; + const took = await deps.mongodbStore.lockWireFormat(documentId); + let kicked = 0; + try { + kicked = await kickNonCapableSockets(io, roomName, socket.id); + } catch (err) { + logger.error({ err, documentId, trigger: "disconnect" }, "wire-format-sweep-failed"); + } + logger.info({ documentId, trigger: "disconnect", took, kicked }, "wire-format-lock"); + io.to(roomName).emit("/document/wire_format", { roomId: documentId, wireFormat: "xchacha" }); +} diff --git a/src/services/wire-format.ts b/src/services/wire-format.ts new file mode 100644 index 0000000..52cc1e7 --- /dev/null +++ b/src/services/wire-format.ts @@ -0,0 +1,41 @@ +// Per-document wire-format negotiation. +// Pure: the auth and disconnect handlers feed it what they read and act on the result. + +export type WireFormat = "ecies" | "xchacha"; + +const KNOWN: readonly WireFormat[] = ["ecies", "xchacha"]; + +// Dedupe so a hostile wireFormats array is never stored verbatim on socket.data: the +// Redis adapter serialises socket.data on every cross-instance fetchSockets. +export const normalizeWireFormats = (value: unknown): WireFormat[] => { + if (!Array.isArray(value)) return ["ecies"]; + const kept = [...new Set(value.filter((v): v is WireFormat => KNOWN.includes(v as WireFormat)))]; + return kept.length ? kept : ["ecies"]; +}; + +export const supportsXChaCha = (formats: WireFormat[]): boolean => formats.includes("xchacha"); + +export interface WireResolution { + announce: WireFormat; + lock: boolean; + refuse: boolean; +} + +export function resolveWireFormat(input: { + locked: boolean; + target: WireFormat; + roomFormats: WireFormat[][]; + joiningFormats: WireFormat[]; +}): WireResolution { + const joinerOk = supportsXChaCha(input.joiningFormats); + if (input.locked) { + return { announce: "xchacha", lock: false, refuse: !joinerOk }; + } + if (input.target !== "xchacha") { + return { announce: "ecies", lock: false, refuse: false }; + } + const allOk = joinerOk && input.roomFormats.every(supportsXChaCha); + return allOk + ? { announce: "xchacha", lock: true, refuse: false } + : { announce: "ecies", lock: false, refuse: false }; +} diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts index 9789d3d..d73e897 100644 --- a/src/tests/services/mongodb-store.test.ts +++ b/src/tests/services/mongodb-store.test.ts @@ -28,6 +28,7 @@ vi.mock("../../database/models", () => { deleteMany: vi.fn().mockResolvedValue(undefined), }, DocumentEditEpochModel: { deleteOne: vi.fn().mockResolvedValue(undefined) }, + DocumentWireFormatModel: { deleteOne: vi.fn().mockResolvedValue(undefined), updateOne: vi.fn() }, }; }); @@ -584,8 +585,8 @@ describe("purgeDocument", () => { vi.clearAllMocks(); }); - it("wipes all seven collections for the documentId", async () => { - const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel, DocumentEditEpochModel } = + it("wipes all eight collections for the documentId", async () => { + const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel, DocumentMirrorModel, DocumentEditEpochModel, DocumentWireFormatModel } = await import("../../database/models"); const store = new MongoDBStore(); @@ -598,6 +599,7 @@ describe("purgeDocument", () => { expect(CounterModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); expect(DocumentMirrorModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); expect(DocumentEditEpochModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); + expect(DocumentWireFormatModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); }); }); @@ -752,3 +754,17 @@ describe("markUpdatesAsCommitted scoping", () => { ); }); }); + +describe("lockWireFormat: duplicate-key race", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves false instead of throwing when a concurrent upsert wins the race", async () => { + const { DocumentWireFormatModel } = await import("../../database/models"); + (DocumentWireFormatModel.updateOne as any).mockRejectedValue({ code: 11000 }); + + const store = new MongoDBStore(); + await expect(store.lockWireFormat("doc-1")).resolves.toBe(false); + }); +}); diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index 60e0c82..6334c32 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { handleAuth, getRoomName } from "../../services/socket-handlers"; import { AppServer, AppSocket, AuthArgs, ErrorCode } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import { config } from "../../config"; function createFakeIO(options?: { broadcastOperator?: { emit: ReturnType }; @@ -74,6 +75,8 @@ describe("handleAuth", () => { .mockImplementation(async (p: { portalAddress: string }) => ({ portalAddress: p.portalAddress, })), + getWireFormat: vi.fn().mockResolvedValue("ecies"), + lockWireFormat: vi.fn().mockResolvedValue(false), } as any; const deps: SocketHandlerDeps = { @@ -227,6 +230,7 @@ describe("handleAuth", () => { sessionType: "new", roomInfo: fakeArgs.roomInfo, title: null, + wireFormat: "ecies", }, }); }); @@ -434,7 +438,10 @@ describe("handleAuth", () => { fetchSockets .mockResolvedValueOnce([oldSocket1]) - .mockResolvedValueOnce([oldSocket2]); + .mockResolvedValueOnce([oldSocket2]) + // Target defaults to "ecies" in this test, so F2 (fix wave 2) skips the + // wire-format room enumeration entirely; nothing consumes this fallback. + .mockResolvedValue([]); fakeSessionManager.getSession.mockResolvedValue(undefined); fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); @@ -504,7 +511,8 @@ describe("handleAuth", () => { ); // Aggregate call-count assertions after verifying per-iteration sequence - // Outside if-else block + // Outside if-else block. Target defaults to "ecies" here, so F2 (fix wave 2) + // skips the wire-format room enumeration; only the termination loop calls fetchSockets. expect(fetchSockets).toHaveBeenCalledTimes(otherSessions.length); expect(fakeSessionManager.terminateSession).toHaveBeenCalledTimes(otherSessions.length); @@ -536,6 +544,7 @@ describe("handleAuth", () => { sessionType: "new", roomInfo: fakeArgs.roomInfo, title: null, + wireFormat: "ecies", }, }); }); @@ -630,10 +639,324 @@ describe("handleAuth", () => { sessionType: "existing", roomInfo: existingSession.roomInfo, title: null, + wireFormat: "ecies", }, }); }); + // Infrastructure failures during resolution degrade to an ECIES announce with no lock + // instead of failing the auth. + describe("wire-format resolution degrades on infrastructure failure", () => { + let originalWireFormatTarget: typeof config.wireFormat.target; + beforeEach(() => { + originalWireFormatTarget = config.wireFormat.target; + config.wireFormat.target = "xchacha"; + }); + afterEach(() => { + config.wireFormat.target = originalWireFormatTarget; + }); + + const capableJoinerOfExistingSession = (): AuthArgs => { + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + wireFormats: ["ecies", "xchacha"], + }; + fakeSessionManager.getSession.mockResolvedValue({ + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + return fakeArgs; + }; + + it("announces ecies and takes no lock when the room enumeration rejects", async () => { + const fetchSockets = vi + .fn() + .mockRejectedValue(new Error("timeout reached: only 1 responses received out of 2")); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs = capableJoinerOfExistingSession(); + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeMongoDBStore.lockWireFormat).not.toHaveBeenCalled(); + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalledWith( + "/document/wire_format", + expect.anything() + ); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + statusCode: 200, + data: expect.objectContaining({ wireFormat: "ecies" }), + }) + ); + }); + + it("announces ecies, skips the sweep and the event, and still tracks the joiner when the lock write rejects", async () => { + const legacyRemote = { + id: "remote-legacy", + data: { wireFormats: ["ecies"] }, + disconnect: vi.fn(), + }; + const fetchSockets = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([legacyRemote]); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs = capableJoinerOfExistingSession(); + const callback = vi.fn(); + fakeMongoDBStore.lockWireFormat.mockRejectedValueOnce(new Error("mongo unavailable")); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeMongoDBStore.lockWireFormat).toHaveBeenCalledWith(fakeArgs.documentId); + expect(legacyRemote.disconnect).not.toHaveBeenCalled(); + expect(fakeBroadcastOperator.emit).not.toHaveBeenCalledWith( + "/document/wire_format", + expect.anything() + ); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + statusCode: 200, + data: expect.objectContaining({ wireFormat: "ecies" }), + }) + ); + }); + }); + + // Refuse before bookkeeping, conditional room enumeration, post-join re-check. + describe("wire-format resolution (fix wave 2)", () => { + let originalWireFormatTarget: typeof config.wireFormat.target; + beforeEach(() => { + originalWireFormatTarget = config.wireFormat.target; + }); + afterEach(() => { + config.wireFormat.target = originalWireFormatTarget; + }); + + it("locks the document, kicks a non-capable socket that appeared after the first enumeration, and announces xchacha", async () => { + config.wireFormat.target = "xchacha"; + const capableRemote = { + id: "remote-capable", + data: { wireFormats: ["ecies", "xchacha"] }, + disconnect: vi.fn(), + }; + const legacyRemote = { + id: "remote-legacy", + data: { wireFormats: ["ecies"] }, + disconnect: vi.fn(), + }; + const fetchSockets = vi + .fn() + .mockResolvedValueOnce([capableRemote]) + .mockResolvedValueOnce([capableRemote, legacyRemote]); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + wireFormats: ["ecies", "xchacha"], + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + fakeMongoDBStore.getWireFormat.mockResolvedValueOnce("ecies"); + fakeMongoDBStore.lockWireFormat.mockResolvedValueOnce(true); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeMongoDBStore.lockWireFormat).toHaveBeenCalledWith(fakeArgs.documentId); + expect(legacyRemote.disconnect).toHaveBeenCalledWith(true); + expect(capableRemote.disconnect).not.toHaveBeenCalled(); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/document/wire_format", { + roomId: fakeArgs.documentId, + wireFormat: "xchacha", + }); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + status: true, + statusCode: 200, + data: expect.objectContaining({ wireFormat: "xchacha" }), + }) + ); + }); + + it("refuses an ecies-only joiner on a locked document before any session bookkeeping", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + fakeMongoDBStore.getWireFormat.mockResolvedValueOnce("xchacha"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 426, + error: "This document requires a newer client", + errorCode: ErrorCode.WIRE_FORMAT_UNSUPPORTED, + }); + expect(fakeSocket.join).not.toHaveBeenCalled(); + expect(fakeSessionManager.createSession).not.toHaveBeenCalled(); + expect(fakeSessionManager.updateRoomInfo).not.toHaveBeenCalled(); + expect(fakeSessionManager.addClientToSession).not.toHaveBeenCalled(); + }); + + it("steps back out when the lock lands between the read and the join", async () => { + config.wireFormat.target = "xchacha"; + const fetchSockets = vi.fn().mockResolvedValue([]); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeSocket = createFakeSocket(); + (fakeSocket as any).leave = vi.fn(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + fakeMongoDBStore.getWireFormat + .mockResolvedValueOnce("ecies") + .mockResolvedValueOnce("xchacha"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect((fakeSocket as any).leave).toHaveBeenCalledWith(roomName); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 426, errorCode: ErrorCode.WIRE_FORMAT_UNSUPPORTED }) + ); + expect(fakeSessionManager.addClientToSession).not.toHaveBeenCalled(); + }); + + // R2: the re-read must keep refusing a lock that landed elsewhere even when this + // instance is running with the target unset (a rolling target change or rollback). + it("steps back out when the lock lands between the read and the join, even off the xchacha target", async () => { + config.wireFormat.target = "ecies"; + const fetchSockets = vi.fn().mockResolvedValue([]); + const fakeIO = createFakeIO({ fetchSockets }); + const fakeSocket = createFakeSocket(); + (fakeSocket as any).leave = vi.fn(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); + fakeMongoDBStore.getWireFormat + .mockResolvedValueOnce("ecies") + .mockResolvedValueOnce("xchacha"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect((fakeSocket as any).leave).toHaveBeenCalledWith(roomName); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 426, errorCode: ErrorCode.WIRE_FORMAT_UNSUPPORTED }) + ); + expect(fakeSessionManager.addClientToSession).not.toHaveBeenCalled(); + }); + + // F7: a refused socket that was already authenticated (rotation-cutover re-auth) + // must be migrated out of its prior room, not left as a ghost in the old session. + it("cleans up the prior room when a cutover re-auth is refused", async () => { + const fakeSocket = createFakeSocket(undefined, { + authenticated: true, + documentId: "doc-1", + sessionDid: "did:old", + }); + (fakeSocket as any).leave = vi.fn(); + const fakeIO = createFakeIO(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "did:new", + collaborationToken: "collab-token", + rotationCutover: true, + }; + const callback = vi.fn(); + + fakeMongoDBStore.getWireFormat.mockResolvedValueOnce("xchacha"); + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const oldRoomName = getRoomName("doc-1", "did:old"); + expect((fakeSocket as any).leave).toHaveBeenCalledWith(oldRoomName); + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledWith( + "doc-1", + "did:old", + fakeSocket.id + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 426, + error: "This document requires a newer client", + errorCode: ErrorCode.WIRE_FORMAT_UNSUPPORTED, + }); + expect(fakeSocket.data.authenticated).toBe(false); + // R5: the refusal answers before it cleans up, so a slow removeClientFromSession + // never delays the ack. + expect(callback.mock.invocationCallOrder[0]).toBeLessThan( + fakeSessionManager.removeClientFromSession.mock.invocationCallOrder[0] + ); + }); + }); + it("rotationCutover: silently migrates room, skips user_joined, and cleans up the old session's client list", async () => { const fakeIO = createFakeIO(); const fakeBroadcastOperator = { emit: vi.fn() }; @@ -940,6 +1263,7 @@ describe("handleAuth", () => { sessionType: "existing", roomInfo: existingSession.roomInfo, title: null, + wireFormat: "ecies", }, }); }); @@ -1040,6 +1364,7 @@ describe("handleAuth", () => { sessionType: "existing", roomInfo: existingSession.roomInfo, title: null, + wireFormat: "ecies", }, }); }); @@ -1143,6 +1468,7 @@ describe("handleAuth", () => { sessionType: "existing", roomInfo: existingSession.roomInfo, title: null, + wireFormat: "ecies", }, }); }); @@ -2059,6 +2385,8 @@ describe("handleAuth — edit-claim admission (existing session, non-owner)", () const fakeMongoDBStore = { getDocumentMeta: vi.fn().mockResolvedValue(null), getMinEditEpoch: vi.fn().mockResolvedValue(0), + getWireFormat: vi.fn().mockResolvedValue("ecies"), + lockWireFormat: vi.fn().mockResolvedValue(false), } as any; const deps: SocketHandlerDeps = { diff --git a/src/tests/services/socket-handlers.handleDisconnecting.test.ts b/src/tests/services/socket-handlers.handleDisconnecting.test.ts index 4310886..8d6f35a 100644 --- a/src/tests/services/socket-handlers.handleDisconnecting.test.ts +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -1,7 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { handleDisconnecting } from "../../services/socket-handlers"; -import type { AppSocket } from "../../types"; +import type { AppServer, AppSocket } from "../../types"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import { config } from "../../config"; +import { markDraining, resetDrainingForTests } from "../../services/lifecycle"; + +/** + * Fake server for handler tests. fetchSockets defaults to resolving with an empty + * room, matching the case where no one else is left to ratchet the wire format for. + */ +function createFakeIO(): AppServer { + return { + to: vi.fn(() => ({ emit: vi.fn() })), + in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })), + } as unknown as AppServer; +} /** * Fake socket for handler tests. @@ -41,7 +54,10 @@ describe("handleDisconnecting", () => { const fakeSessionManager = { removeClientFromSession: vi.fn(), }; - const fakeMongoDBStore = {} as any; + const fakeMongoDBStore = { + getWireFormat: vi.fn().mockResolvedValue("ecies"), + lockWireFormat: vi.fn().mockResolvedValue(true), + } as any; const deps: SocketHandlerDeps = { authService: fakeAuthService as any, sessionManager: fakeSessionManager as any, @@ -55,7 +71,7 @@ describe("handleDisconnecting", () => { it("returns early when socket is not authenticated", async () => { const fakeSocket = createFakeSocket(undefined, { authenticated: false }); - await handleDisconnecting(deps, fakeSocket); + await handleDisconnecting(deps, createFakeIO(), fakeSocket); expect(fakeSocket.to).not.toHaveBeenCalled(); expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); @@ -64,7 +80,7 @@ describe("handleDisconnecting", () => { it("returns early when documentId is missing", async () => { const fakeSocket = createFakeSocket(undefined, { documentId: "" }); - await handleDisconnecting(deps, fakeSocket); + await handleDisconnecting(deps, createFakeIO(), fakeSocket); expect(fakeSocket.to).not.toHaveBeenCalled(); expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); @@ -73,7 +89,7 @@ describe("handleDisconnecting", () => { it("returns early when sessionDid is missing", async () => { const fakeSocket = createFakeSocket(undefined, { sessionDid: "" }); - await handleDisconnecting(deps, fakeSocket); + await handleDisconnecting(deps, createFakeIO(), fakeSocket); expect(fakeSocket.to).not.toHaveBeenCalled(); expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); @@ -85,7 +101,7 @@ describe("handleDisconnecting", () => { fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); - await handleDisconnecting(deps, fakeSocket); + await handleDisconnecting(deps, createFakeIO(), fakeSocket); const roomName = `session::${fakeSocket.data.documentId}__${fakeSocket.data.sessionDid}`; @@ -103,6 +119,10 @@ describe("handleDisconnecting", () => { fakeSocket.data.sessionDid, fakeSocket.id ); + + // F6 sibling assertion (fix wave 2): target is "ecies" here (unset), so the ratchet + // bails before ever reading the lock. + expect(fakeMongoDBStore.lockWireFormat).not.toHaveBeenCalled(); }); it("does not throw when an error occurs during disconnection cleanup", async () => { @@ -114,6 +134,111 @@ describe("handleDisconnecting", () => { }); fakeSessionManager.removeClientFromSession.mockRejectedValue(new Error("db error")); - await expect(handleDisconnecting(deps, fakeSocket)).resolves.not.toThrow(); + await expect(handleDisconnecting(deps, createFakeIO(), fakeSocket)).resolves.not.toThrow(); + }); + + describe("wire-format ratchet on leave", () => { + let originalWireFormatTarget: typeof config.wireFormat.target; + beforeEach(() => { + originalWireFormatTarget = config.wireFormat.target; + }); + afterEach(() => { + config.wireFormat.target = originalWireFormatTarget; + resetDrainingForTests(); + }); + + it("ratchets the room to xchacha when the leaving socket was the last one without it", async () => { + config.wireFormat.target = "xchacha"; + const fakeSocket = createFakeSocket(); + (fakeSocket.data as any).wireFormats = ["ecies"]; + + const leavingSocketEntry = { id: fakeSocket.id, data: { wireFormats: ["ecies"] } }; + const capableRemote = { + id: "remote-capable", + data: { wireFormats: ["ecies", "xchacha"] }, + disconnect: vi.fn(), + }; + const emit = vi.fn(); + const fetchSockets = vi.fn().mockResolvedValue([leavingSocketEntry, capableRemote]); + const fakeIO = { + to: vi.fn(() => ({ emit })), + in: vi.fn(() => ({ fetchSockets })), + } as unknown as AppServer; + + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + + await handleDisconnecting(deps, fakeIO, fakeSocket); + + expect(fakeMongoDBStore.lockWireFormat).toHaveBeenCalledWith(fakeSocket.data.documentId); + expect(capableRemote.disconnect).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith("/document/wire_format", { + roomId: fakeSocket.data.documentId, + wireFormat: "xchacha", + }); + }); + + // R4: a fetchSockets rejection inside the sweep, after the lock is already committed, + // must not suppress the ratchet's emit. (This file does not mock/observe `logger`, so + // the error-log side of R4 is not asserted here beyond the suite staying green.) + it("still locks and emits when the sweep's fetchSockets rejects after the lock lands", async () => { + config.wireFormat.target = "xchacha"; + const fakeSocket = createFakeSocket(); + (fakeSocket.data as any).wireFormats = ["ecies"]; + + const leavingSocketEntry = { id: fakeSocket.id, data: { wireFormats: ["ecies"] } }; + const capableRemote = { + id: "remote-capable", + data: { wireFormats: ["ecies", "xchacha"] }, + disconnect: vi.fn(), + }; + const emit = vi.fn(); + const fetchSockets = vi + .fn() + .mockResolvedValueOnce([leavingSocketEntry, capableRemote]) + .mockRejectedValueOnce(new Error("fetchSockets down")); + const fakeIO = { + to: vi.fn(() => ({ emit })), + in: vi.fn(() => ({ fetchSockets })), + } as unknown as AppServer; + + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + + await handleDisconnecting(deps, fakeIO, fakeSocket); + + expect(fakeMongoDBStore.lockWireFormat).toHaveBeenCalledWith(fakeSocket.data.documentId); + expect(emit).toHaveBeenCalledWith("/document/wire_format", { + roomId: fakeSocket.data.documentId, + wireFormat: "xchacha", + }); + }); + + it("skips the ratchet entirely while the server is draining", async () => { + config.wireFormat.target = "xchacha"; + markDraining(); + const fakeSocket = createFakeSocket(); + (fakeSocket.data as any).wireFormats = ["ecies"]; + + const emit = vi.fn(); + const fetchSockets = vi.fn(); + const fakeIO = { + to: vi.fn(() => ({ emit })), + in: vi.fn(() => ({ fetchSockets })), + } as unknown as AppServer; + + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + + await handleDisconnecting(deps, fakeIO, fakeSocket); + + // Session bookkeeping still runs; only the ratchet is skipped. + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledWith( + fakeSocket.data.documentId, + fakeSocket.data.sessionDid, + fakeSocket.id + ); + expect(fakeMongoDBStore.getWireFormat).not.toHaveBeenCalled(); + expect(fetchSockets).not.toHaveBeenCalled(); + expect(fakeMongoDBStore.lockWireFormat).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalledWith("/document/wire_format", expect.anything()); + }); }); }); diff --git a/src/tests/services/wire-format.test.ts b/src/tests/services/wire-format.test.ts new file mode 100644 index 0000000..d7d3acd --- /dev/null +++ b/src/tests/services/wire-format.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeWireFormats, + resolveWireFormat, + supportsXChaCha, +} from "../../services/wire-format"; + +const E: ["ecies"] = ["ecies"]; +const EX: ["ecies", "xchacha"] = ["ecies", "xchacha"]; + +describe("normalizeWireFormats", () => { + it("defaults to ecies for absent, empty or junk input", () => { + expect(normalizeWireFormats(undefined)).toEqual(["ecies"]); + expect(normalizeWireFormats([])).toEqual(["ecies"]); + expect(normalizeWireFormats("xchacha")).toEqual(["ecies"]); + expect(normalizeWireFormats(["aes", 3])).toEqual(["ecies"]); + }); + it("keeps known values and drops unknown ones", () => { + expect(normalizeWireFormats(["xchacha", "nope", "ecies"])).toEqual(["xchacha", "ecies"]); + }); + it("dedupes a hostile input instead of storing it verbatim", () => { + expect(normalizeWireFormats(Array(10_000).fill("ecies"))).toEqual(["ecies"]); + }); + it("supportsXChaCha reads the list", () => { + expect(supportsXChaCha(E)).toBe(false); + expect(supportsXChaCha(EX)).toBe(true); + }); +}); + +describe("resolveWireFormat", () => { + it("locked: announces xchacha and refuses a joiner without it", () => { + expect(resolveWireFormat({ locked: true, target: "ecies", roomFormats: [], joiningFormats: E })) + .toEqual({ announce: "xchacha", lock: false, refuse: true }); + expect(resolveWireFormat({ locked: true, target: "xchacha", roomFormats: [E], joiningFormats: EX })) + .toEqual({ announce: "xchacha", lock: false, refuse: false }); + expect(resolveWireFormat({ locked: true, target: "xchacha", roomFormats: [], joiningFormats: E })) + .toEqual({ announce: "xchacha", lock: false, refuse: true }); + }); + it("target off: always ecies, never locks or refuses", () => { + expect(resolveWireFormat({ locked: false, target: "ecies", roomFormats: [EX], joiningFormats: EX })) + .toEqual({ announce: "ecies", lock: false, refuse: false }); + expect(resolveWireFormat({ locked: false, target: "ecies", roomFormats: [], joiningFormats: E })) + .toEqual({ announce: "ecies", lock: false, refuse: false }); + }); + it("target on, empty room, capable joiner: locks", () => { + expect(resolveWireFormat({ locked: false, target: "xchacha", roomFormats: [], joiningFormats: EX })) + .toEqual({ announce: "xchacha", lock: true, refuse: false }); + }); + it("target on, capable room, capable joiner: locks", () => { + expect(resolveWireFormat({ locked: false, target: "xchacha", roomFormats: [EX, EX], joiningFormats: EX })) + .toEqual({ announce: "xchacha", lock: true, refuse: false }); + }); + it("target on, one old socket in the room: ecies, no lock", () => { + expect(resolveWireFormat({ locked: false, target: "xchacha", roomFormats: [EX, E], joiningFormats: EX })) + .toEqual({ announce: "ecies", lock: false, refuse: false }); + }); + it("target on, old joiner: ecies, no lock, no refusal", () => { + expect(resolveWireFormat({ locked: false, target: "xchacha", roomFormats: [EX], joiningFormats: E })) + .toEqual({ announce: "ecies", lock: false, refuse: false }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index a1d213a..fbbde8b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import { Server, Socket } from "socket.io"; +import type { WireFormat } from "../services/wire-format"; // *************************************** // Domain Models (unchanged) @@ -85,6 +86,7 @@ export enum ErrorCode { ROOM_NOT_ESTABLISHED = "ROOM_NOT_ESTABLISHED", DB_ERROR = "DB_ERROR", INTERNAL_ERROR = "INTERNAL_ERROR", + WIRE_FORMAT_UNSUPPORTED = "WIRE_FORMAT_UNSUPPORTED", } // *************************************** @@ -120,6 +122,8 @@ export interface AuthArgs { joinOnly?: boolean; // in-place re-auth into the post-rotation sessionDid — suppress the membership blip and leave the old room silently. rotationCutover?: boolean; + /** Wire ciphers this client can read. Absent means ["ecies"]. */ + wireFormats?: WireFormat[]; } export interface AuthResponseData { @@ -130,6 +134,8 @@ export interface AuthResponseData { /** Latest stored roomKey-encrypted title (DocumentMeta) — fresher than the * session-frozen roomInfo blob after a mid-session rename. */ title?: string | null; + /** The write format announced for this document. */ + wireFormat: WireFormat; } export interface DocumentUpdateArgs { @@ -273,6 +279,11 @@ export interface MetaUpdatePayload { title: string | null; } +export interface WireFormatPayload { + roomId: string; + wireFormat: "xchacha"; +} + export interface SessionTerminatedPayload { roomId: string; } @@ -335,6 +346,7 @@ export interface ServerToClientEvents { "/session/terminated": (data: SessionTerminatedPayload) => void; "/session/epoch_available": (data: EpochAvailablePayload) => void; "/session/cutover": (data: CutoverPayload) => void; + "/document/wire_format": (data: WireFormatPayload) => void; } export interface InterServerEvents { @@ -356,6 +368,7 @@ export interface SocketData { /** Rail admission + per-op revocation rechecks apply (ddoc always; dsheet iff the * room has a bound owner identity — legacy dsheet rooms keep legacy semantics). */ editPlaneEnforced?: boolean; + wireFormats?: WireFormat[]; } // *************************************** @@ -431,4 +444,7 @@ export interface ServerConfig { webhook: { apiKey?: string; }; + wireFormat: { + target: WireFormat; + }; } \ No newline at end of file