import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { buildAuthRequest, decodeBuiltin, encodeBuiltin, createAuthRequestSigningInput } 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 }; } async function buildSignedAuthRequest(options: { identifier: string; secret: string; privateKey: string; publicKey: string; nonce: string; proofTimestamp: number; requestId?: string; signatureOverride?: string; publicKeyOverride?: string; }) { const signature = options.signatureOverride ?? (await signMessage( options.privateKey, createAuthRequestSigningInput({ secret: options.secret, nonce: options.nonce, proofTimestamp: options.proofTimestamp }) )); return encodeBuiltin( buildAuthRequest( { identifier: options.identifier, nonce: options.nonce, proofTimestamp: options.proofTimestamp, signature, publicKey: options.publicKeyOverride ?? options.publicKey }, { requestId: options.requestId, timestamp: options.proofTimestamp } ) ); } 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-01: unknown identifier returns auth_failed(unknown_identifier)", async () => { const keyPair = await generateKeyPair(); const store = createMockStore([]); 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("rogue-client"); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "rogue-client", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now, requestId: "req-auth-unknown" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "rogue-client", reason: "unknown_identifier" }); }); it("AF-02: auth before pairing returns auth_failed(not_paired)", async () => { const keyPair = await generateKeyPair(); const store = createMockStore([ { identifier: "client-a", pairingStatus: "unpaired", 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now, requestId: "req-auth-not-paired" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "not_paired" }); }); it("AF-03: invalid signature returns auth_failed(invalid_signature)", async () => { const keyPair = await generateKeyPair(); const wrongKeyPair = 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: wrongKeyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now, requestId: "req-auth-invalid-signature" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); }); it("AF-05: stale timestamp returns auth_failed(stale_timestamp)", 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 - 20, updatedAt: now - 20 } ]); 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now - 11, requestId: "req-auth-stale" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "stale_timestamp" }); }); it("AF-06: future timestamp returns auth_failed(future_timestamp)", 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 - 20, updatedAt: now - 20 } ]); 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now + 11, requestId: "req-auth-future" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "future_timestamp" }); }); 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"; await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce, proofTimestamp: now, requestId: "req-auth-1" }) ); now += 1; await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce, proofTimestamp: now, requestId: "req-auth-2" }) ); 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE9876543210987654321", proofTimestamp: now, requestId: "req-auth-rate-limit" }) ); 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"); }); it("AF-09: wrong public key returns auth_failed(invalid_signature)", async () => { const keyPair = await generateKeyPair(); const rotatedKeyPair = 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() }); await runtime.handleMessage( connection, await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: rotatedKeyPair.privateKey, publicKey: keyPair.publicKey.trim(), publicKeyOverride: rotatedKeyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now, requestId: "req-auth-wrong-public-key" }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); }); it("AF-10: malformed auth_request payload returns protocol error", async () => { const store = createMockStore([]); 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"); await runtime.handleMessage( connection, encodeBuiltin({ type: "auth_request", requestId: "req-auth-malformed", timestamp: now }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("error"); expect(lastMessage.payload).toMatchObject({ code: "MALFORMED_MESSAGE", message: "auth_request payload is required" }); }); it("AF-11: tampered signature returns auth_failed(invalid_signature)", 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 validMessage = decodeBuiltin( await buildSignedAuthRequest({ identifier: "client-a", secret: "shared-secret", privateKey: keyPair.privateKey, publicKey: keyPair.publicKey.trim(), nonce: "NONCE1234567890123456789", proofTimestamp: now, requestId: "req-auth-tampered" }) ); await runtime.handleMessage( connection, encodeBuiltin({ ...validMessage, payload: { ...validMessage.payload, signature: `A${String(validMessage.payload?.signature).slice(1)}` } }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("auth_failed"); expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); }); });