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:
2026-06-15 11:29:23 +02:00
parent 39d4bac419
commit add5fc0166
5 changed files with 294 additions and 1 deletions
+147
View File
@@ -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 };
}
}