dev/2026-04-08 #1
258
tests/auth-failures.test.ts
Normal file
258
tests/auth-failures.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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<ClientRecord>) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user