This commit is contained in:
@@ -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<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
import type { VisionClient } from "../vision-client.js";
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
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<void> } {
|
||||||
|
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
// 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
|
// 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
|
// 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,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
vision?: VisionClient | null,
|
vision?: VisionClient | null,
|
||||||
|
eventLog?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
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<string, unknown>;
|
||||||
|
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<string, string | number | boolean>);
|
||||||
|
} 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
|
// 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
|
// 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.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
+15
-12
@@ -115,11 +115,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const visionClient = new VisionClient(app.log);
|
const visionClient = new VisionClient(app.log);
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
|
// 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 turns an input into a signed vehicle_entry once
|
||||||
|
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||||
|
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||||
|
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||||
|
// 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),
|
||||||
|
);
|
||||||
|
|
||||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
// 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
|
// 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.
|
// 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.
|
// 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),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -158,17 +171,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => deviceMonitor.start());
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||||
// (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),
|
|
||||||
);
|
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
testAnpr,
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
testPrint,
|
testPrint,
|
||||||
|
testRelay,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
type PrintTestResult,
|
type PrintTestResult,
|
||||||
@@ -305,6 +306,7 @@ function AssignmentRow({
|
|||||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||||
|
{assignment.category === "access" && <RelayTester assignment={assignment} />}
|
||||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{error && <span className="text-term-red">{error}</span>}
|
{error && <span className="text-term-red">{error}</span>}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
|
const barriers = relays.filter((r) => r.direction !== "radarAlert");
|
||||||
|
const [busyRelay, setBusyRelay] = useState<number | null>(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 (
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
{barriers.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.relay}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => testRelayNow(r.relay)}
|
||||||
|
disabled={busyRelay != null}
|
||||||
|
title={t("setup.testRelayTitle", { relay: r.relay })}
|
||||||
|
>
|
||||||
|
{busyRelay === r.relay ? t("setup.relayTesting") : t("setup.testRelay", { relay: r.relay })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{result && (
|
||||||
|
<span className={result.ok ? "text-term-green" : "text-term-red"}>
|
||||||
|
{result.ok
|
||||||
|
? t("setup.relayTestOk", { relay: result.relay })
|
||||||
|
: t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Inline summary of an assignment's direction/binding for the list. */
|
/** Inline summary of an assignment's direction/binding for the list. */
|
||||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -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<RelayTestResult> {
|
||||||
|
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id, relay }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin reports -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
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).",
|
"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).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Show password",
|
revealSecret: "Show password",
|
||||||
hideSecret: "Hide password",
|
hideSecret: "Hide password",
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
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).",
|
"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).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Shfaq fjalëkalimin",
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
hideSecret: "Fshih fjalëkalimin",
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ export const REASON_CODES = [
|
|||||||
"sub.refused.unpaidWindow",
|
"sub.refused.unpaidWindow",
|
||||||
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
||||||
"void.ticketCancelled",
|
"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;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -428,6 +431,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"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",
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
"void.ticketCancelled": "ticket cancelled — {reason}",
|
"void.ticketCancelled": "ticket cancelled — {reason}",
|
||||||
|
"setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
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
|
- **[[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
|
- **[[reconciliation]]** against an authority the operator can't alter — *this is what remote
|
||||||
sync really is*: a fraud-control mechanism, not just a backup.
|
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**;
|
- **[[disk-os-hardening]]** still worthwhile (defeats boot-from-USB) but **not the main event**;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
- **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
|
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
|
(`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
|
- **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
|
`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).
|
"dev-only…" placeholder (which would crash boot).
|
||||||
|
|||||||
@@ -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
|
> **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).
|
> 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
|
## What it defends against
|
||||||
|
|
||||||
The appliance sits on-site, physically reachable by the [[threat-model|booth operator (the primary
|
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:
|
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.
|
- **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
|
- **Boot a live USB** to mount and edit the disk → **the BIOS boot-order/boot-menu password is the
|
||||||
a tampered/unsigned kernel; FDE keeps the data unreadable.
|
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 →
|
- **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).
|
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
|
- **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
|
> false safety signal). Before any firmware change, prove a *typed* passphrase still unlocks the disk
|
||||||
> with `--disable-external-tokens` — see [[appliance-provisioning]] §4a.
|
> 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
|
## Where the commands live
|
||||||
|
|
||||||
This page is the rationale. The **verified, run-on-real-hardware commands** are in
|
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.
|
- [[threat-model]] — the operator-adversary framing this hardening serves.
|
||||||
- [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this
|
- [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this
|
||||||
complements, never replaces.
|
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.
|
- [[fleet-deployment-komodo]] — Periphery as part of the trusted computing base.
|
||||||
|
|||||||
@@ -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
|
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]],
|
surface — [[threat-model]]). See [[backup-recovery]], [[fleet-deployment-komodo]], [[disk-os-hardening]],
|
||||||
[[reconciliation]] (#4).
|
[[reconciliation]] (#4).
|
||||||
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
|
6. **Secure-element integration.** _(Updated 2026-07-02: no secure element is on-site today.)_
|
||||||
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
|
Event signing currently runs on the **software `SoftwareSigner`** (HMAC-SHA256, key in
|
||||||
being implemented for now** (access control is the [[dingtian-relay]] behind
|
`EVENT_SIGNING_KEY` — an env var **on the host disk**). So the ledger is tamper-EVIDENT but
|
||||||
[[network-isolation]]); revisit only if prevention-grade device auth becomes a requirement.
|
**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
|
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
|
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
|
[[local-jwt-auth]]) — the same secret signs *and* verifies, so it must live on every host that
|
||||||
|
|||||||
@@ -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
|
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
|
shell stays **thin**: all privileged logic remains in [[fastify]]. One open dependency — the
|
||||||
appliance's WebKitGTK version (see [[open-questions]] #11).
|
appliance's WebKitGTK version (see [[open-questions]] #11).
|
||||||
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
|
- **Integrity:** append-only, hash-chained, **software-signed** event log
|
||||||
([[append-only-event-chain]]); **[[reconciliation]] is the anti-fraud control**; encryption
|
([[append-only-event-chain]]) — hardware-backed signing (a non-extractable key in the
|
||||||
protects only at-rest (see [[threat-model]]).
|
**[[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**
|
- **Access control:** the **[[dingtian-relay]]** relay+input controller, on an **isolated VLAN**
|
||||||
([[network-isolation]]). Chosen because its **inputs are decoupled from its relays**, enabling
|
([[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]].
|
host-in-the-loop ticket-first entry — the resolution to [[access-controller-button-flow]].
|
||||||
|
|||||||
+23
-11
@@ -2,26 +2,38 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, security, crypto]
|
tags: [parking, hardware, security, crypto]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-14
|
updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# ATECC608 (secure element)
|
# ATECC608 (secure element)
|
||||||
|
|
||||||
An inexpensive **secure element** holding a signing key that **cannot be extracted, even by
|
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.)
|
[[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
|
Two distinct **intended** uses:
|
||||||
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.
|
|
||||||
|
|
||||||
Confirming ATECC608 wiring/usage on both ends is [[open-questions]] #6. Listed in the [[bom]]
|
1. **Host-side event signing.** The design has each event in the [[append-only-event-chain]] signed
|
||||||
on the host machine.
|
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
|
> **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
|
> secure element you add/solder, native to embedded boards (the [[esp32-custom-controller]]), not to
|
||||||
|
|||||||
@@ -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
|
> 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.
|
> **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
|
## Open
|
||||||
|
|
||||||
- **Recognizer choice** — **fast-alpr (MIT, YOLOv9+CCT on ONNX) is the baseline, AL-benchmarked**: the
|
- **Recognizer choice** — **fast-alpr (MIT, YOLOv9+CCT on ONNX) is the baseline, AL-benchmarked**: the
|
||||||
|
|||||||
+5
-3
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
type: overview
|
type: overview
|
||||||
tags: [parking, index]
|
tags: [parking, index]
|
||||||
updated: 2026-06-21
|
updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
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 & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[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.
|
- [[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
|
## 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.
|
- [[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]].
|
- [[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.
|
- [[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.)
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||||
|
|
||||||
## Concepts — frontend / operator UI
|
## Concepts — frontend / operator UI
|
||||||
|
|||||||
+68
@@ -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,
|
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
|
capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car
|
||||||
alone either."
|
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.
|
||||||
|
|||||||
+5
-3
@@ -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
|
[[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
|
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).
|
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]]-
|
- **Integrity** is the heart of it: an [[append-only-event-chain]] (hash-chained, signed) plus
|
||||||
signed) plus external [[reconciliation]] — *that's* what remote sync really is. Encryption at
|
external [[reconciliation]] — *that's* what remote sync really is. Signing is **software today**
|
||||||
rest ([[disk-os-hardening]]) defends a secondary threat.
|
(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
|
- **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.
|
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
|
- **Access control** today is the **[[dingtian-relay]]** relay+input controller behind
|
||||||
|
|||||||
Reference in New Issue
Block a user