A zero-knowledge find-my-device & recovery system for the Steam Deck — a Decky Loader plugin, an identity-plane server, and a full-screen owner dashboard. The server stores only ciphertext it can never read. Location and control belong to you and your password alone.
Your Deck quietly seals encrypted Wi-Fi location reports and uploads them. When it goes missing you open the dashboard, decrypt the reports in your own browser, and watch it on a live map. Ring it to find it under the couch, or flip it to Lost — a full-screen banner takes over the Deck with your message and an anonymous two-way chat so whoever has it can reach you without ever seeing who you are.
The twist: the server is blind. A full breach of it yields opaque blobs, upload timestamps, source IPs, and HMAC(pepper, steamid) — never a location, never the ability to flip a Deck into tracking mode.
|
Sealed Wi-Fi scans decrypt only in your browser, then plot a live trail with accuracy, battery, and timestamps. The server never sees where your Deck is. |
Play a loud siren to find it nearby (Android-style), or flip it to Lost for a full-screen takeover and a private, PII-free chat with whoever has it. |
|
Your password derives the keys that decrypt reports and sign commands — on-device, then wiped. A full server breach still can't read or track a thing. |
Event-driven checks (wake, game exit, QAM open, network connect) plus a lazy hourly heartbeat. Passive scans, no GPS, no busy-wait. Under ~1%/day in normal use. |
Owner dashboard — decrypted report history plotted on a full-screen dark map
Each point is a report the Deck sent (per wake / scan / upload), decrypted in your browser and geolocated — snapshots, not continuous GPS. A stationary Deck stacks on one dot; the trail appears when it actually moved.
On the Deck when marked Lost — full-screen takeover + anonymous chat
There's also a SteamID-gated operator page at /admin — sealed-safe operational counts only (installs, active devices, reports stored), never location or personal data.
The whole design is a split: identity answers who are you and may you ask, crypto answers is this secret and did the owner really command it — and the two never share a secret.
flowchart LR
subgraph Deck["🎮 Steam Deck (Decky plugin)"]
S[Passive Wi-Fi scan] --> SEAL[Seal to box_pk<br/>X25519]
CMD[Verify command sig<br/>Ed25519 + counter]
end
subgraph Server["☁️ Server — SEES ONLY CIPHERTEXT"]
DB[(Postgres<br/>blobs · HMAC ids · pubkeys)]
end
subgraph Owner["🧑 Owner browser / dashboard"]
PW[password] --> KDF[Argon2id 256 MiB] --> K[box + sign keypairs]
OPEN[Open sealed reports] --> MAP[Live map]
SIGN[Sign mode command]
end
SEAL -->|POST sealed blob| DB
DB -->|relays byte-exact| OPEN
SIGN -->|payload + sig| DB
DB -->|relays byte-exact| CMD
Steam[Steam OpenID 2.0] -.authorizes session.-> Server
- Identity plane (server). Steam OpenID 2.0, validated server-side (
check_authentication). Sessions are HMAC-signed httpOnly cookies. Every device-scoped route passesrequireDeviceOwner; uploads use per-device bearer tokens, revocable in isolation. The DB storesHMAC(pepper, steamid64), never a raw SteamID. - Crypto plane (client). password → Argon2id (256 MiB, versioned params) → X25519 box keypair (reports sealed to
box_pk) + Ed25519 sign keypair (commands). The device keeps only public keys, its id, and its token. A command applies only if the signature verifies against the enrolledsign_pkand a monotonic counter strictly advances — checked on-device and server-side. The server relays{payload, sig}byte-exact and can never originate a mode change.
The same crypto core runs three ways and is proven wire-compatible by tests: TypeScript (libsodium-wrappers-sumo) in the browser and QAM, Python via ctypes→libsodium on the Deck, and PyNaCl on the test side.
- Sign in to the dashboard through Steam → Enroll a new Deck → a 5-minute, single-use, 6-character pair code.
- On the Deck: Decky → Find My Deck → enter the pair code and choose a recovery password.
- The QAM frontend derives the keypairs on-device, sends public keys only, wipes the password and secret keys, and stores
device_id+device_token.
Enrolling a Deck needs both physical possession of it and your authenticated dashboard session — you can only set up a Deck you actually hold and control.
| mode | what shows on the Deck | reporting |
|---|---|---|
normal |
nothing | passive Wi-Fi scan on wake / network-connect, ~hourly |
lost |
full-screen takeover: owner's message, anonymous contact code, "connect me to Wi-Fi" nudge, two-way chat | ~5 min |
| Play sound | loud siren until dismissed (works in any mode) | — |
Failed uploads park the already-sealed blob in a disk outbox and drain oldest-first on the next network touch. Marking the Deck recovered (→ normal) clears the relay chat automatically.
Point a webhook (Discord / Slack / ntfy / any https sink) at your account and get pinged when it matters (email delivery is wired but off until a mail-provider key is set):
- A finder messaged you through the recovery relay.
- Your Lost Deck checked in — the first report after you mark it Lost ("it's alive, location updated").
- Your Lost Deck went quiet — no check-in for 3h while Lost (maybe off or out of range).
These fire on server-visible metadata only — a message arrived, a report arrived, a last_seen gap. The server still can't read your battery or location (both sealed), so there is deliberately no "alert when it leaves home" geofence — that would require reading a location the server never holds. Normal-mode suspend is never alerted (Decks sleep constantly; that would be noise). Webhook targets are https-only with a loopback/private-range SSRF guard.
npm install # server + dashboard deps
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
# Postgres: per-app role + db (schema auto-creates on boot)
createdb findmydeck && createuser fmsd_app # plus pg_hba entries
npm run build:dashboard
# env (a systemd EnvironmentFile in production):
# DATABASE_URL=postgres://fmsd_app:...@127.0.0.1:5432/findmydeck
# FMSD_BASE_URL=https://findmydeck.0xbanana.com
# FMSD_PEPPER=<hex32> # HMAC pepper — rotating it orphans accounts
# FMSD_SESSION_SECRET=<hex32> # cookie signing — rotating it logs everyone out
npm start
# plugin: cd plugin && npm install && npm run build && bash build.sh
# then install out/findmydeck-plugin.zip via Decky → "install from zip"
FMSD_TEST_DATABASE_URL=postgres://fmsd_app:...@127.0.0.1:5432/findmydeck_test \
FMSD_PYTHON=.venv/bin/python npm test # interop + server + e2e + plugin unitsFMSD_DEV_FAKE_STEAM=1 enables /auth/dev-login?steamid=<id64> for local testing only — never set it in production.
crypto/ts/crypto.mjs canonical client crypto (browser + QAM + tests)
crypto/py/fmsd_crypto.py wire-compatible Python side (PyNaCl, tooling + tests)
server/ identity-plane API (Express), Postgres, Steam OpenID
plugin/ Decky plugin — main.py backend + React QAM frontend
py_modules/fmsd_crypto.py device crypto via ctypes→libsodium (no PyNaCl on Deck)
dashboard/ owner SPA: Steam login, sign commands, decrypt + map
tests/ interop (TS↔Py), server HTTP, plugin units, full e2e
- The password is unrecoverable by design. It is the decryption key and the signing key. Lose it → re-enroll from scratch; old reports stay sealed forever. The UI says this at enrollment and at every unlock.
- Source IPs are the one location signal that can't be sealed away. They're logged only for per-device rate limiting and rotated fast — disclosed here, not hidden.
- No reports while suspended or off. They arrive in bursts on wake/charge. Design expectation, not a bug.
- A SteamOS reimage wipes Decky and the plugin with it. This shines for the lost-couch / airbnb / left-in-the-car case and the pre-wipe window — not against a wipe-first thief.
- Geolocation resolves in the owner's browser (Wi-Fi → coordinates); single-AP environments may not resolve.
- Your data is yours to erase. The dashboard shows a plain reporting-health line (
✓ Reporting normally/⚠ Quiet), links a privacy statement, and Delete account wipes every device, report, relay thread, and setting in one irreversible step.
- Server never receives password / seed / secret keys — enrollment sends pubkeys only (
tests/e2e.test.mjs) - Server cannot decrypt reports — sealed-box round-trip proven both directions (
tests/interop.test.mjs) - Server cannot forge mode changes — garbage sig rejected at
PUT; device re-verifies independently (tests/server.test.mjs,tests/test_plugin.py) openid.claimed_idvalidated server-side; a client-supplied SteamID is trusted nowhere (server/steam.js)- DB stores
HMAC(pepper, steamid)only (server/app.js) - Cross-account access →
403on every device route (tests/server.test.mjs) - Two-gate set-mode: the session authorizes the request, the signature authorizes the command (
tests/e2e.test.mjs) - Replay blocked by a monotonic counter, server-side (
409) and on-device (tests/test_plugin.py)
Architecture cribbed with thanks from FMD (FindMyDevice) · GPLv3-compatible spirit.
Built for the Deck. 🎮


