mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
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:
parent
83f598f187
commit
a8b0fbb2bc
23 changed files with 619 additions and 1046 deletions
|
|
@ -18,25 +18,23 @@ default:
|
||||||
plugin: events-default
|
plugin: events-default
|
||||||
enabled: true
|
enabled: true
|
||||||
services:
|
services:
|
||||||
# ----- Foundations -----
|
# ----- Data layer -----
|
||||||
service-store:
|
service-store:
|
||||||
plugin: service-store
|
plugin: service-store
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
sqlitePath: /var/lib/betterframe/betterframe.db
|
||||||
|
|
||||||
service-secrets:
|
# ----- Admin UI + API (includes secrets + auth config) -----
|
||||||
plugin: service-secrets
|
service-admin-http:
|
||||||
|
plugin: service-admin-http
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
# In production, leave both unset and rely on systemd-creds.
|
host: 127.0.0.1
|
||||||
# In dev, the plugin generates a key in dataDir/secret.key (0600) and warns.
|
port: 18080
|
||||||
|
# Secrets (was service-secrets)
|
||||||
dataDir: /var/lib/betterframe
|
dataDir: /var/lib/betterframe
|
||||||
|
# Auth (was service-auth)
|
||||||
service-auth:
|
|
||||||
plugin: service-auth
|
|
||||||
enabled: true
|
|
||||||
config:
|
|
||||||
sessionIdleSeconds: 43200 # 12h
|
sessionIdleSeconds: 43200 # 12h
|
||||||
sessionMaxSeconds: 2592000 # 30d
|
sessionMaxSeconds: 2592000 # 30d
|
||||||
loginLockoutThreshold: 8
|
loginLockoutThreshold: 8
|
||||||
|
|
@ -45,48 +43,20 @@ default:
|
||||||
argon2TimeCost: 3
|
argon2TimeCost: 3
|
||||||
argon2Parallelism: 2
|
argon2Parallelism: 2
|
||||||
|
|
||||||
# ----- HTTP surfaces (each its own h3 listener; proxy fronts them) -----
|
# ----- Kiosk-facing REST API -----
|
||||||
service-admin-http:
|
|
||||||
plugin: service-admin-http
|
|
||||||
enabled: true
|
|
||||||
config:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: 18080
|
|
||||||
|
|
||||||
service-api-http:
|
service-api-http:
|
||||||
plugin: service-api-http
|
plugin: service-api-http
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 18081
|
port: 18081
|
||||||
|
codeTtlSeconds: 600 # 10m pairing code TTL
|
||||||
|
|
||||||
|
# ----- Live kiosk WebSocket channel -----
|
||||||
service-coordinator-ws:
|
service-coordinator-ws:
|
||||||
plugin: service-coordinator-ws
|
plugin: service-coordinator-ws
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 18082
|
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
|
noderedUrl: http://127.0.0.1:1880
|
||||||
|
|
||||||
service-cec-relay:
|
|
||||||
plugin: service-cec-relay
|
|
||||||
enabled: true
|
|
||||||
config: {}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Port 18080 behind Angie proxy. Initializes secrets + auth as
|
||||||
* /api/admin/*. Port 18080 behind the Angie proxy.
|
* shared modules (not BSB plugins).
|
||||||
*/
|
*/
|
||||||
import * as av from "@anyvali/js";
|
import * as av from "@anyvali/js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,9 +15,10 @@ import {
|
||||||
import { H3, serve } from "h3";
|
import { H3, serve } from "h3";
|
||||||
import type { Server } from "srvx";
|
import type { Server } from "srvx";
|
||||||
|
|
||||||
import type { Plugin as StorePlugin } from "../service-store/index.js";
|
import { getRepo } from "../../shared/plugin-registry.js";
|
||||||
import type { Plugin as AuthPlugin } from "../service-auth/index.js";
|
import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
|
||||||
import type { Plugin as SecretsPlugin } from "../service-secrets/index.js";
|
import { createAuth, type AuthApi } from "../../shared/auth.js";
|
||||||
|
import type { Repository } from "../service-store/repository.js";
|
||||||
|
|
||||||
import { registerMiddleware } from "./middleware.js";
|
import { registerMiddleware } from "./middleware.js";
|
||||||
import { registerSetupRoutes } from "./routes-setup.js";
|
import { registerSetupRoutes } from "./routes-setup.js";
|
||||||
|
|
@ -32,6 +33,19 @@ const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
host: av.string().default("127.0.0.1"),
|
host: av.string().default("127.0.0.1"),
|
||||||
port: av.int().min(1).max(65535).default(18080),
|
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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -57,9 +71,9 @@ export const EventSchemas = createEventSchemas({
|
||||||
// ---- Deps interface shared with route modules -------------------------------
|
// ---- Deps interface shared with route modules -------------------------------
|
||||||
|
|
||||||
export interface AdminDeps {
|
export interface AdminDeps {
|
||||||
store: StorePlugin;
|
repo: Repository;
|
||||||
auth: AuthPlugin;
|
auth: AuthApi;
|
||||||
secrets: SecretsPlugin;
|
secrets: SecretsApi;
|
||||||
cookieName: string;
|
cookieName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,51 +84,44 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
static override EventSchemas = EventSchemas;
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
initBeforePlugins?: string[];
|
initBeforePlugins?: string[];
|
||||||
initAfterPlugins?: string[] = ["service-store", "service-secrets", "service-auth"];
|
initAfterPlugins?: string[] = ["service-store"];
|
||||||
runBeforePlugins?: string[];
|
runBeforePlugins?: string[];
|
||||||
runAfterPlugins?: string[];
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
private _store?: StorePlugin;
|
|
||||||
private _auth?: AuthPlugin;
|
|
||||||
private _secrets?: SecretsPlugin;
|
|
||||||
private server?: Server;
|
private server?: Server;
|
||||||
|
|
||||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
super(cfg);
|
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> {
|
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 = {
|
const deps: AdminDeps = {
|
||||||
store: this.store,
|
repo,
|
||||||
auth: this.auth,
|
auth,
|
||||||
secrets: this.secrets,
|
secrets,
|
||||||
cookieName: this.auth.config.cookieName,
|
cookieName: this.config.cookieName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Order matters: middleware first, then routes
|
const app = new H3();
|
||||||
|
|
||||||
registerMiddleware(app, deps);
|
registerMiddleware(app, deps);
|
||||||
registerStaticRoutes(app);
|
registerStaticRoutes(app);
|
||||||
registerSetupRoutes(app, deps);
|
registerSetupRoutes(app, deps);
|
||||||
|
|
@ -122,11 +129,10 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
registerAdminRoutes(app, deps);
|
registerAdminRoutes(app, deps);
|
||||||
registerAccountRoutes(app, deps);
|
registerAccountRoutes(app, deps);
|
||||||
|
|
||||||
// Health/readiness/version (no auth)
|
|
||||||
app.get("/healthz", () => ({ status: "ok" }));
|
app.get("/healthz", () => ({ status: "ok" }));
|
||||||
app.get("/readyz", () => {
|
app.get("/readyz", () => {
|
||||||
try {
|
try {
|
||||||
deps.store.repo.isSetupComplete(); // touches DB
|
deps.repo.isSetupComplete();
|
||||||
return { status: "ready" };
|
return { status: "ready" };
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "not_ready" };
|
return { status: "not_ready" };
|
||||||
|
|
@ -137,10 +143,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Root redirect
|
|
||||||
app.get("/", () => {
|
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: "/setup" } });
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Auth & setup gate middleware for admin-http.
|
* 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 { AdminDeps } from "./index.js";
|
||||||
import type { User, Session } from "../../shared/types.js";
|
import type { User, Session } from "../../shared/types.js";
|
||||||
|
|
||||||
/** Augment h3 event context with resolved auth info. */
|
|
||||||
declare module "h3" {
|
declare module "h3" {
|
||||||
interface H3EventContext {
|
interface H3EventContext {
|
||||||
user?: User;
|
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 {
|
export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
// Setup gate: if setup not complete, only /setup, /static, /healthz, /readyz, /version allowed
|
|
||||||
app.use((event) => {
|
app.use((event) => {
|
||||||
const path = getRequestPath(event);
|
const path = getRequestPath(event);
|
||||||
|
|
||||||
// Always pass through non-gated paths
|
|
||||||
if (
|
if (
|
||||||
path === "/setup" ||
|
path === "/setup" ||
|
||||||
path.startsWith("/static/") ||
|
path.startsWith("/static/") ||
|
||||||
|
|
@ -39,29 +27,28 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If setup not complete, block everything except setup flow
|
if (!deps.repo.isSetupComplete()) {
|
||||||
if (!deps.store.repo.isSetupComplete()) {
|
|
||||||
if (!path.startsWith("/auth/")) {
|
if (!path.startsWith("/auth/")) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/setup" } });
|
return new Response(null, { status: 302, headers: { location: "/setup" } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth pages don't require session (login/totp/recovery)
|
|
||||||
if (path.startsWith("/auth/")) {
|
if (path.startsWith("/auth/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin pages require valid session
|
|
||||||
if (path.startsWith("/admin") || path.startsWith("/api/admin")) {
|
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) {
|
if (!resolved) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
}
|
}
|
||||||
// TOTP pending — only allow /auth/totp and /auth/recovery
|
|
||||||
if (resolved.session.totp_pending) {
|
if (resolved.session.totp_pending) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
|
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
|
||||||
}
|
}
|
||||||
// Attach to context for downstream handlers
|
|
||||||
event.context.user = resolved.user;
|
event.context.user = resolved.user;
|
||||||
event.context.session = resolved.session;
|
event.context.session = resolved.session;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = await deps.auth.hashPassword(newPw);
|
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)
|
// Revoke all sessions (force re-login)
|
||||||
deps.store.repo.revokeAllSessionsForUser(user.id);
|
deps.repo.revokeAllSessionsForUser(user.id);
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|
@ -77,7 +77,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Store unconfirmed secret + codes
|
// Store unconfirmed secret + codes
|
||||||
const encrypted = deps.auth.encryptTotpSecret(secret);
|
const encrypted = deps.auth.encryptTotpSecret(secret);
|
||||||
deps.store.repo.updateUser(user.id, {
|
deps.repo.updateUser(user.id, {
|
||||||
totp_secret_encrypted: encrypted,
|
totp_secret_encrypted: encrypted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,7 +127,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const codes: string[] = JSON.parse(codesJson);
|
const codes: string[] = JSON.parse(codesJson);
|
||||||
const hashed = await deps.auth.hashRecoveryCodes(codes);
|
const hashed = await deps.auth.hashRecoveryCodes(codes);
|
||||||
|
|
||||||
deps.store.repo.updateUser(user.id, {
|
deps.repo.updateUser(user.id, {
|
||||||
totp_enabled: true,
|
totp_enabled: true,
|
||||||
recovery_codes_hashed: hashed,
|
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_enabled: false,
|
||||||
totp_secret_encrypted: null,
|
totp_secret_encrypted: null,
|
||||||
recovery_codes_hashed: [],
|
recovery_codes_hashed: [],
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/", (event) => {
|
app.get("/admin/", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const cameras = deps.store.repo.listCameras();
|
const cameras = deps.repo.listCameras();
|
||||||
const kiosks = deps.store.repo.listKiosks();
|
const kiosks = deps.repo.listKiosks();
|
||||||
const layouts = deps.store.repo.listDisplays(); // for count
|
const layouts = deps.repo.listDisplays(); // for count
|
||||||
const events = deps.store.repo.recentEvents(10);
|
const events = deps.repo.recentEvents(10);
|
||||||
const onlineKiosks = kiosks.filter((k) => {
|
const onlineKiosks = kiosks.filter((k) => {
|
||||||
if (!k.last_seen_at) return false;
|
if (!k.last_seen_at) return false;
|
||||||
return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000;
|
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) => {
|
app.get("/admin/cameras", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const cameras = deps.store.repo.listCameras();
|
const cameras = deps.repo.listCameras();
|
||||||
const streamCounts = new Map<number, number>();
|
const streamCounts = new Map<number, number>();
|
||||||
for (const cam of cameras) {
|
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 }));
|
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) {
|
if (!name || name.length > 128) {
|
||||||
errors.push("Name required (max 128 chars).");
|
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.");
|
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,
|
name,
|
||||||
type: type!,
|
type: type!,
|
||||||
rtsp_url: rtspUrl ?? null,
|
rtsp_url: rtspUrl ?? null,
|
||||||
|
|
@ -111,7 +111,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
// Create default main stream for RTSP cameras
|
// Create default main stream for RTSP cameras
|
||||||
if (type === "rtsp" && rtspUrl) {
|
if (type === "rtsp" && rtspUrl) {
|
||||||
deps.store.repo.createCameraStream({
|
deps.repo.createCameraStream({
|
||||||
camera_id: cam.id,
|
camera_id: cam.id,
|
||||||
role: "main",
|
role: "main",
|
||||||
name: "Main",
|
name: "Main",
|
||||||
|
|
@ -129,8 +129,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/kiosks", (event) => {
|
app.get("/admin/kiosks", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const kiosks = deps.store.repo.listKiosks();
|
const kiosks = deps.repo.listKiosks();
|
||||||
const pending = deps.store.repo.listPendingPairingCodes();
|
const pending = deps.repo.listPendingPairingCodes();
|
||||||
return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
|
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) => {
|
app.get("/admin/displays", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const displays = deps.store.repo.listDisplays();
|
const displays = deps.repo.listDisplays();
|
||||||
return html(SimpleListPage({
|
return html(SimpleListPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
pageTitle: "Displays",
|
pageTitle: "Displays",
|
||||||
|
|
@ -175,7 +175,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/labels", (event) => {
|
app.get("/admin/labels", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const labels = deps.store.repo.listLabels();
|
const labels = deps.repo.listLabels();
|
||||||
return html(SimpleListPage({
|
return html(SimpleListPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
pageTitle: "Labels",
|
pageTitle: "Labels",
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,11 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return html(LoginPage({ error: "Username and password required.", username }));
|
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) {
|
if (!user || !user.is_active) {
|
||||||
return html(LoginPage({ error: "Invalid credentials.", username }));
|
return html(LoginPage({ error: "Invalid credentials.", username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lockout check
|
|
||||||
if (user.locked_until) {
|
if (user.locked_until) {
|
||||||
const lockEnd = new Date(user.locked_until);
|
const lockEnd = new Date(user.locked_until);
|
||||||
if (lockEnd > new Date()) {
|
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);
|
const valid = await deps.auth.verifyPassword(password, user.password_hash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
// Increment failed count
|
|
||||||
const count = user.failed_login_count + 1;
|
const count = user.failed_login_count + 1;
|
||||||
const patch: Record<string, unknown> = { failed_login_count: count };
|
const patch: Record<string, unknown> = { failed_login_count: count };
|
||||||
if (count >= 8) {
|
if (count >= deps.auth.config.loginLockoutThreshold) {
|
||||||
patch["locked_until"] = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
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 }));
|
return html(LoginPage({ error: "Invalid credentials.", username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset failed login count
|
deps.repo.updateUser(user.id, {
|
||||||
deps.store.repo.updateUser(user.id, {
|
|
||||||
failed_login_count: 0,
|
failed_login_count: 0,
|
||||||
locked_until: null,
|
locked_until: null,
|
||||||
last_login_at: new Date().toISOString(),
|
last_login_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create session
|
|
||||||
const totpPending = user.totp_enabled;
|
const totpPending = user.totp_enabled;
|
||||||
const { cookieValue } = await deps.auth.createSession({
|
const { cookieValue } = await deps.auth.createSession({
|
||||||
user,
|
user,
|
||||||
|
|
@ -75,7 +71,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
setCookie(event, deps.cookieName, cookieValue, {
|
setCookie(event, deps.cookieName, cookieValue, {
|
||||||
...COOKIE_OPTS,
|
...COOKIE_OPTS,
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30d absolute max
|
maxAge: deps.auth.config.sessionMaxSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (totpPending) {
|
if (totpPending) {
|
||||||
|
|
@ -126,8 +122,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return html(TotpPage({ error: "Invalid code. Try again." }));
|
return html(TotpPage({ error: "Invalid code. Try again." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear totp_pending
|
deps.repo.setSessionTotpPending(session.id, false);
|
||||||
deps.store.repo.setSessionTotpPending(session.id, false);
|
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
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." }));
|
return html(RecoveryPage({ error: "Invalid recovery code." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update remaining codes
|
deps.repo.updateUser(user.id, {
|
||||||
deps.store.repo.updateUser(user.id, {
|
|
||||||
recovery_codes_hashed: result.remaining,
|
recovery_codes_hashed: result.remaining,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear totp_pending
|
deps.repo.setSessionTotpPending(session.id, false);
|
||||||
deps.store.repo.setSessionTotpPending(session.id, false);
|
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* First-run setup routes.
|
* 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 H3, readBody, html } from "h3";
|
||||||
import type { AdminDeps } from "./index.js";
|
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 {
|
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.get("/setup", () => {
|
app.get("/setup", () => {
|
||||||
if (deps.store.repo.isSetupComplete()) {
|
if (deps.repo.isSetupComplete()) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
}
|
}
|
||||||
return html(SetupPage({}));
|
return html(SetupPage({}));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/setup", async (event) => {
|
app.post("/setup", async (event) => {
|
||||||
if (deps.store.repo.isSetupComplete()) {
|
if (deps.repo.isSetupComplete()) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
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 password = body?.password ?? "";
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Validate
|
|
||||||
if (!username || username.length < 3 || username.length > 64) {
|
if (!username || username.length < 3 || username.length > 64) {
|
||||||
errors.push("Username must be 3–64 characters.");
|
errors.push("Username must be 3–64 characters.");
|
||||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
} 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 }));
|
return html(SetupPage({ error: errors.join(" "), username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create admin user
|
|
||||||
const hash = await deps.auth.hashPassword(password);
|
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 clusterKey = deps.secrets.generateClusterKey();
|
||||||
const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster");
|
const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster");
|
||||||
deps.store.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
|
||||||
deps.store.repo.markClusterKeyProvisioned();
|
deps.repo.markClusterKeyProvisioned();
|
||||||
|
|
||||||
// Create default display
|
deps.repo.createDefaultDisplay();
|
||||||
deps.store.repo.createDefaultDisplay();
|
deps.repo.markSetupComplete();
|
||||||
|
|
||||||
// Mark setup complete
|
|
||||||
deps.store.repo.markSetupComplete();
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|
|
||||||
|
|
@ -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/*
|
* Serves pairing, bundle, and kiosk management endpoints.
|
||||||
* and /api/pair/*. Port 18081 behind the Angie proxy.
|
* Port 18081 behind Angie proxy.
|
||||||
*/
|
*/
|
||||||
import * as av from "@anyvali/js";
|
import * as av from "@anyvali/js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,12 +13,11 @@ import {
|
||||||
type Observable,
|
type Observable,
|
||||||
} from "@bsb/base";
|
} from "@bsb/base";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
|
||||||
|
|
||||||
const ConfigSchema = av.object(
|
const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
host: av.string().default("127.0.0.1"),
|
host: av.string().default("127.0.0.1"),
|
||||||
port: av.int().min(1).max(65535).default(18081),
|
port: av.int().min(1).max(65535).default(18081),
|
||||||
|
codeTtlSeconds: av.int().min(60).max(3600).default(600),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "strip" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -41,14 +40,12 @@ export const EventSchemas = createEventSchemas({
|
||||||
onBroadcast: {},
|
onBroadcast: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Plugin -----------------------------------------------------------------
|
|
||||||
|
|
||||||
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
static override Config = Config;
|
static override Config = Config;
|
||||||
static override EventSchemas = EventSchemas;
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
initBeforePlugins?: string[];
|
initBeforePlugins?: string[];
|
||||||
initAfterPlugins?: string[] = ["service-store", "service-auth"];
|
initAfterPlugins?: string[] = ["service-store"];
|
||||||
runBeforePlugins?: string[];
|
runBeforePlugins?: string[];
|
||||||
runAfterPlugins?: string[];
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
|
@ -57,12 +54,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(_obs: Observable): Promise<void> {
|
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 run(_obs: Observable): Promise<void> {}
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
async dispose(): Promise<void> {
|
|
||||||
// TODO: close h3 listener
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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> {}
|
|
||||||
}
|
|
||||||
|
|
@ -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> {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* service-coordinator-ws — WebSocket hub for live kiosk channel.
|
* service-coordinator-ws — WebSocket hub for live kiosk channel.
|
||||||
*
|
*
|
||||||
* Kiosks connect here to receive real-time layout switches, power
|
* Kiosks connect here for real-time layout switches, power commands,
|
||||||
* commands, and status pings. Port 18082 behind the Angie proxy.
|
* and status pings. Port 18082 behind Angie proxy.
|
||||||
*/
|
*/
|
||||||
import * as av from "@anyvali/js";
|
import * as av from "@anyvali/js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,12 +13,11 @@ import {
|
||||||
type Observable,
|
type Observable,
|
||||||
} from "@bsb/base";
|
} from "@bsb/base";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
|
||||||
|
|
||||||
const ConfigSchema = av.object(
|
const ConfigSchema = av.object(
|
||||||
{
|
{
|
||||||
host: av.string().default("127.0.0.1"),
|
host: av.string().default("127.0.0.1"),
|
||||||
port: av.int().min(1).max(65535).default(18082),
|
port: av.int().min(1).max(65535).default(18082),
|
||||||
|
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "strip" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -41,14 +40,12 @@ export const EventSchemas = createEventSchemas({
|
||||||
onBroadcast: {},
|
onBroadcast: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Plugin -----------------------------------------------------------------
|
|
||||||
|
|
||||||
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
static override Config = Config;
|
static override Config = Config;
|
||||||
static override EventSchemas = EventSchemas;
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
initBeforePlugins?: string[];
|
initBeforePlugins?: string[];
|
||||||
initAfterPlugins?: string[] = ["service-store", "service-auth"];
|
initAfterPlugins?: string[] = ["service-store"];
|
||||||
runBeforePlugins?: string[];
|
runBeforePlugins?: string[];
|
||||||
runAfterPlugins?: string[];
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
|
@ -61,8 +58,5 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(_obs: Observable): Promise<void> {}
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
async dispose(): Promise<void> {
|
|
||||||
// TODO: close ws server
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {}
|
|
||||||
}
|
|
||||||
|
|
@ -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> {}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
|
|
||||||
import { MIGRATIONS } from "./migrations.js";
|
import { MIGRATIONS } from "./migrations.js";
|
||||||
import { Repository } from "./repository.js";
|
import { Repository } from "./repository.js";
|
||||||
|
import { registerRepo } from "../../shared/plugin-registry.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -135,6 +136,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerRepo(this._repo);
|
||||||
obs.log.info("store ready");
|
obs.log.info("store ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
310
server/src/shared/auth.ts
Normal file
310
server/src/shared/auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
4
server/src/shared/bundle.ts
Normal file
4
server/src/shared/bundle.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Label-scoped bundle generation — shared module stub.
|
||||||
|
* TODO: implement from old-python reference.
|
||||||
|
*/
|
||||||
4
server/src/shared/cec-relay.ts
Normal file
4
server/src/shared/cec-relay.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* CEC command relay — shared module stub.
|
||||||
|
* TODO: implement cec-ctl subprocess + ws message translation.
|
||||||
|
*/
|
||||||
4
server/src/shared/nodered-bridge.ts
Normal file
4
server/src/shared/nodered-bridge.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Node-RED HTTP bridge — shared module stub.
|
||||||
|
* TODO: implement outbound forwarder + inbound callbacks.
|
||||||
|
*/
|
||||||
4
server/src/shared/pairing.ts
Normal file
4
server/src/shared/pairing.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Pairing state machine — shared module stub.
|
||||||
|
* TODO: implement initiate/claim/poll from old-python reference.
|
||||||
|
*/
|
||||||
19
server/src/shared/plugin-registry.ts
Normal file
19
server/src/shared/plugin-registry.ts
Normal 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;
|
||||||
|
}
|
||||||
158
server/src/shared/secrets.ts
Normal file
158
server/src/shared/secrets.ts
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue