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.
This commit is contained in:
Mitchell R 2026-05-10 02:29:25 +02:00
parent 83f598f187
commit a8b0fbb2bc
No known key found for this signature in database
23 changed files with 619 additions and 1046 deletions

View file

@ -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: {}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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<void> {
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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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/" } });

View file

@ -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;

View file

@ -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: [],

View file

@ -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<number, number>();
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",

View file

@ -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<string, unknown> = { 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/" } });
});

View file

@ -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 364 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,

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
}
async init(_obs: Observable): Promise<void> {
// TODO: create h3 app, mount kiosk + pairing routes, start listening
// TODO: create h3 app, mount kiosk + pairing routes
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {
// TODO: close h3 listener
}
async dispose(): Promise<void> {}
}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
// ---- BSB lifecycle -------------------------------------------------------
async init(_obs: Observable): Promise<void> {
// 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<void> {}
async dispose(): Promise<void> {}
/** 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<string> {
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<boolean> {
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<string[]> {
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 `<sid>.<hmac>` 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<ApiKey | null> {
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;
}
}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: implement bundle query + cluster-encrypt
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -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<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-coordinator-ws"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: subscribe to CEC command events, relay via coordinator
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {
// TODO: close ws server
}
async dispose(): Promise<void> {}
}

View file

@ -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<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: set up outbound HTTP forwarder + inbound callback routes
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: implement initiate/claim/poll state machine
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -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: <data_dir>/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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(obs: Observable): Promise<void> {
this.serverKey = this.loadServerKey(obs);
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
// ---- public API for sibling services -------------------------------------
/**
* Encrypt a UTF-8 string at rest. Returns a self-describing ciphertext:
* v1.<iv-b64url>.<tag-b64url>.<ct-b64url>
* `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.<iv-b64url>.<tag-b64url>.<ct-b64url>
*
* 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: <data_dir>/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");
}

View file

@ -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<InstanceType<typeof Config>, typeof Event
}
});
registerRepo(this._repo);
obs.log.info("store ready");
}

310
server/src/shared/auth.ts Normal file
View file

@ -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<string>;
verifyPassword(plain: string, hash: string): Promise<boolean>;
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<string[]>;
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<ApiKey | null>;
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<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: config.argon2Memory,
timeCost: config.argon2TimeCost,
parallelism: config.argon2Parallelism,
});
}
async function verifyPassword(plain: string, hash: string): Promise<boolean> {
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<string[]> {
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<ApiKey | null> {
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,
};
}

View file

@ -0,0 +1,4 @@
/**
* Label-scoped bundle generation shared module stub.
* TODO: implement from old-python reference.
*/

View file

@ -0,0 +1,4 @@
/**
* CEC command relay shared module stub.
* TODO: implement cec-ctl subprocess + ws message translation.
*/

View file

@ -0,0 +1,4 @@
/**
* Node-RED HTTP bridge shared module stub.
* TODO: implement outbound forwarder + inbound callbacks.
*/

View file

@ -0,0 +1,4 @@
/**
* Pairing state machine shared module stub.
* TODO: implement initiate/claim/poll from old-python reference.
*/

View file

@ -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;
}

View file

@ -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");
}