Append-only signed event log; persist Dingtian input pushes
Implement the core anti-fraud primitive: an append-only, hash-chained, signed event log (the schema + types predated this; the writer/signer are new). - EventLog (apps/server): serialized append, monotonic index, prevHash chain, signature; verifyChain() detects tamper/reorder/delete. No update/delete paths. - Signer abstraction (packages/shared) over the ATECC608 secure element, with a SoftwareSigner (HMAC, EVENT_SIGNING_KEY) shipped now since the chip is still open-question #6. Documented: software signer is tamper-evident but NOT unforgeable-by-owner. - Add ParkingEventType "input_received" for raw device inputs (not yet a vehicle_entry, which the entry flow will append later). - Read API: GET /api/events; integrity self-check: GET /api/events/verify (admin). Verified on hardware: shorting the Dingtian inputs produced signed, chained input_received events; verifyChain ok; direct DB tamper/delete detected. NOTE: the log captures host-originated actions only. Out-of-band relay actuation (sniffed relay_pw, string protocol, ip_watchdog) produces no event by design -- the control is reconciliation vs. an independent witness, which is not yet built. See wiki/concepts/append-only-event-chain.md.
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { desc, events, type Db, type EventRow } from "@parking/db";
|
||||||
|
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
||||||
|
|
||||||
|
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||||
|
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||||
|
// events are NEVER edited or deleted; a correction/void is a new appended row.
|
||||||
|
//
|
||||||
|
// Integrity rules enforced here:
|
||||||
|
// - monotonic `index` (prev + 1; the unique constraint is the backstop),
|
||||||
|
// - `prevHash` = hash of the previous row's canonical form (genesis = null),
|
||||||
|
// - `signature` = signer.sign(canonical) over a STABLE field ordering,
|
||||||
|
// - appends are SERIALIZED: read-prev -> compute-hash -> insert must not
|
||||||
|
// interleave, or two events could claim the same index / chain off a stale
|
||||||
|
// prev. SQLite is single-writer, but the read+compute+insert is multi-step,
|
||||||
|
// so we guard it with an in-process async lock as well.
|
||||||
|
|
||||||
|
export interface AppendInput {
|
||||||
|
readonly type: ParkingEventType;
|
||||||
|
readonly lane: number;
|
||||||
|
readonly direction?: Direction | null;
|
||||||
|
readonly source?: IdentitySource | null;
|
||||||
|
readonly identity?: string | null;
|
||||||
|
/** Event time (ISO-8601). Defaults to now. */
|
||||||
|
readonly occurredAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical serialization of an event's signed/hashed content. Order is FIXED
|
||||||
|
* and explicit — the hash chain and signatures depend on byte-stable output, so
|
||||||
|
* this must never change for already-written events (versioned via keyId if it
|
||||||
|
* ever must). The volatile DB row id is deliberately excluded; identity in the
|
||||||
|
* chain is `index` + content.
|
||||||
|
*/
|
||||||
|
export function canonicalize(e: {
|
||||||
|
index: number;
|
||||||
|
type: string;
|
||||||
|
direction: string | null;
|
||||||
|
lane: number;
|
||||||
|
source: string | null;
|
||||||
|
identity: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
prevHash: string | null;
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
e.index,
|
||||||
|
e.type,
|
||||||
|
e.direction ?? null,
|
||||||
|
e.lane,
|
||||||
|
e.source ?? null,
|
||||||
|
e.identity ?? null,
|
||||||
|
e.occurredAt,
|
||||||
|
e.prevHash ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||||
|
export function hashEvent(canonical: string): string {
|
||||||
|
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventLog {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #signer: Signer;
|
||||||
|
/** Serialize appends: each waits for the previous to finish. */
|
||||||
|
#tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(db: Db, signer: Signer) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#signer = signer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||||
|
append(input: AppendInput): Promise<EventRow> {
|
||||||
|
const run = this.#tail.then(() => this.#appendNow(input));
|
||||||
|
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||||
|
this.#tail = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
#appendNow(input: AppendInput): EventRow {
|
||||||
|
const prev = this.#db
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.orderBy(desc(events.index))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const index = (prev?.index ?? 0) + 1;
|
||||||
|
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||||
|
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||||
|
|
||||||
|
const canonical = canonicalize({
|
||||||
|
index,
|
||||||
|
type: input.type,
|
||||||
|
direction: input.direction ?? null,
|
||||||
|
lane: input.lane,
|
||||||
|
source: input.source ?? null,
|
||||||
|
identity: input.identity ?? null,
|
||||||
|
occurredAt,
|
||||||
|
prevHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
index,
|
||||||
|
type: input.type,
|
||||||
|
direction: input.direction ?? null,
|
||||||
|
lane: input.lane,
|
||||||
|
source: input.source ?? null,
|
||||||
|
identity: input.identity ?? null,
|
||||||
|
occurredAt,
|
||||||
|
prevHash,
|
||||||
|
signature: this.#signer.sign(canonical),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#db.insert(events).values(row).run();
|
||||||
|
return row as EventRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||||
|
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||||
|
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||||
|
* row (index gap), and a forged/invalid signature.
|
||||||
|
*/
|
||||||
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||||
|
const rows = this.#db.select().from(events).orderBy(events.index).all();
|
||||||
|
let expectedIndex = 1;
|
||||||
|
let prevHash: string | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.index !== expectedIndex) {
|
||||||
|
return { ok: false, index: row.index, reason: `index gap: expected ${expectedIndex}` };
|
||||||
|
}
|
||||||
|
if ((row.prevHash ?? null) !== prevHash) {
|
||||||
|
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||||
|
}
|
||||||
|
const canonical = canonicalize(row);
|
||||||
|
if (!this.#signer.verify(canonical, row.signature)) {
|
||||||
|
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||||
|
}
|
||||||
|
prevHash = hashEvent(canonical);
|
||||||
|
expectedIndex += 1;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, events, type Db } from "@parking/db";
|
||||||
|
import { requireRole } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
|
// Read access to the append-only signed event log. NO write/update/delete routes
|
||||||
|
// exist by design — events are only ever appended internally (entry flow, device
|
||||||
|
// pushes). Corrections are new appended events, never edits. See
|
||||||
|
// wiki/concepts/append-only-event-chain.md.
|
||||||
|
|
||||||
|
export async function eventRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
eventLog: EventLog,
|
||||||
|
): Promise<void> {
|
||||||
|
// Any authenticated role may read the log (it's the audit trail).
|
||||||
|
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
|
||||||
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||||
|
app.get<{ Querystring: { limit?: string } }>(
|
||||||
|
"/api/events",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => {
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
|
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
||||||
|
return { events: rows };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Integrity self-check: walk the chain and verify hashes + signatures. Admin-
|
||||||
|
// only (it's an audit action). Returns the first break, or ok. This is what a
|
||||||
|
// reconciliation job / "is the log intact?" check calls.
|
||||||
|
app.get(
|
||||||
|
"/api/events/verify",
|
||||||
|
{ preHandler: requireRole("admin") },
|
||||||
|
async () => eventLog.verifyChain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ import jwt from "@fastify/jwt";
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createDb, type Db } from "@parking/db";
|
import { createDb, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { EventLog } from "./event-log.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
|
import { buildSigner } from "./signer.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
@@ -59,7 +63,27 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
||||||
|
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
||||||
|
// trail. The device is NOT trusted; the host record is the source of truth, and
|
||||||
|
// a relay open with no matching signed event is itself the anomaly. We record
|
||||||
|
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
||||||
|
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||||
|
await eventRoutes(app, db, eventLog);
|
||||||
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
|
eventLog
|
||||||
|
.append({
|
||||||
|
type: "input_received",
|
||||||
|
lane: 0, // lane mapping is a TODO — device->lane lookup arrives with setup/lane wiring
|
||||||
|
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
|
||||||
|
occurredAt: e.at,
|
||||||
|
})
|
||||||
|
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
|
// TODO: entry flow (input event → signed event → print → relay); map device→lane.
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { Signer } from "@parking/shared";
|
||||||
|
|
||||||
|
// Concrete signers for the append-only event chain. The Signer interface is the
|
||||||
|
// abstraction over the ATECC608 secure element (open-question #6 — chip not yet
|
||||||
|
// confirmed wired). Until the chip is present we use a software HMAC signer:
|
||||||
|
// it makes the chain self-consistent + tamper-evident, but is NOT unforgeable by
|
||||||
|
// someone who owns the host (only the ATECC608's non-extractable key is). The
|
||||||
|
// swap to hardware is a new Signer impl — no event-log changes.
|
||||||
|
// See wiki/concepts/append-only-event-chain.md and wiki/entities/atecc608.md.
|
||||||
|
|
||||||
|
/** HMAC-SHA256 software signer. Key from env; fail fast if missing in prod. */
|
||||||
|
export class SoftwareSigner implements Signer {
|
||||||
|
readonly keyId: string;
|
||||||
|
readonly #key: Buffer;
|
||||||
|
|
||||||
|
constructor(secret: string, keyId = "sw-hmac-v1") {
|
||||||
|
this.#key = Buffer.from(secret, "utf8");
|
||||||
|
this.keyId = keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(payload: string): string {
|
||||||
|
return createHmac("sha256", this.#key).update(payload, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(payload: string, signature: string): boolean {
|
||||||
|
const expected = this.sign(payload);
|
||||||
|
// Constant-time compare; bail on length mismatch (timingSafeEqual throws).
|
||||||
|
if (expected.length !== signature.length) return false;
|
||||||
|
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the process signer. Uses EVENT_SIGNING_KEY (HMAC secret). Falls back to
|
||||||
|
* the JWT secret only as a last resort so dev works out of the box — logged as a
|
||||||
|
* warning, because reusing the auth secret for event signing is not ideal.
|
||||||
|
*
|
||||||
|
* TODO(atecc608): when the secure element is wired, return an Atecc608Signer here
|
||||||
|
* (keyId "atecc608-slotN"); existing events stay verifiable via their stored keyId.
|
||||||
|
*/
|
||||||
|
export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
|
||||||
|
const dedicated = process.env.EVENT_SIGNING_KEY;
|
||||||
|
if (dedicated && dedicated.length >= 16) {
|
||||||
|
return new SoftwareSigner(dedicated);
|
||||||
|
}
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (jwtSecret && jwtSecret.length >= 16) {
|
||||||
|
log?.warn(
|
||||||
|
"event signing: EVENT_SIGNING_KEY unset — falling back to JWT_SECRET. Set a dedicated key (and wire the ATECC608) before production.",
|
||||||
|
);
|
||||||
|
return new SoftwareSigner(jwtSecret, "sw-hmac-jwtfallback");
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ export interface ParkingEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ParkingEventType =
|
export type ParkingEventType =
|
||||||
|
// A raw device input (e.g. a Dingtian button press) was received and recorded.
|
||||||
|
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
|
||||||
|
// entry flow once a ticket prints and the barrier is commanded.
|
||||||
|
| "input_received"
|
||||||
| "vehicle_entry"
|
| "vehicle_entry"
|
||||||
| "vehicle_exit"
|
| "vehicle_exit"
|
||||||
| "void"
|
| "void"
|
||||||
@@ -48,3 +52,26 @@ export const ROLES: readonly Role[] = [
|
|||||||
"cashier",
|
"cashier",
|
||||||
"readonly",
|
"readonly",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the canonical bytes of an event for the append-only chain. This is the
|
||||||
|
* abstraction over the [[atecc608]] secure element: the real, non-extractable
|
||||||
|
* hardware key is ONE implementation. Whether the chip is wired is still
|
||||||
|
* open-question #6, so the server ships a software signer in the meantime —
|
||||||
|
* same interface, swappable with no business-logic change (the device-adapter
|
||||||
|
* philosophy applied to signing). See wiki/concepts/append-only-event-chain.md.
|
||||||
|
*
|
||||||
|
* IMPORTANT: a software signer makes the chain self-consistent and detectably
|
||||||
|
* tamper-evident, but NOT unforgeable by someone who owns the machine — only the
|
||||||
|
* ATECC608 provides that. Don't conflate the two.
|
||||||
|
*/
|
||||||
|
export interface Signer {
|
||||||
|
/** Stable id of the signer/key (e.g. "sw-hmac-v1", "atecc608-slot0"). Stored
|
||||||
|
* alongside events so verification knows which key to check against. */
|
||||||
|
readonly keyId: string;
|
||||||
|
/** Sign the canonical payload; returns a hex signature. */
|
||||||
|
sign(payload: string): string;
|
||||||
|
/** Verify a signature over the payload (software signers can; the ATECC608
|
||||||
|
* verifies via its public key). */
|
||||||
|
verify(payload: string, signature: string): boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user