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