import { createHash, randomBytes, randomInt } from "node:crypto"; import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; import type { DeviceDrizzleDatabase } from "./database.js"; import { ekklesiaCoreDevice, ekklesiaCoreDevicePairingRequest, ekklesiaCoreDevicePermission, } from "./schema.js"; export type DeviceStoreErrorCode = | "DEVICE_PAIRING_CODE_UNAVAILABLE" | "DEVICE_STORE_FAILED" | "DEVICE_NOT_FOUND"; const deviceStoreMessages: Readonly> = Object.freeze({ DEVICE_NOT_FOUND: "The device does not exist.", DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.", DEVICE_STORE_FAILED: "The device operation could not be completed.", }); /** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */ export class DeviceStoreError extends Error { readonly code: DeviceStoreErrorCode; constructor(code: DeviceStoreErrorCode) { super(deviceStoreMessages[code]); this.name = "DeviceStoreError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** * How long a pairing code is worth showing on a screen. * * Long enough that someone can walk from the display to a computer and type six digits; short * enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not * the only thing protecting pairing — approval requires an authenticated administrator holding * `core.devices.manage`, and the credential is issued only to the browser holding the claim secret * — so this bound is defence in depth rather than the defence. */ export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; /** * How long a device credential is accepted before the device must pair again. * * A display runs unattended for months; forcing a signage player to be re-paired every fortnight * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server * decides on every single request whether the device is still revoked, so an administrator's revoke * takes effect immediately regardless of how long the credential would otherwise have lived. The * expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. */ export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling * realtime and assets would otherwise turn an operational nicety into a continuous write load. */ export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8; /** * How many pairing requests may be waiting at once, across the whole installation. * * Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling * anyone who can reach the platform could fill this table. A building has a handful of screens and * pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows * are swept on the way in, so the ceiling counts genuinely live requests rather than history. */ const MAX_LIVE_PAIRING_REQUESTS = 50; /** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */ export function hashDeviceSecret(secret: string): string { return createHash("sha256").update(secret, "utf8").digest("hex"); } /** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two * values that must never be guessable. */ function generateSecret(): string { return randomBytes(32).toString("base64url"); } /** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six * characters and none is easier to type than another. */ function generatePairingCode(): string { return randomInt(0, 1_000_000).toString().padStart(6, "0"); } export interface CreatePairingRequestInput { readonly moduleId: string; readonly permissionId: PermissionId; readonly now: Date; } export interface CreatedPairingRequest { readonly code: string; readonly expiresAt: Date; /** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or * logged in this form. */ readonly claimSecret: string; } export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown"; export interface PairingRequestView { readonly moduleId: string; readonly permissionId: string; readonly createdAt: Date; readonly expiresAt: Date; readonly status: "pending" | "approved"; } export interface ConsumedPairing { readonly deviceId: EkklesiaDeviceId; /** Returned exactly once, to be written into the device's HttpOnly cookie. */ readonly credential: string; readonly expiresAt: Date; } export interface DeviceSummary { readonly id: EkklesiaDeviceId; readonly name: string; readonly createdAt: Date; readonly lastSeenAt?: Date; readonly revokedAt?: Date; readonly permissions: readonly string[]; } export interface ResolvedDevice { readonly deviceId: EkklesiaDeviceId; readonly lastSeenAt?: Date; } export interface DeviceStore { createPairingRequest(input: CreatePairingRequestInput): Promise; /** Looks a live request up by the code an administrator typed. Never used by the device itself. */ findPendingByCode(code: string, now: Date): Promise; approvePairingRequest(input: { readonly code: string; readonly deviceName: string; readonly approvedBy: EkklesiaUserId; readonly now: Date; }): Promise; /** The device's own poll. Consumes an approved request atomically and issues the credential. */ claimPairingRequest(input: { readonly claimSecret: string; readonly now: Date; }): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>; /** Resolves a presented credential to a live, unrevoked device. */ resolveCredential(credential: string, now: Date): Promise; touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise; hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise; listDevices(): Promise; revokeDevice(input: { readonly deviceId: EkklesiaDeviceId; readonly revokedBy: EkklesiaUserId; readonly now: Date; }): Promise; } function rethrowAsStoreError(error: unknown): never { if (error instanceof DeviceStoreError) { throw error; } throw new DeviceStoreError("DEVICE_STORE_FAILED"); } export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { /** Deletes rows that can no longer do anything: expired-and-never-approved requests, and * consumed ones past their window. Run opportunistically on request creation rather than by a * scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to * trim it, and nothing depends on it having run. */ const sweepDeadRequests = async (now: Date): Promise => { await db .delete(ekklesiaCoreDevicePairingRequest) .where( or( and( lt(ekklesiaCoreDevicePairingRequest.expiresAt, now), eq(ekklesiaCoreDevicePairingRequest.state, "pending"), ), lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)), ), ) .catch(() => undefined); }; const store: DeviceStore = { async approvePairingRequest(input): Promise { try { // Conditional on `pending` and unexpired, so approving twice, approving an expired request, // or approving one already consumed all affect zero rows rather than racing. const rows = await db .update(ekklesiaCoreDevicePairingRequest) .set({ approvedAt: input.now, approvedByUserId: input.approvedBy, deviceName: input.deviceName, state: "approved", }) .where( and( eq(ekklesiaCoreDevicePairingRequest.code, input.code), eq(ekklesiaCoreDevicePairingRequest.state, "pending"), gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now), ), ) .returning({ createdAt: ekklesiaCoreDevicePairingRequest.createdAt, expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, moduleId: ekklesiaCoreDevicePairingRequest.moduleId, permissionId: ekklesiaCoreDevicePairingRequest.permissionId, }); const row = rows[0]; return row === undefined ? undefined : Object.freeze({ ...row, status: "approved" as const }); } catch (error) { rethrowAsStoreError(error); } }, async claimPairingRequest(input): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing; }> { const claimSecretHash = hashDeviceSecret(input.claimSecret); try { return await db.transaction(async (tx) => { const rows = await tx .select({ deviceName: ekklesiaCoreDevicePairingRequest.deviceName, expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, id: ekklesiaCoreDevicePairingRequest.id, permissionId: ekklesiaCoreDevicePairingRequest.permissionId, state: ekklesiaCoreDevicePairingRequest.state, approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId, }) .from(ekklesiaCoreDevicePairingRequest) .where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash)) .limit(1); const request = rows[0]; if (!request) { return { status: "unknown" as const }; } if (request.state === "consumed") { // A credential was already issued for this request. Replaying the claim never issues a // second one. return { status: "unknown" as const }; } if (request.expiresAt.getTime() <= input.now.getTime()) { return { status: "expired" as const }; } if (request.state !== "approved") { return { status: "pending" as const }; } // The single-use gate: only the transaction whose UPDATE actually matches the still // `approved` row proceeds to mint a credential. A concurrent second claim matches zero // rows and is told the request is gone. const consumed = await tx .update(ekklesiaCoreDevicePairingRequest) .set({ consumedAt: input.now, state: "consumed" }) .where( and( eq(ekklesiaCoreDevicePairingRequest.id, request.id), eq(ekklesiaCoreDevicePairingRequest.state, "approved"), ), ) .returning({ id: ekklesiaCoreDevicePairingRequest.id }); if (consumed.length === 0) { return { status: "unknown" as const }; } const credential = generateSecret(); const created = await tx .insert(ekklesiaCoreDevice) .values({ createdByUserId: request.approvedByUserId, credentialHash: hashDeviceSecret(credential), name: request.deviceName ?? "Display device", }) .returning({ id: ekklesiaCoreDevice.id }); const deviceId = created[0]?.id; if (deviceId === undefined) { throw new DeviceStoreError("DEVICE_STORE_FAILED"); } // Exactly the one permission the request was created for — never a bundle, never every // permission the module declares. await tx.insert(ekklesiaCoreDevicePermission).values({ deviceId, grantedByUserId: request.approvedByUserId, permissionId: request.permissionId, }); await tx .update(ekklesiaCoreDevicePairingRequest) .set({ deviceId }) .where(eq(ekklesiaCoreDevicePairingRequest.id, request.id)); return { paired: Object.freeze({ credential, deviceId: deviceId as EkklesiaDeviceId, expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS), }), status: "pending" as const, }; }); } catch (error) { rethrowAsStoreError(error); } }, async createPairingRequest(input): Promise { await sweepDeadRequests(input.now); const live = await db .select({ id: ekklesiaCoreDevicePairingRequest.id }) .from(ekklesiaCoreDevicePairingRequest) .where( and( gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now), eq(ekklesiaCoreDevicePairingRequest.state, "pending"), ), ) .limit(MAX_LIVE_PAIRING_REQUESTS); if (live.length >= MAX_LIVE_PAIRING_REQUESTS) { throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE"); } const claimSecret = generateSecret(); const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS); // Collisions are resolved by the partial unique index, not by reading first and hoping: a // concurrent request that took the same code makes this insert fail and we simply draw again. for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) { const code = generatePairingCode(); try { await db.insert(ekklesiaCoreDevicePairingRequest).values({ claimSecretHash: hashDeviceSecret(claimSecret), code, expiresAt, moduleId: input.moduleId, permissionId: input.permissionId, }); return Object.freeze({ claimSecret, code, expiresAt }); } catch { // Fall through and draw a different code. } } throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE"); }, async findPendingByCode(code, now): Promise { try { const rows = await db .select({ createdAt: ekklesiaCoreDevicePairingRequest.createdAt, expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt, moduleId: ekklesiaCoreDevicePairingRequest.moduleId, permissionId: ekklesiaCoreDevicePairingRequest.permissionId, state: ekklesiaCoreDevicePairingRequest.state, }) .from(ekklesiaCoreDevicePairingRequest) .where( and( eq(ekklesiaCoreDevicePairingRequest.code, code), eq(ekklesiaCoreDevicePairingRequest.state, "pending"), gt(ekklesiaCoreDevicePairingRequest.expiresAt, now), ), ) .limit(1); const row = rows[0]; return row === undefined ? undefined : Object.freeze({ createdAt: row.createdAt, expiresAt: row.expiresAt, moduleId: row.moduleId, permissionId: row.permissionId, status: "pending" as const, }); } catch (error) { rethrowAsStoreError(error); } }, async hasActivePermission(deviceId, permission): Promise { try { // Joined against the device row so a revoked device has no permissions at all, without // every permission row needing to be revoked separately. const rows = await db .select({ id: ekklesiaCoreDevicePermission.id }) .from(ekklesiaCoreDevicePermission) .innerJoin( ekklesiaCoreDevice, eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId), ) .where( and( eq(ekklesiaCoreDevicePermission.deviceId, deviceId), eq(ekklesiaCoreDevicePermission.permissionId, permission), isNull(ekklesiaCoreDevicePermission.revokedAt), isNull(ekklesiaCoreDevice.revokedAt), ), ) .limit(1); return rows.length > 0; } catch (error) { rethrowAsStoreError(error); } }, async listDevices(): Promise { try { const devices = await db .select({ createdAt: ekklesiaCoreDevice.createdAt, id: ekklesiaCoreDevice.id, lastSeenAt: ekklesiaCoreDevice.lastSeenAt, name: ekklesiaCoreDevice.name, revokedAt: ekklesiaCoreDevice.revokedAt, }) .from(ekklesiaCoreDevice) .orderBy(ekklesiaCoreDevice.createdAt); const permissions = await db .select({ deviceId: ekklesiaCoreDevicePermission.deviceId, permissionId: ekklesiaCoreDevicePermission.permissionId, }) .from(ekklesiaCoreDevicePermission) .where(isNull(ekklesiaCoreDevicePermission.revokedAt)); return Object.freeze( devices.map((device) => Object.freeze({ createdAt: device.createdAt, id: device.id as EkklesiaDeviceId, name: device.name, permissions: Object.freeze( permissions .filter((permission) => permission.deviceId === device.id) .map((permission) => permission.permissionId) .sort(), ), ...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }), ...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }), }), ), ); } catch (error) { rethrowAsStoreError(error); } }, async resolveCredential(credential, now): Promise { try { const rows = await db .select({ createdAt: ekklesiaCoreDevice.createdAt, id: ekklesiaCoreDevice.id, lastSeenAt: ekklesiaCoreDevice.lastSeenAt, }) .from(ekklesiaCoreDevice) .where( and( eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)), isNull(ekklesiaCoreDevice.revokedAt), ), ) .limit(1); const row = rows[0]; if (!row) { return undefined; } // The credential's own horizon, measured from when the device was paired. Revocation is // checked above and is what makes a long horizon acceptable. if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) { return undefined; } return Object.freeze({ deviceId: row.id as EkklesiaDeviceId, ...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }), }); } catch (error) { rethrowAsStoreError(error); } }, async revokeDevice(input): Promise { try { const rows = await db .update(ekklesiaCoreDevice) .set({ revokedAt: input.now, revokedByUserId: input.revokedBy }) .where( and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)), ) .returning({ id: ekklesiaCoreDevice.id }); if (rows.length === 0) { // Either no such device or already revoked. Both are "not an active device", and telling // the two apart would only serve enumeration. const existing = await db .select({ id: ekklesiaCoreDevice.id }) .from(ekklesiaCoreDevice) .where(eq(ekklesiaCoreDevice.id, input.deviceId)) .limit(1); if (existing.length === 0) { throw new DeviceStoreError("DEVICE_NOT_FOUND"); } return; } await db .update(ekklesiaCoreDevicePermission) .set({ revokedAt: input.now }) .where( and( eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId), isNull(ekklesiaCoreDevicePermission.revokedAt), ), ); } catch (error) { rethrowAsStoreError(error); } }, async touchLastSeen(deviceId, now): Promise { // Throttled in SQL so concurrent requests from one display cannot each decide to write. await db .update(ekklesiaCoreDevice) .set({ lastSeenAt: now }) .where( and( eq(ekklesiaCoreDevice.id, deviceId), or( isNull(ekklesiaCoreDevice.lastSeenAt), lt( ekklesiaCoreDevice.lastSeenAt, new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS), ), ), ), ) .catch(() => undefined); }, }; return Object.freeze(store); } /** Exported for the schema-drift check and tests; never used to build a query string by hand. */ export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u; export { sql as deviceStoreSql };