From 61b99551608f7b38003a852337b05356a41a407a Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Wed, 1 Jul 2026 13:06:51 +0200 Subject: [PATCH 1/4] deploy(park-buzi): pin TAG=stage-d2ab2e0 (keep dev in sync with stage) Mirrors the pin on the stage branch so resources.toml agrees regardless of which branch the Komodo ResourceSync reads. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- komodo/resources.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index 2a2de15..ab04afa 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-39c778f +TAG=stage-d2ab2e0 COOKIE_SECURE=0 VISION_ENABLED=1 WS_ALLOWED_ORIGINS= From 306d136a08271d3c4dd70d8a80cef1c58ff608ad Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 13:40:58 +0200 Subject: [PATCH 2/4] feat(setup): operator-tested relay pulse, signed into the ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-relay "Test" control on each saved controller in /setup so an admin can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a barrier relay — but because a physical open with no matching signed command is the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest, source manual, attributed to the acting admin) BEFORE it fires. Reconciliation then reads the open as explained, not an anomaly, and there's an audit trail. - Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller (real id → clean attribution; also stops a redirected/unsaved config from opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is reported, not a 500. radarAlert relays (lamps) are excluded from the UI. - New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys. - EventLog constructed before setupRoutes so the route can sign. - Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed barrier_open_command on success, 400 unknown relay w/ no ledger row, 404 unknown controller, 400 bad relay value. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- .../src/routes/setup-relay-test.test.ts | 115 ++++++++++++++++++ apps/server/src/routes/setup.ts | 92 ++++++++++++++ apps/server/src/server.ts | 27 ++-- apps/web/src/SetupWizard.tsx | 55 +++++++++ apps/web/src/api.ts | 15 +++ apps/web/src/lib/i18n/en.ts | 7 ++ apps/web/src/lib/i18n/sq.ts | 7 ++ packages/shared/src/index.ts | 4 + 8 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/routes/setup-relay-test.test.ts diff --git a/apps/server/src/routes/setup-relay-test.test.ts b/apps/server/src/routes/setup-relay-test.test.ts new file mode 100644 index 0000000..715f5e7 --- /dev/null +++ b/apps/server/src/routes/setup-relay-test.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { devices, ledgerEvents, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; + +// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the +// wiring — it physically opens the barrier. Because "a physical open with no matching +// signed command is the fraud signal" (append-only-event-chain / reconciliation), the +// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it +// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs — +// no real hardware) so they exercise the validate → sign → pulse path safely. + +let db: Db; +let close: () => void; +let app: FastifyInstance; + +const CTL = "ctl-stub"; + +beforeEach(async () => { + const t = createTestDb(); + db = t.db; + close = t.close; + db.insert(devices).values({ + id: CTL, + category: "access", + driverId: "stub-access", + config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] }, + enabled: true, + }).run(); + app = await buildServer({ db }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + close(); +}); + +async function pulse( + body: unknown, + auth?: { cookie: string; csrf: string }, +) { + return app.inject({ + method: "POST", + url: "/api/setup/test-relay", + headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {}, + payload: body as Record, + }); +} + +describe("POST /api/setup/test-relay", () => { + it("is admin-only: a non-site:update user is 403", async () => { + await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] }); + const auth = await login(app, "op", "pw"); + const res = await pulse({ id: CTL, relay: 1 }, auth); + expect(res.statusCode).toBe(403); + }); + + it("requires CSRF on the mutation", async () => { + await seedUser(db, { username: "admin", password: "pw" }); + const { cookie } = await login(app, "admin", "pw"); + const res = await app.inject({ + method: "POST", + url: "/api/setup/test-relay", + headers: { cookie }, // no x-csrf-token + payload: { id: CTL, relay: 1 }, + }); + expect(res.statusCode).toBe(403); + }); + + it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => { + await seedUser(db, { username: "admin", password: "pw" }); + const auth = await login(app, "admin", "pw"); + + const res = await pulse({ id: CTL, relay: 2 }, auth); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ ok: true }); + + // The deliberate open is EXPLAINED in the signed ledger — not an anomaly. + const rows = db.select().from(ledgerEvents).all(); + const testOpen = rows.find((r) => r.type === "barrier_open_command"); + expect(testOpen, "a barrier_open_command must be signed").toBeTruthy(); + expect(testOpen!.source).toBe("manual"); // deliberate human action + expect(testOpen!.signature.length).toBeGreaterThan(0); + const payload = testOpen!.payload as Record; + expect(payload.relayTest).toBe(true); + expect(payload.reasonCode).toBe("setup.relayTest"); + expect(payload.relay).toBe(2); + expect(payload.controllerId).toBe(CTL); + expect(payload.operator).toBe("admin"); // attributed to the acting admin + }); + + it("rejects a relay the controller does not declare (400, no ledger row)", async () => { + await seedUser(db, { username: "admin", password: "pw" }); + const auth = await login(app, "admin", "pw"); + const res = await pulse({ id: CTL, relay: 9 }, auth); + expect(res.statusCode).toBe(400); + expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed + }); + + it("404s an unknown controller id", async () => { + await seedUser(db, { username: "admin", password: "pw" }); + const auth = await login(app, "admin", "pw"); + const res = await pulse({ id: "nope", relay: 1 }, auth); + expect(res.statusCode).toBe(404); + }); + + it("rejects a bad relay value (non-positive-integer)", async () => { + await seedUser(db, { username: "admin", password: "pw" }); + const auth = await login(app, "admin", "pw"); + expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400); + expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400); + }); +}); diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 07a7f62..46b0cf7 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -15,7 +15,9 @@ import { type DeviceCategory, type DeviceConfig, } from "@parking/devices"; +import { reasonPayload } from "@parking/shared"; import { requirePermission } from "../auth.js"; +import type { EventLog } from "../event-log.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import type { VisionClient } from "../vision-client.js"; @@ -60,6 +62,12 @@ function redactSecrets(config: Record): Record return out; } +/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup + * relay test needs it; a stub/reader/camera won't have it). */ +function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise } { + return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function"; +} + // Connection-identity keys: the fields that decide WHERE a probe is sent. A stored // secret may only be re-merged when these match the stored row — otherwise an admin // could point a test at an attacker host while keeping a real device id and have the @@ -220,6 +228,7 @@ export async function setupRoutes( app: FastifyInstance, db: Db, vision?: VisionClient | null, + eventLog?: EventLog | null, ): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -456,6 +465,89 @@ export async function setupRoutes( }, ); + // PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any + // vehicle/session. This physically opens the barrier, so unlike the other tests it + // runs only against a SAVED controller (real id → clean attribution) and it SIGNS a + // `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the + // admin's identity. That is the whole point of doing it this way: a physical open with + // no matching signed command is the fraud signal ([[append-only-event-chain]], + // [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a + // silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is + // recorded whether or not the physical pulse then succeeds. Admin-only (site:update). + app.post<{ Body: { id: string; relay: number } }>( + "/api/setup/test-relay", + { preHandler: adminGuard }, + async (req, reply) => { + const { id, relay } = req.body; + if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" }); + if (!Number.isInteger(relay) || relay < 1) { + return reply.code(400).send({ error: "relay must be a 1-based channel number" }); + } + + // A relay test fires REAL hardware, so it must target a persisted controller — no + // firing an unsaved/redirected config (that would let a probe open an arbitrary host's + // barrier). Load the saved row and build straight from its stored config (relayPassword + // included — it's on the row, never in the request). + const row = db.select().from(devices).where(eq(devices.id, id)).get(); + if (!row) return reply.code(404).send({ error: "controller not found" }); + if (row.category !== "access") { + return reply.code(400).send({ error: `device ${id} is not a controller` }); + } + const cfg = (row.config ?? {}) as Record; + const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : []; + if (!relays.some((r) => r.relay === relay)) { + return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` }); + } + + let device; + try { + device = registry.create(row.driverId, cfg as Record); + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }); + } + if (!hasPulseOpen(device)) { + return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` }); + } + + // Sign the deliberate open FIRST — recorded whether or not the physical pulse then + // succeeds. Skip only if no ledger is wired (test/degraded), in which case we still + // refuse rather than fire an unrecorded open. + const operator = req.user?.username ?? "unknown"; + if (!eventLog) { + return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" }); + } + await eventLog.append({ + type: "barrier_open_command", + // A deliberate human action from the admin console → "manual" (the top-level + // IdentitySource). The relayTest marker + reason distinguish it in the payload. + source: "manual", + identity: `relay-test:${id}:${relay}`, + payload: { + ...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }), + relayTest: true, + controllerId: id, + relay, + operator, + }, + }); + + const startedAt = Date.now(); + try { + await device.pulseOpen(relay); + } catch (err) { + // The failure we're testing for (relay unreachable, wrong password). The open is + // already signed; report the pulse failure, don't 500. + return reply.send({ + ok: false, + reason: "pulse-failed", + detail: (err as Error).message, + tookMs: Date.now() - startedAt, + }); + } + return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt }); + }, + ); + // Candidate backend IPs the device can push to, for a given device host. The // wizard pre-fills with the on-subnet one and lets the admin override (matters // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 44a7b89..d627a5f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -115,11 +115,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise + deviceEvents.emitLedger(row), + ); + // Device-agnostic setup: the admin adds controllers (with their relays + entry // button) and binds readers/cameras to a controller relay at first-run. There is // no lane — a parking lot is one pool with a flexible set of entry/exit points. // See wiki/concepts/first-run-setup.md, entry-exit-points.md. - await setupRoutes(app, db, visionClient); + await setupRoutes(app, db, visionClient, eventLog); // Inbound device pushes (e.g. Dingtian Input Link URL → button events), // guarded by source-IP allowlist + a shared-secret path token, both read from @@ -158,17 +171,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise deviceMonitor.start()); app.addHook("onClose", async () => deviceMonitor.stop()); - // Append-only signed business LEDGER (ledger_events). Holds only business facts - // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw - // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED - // in device_events. The entry flow (TODO) turns an input into a signed - // vehicle_entry once a ticket prints + the barrier is commanded. - // See wiki/decisions/event-streams-split.md. - // The 4th arg is a read-side fan-out fired AFTER each durable append — used to - // push the event to live booth clients (WS). It cannot affect the sign/chain path. - const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) => - deviceEvents.emitLedger(row), - ); + // Read routes for the signed ledger (constructed above, before setupRoutes). await eventRoutes(app, db, eventLog); // Admin reporting: read-only charts/totals aggregated from the signed ledger diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d530c6f..ad7a2f5 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -10,6 +10,7 @@ import { testAnpr, testDevice, testPrint, + testRelay, unassignDevice, type AnprTestResult, type PrintTestResult, @@ -305,6 +306,7 @@ function AssignmentRow({ {assignment.driverId} {host && {host}} + {assignment.category === "access" && } {!assignment.enabled && {t("setup.disabled")}} {error && {error}} @@ -318,6 +320,59 @@ function AssignmentRow({ ); } +/** Per-relay "Test" control on a SAVED controller row. Pulses a BARRIER relay to prove + * the wiring — this physically opens the barrier, so it confirms first, and the server + * signs the deliberate open into the ledger (reason setup.relayTest). radarAlert relays + * are lamps, not barriers — excluded (pulsing one is meaningless/wrong). */ +function RelayTester({ assignment }: { assignment: Assignment }) { + const { t } = useTranslation(); + const cfg = assignment.config as Record; + const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; + const barriers = relays.filter((r) => r.direction !== "radarAlert"); + const [busyRelay, setBusyRelay] = useState(null); + const [result, setResult] = useState<{ relay: number; ok: boolean; detail?: string } | null>(null); + + if (barriers.length === 0) return null; + + async function testRelayNow(relay: number) { + if (!confirm(t("setup.confirmRelayTest", { relay }))) return; + setBusyRelay(relay); + setResult(null); + try { + const res = await testRelay(assignment.id, relay); + setResult({ relay, ok: res.ok, detail: res.ok ? undefined : res.detail ?? res.reason }); + } catch (e) { + setResult({ relay, ok: false, detail: (e as Error).message }); + } finally { + setBusyRelay(null); + } + } + + return ( + + {barriers.map((r) => ( + + ))} + {result && ( + + {result.ok + ? t("setup.relayTestOk", { relay: result.relay }) + : t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })} + + )} + + ); +} + /** Inline summary of an assignment's direction/binding for the list. */ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) { const { t } = useTranslation(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6a57731..8891f72 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -460,6 +460,21 @@ export function testPrint( }); } +export type RelayTestResult = + | { ok: true; firedAt: string; tookMs: number } + | { ok: false; reason: string; detail?: string; tookMs?: number }; + +/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the + * barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before + * firing, so the open is explained, not a reconciliation anomaly. Saved controller only + * (needs a persisted id for attribution). */ +export function testRelay(id: string, relay: number): Promise { + return apiFetch("/api/setup/test-relay", { + method: "POST", + body: JSON.stringify({ id, relay }), + }); +} + // --- Admin reports ------------------------------------------------------- export type ReportBucket = "hour" | "day" | "month"; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 61ae09d..677dfdc 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -470,6 +470,13 @@ export const en: Catalog = { "Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.", printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.", "printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).", + // Relay wiring test — physically opens the barrier (the open is signed into the ledger). + testRelay: "Test R{{relay}}", + relayTesting: "Opening…", + testRelayTitle: "Pulse relay {{relay}} — opens the barrier", + confirmRelayTest: "Pulse relay {{relay}} now? This physically opens the barrier and is recorded in the ledger as a test.", + relayTestOk: "✓ R{{relay}} pulsed — barrier opened", + relayTestFailed: "✗ R{{relay}} failed: {{detail}}", // Reveal/hide toggle for a secret field (e.g. the device web password). revealSecret: "Show password", hideSecret: "Hide password", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index a571331..638a308 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -480,6 +480,13 @@ export const sq = { "Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.", printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.", "printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).", + // Provë e releut — hap fizikisht barrierën (hapja regjistrohet në ledger). + testRelay: "Provo R{{relay}}", + relayTesting: "Duke hapur…", + testRelayTitle: "Puls releu {{relay}} — hap barrierën", + confirmRelayTest: "Ky veprim hap fizikisht barrierën dhe regjistrohet në ledger si provë.", + relayTestOk: "✓ R{{relay}} u pulsua — barriera u hap", + relayTestFailed: "✗ R{{relay}} dështoi: {{detail}}", // Reveal/hide toggle for a secret field (e.g. the device web password). revealSecret: "Shfaq fjalëkalimin", hideSecret: "Fshih fjalëkalimin", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee54fa8..54c3756 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -395,6 +395,9 @@ export const REASON_CODES = [ "sub.refused.unpaidWindow", // a wrongly-printed transient ticket cancelled by the operator (signed void event). "void.ticketCancelled", + // an admin fired a barrier relay from Setup to test the wiring. The physical open is + // DELIBERATE — signing it keeps reconciliation from reading it as an out-of-band open. + "setup.relayTest", ] as const; export type ReasonCode = (typeof REASON_CODES)[number]; @@ -428,6 +431,7 @@ export const REASON_EN: Record = { "sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)", "sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", "void.ticketCancelled": "ticket cancelled — {reason}", + "setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup", }; /** From c142166972bf20c77ac21f893bb1f086f9aaa5cd Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 13:41:11 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(wiki):=20ATECC608=20is=20upcoming=20?= =?UTF-8?q?=E2=80=94=20retag=20ledger=20signing=20to=20the=20on-host=20rea?= =?UTF-8?q?lity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No secure element is on-site: event signing runs on the software HMAC (EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is tamper-EVIDENT but forgeable by anyone who owns the host. Several pages overstated it as present-tense "ATECC608-signed / unforgeable"; correct them. - NEW concepts/hardware-signer-options.md: four options for a non-extractable signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded ESP32, wrong part for a PC host). - entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded. - disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO). - open-questions #6 reframed; standing-decisions / overview / threat-model / index de-overstated; log query entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/hardware-signer-options.md | 90 ++++++++++++++++++++++++ wiki/concepts/threat-model.md | 5 +- wiki/decisions/disk-os-hardening.md | 63 ++++++++++++++++- wiki/decisions/open-questions.md | 17 +++-- wiki/decisions/standing-decisions.md | 8 ++- wiki/entities/atecc608.md | 34 ++++++--- wiki/index.md | 8 ++- wiki/log.md | 68 ++++++++++++++++++ wiki/overview.md | 8 ++- 9 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 wiki/concepts/hardware-signer-options.md diff --git a/wiki/concepts/hardware-signer-options.md b/wiki/concepts/hardware-signer-options.md new file mode 100644 index 0000000..167edef --- /dev/null +++ b/wiki/concepts/hardware-signer-options.md @@ -0,0 +1,90 @@ +--- +type: concept +tags: [parking, security, integrity, crypto, hardware, threat-model] +sources: [] +updated: 2026-07-02 +--- + +# Hardware signer options (non-extractable ledger signing key) + +Where the **[[append-only-event-chain|signed ledger]]'s** private signing key should live so that an +adversary who **owns the host** — including one who decrypts the disk via the [[disk-os-hardening]] +physical-tamper chain — still cannot **forge** the ledger. This is the still-open +[[open-questions|open-question #6]], reframed once it became clear (2026-07-02) that **no secure +element is on-site** and the [[atecc608]] isn't even the right part for a PC. + +## The problem in one line + +Today signing uses the software `SoftwareSigner` — **HMAC-SHA256, key in `EVENT_SIGNING_KEY`, an env +var on the host disk**. HMAC is symmetric: the same secret signs *and* verifies, and it sits on the +box. So the chain is tamper-**evident** (a blind editor without the key breaks it, and `verifyChain()` +pinpoints where) but **not unforgeable** — anyone who reads the key re-signs a doctored chain and it +verifies clean. The fix is a signer whose **private key is non-extractable**: the host can ask it to +sign, but can never read the key. That is a property of an HSM / smartcard / TPM — **not** of anything +that merely *stores* a key. + +## The four options + +### 1. USB HSM — Nitrokey HSM 2 / SmartCard-HSM *(recommended target)* +A ~€50 USB device with a non-extractable EC key (secp256r1 — same curve family as the ATECC608). The +host signs each event over **PKCS#11**; the key never leaves the token. It's essentially "the +ATECC608, but on USB instead of soldered", so it drops into the existing `signer.ts` seam (the +`keyId` field + the `TODO(atecc608)` public-key-verifier hook). +- **License fit:** OpenSC (the PKCS#11 stack) is LGPL/permissive — no vendor lock-in, matches the + all-MIT/Apache/BSD [[technology-stack|stack constraint]]. +- **Downside:** it's *removable* — an operator can pocket it. But that fails **closed** (no token → + can't sign → visibly noticed), and it can be epoxied / locked inside the case. + +### 2. YubiKey (PIV or OpenPGP applet) +Also a non-extractable EC/RSA key over PKCS#11; very robust, widely deployed; works the same way as +the Nitrokey for this purpose. Slightly more oriented to human 2FA than to an always-present signing +oracle, and the vendor stack is less fully-open than OpenSC. **~$50–70.** Fine as a substitute for +option 1. + +### 3. Reuse the on-board TPM 2.0 *(recommended interim — free)* +The booth PC already has a TPM (it's what seals LUKS — [[tpm]]). A TPM can also hold a non-extractable +signing key and sign over it. **Zero extra hardware, closes the "key in a plaintext env file" hole +immediately.** +- **The nuance:** don't reuse the *LUKS* sealing arrangement. That key is PCR-7-sealed, and the + battery-pull → live-USB → PCR-7 chain ([[disk-os-hardening]]) defeats PCR-7-only policies. Bind the + **signing** key to the TPM **without a PCR policy** (or with a PIN) so it's about non-extractability, + not boot-state — then an attacker who decrypts the disk still can't pull it. +- **Weaker than a dedicated HSM** against a sophisticated bus-sniffing attacker, but far stronger than + today's on-disk HMAC. + +### 4. Plain USB "sentinel" / dongle *(the trap — avoid)* +A generic USB flash drive holding a key file, or a license-dongle that only gates "is this USB +present". **Useless here:** if the key is *readable* off the stick, the host-owner copies it, exactly +like the env var. Presence-gating is not integrity. Only a device that **signs internally** delivers +non-extractability. Do not go this route. + +## Recommendation + +1. **Now (free):** move signing to a **TPM-held key** (option 3, no PCR policy) — kills the + plaintext-key-on-disk exposure using hardware already present. +2. **Target (purchasable):** a **USB HSM (Nitrokey HSM 2)** as the concrete stand-in for the still- + planned [[atecc608]] — same non-extractable-EC model, fits `signer.ts`, open tooling; physically + lock it in the case. +3. **[[atecc608]]** stays reserved for the **deferred** [[esp32-custom-controller]] (embedded), not + the PC host. + +Whichever is chosen, the code seam already exists: each event stores its `keyId`, so a swap is a new +`Signer` impl and **old events stay verifiable** under their original key — no migration of history. + +## The mental model to keep + +Against the physical adversary who opens the case, **the only real protection for the financial +record is a signing key even root can't read.** That is an HSM/smartcard/TPM property, never a +USB-storage property — and it's precisely what's missing today. Note the layering: [[disk-os-hardening]] +raises the cost of *reaching* the disk; a hardware signer makes the ledger unforgeable *even after* +the disk is reached; and [[reconciliation]] against records the box doesn't hold is the backstop that +survives a fully-owned host. They are complements, not substitutes. + +## Relates +- [[append-only-event-chain]] — the ledger this key signs; why software signing isn't enough. +- [[atecc608]] — the originally-specified secure element (upcoming; embedded, not PC). +- [[tpm]] — the on-board part; option 3, and the LUKS-sealing analysis. +- [[disk-os-hardening]] — the physical-tamper chain that makes a non-extractable key necessary. +- [[open-questions]] #6 (secure-element integration), #7 (JWT symmetric→asymmetric — same key-custody + argument for auth tokens). +- [[threat-model]] / [[reconciliation]] — the adversary and the backstop control. diff --git a/wiki/concepts/threat-model.md b/wiki/concepts/threat-model.md index 2d7fd3a..b9b6e3b 100644 --- a/wiki/concepts/threat-model.md +++ b/wiki/concepts/threat-model.md @@ -26,7 +26,10 @@ authorised access *through the app*. Encryption does nothing against the classic The controls that actually address insider/operator fraud are different in kind: - **[[append-only-event-chain]]** — events appended, never edited/deleted; a "void" is itself a - recorded event, hash-chained, and **[[atecc608]]-signed** (unforgeable). + recorded event, hash-chained, and signed. ⚠ Signing is **software today** (key on the host disk) → + tamper-*evident* but forgeable by a host owner; a non-extractable **hardware signer** + ([[hardware-signer-options]]; [[atecc608]] upcoming) is what makes it truly *unforgeable*. Which is + why the load-bearing insider control is reconciliation, next. - **[[reconciliation]]** against an authority the operator can't alter — *this is what remote sync really is*: a fraud-control mechanism, not just a backup. - **[[disk-os-hardening]]** still worthwhile (defeats boot-from-USB) but **not the main event**; diff --git a/wiki/decisions/disk-os-hardening.md b/wiki/decisions/disk-os-hardening.md index 6307784..e6f19a2 100644 --- a/wiki/decisions/disk-os-hardening.md +++ b/wiki/decisions/disk-os-hardening.md @@ -20,14 +20,27 @@ across the first real provisioning (2026-06-23) and the firmware-update episode > **not** replace reconciliation, and it cannot stop a *legitimate, logged-in* operator from > committing fraud through the app (that's what the signed ledger + reconciliation are for). +> ⚠ **Caveat on the ledger's tamper-resistance (2026-07-02).** The "signed event chain" above is +> **software-signed today** (HMAC, key in `EVENT_SIGNING_KEY` on the host disk) — the [[atecc608]] +> secure element is [[open-questions|upcoming, not present]]. So a physical adversary who *decrypts +> the disk* (see the tamper chain below) reads the signing key and can **forge/re-sign a doctored +> ledger undetectably** — the chain does not save you against a host-owner until a non-extractable +> hardware signer ([[tpm|TPM]] / USB HSM — [[hardware-signer-options]]) is wired. Until then the disk's +> confidentiality/integrity leans harder on the controls below, and the residual backstop against +> forgery is **external reconciliation** against records the box doesn't hold (payments, an offsite +> backup, a separate witness), not the on-disk signature. + ## What it defends against The appliance sits on-site, physically reachable by the [[threat-model|booth operator (the primary adversary)]] and by an outsider who can open the case. Without host hardening, either can: - **Pull the SSD** and read/alter the SQLite ledger offline → FDE (LUKS) defeats this. -- **Boot a live USB** to mount and edit the disk → Secure Boot + TPM-sealing (PCR 7) defeats booting - a tampered/unsigned kernel; FDE keeps the data unreadable. +- **Boot a live USB** to mount and edit the disk → **the BIOS boot-order/boot-menu password is the + load-bearing control here**, NOT Secure Boot. Secure Boot happily runs a *signed* Ubuntu live USB, + and PCR-7-only sealing can't distinguish it from our own boot (same signing authorities → same + PCR 7 → the TPM would unseal). So confidentiality rests on the operator being unable to *select* + the USB. See the physical-tamper chain below (a CMOS reset strips that password). - **Edit the GRUB cmdline** (`init=/bin/bash`) for a no-login root shell on the *decrypted* disk → the GRUB edit-lock defeats this (the TPM seal does NOT — see below). - **Escalate from the operator login** (sudo, `docker`/`lxd` groups) → the unprivileged-operator @@ -74,6 +87,50 @@ turns a routine "security update" into a booth-availability risk: > false safety signal). Before any firmware change, prove a *typed* passphrase still unlocks the disk > with `--disable-external-tokens` — see [[appliance-provisioning]] §4a. +## Physical-tamper chain & accepted risks (traced 2026-07-02, Dell OptiPlex 7070) + +The BIOS admin password gates Setup **and** the one-time boot menu (verified: choosing a USB device +at F12 prompts for the password). That closes the live-USB path — *while the password holds*. The +uncomfortable finding is that the password is a **soft control** against a case-opening adversary: + +1. **CMOS reset** (pull the coin cell, or the on-board PSWD/CMOS-clear jumper — Dell documents its + location) → BIOS *settings* return to factory defaults: **admin password cleared, boot menu + open**. It does NOT wipe the Secure-Boot key databases (PK/KEK/db/dbx live in SPI-flash NVRAM, + not the battery-backed RTC), and the 7070's factory default is **Secure Boot = Enabled**. So + after the reset Secure Boot comes back **on, same MS keys** → **PCR 7 reconstructs to the same + value.** +2. **Boot a signed Ubuntu live USB** (now selectable). Same signing authorities → PCR 7 matches the + sealing policy → the attacker runs `cryptsetup`/`systemd-cryptsetup` and the **TPM releases the + LUKS key**. Disk decrypts; they have **root on the decrypted filesystem**, including the ledger. + - Note the asymmetry: if instead they *disable* Secure Boot in the now-unlocked BIOS, PCR 7 + **changes** → the TPM refuses → the box drops to the slot-0 passphrase prompt they don't have. + So *disabling* Secure Boot locks them out; leaving it at the reset default lets them in. This is + the PCR-7-only "same-signer" weakness (systemd docs recommend PCR 7 **+ a PIN** to close it). + +**Net:** a battery-pull alone yields nothing (disk stays sealed), but **battery-pull → live-USB → +PCR-7 unseal** is a realistic chain to **root on the decrypted data**. What it costs the attacker: a +screwdriver and a signed USB. What still holds after it: they never get the **escrowed slot-0 +passphrase**, and (the point of the caveat above) the ledger's forgery-resistance depends on the +signer — with today's **on-disk HMAC key they CAN forge the ledger**; only a hardware signer +([[hardware-signer-options]]) would keep the financial record unforgeable through this. + +**Accepted risks (named, not silently "covered"):** + +- **PCR-7 same-signer unseal via CMOS reset** (above). *Not* mitigated by the current config. Real + fixes both cost the thing we optimised for: a **TPM PIN** (`--tpm2-with-pin=yes`) closes it but + **kills unattended boot** (someone types a PIN each power event); **custom Secure-Boot keys / more + PCRs** close it but reintroduce re-seal churn on kernel/shim updates (the exact thing PCR-7-only + avoids). Deferred decision — accept for now; the primary control remains reconciliation + escrowed + backups, not disk confidentiality. +- **Unsigned initramfs (evil-maid).** `/boot` is unencrypted and Ubuntu does **not** sign the + initramfs (Secure Boot verifies shim→GRUB→kernel; PCR 7 doesn't measure the initrd). The initramfs + is exactly the code that receives the LUKS key, so a maid who tampers it and waits one boot can + harvest the key. Accepted: needs repeated privileged physical access, and a hardware signer would + still keep the ledger unforgeable even from a fully-owned host. +- **Operator USB at an auto-logged-in session** — unverified what the unprivileged operator account + can read on the host (e.g. can it reach the Docker-mounted `/data`?). **TODO: verify** the operator + can't read the DB volume or `EVENT_SIGNING_KEY` from its own login. + ## Where the commands live This page is the rationale. The **verified, run-on-real-hardware commands** are in @@ -89,4 +146,6 @@ root-capable remote agent — see [[fleet-deployment-komodo]] (bind to the NetBi - [[threat-model]] — the operator-adversary framing this hardening serves. - [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this complements, never replaces. +- [[hardware-signer-options]] — TPM / USB-HSM / ATECC608 options for a non-extractable ledger + signing key (the missing piece this page's tamper chain exposes). - [[fleet-deployment-komodo]] — Periphery as part of the trusted computing base. diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md index 2ed0ddc..3af3d23 100644 --- a/wiki/decisions/open-questions.md +++ b/wiki/decisions/open-questions.md @@ -40,10 +40,19 @@ procurement. (See [[parking-system-architecture]] §10.) but **unverifiable after the machine dies**). **Restore is admin-only/out-of-band** (operator-adversary surface — [[threat-model]]). See [[backup-recovery]], [[fleet-deployment-komodo]], [[disk-os-hardening]], [[reconciliation]] (#4). -6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event - signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not - being implemented for now** (access control is the [[dingtian-relay]] behind - [[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement. +6. **Secure-element integration.** _(Updated 2026-07-02: no secure element is on-site today.)_ + Event signing currently runs on the **software `SoftwareSigner`** (HMAC-SHA256, key in + `EVENT_SIGNING_KEY` — an env var **on the host disk**). So the ledger is tamper-EVIDENT but + **not** unforgeable by anyone who owns the host: a case-opening adversary who decrypts the disk + reads the key and can re-sign a doctored chain (see [[append-only-event-chain]] "pull-the-disk", + [[disk-os-hardening]] physical-tamper chain). The **[[atecc608]] is UPCOMING, not present** — and + it isn't even the right host part: on a PC appliance the realistic non-extractable host signer is + the **[[tpm|TPM 2.0]]** the box already has, or a **USB HSM** (Nitrokey HSM 2 / SmartCard-HSM); + reserve the ATECC608 for the (deferred) [[esp32-custom-controller]]. The concrete menu + + recommendation (TPM interim → USB-HSM target) is in [[hardware-signer-options]]. The + controller command-authentication use is **deferred — not being implemented for now** (access + control is the [[dingtian-relay]] behind [[network-isolation]]); revisit only if prevention-grade + device auth becomes a requirement. 7. **JWT signing: symmetric vs. asymmetric key.** _(Raised by the commit security review, not the source doc.)_ Auth currently uses a symmetric HMAC secret (`@fastify/jwt`, see [[local-jwt-auth]]) — the same secret signs *and* verifies, so it must live on every host that diff --git a/wiki/decisions/standing-decisions.md b/wiki/decisions/standing-decisions.md index 0c3e995..ac7b641 100644 --- a/wiki/decisions/standing-decisions.md +++ b/wiki/decisions/standing-decisions.md @@ -25,9 +25,11 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch deny-by-default native surface that fits [[threat-model|the booth-operator threat model]]. The shell stays **thin**: all privileged logic remains in [[fastify]]. One open dependency — the appliance's WebKitGTK version (see [[open-questions]] #11). -- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log - ([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption - protects only at-rest (see [[threat-model]]). +- **Integrity:** append-only, hash-chained, **software-signed** event log + ([[append-only-event-chain]]) — hardware-backed signing (a non-extractable key in the + **[[tpm|TPM]]** or a **USB HSM**; the [[atecc608]] is [[open-questions|upcoming, not present]]) is + the target that makes it unforgeable by a host owner ([[hardware-signer-options]]). **[[reconciliation]] + is the anti-fraud control**; encryption protects only at-rest (see [[threat-model]]). - **Access control:** the **[[dingtian-relay]]** relay+input controller, on an **isolated VLAN** ([[network-isolation]]). Chosen because its **inputs are decoupled from its relays**, enabling host-in-the-loop ticket-first entry — the resolution to [[access-controller-button-flow]]. diff --git a/wiki/entities/atecc608.md b/wiki/entities/atecc608.md index ed40c09..b59ad13 100644 --- a/wiki/entities/atecc608.md +++ b/wiki/entities/atecc608.md @@ -2,26 +2,38 @@ type: entity tags: [parking, hardware, security, crypto] sources: [parking-system-architecture] -updated: 2026-06-14 +updated: 2026-07-02 --- # ATECC608 (secure element) An inexpensive **secure element** holding a signing key that **cannot be extracted, even by -someone who owns the machine**. The keystone of integrity in this system. (See +someone who owns the machine**. The design's intended keystone of ledger integrity. (See [[parking-system-architecture]] §3, §7.) -Two distinct uses: +> **⚠ STATUS — UPCOMING, NOT PRESENT (2026-07-02).** No ATECC608 (nor any secure element) is on +> the booth today. Event signing runs on the **software `SoftwareSigner`** (HMAC-SHA256, key in +> `EVENT_SIGNING_KEY`, an env var on the host disk). Consequence: the [[append-only-event-chain]] +> is tamper-**evident** but **not** unforgeable by an adversary who owns the host — they can read +> the key and re-sign a doctored chain ([[disk-os-hardening]] physical-tamper chain). Do not describe +> the ledger as "hardware-signed / unforgeable" in present tense. This is the still-open +> [[open-questions|open-question #6]]. -1. **Host-side event signing.** Each event in the [[append-only-event-chain]] is signed by the - ATECC608 on the host machine. This is what makes the hash chain **unforgeable** rather than - merely self-consistent. -2. **Custom controller command authentication.** On the [[esp32-custom-controller]], it holds - the key(s) for [[challenge-response-auth]] — generated on-chip, non-extractable, so popping - the cabinet and dumping flash yields nothing usable. +Two distinct **intended** uses: -Confirming ATECC608 wiring/usage on both ends is [[open-questions]] #6. Listed in the [[bom]] -on the host machine. +1. **Host-side event signing.** The design has each event in the [[append-only-event-chain]] signed + by a non-extractable key so the hash chain is **unforgeable** rather than merely self-consistent. + **Caveat:** the ATECC608 is an external I²C part native to embedded boards, **not a PC component** — + on the PC-based booth appliance the realistic non-extractable host signer is the **[[tpm|TPM 2.0]]** + already on the machine, or a **USB HSM** (Nitrokey HSM 2 / SmartCard-HSM). See + [[hardware-signer-options]] for the full menu + the TPM-interim → USB-HSM-target recommendation. +2. **Custom controller command authentication.** On the (deferred) [[esp32-custom-controller]], it + would hold the key(s) for [[challenge-response-auth]] — generated on-chip, non-extractable, so + popping the cabinet and dumping flash yields nothing usable. This use is **deferred** (access + control is the [[dingtian-relay]] behind [[network-isolation]]). + +Confirming a real secure-element signer on the host is [[open-questions]] #6. Listed (aspirationally) +in the [[bom]]; treat as a future line item until procured. > **Platform caveat (2026-06-21):** the ATECC608 is **not a PC component** — it's an external I²C > secure element you add/solder, native to embedded boards (the [[esp32-custom-controller]]), not to diff --git a/wiki/index.md b/wiki/index.md index fe616bf..1a82db9 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,13 +1,13 @@ --- type: overview tags: [parking, index] -updated: 2026-06-21 +updated: 2026-07-02 --- # Index Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. -Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. +Counts: 4 sources · 19 entities · 47 concepts · 7 decision records. ## Overview & navigation - [[overview]] — the top-level synthesis and entry point. @@ -53,7 +53,8 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. - [[tpm]] — TPM 2.0 hardening: how it works, sealed-LUKS auto-unlock + non-extractable signing key; limits (live-root, bus-sniff) + TPM-vs-ATECC608 by platform; complements, not replaces, reconciliation. ## Concepts — integrity & anti-fraud -- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log. +- [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below). +- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is. - [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]]. - [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only. @@ -107,6 +108,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.) - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline. - [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas. +- [[vision-service-hardening]] — fix/hardening backlog for `apps/vision/` (2026-07-02 reviews): DoS (body-cap, pixel-bomb, event-loop-blocking inference), unauthenticated + operator-writable model weights, `0.0.0.0` default bind, + correctness/hygiene items. Not yet fixed — the to-do list. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. ## Concepts — frontend / operator UI diff --git a/wiki/log.md b/wiki/log.md index 85373a2..f7f0b19 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2139,3 +2139,71 @@ override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warn operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points, capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car alone either." + +## [2026-07-01] deploy | Promote dev → stage (d2ab2e0) → park-buzi, pinned TAG=stage-d2ab2e0 + +Merged dev → stage (no-ff, clean — stage content was fully contained in dev). Shipped to the staging +booth: snapshot content-type fix, Active Sessions/modal rework, DB reset CLI, drawer redesign (operator +records / admin reviews), card tender disabled (no POS), operator-issued entry + exit plate-swap +reconciliation. Push to stage triggered CI → built parking-server/vision :stage + :stage-d2ab2e0. Pinned +TAG=stage-d2ab2e0 in komodo/resources.toml on BOTH stage and dev (the ResourceSync's source branch is a +Core-side config, so both agree — see komodo/README.md; they're identical content anyway). + +Migration note: this promotion carries migrations 0018 (drawer:create) + 0019 (session:create). BOTH are +DATA SEEDS, not schema — INSERT OR IGNORE one role_permissions row each for the built-in `operator` role; +idempotent, no CREATE/ALTER, existing data untouched. They apply automatically at container boot +(docker-entrypoint.sh → migrate-runtime.mjs, before the server starts) against the /data volume DB, which +survives the redeploy. Caveat recorded in container-deployment.md: a permission seeded to the built-in +operator role does NOT reach a CUSTOM role — an admin toggles it in Setup → Roles. + +Deploy (operator, in Komodo Core): refresh ResourceSync (TAG diff enables Execute) → Execute → Deploy +(Destroy+Deploy for a clean recreate; parking-data volume persists). Watch for `[migrate] done` in logs. + +## [2026-07-02] query | Physical-tamper of the booth disk + ledger signing reality (ATECC608 is upcoming, not present) + +Q (operator): can a malicious user boot a live Ubuntu / reset the BIOS (coin cell or PSWD jumper) and +get root on the storage? Traced on the actual box (Dell OptiPlex 7070): the BIOS admin password DOES +gate the F12 boot menu (selecting the USB prompts for it), so the live-USB path is closed **while the +password holds**. But a CMOS reset clears the admin password + reopens the boot menu WITHOUT wiping the +Secure-Boot key DBs (SPI-flash NVRAM, not RTC), and the 7070 default is Secure Boot=Enabled → PCR 7 +reconstructs to the SAME value → a signed live Ubuntu (same signing authorities) matches the PCR-7-only +seal → the TPM releases the LUKS key → root on the decrypted disk. Battery-pull alone = nothing; +battery-pull → live-USB → PCR-7 unseal = realistic root-on-data. (Disabling Secure Boot instead CHANGES +PCR 7 → passphrase prompt → locked out; the same-signer default is the hole. systemd docs: PCR 7 + PIN.) + +BIGGER correction surfaced: the ledger is NOT ATECC608-signed today. No secure element is on-site. Signing +runs on the software SoftwareSigner (HMAC, key = EVENT_SIGNING_KEY, an env var on the host disk). So the +chain is tamper-EVIDENT but forgeable by whoever owns the host — the disk-decryption chain above hands +them the key too. The ATECC608 was overstated as present in several pages; it's also the wrong part for a +PC (external I²C, embedded-native) — reserve it for the deferred ESP32; the realistic host signer is the +on-board TPM or a USB HSM. + +Actions: +- NEW concepts/hardware-signer-options.md — four options (USB HSM/Nitrokey HSM 2 [target], YubiKey, reuse + the TPM [free interim, bind signing key with NO PCR policy], plain USB dongle [trap, avoid]) + the + recommendation (TPM now → USB-HSM target; ATECC608 stays for embedded). Notes the signer.ts keyId seam. +- Retag pass ATECC608 → UPCOMING/NOT-PRESENT + "software-signed today, forgeable by host owner" caveat: + entities/atecc608.md (status banner + PC-vs-embedded), append-only-event-chain already honest, + standing-decisions.md, overview.md, threat-model.md, open-questions.md #6 (reframed), index.md. +- disk-os-hardening.md: fixed the live-USB row (BIOS boot-order password is load-bearing, not Secure + Boot — signed live USB runs), added a caveat banner (software signer → disk decryption = ledger + forgery) + a "Physical-tamper chain & accepted risks" section (CMOS-reset chain; accepted risks: + PCR-7 same-signer unseal, unsigned initramfs evil-maid, operator-USB read TODO). +- Verify items for the box: (a) confirm F12/one-time-boot is password-gated (done — it is); (b) after a + CMOS clear does Secure Boot return Enabled? (expected yes on Dell); (c) can the unprivileged operator + login read /data or EVENT_SIGNING_KEY? +- Residual: signer.ts still uses HMAC (no code change this pass); the load-bearing anti-fraud control + remains reconciliation + escrowed offsite backups, NOT on-disk confidentiality/signature. + +## [2026-07-02] review | Vision service (apps/vision/) hardening + fix backlog +Two code reviews of the Python/FastAPI ANPR service (general: bottlenecks/bugs/best-practice, +and a security-focused pass). Filed the findings as a prioritised, not-yet-fixed to-do list at +[[vision-service-hardening]]; cross-linked from [[opencv-anpr-service]] ("consult before touching") +and cataloged in index.md. Headline items: DoS (12MB cap checked *after* the body is buffered; +`cv2.imdecode` pixel-bomb; CPU inference on the async event loop stalling `/health`); +unauthenticated **and** operator-writable model weights → persistent recognition-poisoning +([[threat-model]]); `0.0.0.0`-by-default bind at all three layers; dev compose publishing 8089 on +all interfaces; plus correctness/hygiene (cwd-relative `.env`, `/health` always-200, pre-warm +swallowing failures, unbounded `min_confidence`). Reassurance recorded: a forged image can't open a +barrier (server re-gates at 0.85 + debounce), content-type isn't trusted, non-root, `.env` not baked +into the image. Nothing fixed yet — this is the backlog to work from. diff --git a/wiki/overview.md b/wiki/overview.md index afa8f44..063e7f6 100644 --- a/wiki/overview.md +++ b/wiki/overview.md @@ -25,9 +25,11 @@ deployed on-site at a parking facility. Two forces shape nearly every decision: [[react-vite-spa]] · [[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]] — all open-licensed to avoid lock-in (cf. rejected [[payload-cms]], [[refine]], [[logto-zitadel-oidc]]). The operator UI ships as a thin **[[desktop-shell-tauri|Tauri v2]]** kiosk shell (chosen over Electron). -- **Integrity** is the heart of it: an [[append-only-event-chain]] (hash-chained, [[atecc608]]- - signed) plus external [[reconciliation]] — *that's* what remote sync really is. Encryption at - rest ([[disk-os-hardening]]) defends a secondary threat. +- **Integrity** is the heart of it: an [[append-only-event-chain]] (hash-chained, signed) plus + external [[reconciliation]] — *that's* what remote sync really is. Signing is **software today** + (key on-disk → forgeable by a host owner); a non-extractable **hardware signer** (TPM / USB-HSM; + the [[atecc608]] is upcoming) is the pending fix — [[hardware-signer-options]]. Encryption at rest + ([[disk-os-hardening]]) defends a secondary threat. - **Devices** sit behind a [[device-adapter-pattern]] (swap hardware → new adapter only), with the [[barrier-not-a-door]] safety principle keeping physical safety in barrier-operator firmware. - **Access control** today is the **[[dingtian-relay]]** relay+input controller behind From f486dcbbfcd3014357032f1de9c3f1f9da35c30e Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 13:41:32 +0200 Subject: [PATCH 4/4] docs(wiki): vision-service hardening backlog + boot-migration data-seed note Two unrelated leftover wiki edits from earlier sessions: - NEW concepts/vision-service-hardening.md: the prioritised to-do list from the 2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/ operator-writable model weights, 0.0.0.0 default bind). Cross-linked from opencv-anpr-service.md ("consult before touching this service"). - container-deployment.md: note that a boot-time migration can be a DATA SEED (e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE), and that a built-in-role grant does not auto-apply to a custom role. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/vision-service-hardening.md | 169 ++++++++++++++++++++++ wiki/decisions/container-deployment.md | 7 +- wiki/entities/opencv-anpr-service.md | 5 + 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 wiki/concepts/vision-service-hardening.md diff --git a/wiki/concepts/vision-service-hardening.md b/wiki/concepts/vision-service-hardening.md new file mode 100644 index 0000000..66281c1 --- /dev/null +++ b/wiki/concepts/vision-service-hardening.md @@ -0,0 +1,169 @@ +--- +type: concept +tags: [parking, vision, anpr, security, hardening, tech-debt] +sources: [] +updated: 2026-07-02 +status: open +--- + +# Vision Service — Hardening & Fix Backlog + +Tracked findings against the [[opencv-anpr-service]] (`apps/vision/`, the Python/FastAPI ANPR +microservice — see [[vision-service-packaging]]). Raised by two code reviews on 2026-07-02: a +general pass (bottlenecks / bugs / best-practice) and a security-focused pass. **Nothing here is +fixed yet** — this page is the to-do list; tick items off (and note the commit) as they land. + +Framing that shapes the priorities below, both from [[threat-model]]: + +- **A forged image cannot open a barrier by itself.** The Node server pushes snapshot bytes to + `/analyze`, then **re-gates** the result server-side (`VISION_ENTRY_MIN_CONFIDENCE` = 0.85, + stricter than the service's own advisory 0.5 floor), with debounce and a mid-poll + "already-transacted" abort (`apps/server/src/anpr-entry.ts`). So the real exposure is + **availability (DoS)** and **network/weight placement**, not decision forgery. See + [[lane-presence-and-anpr-entry]], [[fail-state-safety]] (recognition is advisory; the host falls + back to the ticket path when vision is down/unsure). +- **The primary adversary is the local booth operator**, with filesystem/USB access to the + appliance — which is what makes the model-weight and config-placement items real, not theoretical. + +## Priority 1 — DoS (remotely triggerable, zero appliance access) + +1. **Request body buffered *before* the size cap** — `apps/vision/vision_service/app.py:69`. + `image = await request.body()` concatenates the whole stream into memory before + `len(image) > MAX_IMAGE_BYTES` (12 MB) runs. A chunked POST with no `Content-Length` streams + unbounded bytes → RAM exhaustion → OOM-kill **before** the 413 is ever returned. Killing the + recognizer forces permanent ticket-fallback (which may be the operator's goal — suppress plate + evidence). **Fix:** reject on `Content-Length` up front and cap while draining + `request.stream()`; pass `--limit-max-request-body-size` to uvicorn. + +2. **Pixel-bomb: `cv2.imdecode` has no decoded-dimension bound** — + `apps/vision/vision_service/recognizer.py:143`. A crafted JPEG *under* the 12 MB byte cap can + declare enormous dimensions (e.g. 30000×30000) and decode to a multi-GB BGR ndarray. Combined + with item 3 (inference on the event loop), one request both spikes memory and stalls the whole + service. **Fix:** check `frame.shape` against a max pixel budget immediately after decode; reject + oversize frames with a 422. + +## Priority 2 — Bottleneck (also a DoS amplifier) + +3. **CPU-bound inference runs synchronously on the asyncio event loop** — + `apps/vision/vision_service/app.py:84` (calls `rec.analyze`, which does `cv2.imdecode` + ONNX + YOLO+OCR). uvicorn runs a single event loop; while inference runs (hundreds of ms on the i5-8500, + CPU-only), **nothing else is served — including `GET /health`**. Back-to-back entry/exit + snapshots serialize, and health probes time out, making a live service look down. When the Node + client's 1500 ms `AbortController` gives up, Python keeps burning CPU on the abandoned request. + Flagged independently by ~half the review angles — the one real bottleneck. **Fix:** + `await run_in_threadpool(rec.analyze, image)` (or make the `Recognizer` protocol async), plus an + `asyncio.Semaphore(1–2)` to bound concurrent inferences. This is a property the `Recognizer` + mechanism should own, not a per-call patch. + +## Priority 2 — Security: network placement & weight integrity + +4. **Model weights are unauthenticated *and* operator-writable → persistent recognition-poisoning.** + `fast-alpr`'s `ALPR()` downloads ONNX weights + config over plain `urllib` with **no + checksum/signature** (verified in `.venv`: `open_image_models/detection/core/hub.py`, + `fast_plate_ocr/inference/hub.py`), and a cache-hit is treated as trust (present → skip, never + re-verify). The `vision` user (uid 999) owns **both** the `~/.cache` weights and the runtime + process, so any same-user write primitive overwrites weights in place — no privesc — surviving + container **restarts** (cache is in the writable layer, not re-verified at startup). Under + [[threat-model]], a local operator overwriting a cached `.onnx` is a persistent, targeted fraud + primitive: a detector tuned to never see a specific plate, or an OCR model that reproducibly + substitutes a character. **Fix:** pin + verify a weights hash, mount the weights **read-only**, + and assert a known hash at startup. Relates to [[disk-os-hardening]] (physical-tamper chain), + [[reconciliation]] (the real backstop). + +5. **Service binds `0.0.0.0` by default at every layer** — `settings.py:18`, `Dockerfile:51` + (`ENV VISION_HOST=0.0.0.0`), `Dockerfile:57` (uvicorn `--host 0.0.0.0`). The `.env.example` + documents `127.0.0.1`-only intent, but loopback-only exposure then depends entirely on the + compose `ports:` prefix being right every time. The app provides **zero** defense-in-depth: + `/analyze` and `/health` have no auth, so reachability is the only control. **Fix:** make + `127.0.0.1` the default at all three layers; require explicit opt-in to widen. Relates to + [[network-isolation]], [[trust-boundary]]. + +6. **Dev compose publishes vision on all interfaces** — `docker-compose.dev.yml:29` maps + `"8089:8089"` (binds 0.0.0.0), unlike `docker-compose.prod.yml:91`'s `"127.0.0.1:8089:8089"`. + Anyone on the same LAN as a dev/staging box — or a field deploy that reuses the dev file — can + hit the unauthenticated endpoint: fingerprint the model via `/health`, run the DoS above, or + probe confidence behaviour. **Fix:** bind `127.0.0.1:8089:8089` to match prod. See + [[container-deployment]]. + +7. **No container resource limits or hardening.** The `vision` service has no + `mem_limit`/`cpus`/`pids_limit` and no `security_opt: [no-new-privileges:true]` / + `cap_drop: [ALL]` / `read_only` in any compose file. Since prod runs the server on the **host + network** doing safety-critical device I/O, an unbounded vision container (via items 1–2) can + starve the host of RAM/CPU. **Fix:** add `mem_limit`, `pids_limit`, `cap_drop: [ALL]`, + `no-new-privileges`. (Already good: runs non-root as uid 999; `.env` is **not** baked into any + image layer — `COPY`s are explicit, root `.dockerignore`/`.gitignore` cover `.env`.) See + [[disk-os-hardening]], [[container-deployment]]. + +## Priority 3 — Correctness, robustness, hygiene + +8. **`env_file=".env"` resolves against the process CWD** — `settings.py:16`. Launched from anywhere + but `apps/vision/` (a systemd unit, or a run from the monorepo root), the `.env` isn't found and + pydantic-settings raises **no error** — the service silently boots `recognizer="stub"`, `/health` + reports `ready: true`, and every scan returns `plate: null`. Production ANPR silently does + nothing while looking healthy. **Fix:** anchor the path, e.g. + `env_file=Path(__file__).parent.parent / ".env"`. + +9. **`/health` returns HTTP 200 even when `ready` is false**, and the Docker HEALTHCHECK + (`Dockerfile:55`) only checks `status==200` — `app.py:54`. If the fast-alpr weights fail to load + (e.g. the best-effort build pre-warm was skipped and the appliance is air-gapped), the container + stays "healthy" to Docker forever: no restart, no infra alert; only the in-app device monitor + notices. **Fix:** return 503 from `/health` when not ready, or make the HEALTHCHECK parse the + `ready` field. + +10. **Build pre-warm swallows *all* failures** — `Dockerfile:46` + (`... || echo "skipped (no network)"`). It can't distinguish "no network at build, expected" + from "download corrupted/tampered/interrupted". A skipped pre-warm silently converts the + air-gapped appliance into one that fetches weights from github.com on the **first real + `/analyze`** — an unreviewed runtime network dependency contradicting [[offline-first]] (and a + first-scan DoS if egress is truly blocked). **Fix:** fail the *prod* build on pre-warm failure + (or assert weights present at startup) rather than degrade to a lazy fetch. + +11. **Raw exception strings reflected into HTTP responses** — `app.py:88` (`analysis failed: {exc}`) + and the 503 loader path at `app.py:81`. Echoes native cv2/onnxruntime error text (absolute + paths, library versions) to any caller. **Fix:** log detail server-side; return a generic + message to the client. (Modest severity — the primary adversary already has local access.) + +12. **`min_confidence` has no bounds validation** — `settings.py:33`, unlike `PlateResult.confidence` + (`ge=0, le=1`). `VISION_MIN_CONFIDENCE=50` (someone thinking in percent) silently flags every + read as `low_confidence`, with no startup error or health symptom. **Fix:** + `float = Field(0.5, ge=0.0, le=1.0)`. + +13. **Node client discards the structured error detail** — `apps/server/src/vision-client.ts:187`. + `#post` throws `vision /analyze → HTTP 422` without reading the body, so + `"could not decode image bytes"` (corrupt camera frame — actionable) is indistinguishable in the + logs from a deploy problem. **Fix:** read `detail` from the JSON body before throwing. + (Server-side file, just outside `apps/vision/`, but it's the consumer of this contract.) + +14. **`build_recognizer` docstring is wrong** — `recognizer.py:169`. It says "falls back to the stub + if the real one can't load," but the function returns the **not-ready** `FastAlprRecognizer` + (better behaviour — `/analyze` 503s instead of silently returning no-plate). **Fix:** correct + the docstring so nobody "fixes" the code to match it. + +15. **Model-name env vars are undefended in-repo (latent)** — `settings.py:28–29`. + `detector_model` / `ocr_model` are plain `str`; the allowlist that makes them safe (rejects + unknown names) lives **entirely** in the third-party libs, not here. **Not a bug today** (no + SSRF/traversal), but a future recognizer swap (the pluggable design invites one) could turn an + operator-settable env var into a URL/path primitive. **Fix:** constrain to a `Literal`/enum or + validate explicitly. + +## Lower priority / noted (not scheduled) + +- **Base image tag-, not digest-, pinned** — `Dockerfile:9` + (`ghcr.io/astral-sh/uv:python3.12-bookworm-slim`); `apt-get` without package pinning + (`Dockerfile:16–18`). `uv sync --frozen` locks the Python deps, so this is OS-layer drift only — + low priority hardening. +- **CLI reads image files unbounded** — `cli.py:55` (`read_bytes()`, no size cap). Dev-only tool, + local invocation; mirror the HTTP size discipline for consistency. +- **Architecture note (server, not vision):** `anpr-entry.ts` auto-opens the barrier on a + high-confidence plate match with **no second factor**, so a printed duplicate of a known + subscriber's plate is a physical-spoofing bypass (mitigated only by the signed event log, once a + hardware signer lands — see [[hardware-signer-options]], [[append-only-event-chain]]). A conscious + design sign-off, not a vision-service bug — flagged so it isn't an accident. See + [[lane-presence-and-anpr-entry]], [[plate-reconciliation]]. + +## Clean (checked, no action) + +Content-type is **not** trusted for parsing (cv2 sniffs bytes, ignores the header). The service +already runs non-root (uid 999), loads models once at startup (not per request), and `.env` is not +committed or baked into an image layer. The `getattr`-based fast-alpr result mapping, the typed +`app.state` accessors, and the lifespan factory all have stated rationales and are fine as-is. diff --git a/wiki/decisions/container-deployment.md b/wiki/decisions/container-deployment.md index 1d0f625..31dba36 100644 --- a/wiki/decisions/container-deployment.md +++ b/wiki/decisions/container-deployment.md @@ -92,7 +92,12 @@ prod); `ENV=dev` switches to the dev override. - **Migrations at boot, not at build.** The DB lives on a mounted volume (`/data`), so the entrypoint runs them against the live file via a **drizzle-kit-free** runtime migrator (`packages/db/scripts/migrate-runtime.mjs`, using `drizzle-orm/.../migrator` — drizzle-kit is a - devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing. + devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing. **A migration is + not only schema** — it can also be a **data seed** (e.g. a new RBAC permission granted to the + `operator` role via `INSERT OR IGNORE`, so a new operator capability reaches the booth on the next + deploy). The whole `@parking/db` package ships in the bundle (no `files` allowlist), so every + `drizzle/*.sql` is present in the image. NB a permission seeded to the built-in `operator` role does + NOT auto-apply to a **custom** role — an admin toggles it in Setup → Roles. - **JWT_SECRET** must be a real value at deploy — `auth.ts` rejects anything `<32` chars or matching `change.?me|insecure|dev-only`, so the dev compose default is a benign 32-char string, not a "dev-only…" placeholder (which would crash boot). diff --git a/wiki/entities/opencv-anpr-service.md b/wiki/entities/opencv-anpr-service.md index 4185845..065de3e 100644 --- a/wiki/entities/opencv-anpr-service.md +++ b/wiki/entities/opencv-anpr-service.md @@ -230,6 +230,11 @@ service's `/health` each tick and shows a **"Vision" chip** in the booth footer > vision service must reach that VLAN to pull snapshots — but its own `/analyze` should bind > **localhost** (Node is the only caller). Keep the AGPL/heavy stack contained to this process. +> **Hardening / fix backlog** ([[vision-service-hardening]]): the 2026-07-02 code + security reviews +> logged a prioritised to-do list against `apps/vision/` — DoS gaps (body-cap-after-buffering, +> pixel-bomb, inference on the async event loop), unauthenticated + operator-writable model weights, +> and `0.0.0.0`-by-default binding lead it. **Consult it before touching this service.** + ## Open - **Recognizer choice** — **fast-alpr (MIT, YOLOv9+CCT on ONNX) is the baseline, AL-benchmarked**: the