diff --git a/.github/workflows/source-ci.yml b/.github/workflows/source-ci.yml index 60c055d..851591e 100644 --- a/.github/workflows/source-ci.yml +++ b/.github/workflows/source-ci.yml @@ -66,10 +66,11 @@ jobs: cargo test --locked --manifest-path src-tauri/Cargo.toml --features community-desktop,tauri/custom-protocol staged_installer_ - name: Verify the native Windows media path classifier run: cargo test --locked --manifest-path src-tauri/Cargo.toml --features community-desktop,tauri/custom-protocol windows_network_and_device_paths_are_not_local_media - - name: Verify private remote state permissions and advertised hosts + - name: Verify private remote state permissions, advertised hosts and device revocation run: | cargo test --locked --manifest-path src-tauri/Cargo.toml --features community-desktop,tauri/custom-protocol private_json_is_owner_only_even_when_replacing_a_readable_file -- --nocapture cargo test --locked --manifest-path src-tauri/Cargo.toml --features community-desktop,tauri/custom-protocol remote_allowed_hosts_names_only_the_configured_tunnel_host -- --nocapture + cargo test --locked --manifest-path src-tauri/Cargo.toml --features community-desktop,tauri/custom-protocol revoking_a_mobile_device_lists_its_credential_before_dropping_it -- --nocapture - name: Inspect the Linux GLib dependency path run: cargo tree --locked --manifest-path src-tauri/Cargo.toml --target x86_64-unknown-linux-gnu --features community-desktop,tauri/custom-protocol -i glib - name: Audit the complete desktop lockfile diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 54ddb87..3108348 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -15,7 +15,7 @@ The community source build currently has no signed installer claim. The 39 integ - **Transport:** the default LAN transport is plain HTTP on the local network. A person on the same network can read the pairing link and the device credential in transit, so pair only on a network you trust. The Remote dialog says this before pairing. Cross-network setups that you provide yourself (HTTPS tunnel, WSS relay) encrypt the transport; the credential cookie then carries `Secure`. - **Pairing token:** a 128-bit random token, valid for 10 minutes and for **one successful pairing**. A captured pairing request cannot be replayed to pair a second device, whether it arrives over LAN or the relay. A failed pairing (for example the device limit) gives the token back. To add another phone, pause Remote and open it again; that starts a fresh server with a new token and QR code. - **Device credential:** 256 random bits stored only as a SHA-256 hash. It expires after **30 days without use** (sliding: every authenticated status poll renews the cookie, and the server compares the stored `lastSeen`). An idle device is removed from the trusted list and must pair again. -- **Revocation:** revoking a device in the Remote dialog takes effect on its next request, because every request re-reads the trusted-devices file. Pausing Remote keeps the trusted devices; removing them individually is the only way to revoke them today. +- **Revocation:** revoking a device in the Remote dialog takes effect on its next request, because every request re-reads the trusted-devices file. The server applies its own updates to that file (pairing, last-seen, expiry) to a fresh read of it, and the desktop also adds the revoked credential's hash to a revocation list that only the desktop writes and the server always checks, so an update that overlaps the revoke cannot let the device back in. Pausing Remote keeps the trusted devices; removing them individually is the only way to revoke them today. ## GTK dependency advisory remediation [RUSTSEC-2024-0429](https://rustsec.org/advisories/RUSTSEC-2024-0429.html) concerns unsound `glib::VariantStrIter` iteration in GLib Rust versions `>=0.15, <0.20`; the fixed range is `>=0.20`. The original desktop lockfile resolved GLib 0.18.5 through Tauri 2.11.5 → GTK 0.18.2, including the native window/event-loop, embedded WebKit, and menu dependency chain. Absence of a direct application `VariantStrIter` call is not proof that the advisory is unreachable. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1996e05..6e74338 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5421,6 +5421,79 @@ fn get_mobile_remote_status(state: State<'_, AppState>) -> Result Ok(status) } +/// Credential hashes of revoked devices, written only by the desktop. The Remote +/// server (`src/remote/server.ts`) also rewrites the trusted-device file, and a +/// write it based on a read taken just before a revoke can land just after it +/// and list the device again; the server refuses every credential listed here. +fn mobile_revoked_devices_path(trusted_devices_path: &Path) -> PathBuf { + trusted_devices_path.with_file_name("revoked-devices.json") +} + +const MOBILE_REVOKED_CREDENTIAL_LIMIT: usize = 256; + +fn revoke_trusted_device(trusted_path: &Path, device_id: &str) -> Result { + let mut store = fs::read_to_string(trusted_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .unwrap_or_else(|| json!({ "schemaVersion": 1, "devices": [] })); + let devices = store + .get_mut("devices") + .and_then(Value::as_array_mut) + .ok_or("永久綁定裝置資料格式不正確")?; + let before = devices.len(); + let mut credential_hashes = Vec::new(); + devices.retain(|device| { + if device.get("id").and_then(Value::as_str) != Some(device_id) { + return true; + } + if let Some(hash) = device + .get("credentialHash") + .and_then(Value::as_str) + .filter(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) + { + credential_hashes.push(hash.to_owned()); + } + false + }); + let revoked = devices.len() != before; + if !credential_hashes.is_empty() { + let revoked_path = mobile_revoked_devices_path(trusted_path); + // An unreadable list is replaced so that this revoke still takes effect. + let mut listed: Vec = read_remote_json(&revoked_path) + .ok() + .flatten() + .and_then(|value| { + value + .get("credentialHashes") + .and_then(Value::as_array) + .map(|hashes| { + hashes + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + }) + .unwrap_or_default(); + listed.retain(|hash| !credential_hashes.contains(hash)); + listed.extend(credential_hashes); + let excess = listed.len().saturating_sub(MOBILE_REVOKED_CREDENTIAL_LIMIT); + listed.drain(..excess); + // Listed before the trusted file changes, so the device is refused from here on. + write_remote_json_atomic( + &revoked_path, + &json!({ "schemaVersion": 1, "credentialHashes": listed }), + )?; + } + write_json_atomic(trusted_path, &store)?; + Ok(revoked) +} + #[tauri::command] fn revoke_mobile_device( app: AppHandle, @@ -5431,25 +5504,17 @@ fn revoke_mobile_device( if device_id.is_empty() || device_id.len() > 100 { return Err("裝置識別碼不合法".into()); } - let trusted_path = state + // Held until the revoke is written, so two revokes cannot drop each other's + // entry from the revocation list. + let remote_state = state .mobile_remote .lock() - .map_err(|_| "mobile remote lock poisoned")? + .map_err(|_| "mobile remote lock poisoned")?; + let trusted_path = remote_state .as_ref() .map(|remote| remote.trusted_devices_path.clone()) .unwrap_or(mobile_trusted_devices_path(&app)?); - let mut store = fs::read_to_string(&trusted_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .unwrap_or_else(|| json!({ "schemaVersion": 1, "devices": [] })); - let devices = store - .get_mut("devices") - .and_then(Value::as_array_mut) - .ok_or("永久綁定裝置資料格式不正確")?; - let before = devices.len(); - devices.retain(|device| device.get("id").and_then(Value::as_str) != Some(device_id)); - let revoked = devices.len() != before; - write_json_atomic(&trusted_path, &store)?; + let revoked = revoke_trusted_device(&trusted_path, device_id)?; Ok(json!({ "revoked": revoked, "deviceId": device_id })) } @@ -10791,6 +10856,78 @@ mod tests { fs::remove_dir_all(&root).unwrap(); } + #[test] + fn revoking_a_mobile_device_lists_its_credential_before_dropping_it() { + let root = env::temp_dir().join(format!("editkin-mobile-revoke-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let trusted = root.join("mobile-remote").join("trusted-devices.json"); + let revoked = mobile_revoked_devices_path(&trusted); + let hash = |index: usize| format!("{index:064x}"); + let device = |id: &str, credential: usize| { + json!({ + "id": id, + "name": id, + "credentialHash": hash(credential), + "pairedAt": "2026-01-01T00:00:00.000Z", + "lastSeen": "2026-01-01T00:00:00.000Z" + }) + }; + let listed = || read_remote_json(&revoked).unwrap().unwrap()["credentialHashes"].clone(); + let trusted_ids = || { + let store: Value = + serde_json::from_str(&fs::read_to_string(&trusted).unwrap()).unwrap(); + store["devices"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap().to_owned()) + .collect::>() + }; + write_json_atomic( + &trusted, + &json!({ "schemaVersion": 1, "devices": [device("phone", 1), device("tablet", 2)] }), + ) + .unwrap(); + + assert_eq!( + revoked, + root.join("mobile-remote").join("revoked-devices.json") + ); + assert!(revoke_trusted_device(&trusted, "phone").unwrap()); + assert_eq!(listed(), json!([hash(1)])); + assert_eq!(trusted_ids(), ["tablet"]); + assert!(!revoke_trusted_device(&trusted, "phone").unwrap()); + assert_eq!(listed(), json!([hash(1)])); + + // An unreadable list is replaced instead of blocking the revoke. + fs::write(&revoked, "not json").unwrap(); + assert!(revoke_trusted_device(&trusted, "tablet").unwrap()); + assert_eq!(listed(), json!([hash(2)])); + assert!(trusted_ids().is_empty()); + + // A full list keeps the most recent revocations. + let full = (100..100 + MOBILE_REVOKED_CREDENTIAL_LIMIT) + .map(hash) + .collect::>(); + write_remote_json_atomic( + &revoked, + &json!({ "schemaVersion": 1, "credentialHashes": full }), + ) + .unwrap(); + write_json_atomic( + &trusted, + &json!({ "schemaVersion": 1, "devices": [device("watch", 3)] }), + ) + .unwrap(); + assert!(revoke_trusted_device(&trusted, "watch").unwrap()); + let hashes = listed(); + let hashes = hashes.as_array().unwrap(); + assert_eq!(hashes.len(), MOBILE_REVOKED_CREDENTIAL_LIMIT); + assert_eq!(hashes.first(), Some(&json!(hash(101)))); + assert_eq!(hashes.last(), Some(&json!(hash(3)))); + fs::remove_dir_all(&root).unwrap(); + } + #[test] fn release_ignores_runtime_path_and_unsigned_update_environment_overrides() { let fallback = PathBuf::from("bundled/runtime/node.exe"); diff --git a/src/remote/revocation.test.ts b/src/remote/revocation.test.ts new file mode 100644 index 0000000..af92094 --- /dev/null +++ b/src/remote/revocation.test.ts @@ -0,0 +1,272 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, mkdtemp, open, readdir, readFile, rename, rm, stat, writeFile, type FileHandle } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { build } from "esbuild"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { DEVICE_IDLE_LIFETIME_MS } from "./pairingPolicy"; + +const token = "0123456789abcdef0123456789abcdef"; + +interface Device { id: string; name: string; credentialHash: string; pairedAt: string; lastSeen: string } +interface Run { child: ChildProcess; origin: string; directory: string; trusted: string; revoked: string; status: string; readyAt: number } + +let root: string; +let bundle: string; +let run: Run | undefined; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "editkin-revocation-")); + bundle = join(root, "remote.mjs"); + await build({ entryPoints: [resolve("src/remote/server.ts")], bundle: true, platform: "node", target: "node22", format: "esm", outfile: bundle, logLevel: "silent" }); +}, 60_000); +afterAll(async () => { await rm(root, { recursive: true, force: true }); }); +afterEach(async () => { + const current = run; + run = undefined; + if (!current || current.child.exitCode !== null || current.child.signalCode !== null) return; + const exited = new Promise((done) => current.child.once("exit", done)); + current.child.kill("SIGKILL"); + await exited; +}); + +const credential = (seed: string) => seed.repeat(43); +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString(); +function device(id: string, seed: string, lastSeen = minutesAgo(2)): Device { + return { id, name: id, credentialHash: createHash("sha256").update(credential(seed)).digest("hex"), pairedAt: lastSeen, lastSeen }; +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(temporary, `${JSON.stringify(value)}\n`); + await rename(temporary, path); +} + +function freePort(): Promise { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + probe.close(() => typeof address === "object" && address ? resolvePort(address.port) : reject(new Error("no port"))); + }); + }); +} + +async function start(devices: Device[], revokedHashes?: string[]): Promise { + const directory = await mkdtemp(join(root, "run-")); + const trusted = join(directory, "trusted-devices.json"); + const revoked = join(directory, "revoked-devices.json"); + await writeJsonAtomic(trusted, { schemaVersion: 1, devices }); + if (revokedHashes) await writeJsonAtomic(revoked, { schemaVersion: 1, credentialHashes: revokedHashes }); + await writeFile(join(directory, "snapshot.json"), `${JSON.stringify({ projectName: "Revocation Test" })}\n`); + await mkdir(join(directory, "commands")); + const port = await freePort(); + const child = spawn(process.execPath, [bundle], { + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: process.env.PATH ?? "", + EDITKIN_REMOTE_TOKEN: token, + EDITKIN_REMOTE_HEALTH_PROBE_ID: "2".repeat(32), + EDITKIN_REMOTE_PORT: String(port), + EDITKIN_REMOTE_QUEUE: join(directory, "commands"), + EDITKIN_REMOTE_SNAPSHOT: join(directory, "snapshot.json"), + EDITKIN_REMOTE_DEVICES: join(directory, "devices.json"), + EDITKIN_REMOTE_TRUSTED_DEVICES: trusted, + }, + }); + const ready = Promise.withResolvers(); + let output = ""; + let errors = ""; + child.stdout!.on("data", (chunk) => { output += chunk; if (output.includes('"status":"READY"')) ready.resolve(); }); + child.stderr!.on("data", (chunk) => { errors += chunk; }); + child.once("exit", (code) => ready.reject(new Error(`remote server exited early (${code}): ${errors}`))); + run = { child, origin: `http://127.0.0.1:${port}`, directory, trusted, revoked, status: join(directory, "devices.json"), readyAt: 0 }; + await ready.promise; + run.readyAt = Date.now(); + return run; +} + +/** The desktop's `revoke_mobile_device`: list the credential as revoked, then drop the device. */ +async function revokeLikeDesktop(current: Run, revoked: Device, remaining: Device[], { listRevocation = true } = {}): Promise { + if (listRevocation) await writeJsonAtomic(current.revoked, { schemaVersion: 1, credentialHashes: [revoked.credentialHash] }); + await writeJsonAtomic(current.trusted, { schemaVersion: 1, devices: remaining }); +} + +async function status(current: Run, seed: string): Promise { + const response = await fetch(`${current.origin}/api/status`, { headers: { cookie: `editkin_remote_device=${credential(seed)}` } }); + await response.arrayBuffer(); + return response.status; +} + +async function command(current: Run, seed: string): Promise { + const response = await fetch(`${current.origin}/api/command`, { + method: "POST", + headers: { "content-type": "application/json", origin: current.origin, cookie: `editkin_remote_device=${credential(seed)}` }, + body: JSON.stringify({ instruction: "undo" }), + }); + await response.arrayBuffer(); + return response.status; +} + +async function pair(current: Run, deviceId: string): Promise { + const response = await fetch(`${current.origin}/api/pair`, { + method: "POST", + headers: { "content-type": "application/json", origin: current.origin }, + body: JSON.stringify({ token, deviceId, name: "New phone" }), + }); + await response.arrayBuffer(); + return response.status; +} + +async function trustedIds(current: Run): Promise { + return (JSON.parse(await readFile(current.trusted, "utf8")) as { devices: Device[] }).devices.map((item) => item.id).sort(); +} + +async function listedIds(current: Run): Promise { + return (JSON.parse(await readFile(current.status, "utf8")) as { devices: Array<{ id: string }> }).devices.map((item) => item.id).sort(); +} + +const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms)); + +/** + * The server also re-reads the trusted-device file for its status file every + * 3 s, starting 3 s after READY. Begin a race only in that quiet stretch, so the + * request under test is the only reader of the FIFO. + */ +async function quietStretch(current: Run): Promise { + if (Date.now() - current.readyAt < 1_000) return; + const before = (await stat(current.status)).mtimeMs; + for (const deadline = Date.now() + 5_000; (await stat(current.status)).mtimeMs === before;) { + if (Date.now() > deadline) throw new Error("the device status file was not refreshed"); + await sleep(10); + } +} + +/** Opens the FIFO for writing once the server has opened it for reading. */ +async function openWhenRead(fifo: string): Promise { + for (const deadline = Date.now() + 5_000; ;) { + try { + return await open(fifo, constants.O_WRONLY | constants.O_NONBLOCK); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENXIO" || Date.now() > deadline) throw error; + await sleep(5); + } + } +} + +/** + * Makes `revoke` land between the server reading the trusted-device file and + * acting on it. The file is swapped for a FIFO, so the server's next read stays + * open until the test closes it; the server then receives `seen`, the store as it + * was before the revoke, while the revoke is already on disk. + */ +async function revokeDuring(current: Run, seen: Device[], request: (current: Run) => Promise, revoke: () => Promise): Promise { + await quietStretch(current); + const fifo = join(current.directory, "trusted.fifo"); + execFileSync("mkfifo", [fifo]); + await rename(fifo, current.trusted); + const [answer] = await Promise.all([request(current), (async () => { + const writer = await openWhenRead(current.trusted); + try { + await writer.write(JSON.stringify({ schemaVersion: 1, devices: seen })); + await revoke(); + } finally { + await writer.close(); + } + })()]); + return answer; +} + +describe.skipIf(process.platform === "win32")("revocation racing a trusted-device write", () => { + const phone = device("phone", "p"); + const tablet = device("tablet", "t"); + const idle = device("idle-phone", "i", minutesAgo(DEVICE_IDLE_LIFETIME_MS / 60_000 + 60)); + + it.each([ + { + name: "another device's activity is recorded", + before: [tablet, phone], + after: [tablet], + request: (current: Run) => status(current, "t"), + answer: 200, + kept: ["tablet"], + }, + { + name: "the revoked device's own activity is recorded", + before: [tablet, phone], + after: [tablet], + request: (current: Run) => status(current, "p"), + answer: undefined, + kept: ["tablet"], + }, + { + name: "a new device pairs", + before: [phone], + after: [], + request: (current: Run) => pair(current, "new-phone"), + answer: 201, + kept: ["new-phone"], + }, + { + name: "an idle device expires", + before: [idle, phone], + after: [idle], + request: (current: Run) => status(current, "i"), + answer: 401, + kept: [], + }, + ])("does not restore a device revoked while $name", async ({ before, after, request, answer, kept }) => { + const current = await start(before); + const reply = await revokeDuring(current, before, request, () => revokeLikeDesktop(current, phone, after)); + if (answer !== undefined) expect(reply).toBe(answer); + expect(await status(current, "p")).toBe(401); + expect(await command(current, "p")).toBe(401); + expect(await readdir(join(current.directory, "commands"))).toEqual([]); + expect(await trustedIds(current)).toEqual(kept); + }, 20_000); + + it("rewrites the trusted-device file only from what is on disk, even without the revocation list", async () => { + const current = await start([tablet, phone]); + expect(await revokeDuring(current, [tablet, phone], (target) => status(target, "t"), () => revokeLikeDesktop(current, phone, [tablet], { listRevocation: false }))).toBe(200); + expect(await status(current, "p")).toBe(401); + expect(await trustedIds(current)).toEqual(["tablet"]); + }, 20_000); +}); + +describe("revoked Remote devices", () => { + it("rejects a revoked device on its next request", async () => { + const phone = device("phone", "p"); + const tablet = device("tablet", "t"); + const current = await start([tablet, phone]); + expect(await status(current, "p")).toBe(200); + await revokeLikeDesktop(current, phone, [tablet]); + expect(await status(current, "p")).toBe(401); + expect(await command(current, "p")).toBe(401); + expect(await readdir(join(current.directory, "commands"))).toEqual([]); + expect(await status(current, "t")).toBe(200); + expect(await trustedIds(current)).toEqual(["tablet"]); + }, 20_000); + + it("keeps refusing a revoked credential that reappears in the trusted-device file", async () => { + const phone = device("phone", "p"); + const tablet = device("tablet", "t"); + const current = await start([tablet, phone], [phone.credentialHash]); + expect(await status(current, "p")).toBe(401); + expect(await command(current, "p")).toBe(401); + expect(await status(current, "t")).toBe(200); + expect(await listedIds(current)).toEqual(["tablet"]); + expect(await trustedIds(current)).toEqual(["tablet"]); + }, 20_000); + + it("refuses every device and changes nothing while the revocation list cannot be read", async () => { + const current = await start([device("tablet", "t")]); + await writeFile(current.revoked, "not json"); + expect(await status(current, "t")).toBe(401); + expect(await pair(current, "new-phone")).toBe(500); + expect(await trustedIds(current)).toEqual(["tablet"]); + }, 20_000); +}); diff --git a/src/remote/server.ts b/src/remote/server.ts index 75b13e3..a9c4e92 100644 --- a/src/remote/server.ts +++ b/src/remote/server.ts @@ -27,6 +27,8 @@ const remoteQueuePath = queuePath; const remoteSnapshotPath = snapshotPath; const remoteDevicesPath = devicesPath; const remoteTrustedDevicesPath = trustedDevicesPath; +// Written only by the desktop, next to the trusted-device file, when it revokes a device. +const remoteRevokedDevicesPath = join(remoteTrustedDevicesPath, "..", "revoked-devices.json"); const remoteHealthProbeId = healthProbeId; const pairingExpiresAt = Date.now() + 10 * 60_000; const pairingWindow = new PairingWindow(pairingExpiresAt); @@ -172,11 +174,39 @@ function validTrustedDevice(value: unknown): value is TrustedDevice { && typeof item.lastSeen === "string" && Number.isFinite(Date.parse(item.lastSeen)); } -async function readTrustedDevices(): Promise { +// The desktop lists the credential hash of every device it revokes and the +// server never writes that list, so a revoked credential stays refused even if +// a trusted-device write that overlapped the revoke put the device back. +async function readRevokedCredentialHashes(): Promise> { + let parsed: { credentialHashes?: unknown }; + try { + parsed = JSON.parse(await readFile(remoteRevokedDevicesPath, "utf8")) as { credentialHashes?: unknown }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(); + throw error; + } + if (!Array.isArray(parsed.credentialHashes)) throw new Error("revoked device list is malformed"); + return new Set(parsed.credentialHashes.filter((hash): hash is string => typeof hash === "string")); +} + +// Throws when the revocation list cannot be read, so callers fail closed. +async function loadTrustedDevices(): Promise { + let devices: TrustedDevice[] = []; try { const parsed = JSON.parse(await readFile(remoteTrustedDevicesPath, "utf8")) as Partial; - const devices = Array.isArray(parsed.devices) ? parsed.devices.filter(validTrustedDevice) : []; - return { schemaVersion: 1, devices: devices.slice(0, 20) }; + devices = Array.isArray(parsed.devices) ? parsed.devices.filter(validTrustedDevice) : []; + } catch { + // A missing or unreadable store trusts no device. + } + // Read after the store: the desktop lists a revocation before it rewrites the + // store, so a store read that overlapped a revoke is still checked against it. + const revoked = await readRevokedCredentialHashes(); + return { schemaVersion: 1, devices: devices.filter((device) => !revoked.has(device.credentialHash)).slice(0, 20) }; +} + +async function readTrustedDevices(): Promise { + try { + return await loadTrustedDevices(); } catch { return { schemaVersion: 1, devices: [] }; } @@ -194,6 +224,49 @@ async function writeTrustedDevices(store: TrustedDeviceStore): Promise { } } +let trustedDeviceUpdates: Promise = Promise.resolve(); + +// Changes the trusted-device file as it is on disk now, one change at a time. +// The desktop revokes a device by rewriting this file, so a write based on an +// earlier read would restore the revoked device. `change` returns the devices +// to store, or undefined to leave the file untouched. +function updateTrustedDevices(change: (devices: TrustedDevice[]) => TrustedDevice[] | undefined): Promise { + const update = trustedDeviceUpdates.then(async () => { + const devices = change((await loadTrustedDevices()).devices); + if (devices) await writeTrustedDevices({ schemaVersion: 1, devices }); + }); + trustedDeviceUpdates = update.catch(() => undefined); + return update; +} + +// Replaces any earlier pairing of the same device id; false at the device limit. +async function addTrustedDevice(device: TrustedDevice): Promise { + let added = false; + await updateTrustedDevices((devices) => { + const others = devices.filter((item) => item.id !== device.id); + if (others.length >= 20) return undefined; + added = true; + return [...others, device]; + }); + return added; +} + +function recordTrustedDeviceSeen(device: TrustedDevice, now: number): Promise { + return updateTrustedDevices((devices) => { + const current = devices.find((item) => item.id === device.id && item.credentialHash === device.credentialHash); + if (!current || Date.parse(current.lastSeen) >= now) return undefined; + current.lastSeen = new Date(now).toISOString(); + return devices; + }); +} + +function removeTrustedDevice(device: TrustedDevice): Promise { + return updateTrustedDevices((devices) => { + const remaining = devices.filter((item) => item.credentialHash !== device.credentialHash); + return remaining.length < devices.length ? remaining : undefined; + }); +} + async function writeDeviceStatus() { const now = Date.now(); const trusted = await readTrustedDevices(); @@ -241,16 +314,12 @@ async function pairRelayDevice(input: { token?: unknown; deviceId?: unknown; nam const deviceId = typeof input.deviceId === "string" ? input.deviceId.trim().slice(0, 100) : ""; const name = typeof input.name === "string" ? input.name.trim().slice(0, 60) : "手機"; if (!deviceId) throw new Error("DEVICE_ID_REQUIRED"); - const trusted = await readTrustedDevices(); for (const [hash, session] of sessions) if (session.deviceId === deviceId) sessions.delete(hash); - trusted.devices = trusted.devices.filter((device) => device.id !== deviceId); - if (trusted.devices.length >= 20) throw new Error("DEVICE_LIMIT"); const credential = randomBytes(32).toString("base64url"); const hash = credentialHash(credential); const now = Date.now(); const pairedAt = new Date(now).toISOString(); - trusted.devices.push({ id: deviceId, name: name || "手機", credentialHash: hash, pairedAt, lastSeen: pairedAt }); - await writeTrustedDevices(trusted); + if (!await addTrustedDevice({ id: deviceId, name: name || "手機", credentialHash: hash, pairedAt, lastSeen: pairedAt })) throw new Error("DEVICE_LIMIT"); paired = true; sessions.set(hash, { credentialHash: hash, deviceId, name: name || "手機", pairedAt: now, lastSeen: now }); await writeDeviceStatus(); @@ -273,17 +342,13 @@ async function authenticateRelayDevice(credential: unknown, deviceId: unknown, c } const now = Date.now(); if (deviceIdleExpired(device.lastSeen, now)) { - trusted.devices = trusted.devices.filter((item) => item !== device); relaySessions.delete(clientId); sessions.delete(remembered.credentialHash); - await writeTrustedDevices(trusted); + await removeTrustedDevice(device); return undefined; } remembered.lastSeen = now; - if (now - Date.parse(device.lastSeen) >= 60_000) { - device.lastSeen = new Date(now).toISOString(); - await writeTrustedDevices(trusted); - } + if (now - Date.parse(device.lastSeen) >= 60_000) await recordTrustedDeviceSeen(device, now); sessions.set(remembered.credentialHash, remembered); await writeDeviceStatus(); return remembered; @@ -294,18 +359,14 @@ async function authenticateRelayDevice(credential: unknown, deviceId: unknown, c if (!device) return undefined; const now = Date.now(); if (deviceIdleExpired(device.lastSeen, now)) { - trusted.devices = trusted.devices.filter((item) => item !== device); sessions.delete(hash); - await writeTrustedDevices(trusted); + await removeTrustedDevice(device); return undefined; } const session = { credentialHash: hash, deviceId: device.id, name: device.name, pairedAt: Date.parse(device.pairedAt), lastSeen: now }; sessions.set(hash, session); relaySessions.set(clientId, session); - if (now - Date.parse(device.lastSeen) >= 60_000) { - device.lastSeen = new Date(now).toISOString(); - await writeTrustedDevices(trusted); - } + if (now - Date.parse(device.lastSeen) >= 60_000) await recordTrustedDeviceSeen(device, now); await writeDeviceStatus(); return session; } @@ -400,9 +461,8 @@ async function authenticate(request: IncomingMessage, url: URL): Promise item !== device); sessions.delete(hash); - await writeTrustedDevices(trusted); + await removeTrustedDevice(device); return false; } const existing = sessions.get(hash); @@ -413,10 +473,7 @@ async function authenticate(request: IncomingMessage, url: URL): Promise= 60_000) { - device.lastSeen = new Date(now).toISOString(); - await writeTrustedDevices(trusted); - } + if (!existing || now - Date.parse(device.lastSeen) >= 60_000) await recordTrustedDeviceSeen(device, now); await writeDeviceStatus(); return true; } @@ -501,15 +558,13 @@ const server = createServer(async (request, response) => { const deviceId = typeof input.deviceId === "string" ? input.deviceId.trim().slice(0, 100) : ""; const name = typeof input.name === "string" ? input.name.trim().slice(0, 60) : "手機"; if (!deviceId) return send(response, 400, JSON.stringify({ error: "缺少裝置識別碼" })); - const trusted = await readTrustedDevices(); for (const [hash, session] of sessions) if (session.deviceId === deviceId) sessions.delete(hash); - trusted.devices = trusted.devices.filter((device) => device.id !== deviceId); - if (trusted.devices.length >= 20) return send(response, 429, JSON.stringify({ error: "已達綁定裝置上限" })); const deviceCredential = randomBytes(32).toString("base64url"); const hash = credentialHash(deviceCredential); const pairedAt = new Date(now).toISOString(); - trusted.devices.push({ id: deviceId, name: name || "手機", credentialHash: hash, pairedAt, lastSeen: pairedAt }); - await writeTrustedDevices(trusted); + if (!await addTrustedDevice({ id: deviceId, name: name || "手機", credentialHash: hash, pairedAt, lastSeen: pairedAt })) { + return send(response, 429, JSON.stringify({ error: "已達綁定裝置上限" })); + } paired = true; sessions.set(hash, { credentialHash: hash, deviceId, name: name || "手機", pairedAt: now, lastSeen: now }); await writeDeviceStatus();