import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { buildAuthRequest, decodeBuiltin, encodeBuiltin, createAuthRequestSigningInput, YONEXUS_PROTOCOL_VERSION } from "../../Yonexus.Protocol/src/index.js"; import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; import type { ClientRecord } from "../plugin/core/persistence.js"; import type { YonexusServerStore } from "../plugin/core/store.js"; import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; import { generateKeyPair, signMessage } from "../../Yonexus.Client/plugin/crypto/keypair.js"; function createMockSocket() { return { close: vi.fn() } as unknown as ClientConnection["ws"]; } function createConnection(identifier: string | null = null): ClientConnection { return { identifier, ws: createMockSocket(), connectedAt: 1_710_000_000, isAuthenticated: false }; } function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { const persisted = new Map(initialClients.map((record) => [record.identifier, record])); return { filePath: "/tmp/yonexus-server-auth-failures.json", load: vi.fn(async () => ({ version: 1, persistedAt: 1_710_000_000, clients: new Map(persisted) })), save: vi.fn(async (clients: Iterable) => { persisted.clear(); for (const client of clients) { persisted.set(client.identifier, client); } }) }; } function createMockTransport() { const sent: Array<{ connection: ClientConnection; message: string }> = []; const transport: ServerTransport = { isRunning: false, connections: new Map(), start: vi.fn(), stop: vi.fn(), send: vi.fn((identifier: string, message: string) => { sent.push({ connection: { identifier } as ClientConnection, message }); return true; }), sendToConnection: vi.fn((connection: ClientConnection, message: string) => { sent.push({ connection, message }); return true; }), broadcast: vi.fn(), closeConnection: vi.fn(), promoteToAuthenticated: vi.fn(), removeTempConnection: vi.fn(), assignIdentifierToTemp: vi.fn() }; return { transport, sent }; } describe("YNX-1105c: Auth Failure Paths", () => { let now = 1_710_000_000; beforeEach(() => { now = 1_710_000_000; vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("AF-07: nonce collision triggers re_pair_required", async () => { const keyPair = await generateKeyPair(); const store = createMockStore([ { identifier: "client-a", pairingStatus: "paired", publicKey: keyPair.publicKey.trim(), secret: "shared-secret", status: "offline", recentNonces: [], recentHandshakeAttempts: [], createdAt: now - 10, updatedAt: now - 10 } ]); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "stub-token", adminUserId: "admin-user", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const connection = createConnection("client-a"); runtime.state.registry.sessions.set("client-a", { identifier: "client-a", socket: connection.ws, isAuthenticated: false, connectedAt: now, lastActivityAt: now, publicKey: keyPair.publicKey.trim() }); const nonce = "NONCE1234567890123456789"; const signingInput = createAuthRequestSigningInput({ secret: "shared-secret", nonce, proofTimestamp: now }); const signature = await signMessage(keyPair.privateKey, signingInput); await runtime.handleMessage( connection, encodeBuiltin( buildAuthRequest( { identifier: "client-a", nonce, proofTimestamp: now, signature, publicKey: keyPair.publicKey.trim() }, { requestId: "req-auth-1", timestamp: now } ) ) ); // Second request with same nonce triggers re-pair now += 1; const signingInput2 = createAuthRequestSigningInput({ secret: "shared-secret", nonce, proofTimestamp: now }); const signature2 = await signMessage(keyPair.privateKey, signingInput2); await runtime.handleMessage( connection, encodeBuiltin( buildAuthRequest( { identifier: "client-a", nonce, proofTimestamp: now, signature: signature2, publicKey: keyPair.publicKey.trim() }, { requestId: "req-auth-2", timestamp: now } ) ) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("re_pair_required"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "nonce_collision" }); const record = runtime.state.registry.clients.get("client-a"); expect(record?.secret).toBeUndefined(); expect(record?.pairingStatus).toBe("revoked"); }); it("AF-08: rate limit triggers re_pair_required", async () => { const keyPair = await generateKeyPair(); const store = createMockStore([ { identifier: "client-a", pairingStatus: "paired", publicKey: keyPair.publicKey.trim(), secret: "shared-secret", status: "offline", recentNonces: [], recentHandshakeAttempts: Array.from({ length: 10 }, () => now - 1), createdAt: now - 10, updatedAt: now - 10 } ]); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "stub-token", adminUserId: "admin-user", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const connection = createConnection("client-a"); runtime.state.registry.sessions.set("client-a", { identifier: "client-a", socket: connection.ws, isAuthenticated: false, connectedAt: now, lastActivityAt: now, publicKey: keyPair.publicKey.trim() }); const nonce = "NONCE987654321098765432"; const signingInput = createAuthRequestSigningInput({ secret: "shared-secret", nonce, proofTimestamp: now }); const signature = await signMessage(keyPair.privateKey, signingInput); await runtime.handleMessage( connection, encodeBuiltin( buildAuthRequest( { identifier: "client-a", nonce, proofTimestamp: now, signature, publicKey: keyPair.publicKey.trim() }, { requestId: "req-auth", timestamp: now } ) ) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("re_pair_required"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "rate_limited" }); const record = runtime.state.registry.clients.get("client-a"); expect(record?.secret).toBeUndefined(); expect(record?.pairingStatus).toBe("revoked"); }); });