c2f06a5d2a
Add @fastify/websocket. EventLog fires an onAppended callback after each durable append; device-events gains a ledger channel (emitLedger). /api/ws fans out ledger + occupancy + printer-status to authenticated booth clients. Origin allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH). Note: server.ts also reflects later booth route wiring; the final HEAD builds.
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
|
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, 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: LedgerEventType;
|
|
readonly direction?: Direction | null;
|
|
readonly source?: IdentitySource | null;
|
|
readonly identity?: string | null;
|
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
|
readonly payload?: LedgerPayload | 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;
|
|
source: string | null;
|
|
identity: string | null;
|
|
payload: Record<string, unknown> | null;
|
|
occurredAt: string;
|
|
prevHash: string | null;
|
|
}): string {
|
|
return JSON.stringify([
|
|
e.index,
|
|
e.type,
|
|
e.direction ?? null,
|
|
e.source ?? null,
|
|
e.identity ?? null,
|
|
// Payload is part of the signed form so business data is tamper-evident.
|
|
// Serialize with sorted keys for byte-stability (object key order must not
|
|
// change a signature). null when the event type carries no payload.
|
|
canonicalPayload(e.payload),
|
|
e.occurredAt,
|
|
e.prevHash ?? null,
|
|
]);
|
|
}
|
|
|
|
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
|
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
|
if (p == null) return null;
|
|
const sort = (v: unknown): unknown => {
|
|
if (Array.isArray(v)) return v.map(sort);
|
|
if (v && typeof v === "object") {
|
|
return Object.keys(v as Record<string, unknown>)
|
|
.sort()
|
|
.reduce<Record<string, unknown>>((o, k) => {
|
|
o[k] = sort((v as Record<string, unknown>)[k]);
|
|
return o;
|
|
}, {});
|
|
}
|
|
return v;
|
|
};
|
|
return sort(p);
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
|
* Returns undefined when the key that signed an event is not available. */
|
|
export type SignerResolver = (keyId: string) => Signer | undefined;
|
|
|
|
export class EventLog {
|
|
readonly #db: Db;
|
|
readonly #signer: Signer;
|
|
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
|
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
|
* callers that don't pass one (single-key chains, tests). */
|
|
readonly #resolveVerifier: SignerResolver;
|
|
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
|
* to fan the event out to live booth clients (WS). It is best-effort and must
|
|
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
|
readonly #onAppended?: (row: LedgerEventRow) => void;
|
|
/** Serialize appends: each waits for the previous to finish. */
|
|
#tail: Promise<unknown> = Promise.resolve();
|
|
|
|
constructor(
|
|
db: Db,
|
|
signer: Signer,
|
|
resolveVerifier?: SignerResolver,
|
|
onAppended?: (row: LedgerEventRow) => void,
|
|
) {
|
|
this.#db = db;
|
|
this.#signer = signer;
|
|
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
|
this.#onAppended = onAppended;
|
|
}
|
|
|
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
|
append(input: AppendInput): Promise<LedgerEventRow> {
|
|
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);
|
|
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
|
// failing sink can never reject the append or break the chain lock above.
|
|
return run.then((row) => {
|
|
try {
|
|
this.#onAppended?.(row);
|
|
} catch {
|
|
// best-effort fan-out only — swallow.
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
#appendNow(input: AppendInput): LedgerEventRow {
|
|
const prev = this.#db
|
|
.select()
|
|
.from(ledgerEvents)
|
|
.orderBy(desc(ledgerEvents.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 payload = input.payload ?? null;
|
|
|
|
const canonical = canonicalize({
|
|
index,
|
|
type: input.type,
|
|
direction: input.direction ?? null,
|
|
source: input.source ?? null,
|
|
identity: input.identity ?? null,
|
|
payload,
|
|
occurredAt,
|
|
prevHash,
|
|
});
|
|
|
|
const row = {
|
|
id: randomUUID(),
|
|
index,
|
|
type: input.type,
|
|
direction: input.direction ?? null,
|
|
source: input.source ?? null,
|
|
identity: input.identity ?? null,
|
|
payload,
|
|
occurredAt,
|
|
prevHash,
|
|
signature: this.#signer.sign(canonical),
|
|
keyId: this.#signer.keyId,
|
|
};
|
|
|
|
this.#db.insert(ledgerEvents).values(row).run();
|
|
return row as LedgerEventRow;
|
|
}
|
|
|
|
/**
|
|
* 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), a forged/invalid signature, and an event signed under a key
|
|
* that is no longer configured.
|
|
*
|
|
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
|
* append signer — so a chain that spans a key rotation (e.g. early events under
|
|
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
|
* verifies end to end. See signer.buildVerifier.
|
|
*/
|
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.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 verifier = this.#resolveVerifier(row.keyId);
|
|
if (!verifier) {
|
|
return {
|
|
ok: false,
|
|
index: row.index,
|
|
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
|
};
|
|
}
|
|
const canonical = canonicalize(row);
|
|
if (!verifier.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 };
|
|
}
|
|
}
|