Dingtian: close password-less string-protocol relay-fire hole

The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".

- #status() now reads via the authenticated binary command (relay cmd 0x00)
  instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
  V3.6J's config API silently refuses to disable udp2 (the device web UI can),
  so it's not part of the blocking verify -- harden() re-checks and returns a
  warning instead of throwing. After a web-UI disable, the attack is dead and
  binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
  to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
  defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
  host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
  state -- it's an operational credential for the admin-only device area;
  pushPassword/relayPassword stay machine-only.

Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
This commit is contained in:
2026-06-15 11:29:55 +02:00
parent add5fc0166
commit 7db5cfa0e4
6 changed files with 270 additions and 79 deletions
+24 -6
View File
@@ -32,10 +32,15 @@ interface TestBody {
config: Record<string, string | number | boolean>;
}
// 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<string, unknown>): Record<string, unknown> {
const out = { ...config };
@@ -157,6 +162,9 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...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<void> {
}
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<void> {
};
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 } : {}),
});
},
);