191 lines
5.0 KiB
TypeScript
191 lines
5.0 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|