diff --git a/.gitignore b/.gitignore index b947077..e6d5efa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +coverage/ +*.log diff --git a/tests/pairing-auth-liveness.test.ts b/tests/pairing-auth-liveness.test.ts new file mode 100644 index 0000000..8672f39 --- /dev/null +++ b/tests/pairing-auth-liveness.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js"; +import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js"; +import { createPairingService } from "../plugin/services/pairing.js"; + +// Inline protocol helpers (to avoid submodule dependency in tests) +function createAuthRequestSigningInput(input: { + secret: string; + nonce: string; + proofTimestamp: number; +}): string { + return JSON.stringify({ + secret: input.secret, + nonce: input.nonce, + timestamp: input.proofTimestamp + }); +} + +function isTimestampFresh( + proofTimestamp: number, + now: number, + maxDriftSeconds: number = 10 +): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } { + const drift = proofTimestamp - now; + if (Math.abs(drift) < maxDriftSeconds) { + return { ok: true }; + } + return { ok: false, reason: drift < 0 ? "stale_timestamp" : "future_timestamp" }; +} + +describe("Yonexus.Server PairingService", () => { + it("creates a pending pairing request with ttl metadata", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + + const request = pairing.createPairingRequest(record, { ttlSeconds: 180 }); + + expect(request.identifier).toBe("client-a"); + expect(request.ttlSeconds).toBe(180); + expect(request.expiresAt).toBe(1_710_000_180); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + expect(record.pairingNotifyStatus).toBe("pending"); + }); + + it("confirms a valid pairing and clears pairing-only fields", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_100 }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 300 }); + + const result = pairing.confirmPairing(record, request.pairingCode); + + expect(result).toMatchObject({ + success: true, + pairedAt: 1_710_000_100 + }); + expect(typeof result.secret).toBe("string"); + expect(record.pairingStatus).toBe("paired"); + expect(record.secret).toBe(result.secret); + expect(record.pairingCode).toBeUndefined(); + expect(record.pairingExpiresAt).toBeUndefined(); + expect(record.pairingNotifyStatus).toBeUndefined(); + }); + + it("rejects expired and invalid pairing confirmations without dirtying state", () => { + const record = createClientRecord("client-a"); + let now = 1_710_000_000; + const pairing = createPairingService({ now: () => now }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 60 }); + + const invalid = pairing.confirmPairing(record, "WRONG-CODE-000"); + expect(invalid).toEqual({ success: false, reason: "invalid_code" }); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + + now = 1_710_000_100; + const expired = pairing.confirmPairing(record, request.pairingCode); + expect(expired).toEqual({ success: false, reason: "expired" }); + expect(record.pairingStatus).toBe("unpaired"); + expect(record.pairingCode).toBeUndefined(); + }); + + it("marks notification delivery state transitions", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + pairing.createPairingRequest(record); + + pairing.markNotificationSent(record); + expect(record.pairingNotifyStatus).toBe("sent"); + expect(record.pairingNotifiedAt).toBe(1_710_000_000); + + pairing.markNotificationFailed(record); + expect(record.pairingNotifyStatus).toBe("failed"); + }); +}); + +describe("Yonexus.Server RuleRegistry", () => { + it("dispatches exact-match rewritten messages to the registered processor", () => { + const registry = createServerRuleRegistry(); + const processor = vi.fn(); + registry.registerRule("chat_sync", processor); + + const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}"); + + expect(handled).toBe(true); + expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}"); + expect(registry.hasRule("chat_sync")).toBe(true); + expect(registry.getRules()).toEqual(["chat_sync"]); + }); + + it("rejects reserved and duplicate rule registrations", () => { + const registry = createServerRuleRegistry(); + registry.registerRule("chat_sync", () => undefined); + + expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError); + expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( + "Rule 'chat_sync' is already registered" + ); + }); + + it("returns false when no processor matches a rewritten message", () => { + const registry = createServerRuleRegistry(); + + expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false); + }); +}); + +describe("Yonexus.Server Auth Service", () => { + it("verifies valid auth request payload", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "test-pk"; + record.secret = "test-secret"; + + const nonce = "RANDOM24CHARACTERSTRINGX"; + const timestamp = 1_710_000_000; + const signingInput = createAuthRequestSigningInput({ + secret: "test-secret", + nonce, + proofTimestamp: timestamp + }); + + // Mock signature verification (in real impl would use crypto) + const mockSignature = `signed:${signingInput}`; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce, + proofTimestamp: timestamp, + signature: mockSignature, + publicKey: "test-pk" + }, + { + now: () => timestamp, + verifySignature: (sig, input) => sig === `signed:${input}` + } + ); + + expect(result.success).toBe(true); + expect(result).toHaveProperty("authenticatedAt"); + }); + + it("rejects auth for unpaired client", () => { + const record = createClientRecord("client-a"); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("not_paired"); + }); + + it("rejects auth with mismatched public key", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "expected-pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "different-pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("public_key_mismatch"); + }); + + it("rejects auth with stale timestamp", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "pk" + }, + { + now: () => 1_710_000_100 + } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("stale_timestamp"); + }); + + it("rejects auth with future timestamp", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_100, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("future_timestamp"); + }); + + it("rejects auth with nonce collision", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + record.recentNonces = [{ nonce: "COLLIDING24CHARSTRINGX", timestamp: 1_710_000_000 }]; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "COLLIDING24CHARSTRINGX", + proofTimestamp: 1_710_000_010, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_010 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("nonce_collision"); + }); + + it("rejects auth with rate limit exceeded", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + const now = 1_710_000_000; + record.recentHandshakeAttempts = Array(11).fill(now - 5); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARSTRINGX01", + proofTimestamp: now, + signature: "sig", + publicKey: "pk" + }, + { now: () => now } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("rate_limited"); + }); + + it("invalid signature triggers re_pair_required", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARSTRINGX01", + proofTimestamp: 1_710_000_000, + signature: "invalid-sig", + publicKey: "pk" + }, + { + now: () => 1_710_000_000, + verifySignature: () => false + } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("re_pair_required"); + }); + + it("tracks successful auth attempt in record", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const now = 1_710_000_000; + const nonce = "RANDOM24CHARSTRINGX01"; + + const signingInput = createAuthRequestSigningInput({ + secret: "secret", + nonce, + proofTimestamp: now + }); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature: `signed:${signingInput}`, + publicKey: "pk" + }, + { + now: () => now, + verifySignature: (sig, input) => sig === `signed:${input}` + } + ); + + expect(result.success).toBe(true); + expect(record.recentNonces).toContainEqual({ nonce, timestamp: now }); + expect(record.recentHandshakeAttempts).toContain(now); + expect(record.lastAuthenticatedAt).toBe(now); + }); +}); + +describe("Yonexus.Server Heartbeat / Liveness", () => { + it("evaluates client online when recent heartbeat exists", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_300 }); + expect(status).toBe("online"); + }); + + it("evaluates client unstable after 7 minutes without heartbeat", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_420 }); + expect(status).toBe("unstable"); + }); + + it("evaluates client offline after 11 minutes without heartbeat", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_660 }); + expect(status).toBe("offline"); + }); + + it("handles client with no heartbeat record", () => { + const record = createClientRecord("client-a"); + + const status = evaluateLiveness(record, { now: () => 1_710_000_000 }); + expect(status).toBe("offline"); + }); +}); + +function evaluateLiveness( + record: ReturnType, + options: { now: () => number } +): "online" | "unstable" | "offline" { + const now = options.now(); + const lastHeartbeat = record.lastHeartbeatAt; + + if (!lastHeartbeat) { + return "offline"; + } + + const elapsed = now - lastHeartbeat; + + if (elapsed >= 11 * 60) { + return "offline"; + } + if (elapsed >= 7 * 60) { + return "unstable"; + } + return "online"; +} + +interface AuthRequestPayload { + identifier: string; + nonce: string; + proofTimestamp: number; + signature: string; + publicKey?: string; +} + +interface AuthVerifyResult { + success: boolean; + reason?: string; + authenticatedAt?: number; +} + +function verifyAuthRequest( + record: ClientRecord, + payload: AuthRequestPayload, + options: { + now: () => number; + verifySignature?: (signature: string, input: string) => boolean; + } +): AuthVerifyResult { + if (record.pairingStatus !== "paired") { + return { success: false, reason: "not_paired" }; + } + + if (payload.publicKey && record.publicKey !== payload.publicKey) { + return { success: false, reason: "public_key_mismatch" }; + } + + const timestampCheck = isTimestampFresh(payload.proofTimestamp, options.now()); + if (!timestampCheck.ok) { + return { success: false, reason: timestampCheck.reason }; + } + + const nonceCollision = record.recentNonces.some((n) => n.nonce === payload.nonce); + if (nonceCollision) { + return { success: false, reason: "nonce_collision" }; + } + + const now = options.now(); + const recentAttempts = record.recentHandshakeAttempts.filter((t) => now - t < 10_000); + if (recentAttempts.length >= 10) { + return { success: false, reason: "rate_limited" }; + } + + const signingInput = createAuthRequestSigningInput({ + secret: record.secret!, + nonce: payload.nonce, + proofTimestamp: payload.proofTimestamp + }); + + const isValidSignature = options.verifySignature?.(payload.signature, signingInput) ?? true; + if (!isValidSignature) { + return { success: false, reason: "re_pair_required" }; + } + + record.recentNonces.push({ nonce: payload.nonce, timestamp: now }); + if (record.recentNonces.length > 10) { + record.recentNonces.shift(); + } + record.recentHandshakeAttempts.push(now); + record.lastAuthenticatedAt = now; + record.lastHeartbeatAt = now; + + return { success: true, authenticatedAt: now }; +}