/** * Yonexus Server - Pairing Service * * Manages client pairing flow: * - Creating pairing requests with codes * - Tracking pairing expiration * - Validating pairing confirmations * - Issuing shared secrets after successful pairing */ import type { ClientRecord } from "../core/persistence.js"; import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js"; export interface PairingRequest { readonly identifier: string; readonly pairingCode: string; readonly expiresAt: number; readonly ttlSeconds: number; readonly createdAt: number; } export interface PairingResult { readonly success: boolean; readonly secret?: string; readonly pairedAt?: number; readonly reason?: PairingFailureReason; } export type PairingFailureReason = | "expired" | "invalid_code" | "not_pending" | "internal_error"; export class PairingService { private readonly now: () => number; constructor(options: { now?: () => number } = {}) { this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); } /** * Create a new pairing request for a client. * Updates the client record with pending pairing state. */ createPairingRequest( record: ClientRecord, options: { ttlSeconds?: number } = {} ): PairingRequest { const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS; const now = this.now(); const pairingCode = generatePairingCode(); // Update the client record record.pairingStatus = "pending"; record.pairingCode = pairingCode; record.pairingExpiresAt = now + ttlSeconds; record.pairingNotifyStatus = "pending"; record.updatedAt = now; return { identifier: record.identifier, pairingCode, expiresAt: record.pairingExpiresAt, ttlSeconds, createdAt: now }; } /** * Validate a pairing confirmation from a client. * Returns the pairing result and updates the record on success. */ confirmPairing( record: ClientRecord, submittedCode: string ): PairingResult { const now = this.now(); // Check if pairing is pending if (record.pairingStatus !== "pending") { return { success: false, reason: "not_pending" }; } // Check if pairing has expired if (record.pairingExpiresAt && now > record.pairingExpiresAt) { this.clearPairingState(record); return { success: false, reason: "expired" }; } // Validate the pairing code if (record.pairingCode !== submittedCode) { return { success: false, reason: "invalid_code" }; } // Pairing successful - generate secret and update record const secret = generateSecret(); record.pairingStatus = "paired"; record.secret = secret; record.pairedAt = now; record.updatedAt = now; // Clear pairing-specific fields record.pairingCode = undefined; record.pairingExpiresAt = undefined; record.pairingNotifiedAt = undefined; record.pairingNotifyStatus = undefined; return { success: true, secret, pairedAt: now }; } /** * Mark a pairing notification as sent. */ markNotificationSent(record: ClientRecord): void { if (record.pairingStatus === "pending") { record.pairingNotifyStatus = "sent"; record.pairingNotifiedAt = this.now(); record.updatedAt = this.now(); } } /** * Mark a pairing notification as failed. */ markNotificationFailed(record: ClientRecord): void { if (record.pairingStatus === "pending") { record.pairingNotifyStatus = "failed"; record.updatedAt = this.now(); } } /** * Clear pairing state for a client. * Used when pairing fails or is cancelled. */ clearPairingState(record: ClientRecord): void { record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired"; record.pairingCode = undefined; record.pairingExpiresAt = undefined; record.pairingNotifiedAt = undefined; record.pairingNotifyStatus = undefined; record.updatedAt = this.now(); } /** * Revoke pairing for a client. * Clears secret and returns to unpaired state. */ revokePairing(record: ClientRecord): void { record.pairingStatus = "revoked"; record.secret = undefined; record.publicKey = undefined; record.pairedAt = undefined; this.clearPairingState(record); } /** * Check if a pairing request is expired. */ isExpired(record: ClientRecord): boolean { if (!record.pairingExpiresAt) return false; return this.now() > record.pairingExpiresAt; } /** * Get remaining TTL for a pending pairing. * Returns 0 if expired or not pending. */ getRemainingTtl(record: ClientRecord): number { if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) { return 0; } const remaining = record.pairingExpiresAt - this.now(); return Math.max(0, remaining); } } /** * Factory function to create a pairing service. */ export function createPairingService( options: { now?: () => number } = {} ): PairingService { return new PairingService(options); }