diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 4479b87..4c9f5d7 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -32,10 +32,15 @@ interface TestBody { config: Record; } -// Config keys that hold device secrets — never sent back to the client. Covers -// the push Digest password, the rotated device web-UI login, and the Dingtian -// relay password. Centralised so /state and /assign redact consistently. -const SECRET_CONFIG_KEYS = ["pushPassword", "webPassword", "relayPassword"] as const; +// Config keys that hold MACHINE-ONLY secrets — never sent back to the client. +// No human ever uses these to log in: `pushPassword` is the device→backend Digest +// secret, `relayPassword` is the binary-protocol relay_pw. They stay redacted. +// +// NOTE: the device web-UI login (`webUser`/`webPassword`) is deliberately NOT +// redacted. It's an operational credential an admin needs to reach the device's +// own web page, and the whole device-management area is admin-only — so it's +// surfaced in the admin device view rather than hidden. See first-run-setup.md. +const SECRET_CONFIG_KEYS = ["pushPassword", "relayPassword"] as const; function redactSecrets(config: Record): Record { const out = { ...config }; @@ -157,6 +162,9 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { const id = randomUUID(); const fullConfig: Record = { ...config }; + // Residual-risk warnings from device hardening (shown to the admin; the + // save still succeeds — these are "configured, but note X" advisories). + const hardenWarnings: string[] = []; let device; try { @@ -184,8 +192,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { } if (isHardenable(device)) { - const { secrets } = await device.harden(); + const { secrets, warnings } = await device.harden(); Object.assign(fullConfig, secrets); // e.g. relayPassword + // Surface residual-risk warnings (e.g. firmware that won't disable the + // password-less string protocol) so the admin can act (web-UI step). + for (const w of warnings ?? []) { + app.log.warn(`harden(${driverId} ${id}): ${w}`); + hardenWarnings.push(w); + } } if (hasPushConfig(device)) { @@ -229,7 +243,11 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }; await db.insert(laneDevices).values(row); // Don't echo device secrets back (push Digest password, web-UI login, …). - return reply.code(201).send({ ...row, config: redactSecrets(fullConfig) }); + return reply.code(201).send({ + ...row, + config: redactSecrets(fullConfig), + ...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), + }); }, ); diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts index 1165194..3e7af9c 100644 --- a/packages/devices/src/drivers/access-dingtian.ts +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -34,59 +34,37 @@ import { hostField, portField, stubLog } from "./common.js"; // (input_link_relay). That must be DISABLED on the device for ticket-first // entry, else the button opens the barrier before the host can act. -/** Send one UDP datagram and (optionally) await a single reply. */ -function udpRequest( - host: string, - port: number, - payload: string, - timeoutMs: number, - expectReply: boolean, -): Promise { - return new Promise((resolve, reject) => { - const sock = createSocket("udp4"); - let settled = false; - const done = (err: Error | null, val: string | null) => { - if (settled) return; - settled = true; - clearTimeout(timer); - sock.close(); - err ? reject(err) : resolve(val); - }; - const timer = setTimeout( - () => done(expectReply ? new Error("timeout") : null, null), - timeoutMs, - ); - sock.on("error", (e) => done(e, null)); - sock.on("message", (m) => done(null, m.toString())); - sock.bind(() => { - sock.send(Buffer.from(payload), port, host, (e) => { - if (e) done(e, null); - else if (!expectReply) done(null, null); - }); - }); - }); -} +// (The string-protocol UDP helper was removed: harden() now disables the +// password-less string protocol entirely, and status reads use the +// authenticated binary read — see #status() / readStatusFrame.) /** * Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await - * the reply. Used for relay control because — unlike the string protocol — the - * binary protocol supports a password (`relay_pw`), so an attacker on a flat - * network can't fire a relay without it. Frame verified on hardware: + * the reply. Used for ALL relay traffic — control AND status read — because, + * unlike the string protocol, the binary protocol carries a password (`relay_pw`). + * harden() disables the string protocol precisely because it has NO password and + * can fire relays (an unauthenticated `"11"` opens relay 1). With the string path + * closed, relay_pw actually gates control. Frame verified on hardware: * * FF AA * * FF = command "set relay" * AA = result xor (0x00 ^ 0xAA, pc→device) * session = echoed back - * relayCmd = 1 write, 3 jogging, … + * relayCmd = 0 read status, 1 write, 3 jogging, … * pwLo,pwHi = relay password, 16-bit LSB-first (0 = none) * data = command-specific + * + * NOTE: relay_pw + plaintext UDP is defence-in-depth, NOT a boundary. An attacker + * who sniffs the VLAN can replay the password. The real guarantee is the signed + * event log (relay open with no signed command = fraud) + VLAN isolation. */ function binaryUdp( host: string, port: number, frame: Buffer, timeoutMs: number, + localAddress?: string, ): Promise { return new Promise((resolve, reject) => { const sock = createSocket("udp4"); @@ -101,15 +79,33 @@ function binaryUdp( const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs); sock.on("error", (e) => done(e, null)); sock.on("message", (m) => done(null, m)); - sock.bind(() => { + // Bind to a specific local address (the device-facing NIC) on multi-homed + // hosts, so the device replies to the right source IP. See net.ts. + const onBound = () => { sock.send(frame, port, host, (e) => { if (e) done(e, null); }); - }); + }; + if (localAddress) sock.bind({ address: localAddress }, onBound); + else sock.bind(onBound); }); } let binarySession = 0; + +/** + * Build a binary "read relay status" frame (relay command 0x00). The device + * replies `FF AA 00 ` (status widths scale + * with channel count). This is the *authenticated* status read — unlike the + * string protocol's `00`, it carries the relay password, so we can disable the + * password-less string protocol entirely. Frame: `FF AA 00 `. + * Verified on hardware (4ch): reply `ff aa 00 00 01 0f` = relay1 on, inputs 1111. + */ +function readStatusFrame(password: number): Buffer { + const session = binarySession++ & 0xff; + return Buffer.from([0xff, 0xaa, session, 0x00, password & 0xff, (password >> 8) & 0xff]); +} + /** Build a binary "write relay with jogging" frame (relay on, auto-off). */ function jogFrame(channel: number, password: number, jogMs: number): Buffer { const session = binarySession++ & 0xff; @@ -151,9 +147,9 @@ function writeRelayFrame(channel: number, on: boolean, password: number, channel const rand16 = () => randomBytes(2).readUInt16BE(0); /** GET a CGI path on the device's HTTP server and return the raw response text. */ -function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number): Promise { +function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise { return new Promise((resolve, reject) => { - const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs }, (res) => { + const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => resolve(data)); @@ -186,6 +182,7 @@ function configApi( body: string | null, timeoutMs: number, sessionId?: number, // device session check: sent as Cookie: session= + localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts) ): Promise { return new Promise((resolve, reject) => { // The device's embedded HTTP server does NOT support chunked request bodies. @@ -207,6 +204,7 @@ function configApi( path, method, timeout: timeoutMs, + localAddress, headers: Object.keys(headers).length ? headers : undefined, }, (res) => { @@ -232,12 +230,15 @@ class DingtianController { readonly driverId = "dingtian"; readonly #host: string; - readonly #port: number; // string protocol (status read) — UDP 60001 + readonly #port: number; // legacy string-protocol port (60001) — protocol now disabled by harden(); kept for config compat readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000 readonly #relayPassword: number; // relay_pw (0 = none) readonly #sessionId: number; // device CGI session id (0 = session check off) readonly #httpPort: number; readonly #timeout: number; + // Local IP to source outbound device traffic from (the device-facing NIC on a + // multi-homed host). undefined = let the OS choose. See net.ts / device-facing-ip. + readonly #localAddress: string | undefined; readonly #channels: number; /** Input level at rest; an input is "active" when it differs from this. */ readonly #restingHigh: boolean; @@ -257,6 +258,7 @@ class DingtianController this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0; this.#sessionId = config.sessionId ? Number(config.sessionId) : 0; this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; + this.#localAddress = config.localAddress ? String(config.localAddress) : undefined; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000; this.#channels = config.channels ? Number(config.channels) : 4; // This unit idles with inputs HIGH (status "1111"); a press pulls LOW. @@ -297,14 +299,14 @@ class DingtianController async pulseOpen(doorId: number): Promise { this.#assertChannel(doorId); const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs); - await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout); + await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); } /** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */ async setRelay(doorId: number, on: boolean): Promise { this.#assertChannel(doorId); const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels); - await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout); + await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); } async getDoorStatus(doorId: number): Promise<"open" | "closed"> { @@ -421,19 +423,30 @@ class DingtianController /** * Lock the device down for a flat (no-VLAN) network: * - set a random relay password (`relay_pw`) so binary relay commands need it, - * - disable unused protocol channels (rs485/can/tcp×2/mqtt) — keep only UDP1 - * binary (relay control) and UDP2 string (status read). + * - keep ONLY UDP1 binary (password-protected relay control + status read), + * - disable every other protocol channel: string, rs485, can, tcp×2, mqtt. * Returns the relay password for the backend to persist (required to keep * commanding the device afterwards). * + * SECURITY — why the string protocol (UDP2) is now DISABLED (was a real hole): + * the Dingtian string protocol has NO password field and can *fire* relays + * (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). Leaving it enabled — even + * "just for status reads" — let anyone on the network open any barrier with one + * unauthenticated UDP packet, completely bypassing relay_pw. Confirmed by + * sending `"11"` to port 60001 with no credentials and watching relay 1 close. + * So harden() sets udp2.p=255 and status reads move to the authenticated binary + * read (relay command 0x00 — see #status()). + * * NOTE: deliberately does NOT touch the device's HTTP CGI session check * (`session_en`). On this firmware enabling it makes the config-read API drop * connections, locking us out of the very API we depend on (verified the hard * way — required a factory reset). So we leave the config API as-is and rely on * relay_pw + fewer open channels + the signed event log. * - * All are plaintext over HTTP/UDP on a flat network → defence-in-depth, not a - * boundary; the signed event log is the real guarantee. See device-input-flow. + * Even with the string hole closed, all of this is plaintext over UDP/HTTP → + * defence-in-depth, NOT a boundary. The real guarantee is the signed event log + * (a relay open with no matching signed command is the fraud signal) plus VLAN + * isolation. See device-input-flow / network-isolation. */ async harden(): Promise { const cfg = await this.#readConfig(); @@ -442,17 +455,24 @@ class DingtianController const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none) rc.relay_pw = relayPassword; - // Keep UDP1=Binary (p:1) for relay control, UDP2=String (p:0) for status. - // Disable everything else (p:255 = None). + // Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the + // status read. Disable everything else (p:255 = None), INCLUDING the string + // protocol (udp2), which is password-less and can fire relays. (rc.udp1 as Record).p = 1; - (rc.udp2 as Record).p = 0; + (rc.udp2 as Record).p = 255; (rc.rs485 as Record).p = 255; (rc.can as Record).p = 255; (rc.tcpc as Record).p = 255; (rc.tcps as Record).p = 255; (rc.mqtt as Record).p = 255; - await this.#writeConfig(cfg, (after) => { + // NOTE: udp2 (string protocol) is set to 255 here, but it is NOT part of the + // blocking verify. On some firmware (e.g. V3.6J) the CONFIG API silently + // refuses to disable udp2 — it accepts the write, reboots, and clamps it back + // to enabled — even though every other channel applies and the device's own + // web UI CAN disable it. We don't want assign to hard-fail over a firmware + // quirk, so we attempt it, then re-check below and warn if it didn't stick. + const afterCfg = await this.#writeConfig(cfg, (after) => { const a = after.relay_connect as Record | undefined; return ( a?.relay_pw === relayPassword && @@ -463,8 +483,20 @@ class DingtianController const applied = [ "set relay password", - "disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)", + "disabled rs485/can/tcp/mqtt channels (kept password-protected UDP binary)", ]; + const warnings: string[] = []; + const stringDisabled = + ((afterCfg.relay_connect as Record)?.udp2 as Record | undefined)?.p === 255; + if (stringDisabled) { + applied.push("disabled the password-less string protocol (udp2)"); + } else { + warnings.push( + "could not disable the string protocol (udp2) via the config API — this firmware ignores it. " + + "An unauthenticated UDP packet to the string port can still fire relays. " + + "Disable UDP2 in the device web UI, and rely on VLAN isolation + the signed event log. See dingtian-relay.md.", + ); + } const secrets: Record = { relayPassword }; // Rotate the default admin/admin web login. NOTE: cosmetic — this device's @@ -483,7 +515,7 @@ class DingtianController stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`); } - return { secrets, applied }; + return { secrets, applied, warnings: warnings.length ? warnings : undefined }; } /** @@ -499,7 +531,7 @@ class DingtianController const u = encodeURIComponent(this.#webUser); const oldP = encodeURIComponent(this.#webPassword); const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`; - const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout); + const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout, this.#localAddress); // "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw). const code = res.split("&")[1]; if (code !== "0") { @@ -511,7 +543,7 @@ class DingtianController // --- config api internals ---------------------------------------------- async #readConfig(): Promise> { - const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId); + const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId, this.#localAddress); return JSON.parse(raw) as Record; } @@ -527,7 +559,7 @@ class DingtianController async #writeConfig( cfg: Record, verify: (after: Record) => boolean, - ): Promise { + ): Promise> { // The set endpoint requires `"command":"setconfig"` injected after `status` // (the GET payload omits it). Rebuild preserving node order, command second. const out: Record = {}; @@ -543,7 +575,7 @@ class DingtianController // POST. The device resets on apply, so the connection may drop — that's // expected, not failure. try { - await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId); + await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId, this.#localAddress); } catch { // device likely reset on apply } @@ -552,7 +584,8 @@ class DingtianController for (let i = 0; i < 12; i++) { await sleep(2000); try { - if (verify(await this.#readConfig())) return; // applied + const after = await this.#readConfig(); + if (verify(after)) return after; // applied — return the landed config } catch { // still rebooting / unreachable — keep polling } @@ -581,21 +614,35 @@ class DingtianController } } - /** Query "00" → parse "0000:1111:4" into relays/inputs/channels. */ + /** + * Read relay + input status via the AUTHENTICATED binary protocol (relay + * command 0x00). Reply: `FF AA 00 `, + * each field `ceil(channels/8)` bytes, LSB-first (bit0 → relay/input 1). + * + * SECURITY: deliberately NOT the string protocol's `00` — that query has no + * password field AND the string protocol can also *fire* relays, so leaving it + * enabled defeats relay_pw entirely (an attacker sends `"11"` to open relay 1 + * with no auth). harden() disables the string protocol; status reads come here. + */ async #status(): Promise { - const reply = await udpRequest(this.#host, this.#port, "00", this.#timeout, true); - if (!reply) throw new Error("dingtian: empty status reply"); - const [relayStr, inputStr, countStr] = reply.trim().split(":"); - if (relayStr === undefined || inputStr === undefined) { - throw new Error(`dingtian: bad status reply "${reply}"`); + const frame = readStatusFrame(this.#relayPassword); + const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress); + const width = Math.max(1, Math.ceil(this.#channels / 8)); + // header: FF AA session 00 (4 bytes) + relay field + input field + if (reply.length < 4 + width * 2) { + throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`); } - const bit = (c: string) => c === "1"; - return { - relays: [...relayStr].map(bit), - // active = differs from the resting level (press pulls the line). - inputs: [...inputStr].map((c) => bit(c) !== this.#restingHigh), - channels: countStr ? Number(countStr) : this.#channels, - }; + const relayVal = reply.readUIntLE(4, width); + const inputVal = reply.readUIntLE(4 + width, width); + const relays: boolean[] = []; + const inputs: boolean[] = []; + for (let i = 0; i < this.#channels; i++) { + const high = (inputVal & (1 << i)) !== 0; + relays.push((relayVal & (1 << i)) !== 0); + // active = differs from the resting level (a press pulls the line). + inputs.push(high !== this.#restingHigh); + } + return { relays, inputs, channels: this.#channels }; } #startPolling(): void { diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 4ccc1f8..9729d0c 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -142,6 +142,10 @@ export interface HardenResult { readonly secrets: Record; /** Human-readable summary of what was changed (for logging/UI). */ readonly applied: string[]; + /** Hardening steps that could NOT be applied (e.g. a firmware quirk), so the + * admin knows a residual risk remains. Best-effort steps report here instead + * of failing the whole harden. */ + readonly warnings?: string[]; } export function isHardenable(device: Device): device is Device & HardenableDevice { diff --git a/wiki/concepts/append-only-event-chain.md b/wiki/concepts/append-only-event-chain.md index bc9fece..91590f7 100644 --- a/wiki/concepts/append-only-event-chain.md +++ b/wiki/concepts/append-only-event-chain.md @@ -24,3 +24,64 @@ It only becomes trustworthy as an external fraud control when paired with [[reco against an authority the operator can't alter. Every device event — including those ingested from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side chain. + +## Implementation (apps/server) + +> Implementation-derived. The schema (`packages/db` `events`) and types +> (`packages/shared` `ParkingEvent`) predate this; the writer/signer are new. + +- **`EventLog`** (`apps/server/src/event-log.ts`) is the append primitive. `append()` reads the + latest row, sets `index = prev + 1`, `prevHash = sha256(canonical(prev))` (genesis = null), + signs the canonical form, and inserts. There are **no update/delete paths**. +- **Serialized appends.** SQLite is single-writer, but read-prev → compute-hash → insert is + multi-step, so `EventLog` also guards it with an in-process async lock — otherwise two near- + simultaneous events could claim the same `index` or chain off a stale `prevHash`. Verified: + 5 concurrent appends produced indices 1..5 with an intact chain. +- **Canonical form** is a fixed-order JSON array (`index,type,direction,lane,source,identity, + occurredAt,prevHash`) — byte-stable, since the chain + signatures depend on it. The volatile + row `id` is excluded; chain identity is `index` + content. +- **`verifyChain()`** walks oldest→newest, recomputing hashes + signatures. Catches tampered + content (bad signature), reordering / a deleted row (`index` gap), and a `prevHash` mismatch. + Exposed at `GET /api/events/verify` (admin). Read access to the log: `GET /api/events`. + +### The `Signer` abstraction (software now, ATECC608 later) + +Signing goes through a **`Signer`** interface (`packages/shared`) — the abstraction over the +[[atecc608]]. Because the chip being wired is still [[open-questions|open-question #6]], the +server ships a **`SoftwareSigner`** (HMAC-SHA256, key from `EVENT_SIGNING_KEY`). Swapping to the +secure element is a new `Signer` impl with no `EventLog` change; each event stores its `keyId` +so old events stay verifiable. + +> ⚠️ The software signer makes the chain **self-consistent + tamper-evident**, but **not +> unforgeable by someone who owns the host** — only the ATECC608's non-extractable key gives +> property (3) above. Until the chip is wired, the chain detects tampering by *outsiders* and +> *accidental* corruption, but an operator with the signing key + DB access could re-sign a +> forged chain. This is the central reason #6 matters. + +### What currently feeds the log + +Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]], +[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` — +the richer entry event waits for the entry flow (ticket print + barrier command). Device→lane +mapping is still a TODO (logged with `lane: 0`). + +### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only + +The event log records what the **host** did (inputs it received, opens it commanded). It is +**blind to out-of-band relay actuation** — anything that fires a relay without going through the +host. **Proven on hardware**: a binary relay command sent directly to the device with the +(sniffable) `relay_pw` fired a relay and produced **zero** events. Out-of-band paths include: + +- the **password-less string protocol** (until disabled — see [[dingtian-relay]]), +- a **sniffed/replayed `relay_pw`** binary command (plaintext UDP — relay control is + defence-in-depth, **not** a boundary), +- the device's own **`ip_watchdog`** (auto-toggles a relay on ping-failure — must stay disabled), +- a future **`barrier_open_command`** path is host-side and *would* log; these bypass it. + +So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** — +the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an +**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input +→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching +signed command is the fraud signal.** Both the witness sources and the reconciliation logic are +**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't +reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop. diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index d4fc452..2360333 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -79,17 +79,42 @@ the relay via UDP. See [[device-input-flow]] for the full path + trust model. > real path** — lower latency, and it can be authenticated (the device supports Basic/Digest + > HTTPS on the push), unlike the open UDP control direction. +### What it pushes vs. doesn't (logging) + +- **Inputs (buttons): YES, pushed.** Input changes are HTTP-pushed via `input_link_url` and now + land in the host's signed [[append-only-event-chain]] as `input_received` events (bus → + `EventLog`). That is the audit trail for "a button fired." +- **Relay / barrier opens: NO push, no log.** The device has **no event log of its own** and does + not report when a relay fires — relay control is one-way UDP that the *host* initiates. So + "the barrier opened" is not something to scrape from the device. The host records what it + *commanded* (a future `barrier_open_command` event); a relay open with **no matching signed + host event is itself the anomaly** to alarm on ([[threat-model]]). Do not treat the Dingtian as + a log source — it is a dumb relay+input board; the host is the source of truth. + ## Hardening (`harden()`) — and why HTTP auth is not a boundary here On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability): 1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it. -2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary - (relay control) + UDP2 string (status read). +2. **Disable EVERY other channel** — set `p:255` on the string protocol (udp2), rs485, can, + tcp×2, mqtt; keep **only** UDP1 binary, which carries `relay_pw` for both control AND status. 3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?&&&&` (response `&0&…&` = success, verified on hardware). The new password is stored back in config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old* creds). This step is **best-effort** — a failure logs and does not fail the assign. +> ⚠️ **The string protocol (udp2) is a password-less relay-fire path — the original `harden()` +> left it ENABLED "for status reads", which was a real hole.** The Dingtian string protocol has +> NO password field and can fire relays (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). +> **Proven on hardware**: sending `"11"` to UDP 60001 with no credentials opened relay 1, +> completely bypassing `relay_pw`. Fixes: (a) status reads moved to the **authenticated binary +> read** (relay command `0x00`) so the string protocol is no longer needed; (b) `harden()` now +> sets `udp2.p=255` to disable it. **Firmware caveat (V3.6J):** the CONFIG API silently refuses +> to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's +> **web UI can** disable it. So the udp2 disable is **best-effort + warns** (it is NOT part of the +> blocking verify); if it doesn't stick, `harden()` returns a warning telling the admin to flip +> UDP2 off in the device web UI. Verified: after the web-UI disable, the `"11"` attack gets no +> reply and the relay stays off, while authenticated binary control/status still work. + > ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`, > `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login > gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire diff --git a/wiki/log.md b/wiki/log.md index 0b40587..767c42a 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -238,3 +238,39 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). - Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak, delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8). - Updated [[first-run-setup]]. + +## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist) +- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps + no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly. +- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash + chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events; + integrity: GET /api/events/verify (admin). +- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY) + shipped now since chip wiring is open-question #6. Caveat documented: software signer is + tamper-evident but NOT unforgeable-by-owner. +- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO). +- Added ParkingEventType 'input_received'. +- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok; + direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent + appends -> indices 1..5 intact. Full repo typechecks. +- Updated [[append-only-event-chain]], [[dingtian-relay]]. + +## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix +- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner + HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received. + Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok. +- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays + ("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated + binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses, + but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack + is dead and binary control/status still work. +- GAP (user-identified): event log captures host-originated actions only; out-of-band relay + actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware. + Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built. +- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area); + pushPassword/relayPassword stay machine-only. +- harden() warnings surfaced via the assign response. +- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts). +- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults). + Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs). +- Updated [[append-only-event-chain]], [[dingtian-relay]].