From a8b0fbb2bcd423cb99cb60c1f0ffd5d8a1a0e50f Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 02:29:25 +0200 Subject: [PATCH] refactor: collapse 6 non-service plugins into shared modules BSB plugins should be actual services (own port, lifecycle, resource ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and cec-relay from plugin folders to shared modules under server/src/shared/. 4 BSB plugins remain: service-store, service-admin-http, service-api-http, service-coordinator-ws. service-admin-http now initializes secrets + auth as plain modules in init() using the store repo from the plugin-registry singleton. No more setSiblings() hack or inter-plugin wiring. sec-config.yaml updated: secrets/auth config moved into service-admin-http, pairing config into service-api-http, nodered config into service-coordinator-ws. --- sec-config.yaml | 52 +-- .../src/plugins/service-admin-http/index.ts | 96 ++--- .../plugins/service-admin-http/middleware.ts | 27 +- .../service-admin-http/routes-account.ts | 10 +- .../service-admin-http/routes-admin.ts | 26 +- .../plugins/service-admin-http/routes-auth.ts | 25 +- .../service-admin-http/routes-setup.ts | 23 +- server/src/plugins/service-api-http/index.ts | 20 +- server/src/plugins/service-auth/index.ts | 382 ------------------ server/src/plugins/service-bundle/index.ts | 61 --- server/src/plugins/service-cec-relay/index.ts | 60 --- .../plugins/service-coordinator-ws/index.ts | 16 +- .../plugins/service-nodered-bridge/index.ts | 65 --- server/src/plugins/service-pairing/index.ts | 65 --- server/src/plugins/service-secrets/index.ts | 232 ----------- server/src/plugins/service-store/index.ts | 2 + server/src/shared/auth.ts | 310 ++++++++++++++ server/src/shared/bundle.ts | 4 + server/src/shared/cec-relay.ts | 4 + server/src/shared/nodered-bridge.ts | 4 + server/src/shared/pairing.ts | 4 + server/src/shared/plugin-registry.ts | 19 + server/src/shared/secrets.ts | 158 ++++++++ 23 files changed, 619 insertions(+), 1046 deletions(-) delete mode 100644 server/src/plugins/service-auth/index.ts delete mode 100644 server/src/plugins/service-bundle/index.ts delete mode 100644 server/src/plugins/service-cec-relay/index.ts delete mode 100644 server/src/plugins/service-nodered-bridge/index.ts delete mode 100644 server/src/plugins/service-pairing/index.ts delete mode 100644 server/src/plugins/service-secrets/index.ts create mode 100644 server/src/shared/auth.ts create mode 100644 server/src/shared/bundle.ts create mode 100644 server/src/shared/cec-relay.ts create mode 100644 server/src/shared/nodered-bridge.ts create mode 100644 server/src/shared/pairing.ts create mode 100644 server/src/shared/plugin-registry.ts create mode 100644 server/src/shared/secrets.ts diff --git a/sec-config.yaml b/sec-config.yaml index be88173..dd602ac 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -18,25 +18,23 @@ default: plugin: events-default enabled: true services: - # ----- Foundations ----- + # ----- Data layer ----- service-store: plugin: service-store enabled: true config: sqlitePath: /var/lib/betterframe/betterframe.db - service-secrets: - plugin: service-secrets + # ----- Admin UI + API (includes secrets + auth config) ----- + service-admin-http: + plugin: service-admin-http enabled: true config: - # In production, leave both unset and rely on systemd-creds. - # In dev, the plugin generates a key in dataDir/secret.key (0600) and warns. + host: 127.0.0.1 + port: 18080 + # Secrets (was service-secrets) dataDir: /var/lib/betterframe - - service-auth: - plugin: service-auth - enabled: true - config: + # Auth (was service-auth) sessionIdleSeconds: 43200 # 12h sessionMaxSeconds: 2592000 # 30d loginLockoutThreshold: 8 @@ -45,48 +43,20 @@ default: argon2TimeCost: 3 argon2Parallelism: 2 - # ----- HTTP surfaces (each its own h3 listener; proxy fronts them) ----- - service-admin-http: - plugin: service-admin-http - enabled: true - config: - host: 127.0.0.1 - port: 18080 - + # ----- Kiosk-facing REST API ----- service-api-http: plugin: service-api-http enabled: true config: host: 127.0.0.1 port: 18081 + codeTtlSeconds: 600 # 10m pairing code TTL + # ----- Live kiosk WebSocket channel ----- service-coordinator-ws: plugin: service-coordinator-ws enabled: true config: host: 127.0.0.1 port: 18082 - - # ----- Domain orchestrators ----- - service-pairing: - plugin: service-pairing - enabled: true - config: - codeTtlSeconds: 600 # 10m - - service-bundle: - plugin: service-bundle - enabled: true - config: {} - - # ----- Bridges ----- - service-nodered-bridge: - plugin: service-nodered-bridge - enabled: true - config: noderedUrl: http://127.0.0.1:1880 - - service-cec-relay: - plugin: service-cec-relay - enabled: true - config: {} diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index e44a076..0e98025 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -1,8 +1,8 @@ /** - * service-admin-http — h3 listener for the admin UI and admin API. + * service-admin-http — h3 listener for admin UI and admin API. * - * Serves jsx-htmx rendered pages at /admin/* and JSON endpoints at - * /api/admin/*. Port 18080 behind the Angie proxy. + * Port 18080 behind Angie proxy. Initializes secrets + auth as + * shared modules (not BSB plugins). */ import * as av from "@anyvali/js"; import { @@ -15,9 +15,10 @@ import { import { H3, serve } from "h3"; import type { Server } from "srvx"; -import type { Plugin as StorePlugin } from "../service-store/index.js"; -import type { Plugin as AuthPlugin } from "../service-auth/index.js"; -import type { Plugin as SecretsPlugin } from "../service-secrets/index.js"; +import { getRepo } from "../../shared/plugin-registry.js"; +import { initSecrets, type SecretsApi } from "../../shared/secrets.js"; +import { createAuth, type AuthApi } from "../../shared/auth.js"; +import type { Repository } from "../service-store/repository.js"; import { registerMiddleware } from "./middleware.js"; import { registerSetupRoutes } from "./routes-setup.js"; @@ -32,6 +33,19 @@ const ConfigSchema = av.object( { host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18080), + // Secrets config (was service-secrets) + dataDir: av.string().minLength(1).default("/var/lib/betterframe"), + systemdCredsName: av.string().default("betterframe-secret"), + // Auth config (was service-auth) + sessionIdleSeconds: av.int().min(60).default(43200), + sessionMaxSeconds: av.int().min(3600).default(2592000), + loginLockoutThreshold: av.int().min(1).default(8), + loginLockoutSeconds: av.int().min(1).default(900), + argon2Memory: av.int().min(8).default(65536), + argon2TimeCost: av.int().min(1).default(3), + argon2Parallelism: av.int().min(1).default(2), + totpIssuer: av.string().minLength(1).default("BetterFrame"), + cookieName: av.string().minLength(1).default("betterframe_session"), }, { unknownKeys: "strip" }, ); @@ -57,9 +71,9 @@ export const EventSchemas = createEventSchemas({ // ---- Deps interface shared with route modules ------------------------------- export interface AdminDeps { - store: StorePlugin; - auth: AuthPlugin; - secrets: SecretsPlugin; + repo: Repository; + auth: AuthApi; + secrets: SecretsApi; cookieName: string; } @@ -70,51 +84,44 @@ export class Plugin extends BSBService, typeof Event static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-secrets", "service-auth"]; + initAfterPlugins?: string[] = ["service-store"]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; - private _store?: StorePlugin; - private _auth?: AuthPlugin; - private _secrets?: SecretsPlugin; private server?: Server; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); } - // TODO(handoff): replace with BSB plugin clients - setSiblings(store: StorePlugin, auth: AuthPlugin, secrets: SecretsPlugin): void { - this._store = store; - this._auth = auth; - this._secrets = secrets; - } - - get store(): StorePlugin { - if (!this._store) throw new Error("service-admin-http: siblings not wired"); - return this._store; - } - - get auth(): AuthPlugin { - if (!this._auth) throw new Error("service-admin-http: siblings not wired"); - return this._auth; - } - - get secrets(): SecretsPlugin { - if (!this._secrets) throw new Error("service-admin-http: siblings not wired"); - return this._secrets; - } - async init(obs: Observable): Promise { - const app = new H3(); + // Init shared modules — no inter-plugin wiring needed + const repo = getRepo(); + const secrets = initSecrets( + { dataDir: this.config.dataDir, systemdCredsName: this.config.systemdCredsName }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); + const auth = createAuth(repo, secrets, { + sessionIdleSeconds: this.config.sessionIdleSeconds, + sessionMaxSeconds: this.config.sessionMaxSeconds, + loginLockoutThreshold: this.config.loginLockoutThreshold, + loginLockoutSeconds: this.config.loginLockoutSeconds, + argon2Memory: this.config.argon2Memory, + argon2TimeCost: this.config.argon2TimeCost, + argon2Parallelism: this.config.argon2Parallelism, + totpIssuer: this.config.totpIssuer, + cookieName: this.config.cookieName, + }); + const deps: AdminDeps = { - store: this.store, - auth: this.auth, - secrets: this.secrets, - cookieName: this.auth.config.cookieName, + repo, + auth, + secrets, + cookieName: this.config.cookieName, }; - // Order matters: middleware first, then routes + const app = new H3(); + registerMiddleware(app, deps); registerStaticRoutes(app); registerSetupRoutes(app, deps); @@ -122,11 +129,10 @@ export class Plugin extends BSBService, typeof Event registerAdminRoutes(app, deps); registerAccountRoutes(app, deps); - // Health/readiness/version (no auth) app.get("/healthz", () => ({ status: "ok" })); app.get("/readyz", () => { try { - deps.store.repo.isSetupComplete(); // touches DB + deps.repo.isSetupComplete(); return { status: "ready" }; } catch { return { status: "not_ready" }; @@ -137,10 +143,8 @@ export class Plugin extends BSBService, typeof Event version: "0.1.0", now: new Date().toISOString(), })); - - // Root redirect app.get("/", () => { - if (!deps.store.repo.isSetupComplete()) { + if (!deps.repo.isSetupComplete()) { return new Response(null, { status: 302, headers: { location: "/setup" } }); } return new Response(null, { status: 302, headers: { location: "/admin/" } }); diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 8db8fac..b29e40e 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -1,11 +1,10 @@ /** * Auth & setup gate middleware for admin-http. */ -import { type H3, getCookie, createError, type H3Event, getRequestPath } from "h3"; +import { type H3, getCookie, getRequestPath } from "h3"; import type { AdminDeps } from "./index.js"; import type { User, Session } from "../../shared/types.js"; -/** Augment h3 event context with resolved auth info. */ declare module "h3" { interface H3EventContext { user?: User; @@ -13,21 +12,10 @@ declare module "h3" { } } -/** - * Resolve session from cookie. Returns null if invalid/missing. - */ -function resolveSession(event: H3Event, deps: AdminDeps): { user: User; session: Session } | null { - const cookie = getCookie(event, deps.cookieName); - if (!cookie) return null; - return deps.auth.resolveSession(cookie); -} - export function registerMiddleware(app: H3, deps: AdminDeps): void { - // Setup gate: if setup not complete, only /setup, /static, /healthz, /readyz, /version allowed app.use((event) => { const path = getRequestPath(event); - // Always pass through non-gated paths if ( path === "/setup" || path.startsWith("/static/") || @@ -39,29 +27,28 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { return; } - // If setup not complete, block everything except setup flow - if (!deps.store.repo.isSetupComplete()) { + if (!deps.repo.isSetupComplete()) { if (!path.startsWith("/auth/")) { return new Response(null, { status: 302, headers: { location: "/setup" } }); } } - // Auth pages don't require session (login/totp/recovery) if (path.startsWith("/auth/")) { return; } - // Admin pages require valid session if (path.startsWith("/admin") || path.startsWith("/api/admin")) { - const resolved = resolveSession(event, deps); + const cookie = getCookie(event, deps.cookieName); + if (!cookie) { + return new Response(null, { status: 302, headers: { location: "/auth/login" } }); + } + const resolved = deps.auth.resolveSession(cookie); if (!resolved) { return new Response(null, { status: 302, headers: { location: "/auth/login" } }); } - // TOTP pending — only allow /auth/totp and /auth/recovery if (resolved.session.totp_pending) { return new Response(null, { status: 302, headers: { location: "/auth/totp" } }); } - // Attach to context for downstream handlers event.context.user = resolved.user; event.context.session = resolved.session; return; diff --git a/server/src/plugins/service-admin-http/routes-account.ts b/server/src/plugins/service-admin-http/routes-account.ts index 2ad2cad..9eb9cb7 100644 --- a/server/src/plugins/service-admin-http/routes-account.ts +++ b/server/src/plugins/service-admin-http/routes-account.ts @@ -47,10 +47,10 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { } const hash = await deps.auth.hashPassword(newPw); - deps.store.repo.updateUser(user.id, { password_hash: hash }); + deps.repo.updateUser(user.id, { password_hash: hash }); // Revoke all sessions (force re-login) - deps.store.repo.revokeAllSessionsForUser(user.id); + deps.repo.revokeAllSessionsForUser(user.id); return new Response(null, { status: 302, @@ -77,7 +77,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { // Store unconfirmed secret + codes const encrypted = deps.auth.encryptTotpSecret(secret); - deps.store.repo.updateUser(user.id, { + deps.repo.updateUser(user.id, { totp_secret_encrypted: encrypted, }); @@ -127,7 +127,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { const codes: string[] = JSON.parse(codesJson); const hashed = await deps.auth.hashRecoveryCodes(codes); - deps.store.repo.updateUser(user.id, { + deps.repo.updateUser(user.id, { totp_enabled: true, recovery_codes_hashed: hashed, }); @@ -154,7 +154,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void { })); } - deps.store.repo.updateUser(user.id, { + deps.repo.updateUser(user.id, { totp_enabled: false, totp_secret_encrypted: null, recovery_codes_hashed: [], diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index dca383c..7fbd07d 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -16,10 +16,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/", (event) => { const user = event.context.user!; - const cameras = deps.store.repo.listCameras(); - const kiosks = deps.store.repo.listKiosks(); - const layouts = deps.store.repo.listDisplays(); // for count - const events = deps.store.repo.recentEvents(10); + const cameras = deps.repo.listCameras(); + const kiosks = deps.repo.listKiosks(); + const layouts = deps.repo.listDisplays(); // for count + const events = deps.repo.recentEvents(10); const onlineKiosks = kiosks.filter((k) => { if (!k.last_seen_at) return false; return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000; @@ -44,10 +44,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/cameras", (event) => { const user = event.context.user!; - const cameras = deps.store.repo.listCameras(); + const cameras = deps.repo.listCameras(); const streamCounts = new Map(); for (const cam of cameras) { - streamCounts.set(cam.id, deps.store.repo.listCameraStreams(cam.id).length); + streamCounts.set(cam.id, deps.repo.listCameraStreams(cam.id).length); } return html(CamerasPage({ user: user.username, cameras, streamCounts })); }); @@ -66,7 +66,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!name || name.length > 128) { errors.push("Name required (max 128 chars)."); - } else if (deps.store.repo.getCameraByName(name)) { + } else if (deps.repo.getCameraByName(name)) { errors.push("Camera name already in use."); } @@ -99,7 +99,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { })); } - const cam = deps.store.repo.createCamera({ + const cam = deps.repo.createCamera({ name, type: type!, rtsp_url: rtspUrl ?? null, @@ -111,7 +111,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Create default main stream for RTSP cameras if (type === "rtsp" && rtspUrl) { - deps.store.repo.createCameraStream({ + deps.repo.createCameraStream({ camera_id: cam.id, role: "main", name: "Main", @@ -129,8 +129,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/kiosks", (event) => { const user = event.context.user!; - const kiosks = deps.store.repo.listKiosks(); - const pending = deps.store.repo.listPendingPairingCodes(); + const kiosks = deps.repo.listKiosks(); + const pending = deps.repo.listPendingPairingCodes(); return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); }); @@ -160,7 +160,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/displays", (event) => { const user = event.context.user!; - const displays = deps.store.repo.listDisplays(); + const displays = deps.repo.listDisplays(); return html(SimpleListPage({ user: user.username, pageTitle: "Displays", @@ -175,7 +175,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/labels", (event) => { const user = event.context.user!; - const labels = deps.store.repo.listLabels(); + const labels = deps.repo.listLabels(); return html(SimpleListPage({ user: user.username, pageTitle: "Labels", diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index f44e450..0851f73 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -30,12 +30,11 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { return html(LoginPage({ error: "Username and password required.", username })); } - const user = deps.store.repo.getUserByUsername(username); + const user = deps.repo.getUserByUsername(username); if (!user || !user.is_active) { return html(LoginPage({ error: "Invalid credentials.", username })); } - // Lockout check if (user.locked_until) { const lockEnd = new Date(user.locked_until); if (lockEnd > new Date()) { @@ -45,24 +44,21 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { const valid = await deps.auth.verifyPassword(password, user.password_hash); if (!valid) { - // Increment failed count const count = user.failed_login_count + 1; const patch: Record = { failed_login_count: count }; - if (count >= 8) { - patch["locked_until"] = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + if (count >= deps.auth.config.loginLockoutThreshold) { + patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); } - deps.store.repo.updateUser(user.id, patch); + deps.repo.updateUser(user.id, patch); return html(LoginPage({ error: "Invalid credentials.", username })); } - // Reset failed login count - deps.store.repo.updateUser(user.id, { + deps.repo.updateUser(user.id, { failed_login_count: 0, locked_until: null, last_login_at: new Date().toISOString(), }); - // Create session const totpPending = user.totp_enabled; const { cookieValue } = await deps.auth.createSession({ user, @@ -75,7 +71,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { setCookie(event, deps.cookieName, cookieValue, { ...COOKIE_OPTS, - maxAge: 30 * 24 * 60 * 60, // 30d absolute max + maxAge: deps.auth.config.sessionMaxSeconds, }); if (totpPending) { @@ -126,8 +122,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { return html(TotpPage({ error: "Invalid code. Try again." })); } - // Clear totp_pending - deps.store.repo.setSessionTotpPending(session.id, false); + deps.repo.setSessionTotpPending(session.id, false); return new Response(null, { status: 302, headers: { location: "/admin/" } }); }); @@ -170,13 +165,11 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { return html(RecoveryPage({ error: "Invalid recovery code." })); } - // Update remaining codes - deps.store.repo.updateUser(user.id, { + deps.repo.updateUser(user.id, { recovery_codes_hashed: result.remaining, }); - // Clear totp_pending - deps.store.repo.setSessionTotpPending(session.id, false); + deps.repo.setSessionTotpPending(session.id, false); return new Response(null, { status: 302, headers: { location: "/admin/" } }); }); diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index 4d3468e..3a01570 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -1,8 +1,5 @@ /** * First-run setup routes. - * - * GET /setup — render setup form - * POST /setup — create admin user, provision cluster key, create default display */ import { type H3, readBody, html } from "h3"; import type { AdminDeps } from "./index.js"; @@ -10,14 +7,14 @@ import { SetupPage } from "../../web-templates/auth-pages.js"; export function registerSetupRoutes(app: H3, deps: AdminDeps): void { app.get("/setup", () => { - if (deps.store.repo.isSetupComplete()) { + if (deps.repo.isSetupComplete()) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } return html(SetupPage({})); }); app.post("/setup", async (event) => { - if (deps.store.repo.isSetupComplete()) { + if (deps.repo.isSetupComplete()) { return new Response(null, { status: 302, headers: { location: "/admin/" } }); } @@ -26,7 +23,6 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { const password = body?.password ?? ""; const errors: string[] = []; - // Validate if (!username || username.length < 3 || username.length > 64) { errors.push("Username must be 3–64 characters."); } else if (!/^[a-zA-Z0-9_-]+$/.test(username)) { @@ -40,21 +36,16 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { return html(SetupPage({ error: errors.join(" "), username })); } - // Create admin user const hash = await deps.auth.hashPassword(password); - deps.store.repo.createUser({ username, password_hash: hash, role: "admin" }); + deps.repo.createUser({ username, password_hash: hash, role: "admin" }); - // Provision cluster key const clusterKey = deps.secrets.generateClusterKey(); const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster"); - deps.store.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); - deps.store.repo.markClusterKeyProvisioned(); + deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); + deps.repo.markClusterKeyProvisioned(); - // Create default display - deps.store.repo.createDefaultDisplay(); - - // Mark setup complete - deps.store.repo.markSetupComplete(); + deps.repo.createDefaultDisplay(); + deps.repo.markSetupComplete(); return new Response(null, { status: 302, diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 219fa95..2a15756 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -1,8 +1,8 @@ /** - * service-api-http — h3 listener for the kiosk-facing REST API. + * service-api-http — h3 listener for kiosk-facing REST API. * - * Serves pairing, bundle, and kiosk management endpoints at /api/kiosk/* - * and /api/pair/*. Port 18081 behind the Angie proxy. + * Serves pairing, bundle, and kiosk management endpoints. + * Port 18081 behind Angie proxy. */ import * as av from "@anyvali/js"; import { @@ -13,12 +13,11 @@ import { type Observable, } from "@bsb/base"; -// ---- Config ----------------------------------------------------------------- - const ConfigSchema = av.object( { host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18081), + codeTtlSeconds: av.int().min(60).max(3600).default(600), }, { unknownKeys: "strip" }, ); @@ -41,14 +40,12 @@ export const EventSchemas = createEventSchemas({ onBroadcast: {}, }); -// ---- Plugin ----------------------------------------------------------------- - export class Plugin extends BSBService, typeof EventSchemas> { static override Config = Config; static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-auth"]; + initAfterPlugins?: string[] = ["service-store"]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; @@ -57,12 +54,9 @@ export class Plugin extends BSBService, typeof Event } async init(_obs: Observable): Promise { - // TODO: create h3 app, mount kiosk + pairing routes, start listening + // TODO: create h3 app, mount kiosk + pairing routes } async run(_obs: Observable): Promise {} - - async dispose(): Promise { - // TODO: close h3 listener - } + async dispose(): Promise {} } diff --git a/server/src/plugins/service-auth/index.ts b/server/src/plugins/service-auth/index.ts deleted file mode 100644 index ec00731..0000000 --- a/server/src/plugins/service-auth/index.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * service-auth — credentials and session management. - * - * Like service-store, exposes a public class API to sibling services rather - * than wrapping every operation in a typed event. Calls cross processes only - * if/when we shard auth across instances; until then this is a tight, fast, - * single-binary service. - * - * Responsibilities: - * - argon2id password hashing/verification (tuned for Pi5 ~100ms) - * - TOTP secret gen + verify, recovery code gen + single-use consumption - * - Session create/lookup/revoke (signed cookie envelope) - * - API key create / verify-by-bearer - */ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; - -import argon2 from "argon2"; -import * as av from "@anyvali/js"; -import { TOTP, Secret } from "otpauth"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -import type { ApiKey, ApiKeyScope, Session, User } from "../../shared/types.js"; -import type { Plugin as StorePlugin } from "../service-store/index.js"; -import type { Plugin as SecretsPlugin } from "../service-secrets/index.js"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object( - { - sessionIdleSeconds: av.int().min(60).default(43200), - sessionMaxSeconds: av.int().min(3600).default(2592000), - loginLockoutThreshold: av.int().min(1).default(8), - loginLockoutSeconds: av.int().min(1).default(900), - argon2Memory: av.int().min(8).default(65536), // KiB - argon2TimeCost: av.int().min(1).default(3), - argon2Parallelism: av.int().min(1).default(2), - /** Issuer string used in TOTP provisioning URIs. */ - totpIssuer: av.string().minLength(1).default("BetterFrame"), - /** Cookie name (used by service-admin-http to set/read). */ - cookieName: av.string().minLength(1).default("betterframe_session"), - }, - { unknownKeys: "strip" }, -); - -export const Config = createConfigSchema( - { - name: "service-auth", - description: - "Authentication primitives: argon2id passwords, TOTP, recovery codes, " + - "sessions (signed cookie envelope), and API keys.", - tags: ["service", "auth"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Constants ------------------------------------------------------------- - -const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I -const RECOVERY_CODE_COUNT = 10; -const RECOVERY_CODE_LENGTH = 10; - -// ---- Plugin ---------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-secrets"]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - // Sibling services: set in init() once they've initialized themselves. - // TODO(handoff): Replace with proper BSB plugin clients once we generate - // them. For v0.1 we resolve via the runtime's plugin lookup. - // The actual lookup mechanism is provided by the BSB framework — this - // file pretends the references arrive in init(). Wire-up happens in run(). - private _store?: StorePlugin; - private _secrets?: SecretsPlugin; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - // ---- BSB lifecycle ------------------------------------------------------- - - async init(_obs: Observable): Promise { - // TODO(handoff): wire sibling-service references via plugin clients. - // For now `setSiblings()` is called by the boot script (see CLAUDE.md). - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} - - /** Called once by the boot wrapper after all plugins have constructed. */ - setSiblings(store: StorePlugin, secrets: SecretsPlugin): void { - this._store = store; - this._secrets = secrets; - } - - private get store(): StorePlugin { - if (!this._store) throw new Error("service-auth: siblings not set"); - return this._store; - } - - private get secrets(): SecretsPlugin { - if (!this._secrets) throw new Error("service-auth: siblings not set"); - return this._secrets; - } - - // ========================================================================= - // Passwords - // ========================================================================= - - async hashPassword(plain: string): Promise { - return argon2.hash(plain, { - type: argon2.argon2id, - memoryCost: this.config.argon2Memory, - timeCost: this.config.argon2TimeCost, - parallelism: this.config.argon2Parallelism, - }); - } - - async verifyPassword(plain: string, hash: string): Promise { - try { - return await argon2.verify(hash, plain); - } catch { - return false; - } - } - - needsRehash(hash: string): boolean { - return argon2.needsRehash(hash, { - memoryCost: this.config.argon2Memory, - timeCost: this.config.argon2TimeCost, - parallelism: this.config.argon2Parallelism, - }); - } - - // ========================================================================= - // TOTP - // ========================================================================= - - generateTotpSecret(): string { - // 20 bytes (160 bits) base32-encoded by otpauth's Secret class - return new Secret({ size: 20 }).base32; - } - - totpProvisioningUri(username: string, secretBase32: string): string { - const totp = new TOTP({ - issuer: this.config.totpIssuer, - label: username, - algorithm: "SHA1", - digits: 6, - period: 30, - secret: Secret.fromBase32(secretBase32), - }); - return totp.toString(); - } - - verifyTotpCode(secretBase32: string, code: string): boolean { - const totp = new TOTP({ - issuer: this.config.totpIssuer, - algorithm: "SHA1", - digits: 6, - period: 30, - secret: Secret.fromBase32(secretBase32), - }); - // Tolerate ±1 step for clock skew - return totp.validate({ token: code, window: 1 }) !== null; - } - - encryptTotpSecret(secret: string): string { - return this.secrets.encryptString(secret, "totp"); - } - - decryptTotpSecret(ciphertext: string): string { - return this.secrets.decryptString(ciphertext, "totp"); - } - - // ---- Recovery codes ------------------------------------------------------ - - generateRecoveryCodes(): string[] { - const out: string[] = []; - for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { - const chars: string[] = []; - const buf = randomBytes(RECOVERY_CODE_LENGTH); - for (let j = 0; j < RECOVERY_CODE_LENGTH; j++) { - chars.push(RECOVERY_ALPHABET[buf[j]! % RECOVERY_ALPHABET.length]!); - } - out.push(chars.join("")); - } - return out; - } - - async hashRecoveryCodes(codes: string[]): Promise { - return Promise.all(codes.map((c) => this.hashPassword(c))); - } - - async consumeRecoveryCode( - code: string, - hashedCodes: string[], - ): Promise<{ ok: boolean; remaining: string[] }> { - const remaining: string[] = []; - let consumed = false; - for (const h of hashedCodes) { - if (!consumed && (await this.verifyPassword(code, h))) { - consumed = true; - continue; - } - remaining.push(h); - } - return { ok: consumed, remaining }; - } - - // ========================================================================= - // Sessions (signed cookie envelope) - // ========================================================================= - - /** - * Create a session row + return (Session, signedCookieValue). - * Cookie envelope is `.` where hmac uses the server-local key - * (info="cookie"). Tampering with the sid invalidates the cookie. - */ - async createSession(input: { - user: User; - userAgent: string | null; - ipAddress: string | null; - totpPending: boolean; - }): Promise<{ session: Session; cookieValue: string }> { - const id = randomBytes(32).toString("hex"); - const csrfToken = randomBytes(32).toString("hex"); - const expiresAt = new Date( - Date.now() + this.config.sessionMaxSeconds * 1000, - ).toISOString(); - const session = this.store.repo.createSession({ - id, - user_id: input.user.id, - csrf_token: csrfToken, - totp_pending: input.totpPending, - user_agent: input.userAgent, - ip_address: input.ipAddress, - expires_at: expiresAt, - }); - return { session, cookieValue: this.signCookie(id) }; - } - - /** - * Verify a cookie value and look up the session. - * Also enforces sliding (idle) and absolute expiry. Touches last_seen_at - * if valid. - */ - resolveSession( - cookieValue: string, - ): { session: Session; user: User } | null { - const sid = this.unsignCookie(cookieValue); - if (!sid) return null; - const session = this.store.repo.getSessionById(sid); - if (!session) return null; - if (session.revoked_at) return null; - const now = new Date(); - const expiresAt = new Date(session.expires_at); - if (expiresAt <= now) return null; - const lastSeen = new Date(session.last_seen_at); - const idleMs = this.config.sessionIdleSeconds * 1000; - if (now.getTime() - lastSeen.getTime() > idleMs) { - this.store.repo.revokeSession(sid); - return null; - } - const user = this.store.repo.getUserById(session.user_id); - if (!user || !user.is_active) return null; - this.store.repo.touchSession(sid, now.toISOString()); - return { session, user }; - } - - revokeSession(sid: string): void { - this.store.repo.revokeSession(sid); - } - - // ---- Cookie signing ------------------------------------------------------ - - private signCookie(sid: string): string { - const mac = this.cookieMac(sid); - return `${sid}.${mac}`; - } - - /** Return the sid iff the signature is valid; null otherwise. */ - private unsignCookie(cookieValue: string): string | null { - const dot = cookieValue.indexOf("."); - if (dot < 0) return null; - const sid = cookieValue.slice(0, dot); - const mac = cookieValue.slice(dot + 1); - const expected = this.cookieMac(sid); - const a = Buffer.from(mac, "hex"); - const b = Buffer.from(expected, "hex"); - if (a.length !== b.length) return null; - return timingSafeEqual(a, b) ? sid : null; - } - - private cookieMac(sid: string): string { - // Derive a cookie-signing key off the server key with HKDF info="cookie". - // We don't have direct access to the key; ask service-secrets to do an - // HMAC for us. To avoid a round-trip API, we add a small helper there - // later if profiling shows it. For now we compute on a derived subkey by - // running encryptString with deterministic IV (NO — that leaks). Better: - // use HKDF via secrets internally. For v0.1 we expose `signCookie` here - // as HMAC-SHA256 keyed on the encryption of a fixed plaintext, which - // produces a stable subkey-equivalent. This is acceptable but a TODO. - // TODO(handoff): expose `secrets.deriveSubkey(info)` publicly so we can - // hold a Buffer here and stop round-tripping through encryptString. - const subkeyMaterial = this.secrets.encryptString("cookie-subkey", "cookie-derivation"); - return createHmac("sha256", subkeyMaterial).update(sid).digest("hex"); - } - - // ========================================================================= - // API keys - // ========================================================================= - - async createApiKey(input: { - name: string; - scopes: ApiKeyScope[]; - expiresAt: string | null; - }): Promise<{ apiKey: ApiKey; plaintext: string }> { - const plaintext = `bf-${randomBytes(24).toString("base64url")}`; - const keyHash = await this.hashPassword(plaintext); - const keyPrefix = plaintext.slice(0, 8); - const apiKey = this.store.repo.createApiKey({ - name: input.name, - key_hash: keyHash, - key_prefix: keyPrefix, - scopes: input.scopes, - expires_at: input.expiresAt, - }); - return { apiKey, plaintext }; - } - - async verifyApiKey(plaintext: string, ip: string | null): Promise { - const prefix = plaintext.slice(0, 8); - const candidates = this.store.repo.listApiKeysByPrefix(prefix); - for (const cand of candidates) { - if (cand.revoked_at) continue; - if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue; - if (await this.verifyPassword(plaintext, cand.key_hash)) { - this.store.repo.touchApiKey(cand.id, ip); - return cand; - } - } - return null; - } - - // ========================================================================= - // Kiosk-key verification (mirror of API key verify but for the kiosks table) - // ========================================================================= - - async verifyKioskKey(plaintext: string): Promise<{ id: number } | null> { - if (plaintext.length < 8) return null; - const prefix = plaintext.slice(0, 8); - const candidates = this.store.repo.listKiosksByKeyPrefix(prefix); - for (const cand of candidates) { - if (await this.verifyPassword(plaintext, cand.key_hash)) { - return { id: cand.id }; - } - } - return null; - } -} diff --git a/server/src/plugins/service-bundle/index.ts b/server/src/plugins/service-bundle/index.ts deleted file mode 100644 index feb885d..0000000 --- a/server/src/plugins/service-bundle/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * service-bundle — label-scoped bundle generation for kiosks. - * - * Queries layouts/cameras/labels for a kiosk's label set, encrypts ONVIF - * passwords with the cluster key, and returns a versioned JSON bundle - * the kiosk caches locally. - */ -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object({}, { unknownKeys: "strip" }); - -export const Config = createConfigSchema( - { - name: "service-bundle", - description: "Label-aware bundle generation for kiosks.", - tags: ["service", "bundle", "kiosk"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-secrets"]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(_obs: Observable): Promise { - // TODO: implement bundle query + cluster-encrypt - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} -} diff --git a/server/src/plugins/service-cec-relay/index.ts b/server/src/plugins/service-cec-relay/index.ts deleted file mode 100644 index d93da25..0000000 --- a/server/src/plugins/service-cec-relay/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * service-cec-relay — translates CEC commands to ws messages. - * - * Receives CEC control requests from the admin API or Node-RED and - * relays them to the authoritative kiosk via the coordinator WS channel. - */ -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object({}, { unknownKeys: "strip" }); - -export const Config = createConfigSchema( - { - name: "service-cec-relay", - description: "Relay CEC commands to the authoritative kiosk.", - tags: ["service", "cec", "relay"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-coordinator-ws"]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(_obs: Observable): Promise { - // TODO: subscribe to CEC command events, relay via coordinator - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} -} diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 28480c8..def6feb 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -1,8 +1,8 @@ /** * service-coordinator-ws — WebSocket hub for live kiosk channel. * - * Kiosks connect here to receive real-time layout switches, power - * commands, and status pings. Port 18082 behind the Angie proxy. + * Kiosks connect here for real-time layout switches, power commands, + * and status pings. Port 18082 behind Angie proxy. */ import * as av from "@anyvali/js"; import { @@ -13,12 +13,11 @@ import { type Observable, } from "@bsb/base"; -// ---- Config ----------------------------------------------------------------- - const ConfigSchema = av.object( { host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18082), + noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), }, { unknownKeys: "strip" }, ); @@ -41,14 +40,12 @@ export const EventSchemas = createEventSchemas({ onBroadcast: {}, }); -// ---- Plugin ----------------------------------------------------------------- - export class Plugin extends BSBService, typeof EventSchemas> { static override Config = Config; static override EventSchemas = EventSchemas; initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-auth"]; + initAfterPlugins?: string[] = ["service-store"]; runBeforePlugins?: string[]; runAfterPlugins?: string[]; @@ -61,8 +58,5 @@ export class Plugin extends BSBService, typeof Event } async run(_obs: Observable): Promise {} - - async dispose(): Promise { - // TODO: close ws server - } + async dispose(): Promise {} } diff --git a/server/src/plugins/service-nodered-bridge/index.ts b/server/src/plugins/service-nodered-bridge/index.ts deleted file mode 100644 index 0d6755f..0000000 --- a/server/src/plugins/service-nodered-bridge/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * service-nodered-bridge — bidirectional HTTP bridge to Node-RED. - * - * Forwards events from the BSB bus to Node-RED HTTP-in endpoints, - * and exposes callbacks for Node-RED to push back into the bus. - */ -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object( - { - noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), - }, - { unknownKeys: "strip" }, -); - -export const Config = createConfigSchema( - { - name: "service-nodered-bridge", - description: "HTTP bridge between BSB event bus and Node-RED.", - tags: ["service", "nodered", "bridge"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store"]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(_obs: Observable): Promise { - // TODO: set up outbound HTTP forwarder + inbound callback routes - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} -} diff --git a/server/src/plugins/service-pairing/index.ts b/server/src/plugins/service-pairing/index.ts deleted file mode 100644 index d56d34c..0000000 --- a/server/src/plugins/service-pairing/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * service-pairing — 8-char code state machine for kiosk pairing. - * - * Kiosk shows code on screen, admin enters it in UI, server delivers - * kiosk_key + cluster_key + bundle_url via one-shot poll. - */ -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object( - { - codeTtlSeconds: av.int().min(60).max(3600).default(600), - }, - { unknownKeys: "strip" }, -); - -export const Config = createConfigSchema( - { - name: "service-pairing", - description: "Kiosk pairing code state machine.", - tags: ["service", "pairing", "kiosk"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[] = ["service-store", "service-secrets"]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(_obs: Observable): Promise { - // TODO: implement initiate/claim/poll state machine - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} -} diff --git a/server/src/plugins/service-secrets/index.ts b/server/src/plugins/service-secrets/index.ts deleted file mode 100644 index 0631155..0000000 --- a/server/src/plugins/service-secrets/index.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * service-secrets — symmetric crypto and the cluster key. - * - * Two roles: - * 1. Field encryption for ONVIF passwords (and anything else stored - * sensitively at rest). Uses AES-256-GCM with a server-local key. - * 2. Holding the cluster key (the shared symmetric secret kiosks use to - * decrypt the camera credentials in their bundle). Cluster key is - * generated at first-run setup and stored in setup_state.extras - * (server-encrypted). - * - * Server-local key sources (priority order): - * 1. systemd-creds: $CREDENTIALS_DIRECTORY/betterframe-secret - * 2. Dev fallback: /secret.key (chmod 0600). Generated if - * missing, with a WARN log so deploys notice. - * - * The cluster key never reaches disk in plaintext; it's encrypted with the - * server-local key and stored in setup_state.extras["cluster_key_encrypted"]. - */ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { - createCipheriv, - createDecipheriv, - randomBytes, - hkdfSync, -} from "node:crypto"; - -import * as av from "@anyvali/js"; -import { - BSBService, - type BSBServiceConstructor, - createConfigSchema, - createEventSchemas, - type Observable, -} from "@bsb/base"; - -// ---- Config ----------------------------------------------------------------- - -const ConfigSchema = av.object( - { - dataDir: av.string().minLength(1).default("/var/lib/betterframe"), - /** Override the systemd-creds credential name. */ - systemdCredsName: av.string().default("betterframe-secret"), - }, - { unknownKeys: "strip" }, -); - -export const Config = createConfigSchema( - { - name: "service-secrets", - description: - "Symmetric crypto for at-rest secrets and the inter-kiosk cluster key.", - tags: ["service", "secrets", "crypto"], - }, - ConfigSchema, -); - -export const EventSchemas = createEventSchemas({ - emitEvents: {}, - onEvents: {}, - emitReturnableEvents: {}, - onReturnableEvents: {}, - emitBroadcast: {}, - onBroadcast: {}, -}); - -// ---- Plugin ----------------------------------------------------------------- - -export class Plugin extends BSBService, typeof EventSchemas> { - static override Config = Config; - static override EventSchemas = EventSchemas; - - initBeforePlugins?: string[]; - initAfterPlugins?: string[]; - runBeforePlugins?: string[]; - runAfterPlugins?: string[]; - - /** 32-byte server-local key. Used to wrap field secrets and the cluster key. */ - private serverKey?: Buffer; - - constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { - super(cfg); - } - - async init(obs: Observable): Promise { - this.serverKey = this.loadServerKey(obs); - } - - async run(_obs: Observable): Promise {} - - async dispose(): Promise {} - - // ---- public API for sibling services ------------------------------------- - - /** - * Encrypt a UTF-8 string at rest. Returns a self-describing ciphertext: - * v1... - * `info` lets us domain-separate keys (e.g. "field" vs "cluster") so the - * same server key can be used for distinct purposes safely. - */ - encryptString(plaintext: string, info: string = "field"): string { - const subkey = this.deriveSubkey(info); - const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", subkey, iv); - const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); - return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`; - } - - decryptString(ciphertext: string, info: string = "field"): string { - const parts = ciphertext.split("."); - if (parts.length !== 4 || parts[0] !== "v1") { - throw new Error("ciphertext: bad format"); - } - const iv = b64uDecode(parts[1]!); - const tag = b64uDecode(parts[2]!); - const ct = b64uDecode(parts[3]!); - const subkey = this.deriveSubkey(info); - const decipher = createDecipheriv("aes-256-gcm", subkey, iv); - decipher.setAuthTag(tag); - const pt = Buffer.concat([decipher.update(ct), decipher.final()]); - return pt.toString("utf8"); - } - - /** Generate a fresh cluster key (32 bytes, base64url). */ - generateClusterKey(): string { - return b64u(randomBytes(32)); - } - - /** - * Encrypt-for-cluster: takes a plaintext + the cluster key, returns the - * format the kiosk expects in its bundle. Symmetric counterpart in Rust. - * - * v1... - * - * Same envelope shape as encryptString but keyed off the cluster key. - */ - encryptForCluster(plaintext: string, clusterKeyB64u: string): string { - const key = b64uDecode(clusterKeyB64u); - if (key.length !== 32) throw new Error("cluster key must be 32 bytes"); - const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", key, iv); - const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); - return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`; - } - - // ---- internals ----------------------------------------------------------- - - private deriveSubkey(info: string): Buffer { - if (!this.serverKey) throw new Error("service-secrets not initialized"); - // HKDF-SHA256 with the info string as the context. - const out = hkdfSync( - "sha256", - this.serverKey, - Buffer.alloc(0), - Buffer.from(`betterframe.${info}`, "utf8"), - 32, - ); - return Buffer.from(out); - } - - private loadServerKey(obs: Observable): Buffer { - // 1. systemd-creds - const credsDir = process.env["CREDENTIALS_DIRECTORY"]; - if (credsDir) { - const path = join(credsDir, this.config.systemdCredsName); - if (existsSync(path)) { - const buf = readFileSync(path); - if (buf.length >= 32) { - obs.log.info("server key loaded from systemd-creds"); - return buf.subarray(0, 32); - } - obs.log.warn( - "systemd-creds file too short ({len}); falling back to dev key", - { len: buf.length }, - ); - } - } - - // 2. Dev fallback: /secret.key - const path = join(this.config.dataDir, "secret.key"); - if (existsSync(path)) { - const buf = readFileSync(path); - if (buf.length >= 32) { - obs.log.info("server key loaded from {path}", { path }); - return buf.subarray(0, 32); - } - } - - // 3. Generate new dev key - obs.log.warn( - "GENERATING DEV SERVER KEY at {path} — production deploys should use systemd-creds (CREDENTIALS_DIRECTORY/{name}) instead", - { path, name: this.config.systemdCredsName }, - ); - try { - mkdirSync(dirname(path), { recursive: true }); - } catch { - /* already exists or insufficient perms */ - } - const fresh = randomBytes(32); - writeFileSync(path, fresh, { mode: 0o600 }); - try { - chmodSync(path, 0o600); - } catch { - /* not POSIX; fine on dev */ - } - return fresh; - } -} - -// ---- base64url helpers (no padding) ---------------------------------------- - -function b64u(buf: Buffer): string { - return buf - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - -function b64uDecode(s: string): Buffer { - const padded = s + "=".repeat((4 - (s.length % 4)) % 4); - return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); -} diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index 184931b..a6fa0b1 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -34,6 +34,7 @@ import { import { MIGRATIONS } from "./migrations.js"; import { Repository } from "./repository.js"; +import { registerRepo } from "../../shared/plugin-registry.js"; // ---- Config ----------------------------------------------------------------- @@ -135,6 +136,7 @@ export class Plugin extends BSBService, typeof Event } }); + registerRepo(this._repo); obs.log.info("store ready"); } diff --git a/server/src/shared/auth.ts b/server/src/shared/auth.ts new file mode 100644 index 0000000..9eb1a73 --- /dev/null +++ b/server/src/shared/auth.ts @@ -0,0 +1,310 @@ +/** + * Auth — shared module (not a BSB plugin). + * + * createAuth(repo, secrets, config) → AuthApi + */ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import argon2 from "argon2"; +import { TOTP, Secret } from "otpauth"; + +import type { Repository } from "../plugins/service-store/repository.js"; +import type { SecretsApi } from "./secrets.js"; +import type { ApiKey, ApiKeyScope, Session, User } from "./types.js"; + +// ---- Public interface ------------------------------------------------------- + +export interface AuthConfig { + sessionIdleSeconds: number; + sessionMaxSeconds: number; + loginLockoutThreshold: number; + loginLockoutSeconds: number; + argon2Memory: number; + argon2TimeCost: number; + argon2Parallelism: number; + totpIssuer: string; + cookieName: string; +} + +export interface AuthApi { + readonly config: AuthConfig; + hashPassword(plain: string): Promise; + verifyPassword(plain: string, hash: string): Promise; + needsRehash(hash: string): boolean; + generateTotpSecret(): string; + totpProvisioningUri(username: string, secretBase32: string): string; + verifyTotpCode(secretBase32: string, code: string): boolean; + encryptTotpSecret(secret: string): string; + decryptTotpSecret(ciphertext: string): string; + generateRecoveryCodes(): string[]; + hashRecoveryCodes(codes: string[]): Promise; + consumeRecoveryCode(code: string, hashedCodes: string[]): Promise<{ ok: boolean; remaining: string[] }>; + createSession(input: { + user: User; + userAgent: string | null; + ipAddress: string | null; + totpPending: boolean; + }): Promise<{ session: Session; cookieValue: string }>; + resolveSession(cookieValue: string): { session: Session; user: User } | null; + revokeSession(sid: string): void; + createApiKey(input: { + name: string; + scopes: ApiKeyScope[]; + expiresAt: string | null; + }): Promise<{ apiKey: ApiKey; plaintext: string }>; + verifyApiKey(plaintext: string, ip: string | null): Promise; + verifyKioskKey(plaintext: string): Promise<{ id: number } | null>; +} + +// ---- Constants -------------------------------------------------------------- + +const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const RECOVERY_CODE_COUNT = 10; +const RECOVERY_CODE_LENGTH = 10; + +// ---- Factory ---------------------------------------------------------------- + +export function createAuth( + repo: Repository, + secrets: SecretsApi, + config: AuthConfig, +): AuthApi { + + // ---- Passwords ------------------------------------------------------------ + + async function hashPassword(plain: string): Promise { + return argon2.hash(plain, { + type: argon2.argon2id, + memoryCost: config.argon2Memory, + timeCost: config.argon2TimeCost, + parallelism: config.argon2Parallelism, + }); + } + + async function verifyPassword(plain: string, hash: string): Promise { + try { + return await argon2.verify(hash, plain); + } catch { + return false; + } + } + + function needsRehash(hash: string): boolean { + return argon2.needsRehash(hash, { + memoryCost: config.argon2Memory, + timeCost: config.argon2TimeCost, + parallelism: config.argon2Parallelism, + }); + } + + // ---- TOTP ----------------------------------------------------------------- + + function generateTotpSecret(): string { + return new Secret({ size: 20 }).base32; + } + + function totpProvisioningUri(username: string, secretBase32: string): string { + const totp = new TOTP({ + issuer: config.totpIssuer, + label: username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: Secret.fromBase32(secretBase32), + }); + return totp.toString(); + } + + function verifyTotpCode(secretBase32: string, code: string): boolean { + const totp = new TOTP({ + issuer: config.totpIssuer, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: Secret.fromBase32(secretBase32), + }); + return totp.validate({ token: code, window: 1 }) !== null; + } + + function encryptTotpSecret(secret: string): string { + return secrets.encryptString(secret, "totp"); + } + + function decryptTotpSecret(ciphertext: string): string { + return secrets.decryptString(ciphertext, "totp"); + } + + // ---- Recovery codes ------------------------------------------------------- + + function generateRecoveryCodes(): string[] { + const out: string[] = []; + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { + const chars: string[] = []; + const buf = randomBytes(RECOVERY_CODE_LENGTH); + for (let j = 0; j < RECOVERY_CODE_LENGTH; j++) { + chars.push(RECOVERY_ALPHABET[buf[j]! % RECOVERY_ALPHABET.length]!); + } + out.push(chars.join("")); + } + return out; + } + + async function hashRecoveryCodes(codes: string[]): Promise { + return Promise.all(codes.map((c) => hashPassword(c))); + } + + async function consumeRecoveryCode( + code: string, + hashedCodes: string[], + ): Promise<{ ok: boolean; remaining: string[] }> { + const remaining: string[] = []; + let consumed = false; + for (const h of hashedCodes) { + if (!consumed && (await verifyPassword(code, h))) { + consumed = true; + continue; + } + remaining.push(h); + } + return { ok: consumed, remaining }; + } + + // ---- Sessions ------------------------------------------------------------- + + function cookieMac(sid: string): string { + const subkeyMaterial = secrets.encryptString("cookie-subkey", "cookie-derivation"); + return createHmac("sha256", subkeyMaterial).update(sid).digest("hex"); + } + + function signCookie(sid: string): string { + return `${sid}.${cookieMac(sid)}`; + } + + function unsignCookie(cookieValue: string): string | null { + const dot = cookieValue.indexOf("."); + if (dot < 0) return null; + const sid = cookieValue.slice(0, dot); + const mac = cookieValue.slice(dot + 1); + const expected = cookieMac(sid); + const a = Buffer.from(mac, "hex"); + const b = Buffer.from(expected, "hex"); + if (a.length !== b.length) return null; + return timingSafeEqual(a, b) ? sid : null; + } + + async function createSession(input: { + user: User; + userAgent: string | null; + ipAddress: string | null; + totpPending: boolean; + }): Promise<{ session: Session; cookieValue: string }> { + const id = randomBytes(32).toString("hex"); + const csrfToken = randomBytes(32).toString("hex"); + const expiresAt = new Date( + Date.now() + config.sessionMaxSeconds * 1000, + ).toISOString(); + const session = repo.createSession({ + id, + user_id: input.user.id, + csrf_token: csrfToken, + totp_pending: input.totpPending, + user_agent: input.userAgent, + ip_address: input.ipAddress, + expires_at: expiresAt, + }); + return { session, cookieValue: signCookie(id) }; + } + + function resolveSession( + cookieValue: string, + ): { session: Session; user: User } | null { + const sid = unsignCookie(cookieValue); + if (!sid) return null; + const session = repo.getSessionById(sid); + if (!session) return null; + if (session.revoked_at) return null; + const now = new Date(); + if (new Date(session.expires_at) <= now) return null; + const idleMs = config.sessionIdleSeconds * 1000; + if (now.getTime() - new Date(session.last_seen_at).getTime() > idleMs) { + repo.revokeSession(sid); + return null; + } + const user = repo.getUserById(session.user_id); + if (!user || !user.is_active) return null; + repo.touchSession(sid, now.toISOString()); + return { session, user }; + } + + function revokeSession(sid: string): void { + repo.revokeSession(sid); + } + + // ---- API keys ------------------------------------------------------------- + + async function createApiKey(input: { + name: string; + scopes: ApiKeyScope[]; + expiresAt: string | null; + }): Promise<{ apiKey: ApiKey; plaintext: string }> { + const plaintext = `bf-${randomBytes(24).toString("base64url")}`; + const keyHash = await hashPassword(plaintext); + const keyPrefix = plaintext.slice(0, 8); + const apiKey = repo.createApiKey({ + name: input.name, + key_hash: keyHash, + key_prefix: keyPrefix, + scopes: input.scopes, + expires_at: input.expiresAt, + }); + return { apiKey, plaintext }; + } + + async function verifyApiKey(plaintext: string, ip: string | null): Promise { + const prefix = plaintext.slice(0, 8); + const candidates = repo.listApiKeysByPrefix(prefix); + for (const cand of candidates) { + if (cand.revoked_at) continue; + if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue; + if (await verifyPassword(plaintext, cand.key_hash)) { + repo.touchApiKey(cand.id, ip); + return cand; + } + } + return null; + } + + async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> { + if (plaintext.length < 8) return null; + const prefix = plaintext.slice(0, 8); + const candidates = repo.listKiosksByKeyPrefix(prefix); + for (const cand of candidates) { + if (await verifyPassword(plaintext, cand.key_hash)) { + return { id: cand.id }; + } + } + return null; + } + + // ---- Return --------------------------------------------------------------- + + return { + config, + hashPassword, + verifyPassword, + needsRehash, + generateTotpSecret, + totpProvisioningUri, + verifyTotpCode, + encryptTotpSecret, + decryptTotpSecret, + generateRecoveryCodes, + hashRecoveryCodes, + consumeRecoveryCode, + createSession, + resolveSession, + revokeSession, + createApiKey, + verifyApiKey, + verifyKioskKey, + }; +} diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts new file mode 100644 index 0000000..3ff771b --- /dev/null +++ b/server/src/shared/bundle.ts @@ -0,0 +1,4 @@ +/** + * Label-scoped bundle generation — shared module stub. + * TODO: implement from old-python reference. + */ diff --git a/server/src/shared/cec-relay.ts b/server/src/shared/cec-relay.ts new file mode 100644 index 0000000..d6316f0 --- /dev/null +++ b/server/src/shared/cec-relay.ts @@ -0,0 +1,4 @@ +/** + * CEC command relay — shared module stub. + * TODO: implement cec-ctl subprocess + ws message translation. + */ diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts new file mode 100644 index 0000000..d4b5839 --- /dev/null +++ b/server/src/shared/nodered-bridge.ts @@ -0,0 +1,4 @@ +/** + * Node-RED HTTP bridge — shared module stub. + * TODO: implement outbound forwarder + inbound callbacks. + */ diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts new file mode 100644 index 0000000..f1e8659 --- /dev/null +++ b/server/src/shared/pairing.ts @@ -0,0 +1,4 @@ +/** + * Pairing state machine — shared module stub. + * TODO: implement initiate/claim/poll from old-python reference. + */ diff --git a/server/src/shared/plugin-registry.ts b/server/src/shared/plugin-registry.ts new file mode 100644 index 0000000..54e04ec --- /dev/null +++ b/server/src/shared/plugin-registry.ts @@ -0,0 +1,19 @@ +/** + * Module-level store registry — the one cross-plugin reference needed. + * + * service-store registers its repo in init(). Downstream plugins + * (admin-http, api-http, coordinator-ws) look it up in their init(). + * initAfterPlugins guarantees ordering. + */ +import type { Repository } from "../plugins/service-store/repository.js"; + +let _repo: Repository | undefined; + +export function registerRepo(repo: Repository): void { + _repo = repo; +} + +export function getRepo(): Repository { + if (!_repo) throw new Error("plugin-registry: store repo not registered (init order bug?)"); + return _repo; +} diff --git a/server/src/shared/secrets.ts b/server/src/shared/secrets.ts new file mode 100644 index 0000000..d1239b7 --- /dev/null +++ b/server/src/shared/secrets.ts @@ -0,0 +1,158 @@ +/** + * Symmetric crypto and cluster key — shared module (not a BSB plugin). + * + * initSecrets(config, log) → SecretsApi + */ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + hkdfSync, +} from "node:crypto"; + +// ---- Public interface ------------------------------------------------------- + +export interface SecretsConfig { + dataDir: string; + systemdCredsName?: string; +} + +export interface SecretsLog { + info(msg: string): void; + warn(msg: string): void; +} + +export interface SecretsApi { + encryptString(plaintext: string, info?: string): string; + decryptString(ciphertext: string, info?: string): string; + generateClusterKey(): string; + encryptForCluster(plaintext: string, clusterKeyB64u: string): string; +} + +// ---- Init ------------------------------------------------------------------- + +export function initSecrets(config: SecretsConfig, log: SecretsLog): SecretsApi { + const serverKey = loadServerKey(config, log); + + function deriveSubkey(info: string): Buffer { + const out = hkdfSync( + "sha256", + serverKey, + Buffer.alloc(0), + Buffer.from(`betterframe.${info}`, "utf8"), + 32, + ); + return Buffer.from(out); + } + + return { + encryptString(plaintext: string, info: string = "field"): string { + const subkey = deriveSubkey(info); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", subkey, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`; + }, + + decryptString(ciphertext: string, info: string = "field"): string { + const parts = ciphertext.split("."); + if (parts.length !== 4 || parts[0] !== "v1") { + throw new Error("ciphertext: bad format"); + } + const iv = b64uDecode(parts[1]!); + const tag = b64uDecode(parts[2]!); + const ct = b64uDecode(parts[3]!); + const subkey = deriveSubkey(info); + const decipher = createDecipheriv("aes-256-gcm", subkey, iv); + decipher.setAuthTag(tag); + const pt = Buffer.concat([decipher.update(ct), decipher.final()]); + return pt.toString("utf8"); + }, + + generateClusterKey(): string { + return b64u(randomBytes(32)); + }, + + encryptForCluster(plaintext: string, clusterKeyB64u: string): string { + const key = b64uDecode(clusterKeyB64u); + if (key.length !== 32) throw new Error("cluster key must be 32 bytes"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`; + }, + }; +} + +// ---- Key loading ------------------------------------------------------------ + +function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer { + const credsName = config.systemdCredsName ?? "betterframe-secret"; + + // 1. systemd-creds + const credsDir = process.env["CREDENTIALS_DIRECTORY"]; + if (credsDir) { + const p = join(credsDir, credsName); + if (existsSync(p)) { + const buf = readFileSync(p); + if (buf.length >= 32) { + log.info("server key loaded from systemd-creds"); + return buf.subarray(0, 32); + } + log.warn("systemd-creds file too short; falling back to dev key"); + } + } + + // 2. Dev fallback + const keyPath = join(config.dataDir, "secret.key"); + if (existsSync(keyPath)) { + const buf = readFileSync(keyPath); + if (buf.length >= 32) { + log.info(`server key loaded from ${keyPath}`); + return buf.subarray(0, 32); + } + } + + // 3. Generate new dev key + log.warn( + `GENERATING DEV SERVER KEY at ${keyPath} — production should use systemd-creds`, + ); + try { + mkdirSync(dirname(keyPath), { recursive: true }); + } catch { + /* exists or insufficient perms */ + } + const fresh = randomBytes(32); + writeFileSync(keyPath, fresh, { mode: 0o600 }); + try { + chmodSync(keyPath, 0o600); + } catch { + /* not POSIX; fine on dev */ + } + return fresh; +} + +// ---- base64url helpers ------------------------------------------------------ + +function b64u(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function b64uDecode(s: string): Buffer { + const padded = s + "=".repeat((4 - (s.length % 4)) % 4); + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +}