amber-backend/docs/device-auth-contract.md
Claude b98b80b33b feat(device-auth): codeless device sign-in backend (#12)
A new device (TV) signs into an account with no password typed on it and
no pairing code, approved from an already-logged-in phone/PC.

- device_auth collection (migration): code (unique), deviceName, status,
  user, expiresAt + hidden authToken/keyCiphertext/pollSecret/devicePubKey.
  Owner-scoped list/view/delete only (the device list + revoke); create/
  update locked to the hooks.
- pb_hooks/device_auth.pb.js: public request + info + pollSecret-gated
  poll routes (the TV is unauthenticated), auth-gated approve (mints
  e.auth.newAuthToken(), stores the E2E vault-key ciphertext) + decline,
  and a cleanup cron that scrubs delivered tokens and stale rows.
- pb_hooks/device_page.pb.js: self-contained approve page at GET /device.
  Signs in, derives the addon-config vault key (#20) with WebCrypto PBKDF2,
  seals {salt,keyB64} to the TV's X25519 pubkey (vendored TweetNaCl +
  WebCrypto HKDF/AES-GCM), and approves. The server never sees the key.
- Dockerfile ships pb_hooks (--hooksDir); verify.py covers the schema +
  that the routes are served.

Crypto matches the app byte-for-byte (proven by the app's fixed-vector
tests). Full contract: docs/device-auth-contract.md. JWT-revocation
limitation documented (v1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:36:15 +02:00

7.7 KiB

Codeless device sign-in — contract (issue #12)

Signing a new device (a TV) into an account with no password typed on the TV and no code transcription. The TV shows a QR + short code; an already-logged-in phone/PC opens the approve page, authenticates, and authorizes the TV, which receives a minted session and — end-to-end, without the server ever seeing it — the addon-config vault key (#20) so it can decrypt the account's addons.

Server pieces (this repo):

  • pb_migrations/1786000000_device_auth.js — the device_auth collection.
  • pb_hooks/device_auth.pb.js — the request/info/poll/approve/decline routes + a cleanup cron.
  • pb_hooks/device_page.pb.js — the self-contained approve web page at /device.

Client pieces (amber-app, branch feature/codeless-signin):

  • lib/data/sync/device_key_transfer.dart — the E2E key-transfer crypto.
  • lib/data/sync/device_sign_in_service.dart — request + poll state machine.
  • lib/features/auth/device_sign_in_screen.dart — the QR/code screen.
  • lib/data/sync/device_registry_service.dart + features/settings/device_list.dart — device list / revoke.

device_auth collection

Field Type Notes
code text, unique Short human code (8 chars, Crockford minus I/L/O/U), also the QR's ?code=. Stored without a dash; shown XXXX-XXXX.
deviceName text Shown in "Approve <deviceName>?".
status select pending | approved | denied.
user rel→users Set on approval (the authorizing account).
devicePubKey text, hidden TV's ephemeral X25519 public key (base64). Public by nature.
authToken text, hidden Minted user session token. Delivered once via poll, then scrubbed.
keyCiphertext text, hidden Vault key sealed to devicePubKey (E2E; opaque). Delivered once, then scrubbed.
pollSecret text, hidden Only the requesting TV holds it; gates the poll route.
expiresAt number Unix seconds. Pending TTL 300 s; reset to a 120 s pickup window on approval.
created/updated autodate created is the device-list timestamp.

Access rules. list/view/delete are owner-scoped (user = @request.auth.id) — that's the device list + revoke. create/update are locked (empty rules → superuser only); every write goes through the hooks under superuser context. All sensitive fields are hidden, so even the owner never reads a token/ciphertext/secret through the record API.

Endpoints

All JSON. Base is the PocketBase origin (https://pb.petruzalekr.cz).

POST /api/device-auth/request (public)

The TV creates a request. Body { deviceName, devicePubKey }. → { id, code, pollSecret, expiresAt, ttl }.

GET /api/device-auth/info?code=CODE (public, non-secret)

Approve page reads what to show. → { deviceName, devicePubKey, status }. 404 if unknown or an expired-pending request.

GET /api/device-auth/poll?id=..&secret=.. (public, pollSecret-gated)

The TV polls. secret must equal the stored pollSecret (else 403).

  • { status: "pending", expiresAt }
  • { status: "denied" }
  • { status: "expired" }
  • { status: "approved", token, keyCiphertext, record: {id,email,username,verified} } — sensitive fields returned once; a later poll after pickup returns just { status: "approved" }.

POST /api/device-auth/approve (auth: users)

Body { code, keyCiphertext }. Mints e.auth.newAuthToken(), sets status=approved, user=@request.auth.id, stores the token + ciphertext, resets expiresAt to now+120 s. → { ok, deviceName }.

POST /api/device-auth/decline (auth: users)

Body { code }. Sets status=denied and scrubs the pending secrets. → { ok }.

Device list / revoke (plain collection REST, user token)

  • List: GET /api/collections/device_auth/records?filter=(status='approved')&sort=-created
  • Revoke: DELETE /api/collections/device_auth/records/{id}

GET /device?code=CODE (HTML)

The self-contained approve page (device_page.pb.js). No external assets.

Cleanup cron (*/5 * * * *)

Scrubs authToken/keyCiphertext/pollSecret/devicePubKey from approved rows past their pickup window; deletes pending/denied rows a day after expiry. Approved rows remain as the device list.

Crypto (must match the app byte-for-byte)

Two independent pieces. Both are pinned by app unit tests against fixed vectors this repo's page code produced under Node (scratchpad/cryptocheck/* in the app work — see the app PR), so a drift on either side fails a test.

1. Addon-config vault key (PBKDF2) — what the page derives

Matches addon_config_crypto.dart:

  • PBKDF2-HMAC-SHA256, 210000 iterations, 256-bit output.
  • Salt = the account's addon_config.salt (base64-decoded) if a blob exists; otherwise a fresh 16-byte salt.
  • The page derives the key from the entered password, then transfers { salt, keyB64 } (base64) to the TV, which caches it exactly where the app's password-less reconcile reads it (CloudSyncService.unlockWithKey).

2. Key transfer (X25519 → HKDF → AES-GCM) — page seals, TV opens

Matches device_key_transfer.dart:

  • X25519 ECDH (RFC 7748) → 32-byte raw shared secret. Page uses vendored TweetNaCl scalarMult; app uses cryptography's X25519. Both clamp the scalar.
  • HKDF-SHA256(ikm = shared secret, salt = "", info = "amber-device-key-v1") → 32-byte AES key.
  • AES-256-GCM, 12-byte random nonce, 128-bit tag.
  • Public keys: raw 32-byte X25519 u-coordinate, base64.
  • Sealed blob (base64): ephemeralPubKey[32] ‖ nonce[12] ‖ ciphertext ‖ tag[16].

The password never leaves the browser; the vault key is sealed to the TV and the server only forwards ciphertext. This preserves #20's "the server can't read the addon config" property across the device-sign-in path.

Flow

TV                        PocketBase (hooks)                Phone/PC (approve page)
── generate X25519 kp
── POST request ─────────► create pending row ──► {id,code,pollSecret}
   show QR (/device?code=)
                                                   ── open /device?code=CODE
                                                   ── GET info → deviceName,pubKey
                                                   ── sign in (email+password)
                                                   ── derive vault key (PBKDF2)
                                                   ── seal {salt,keyB64} to pubKey
── GET poll (secret) ────► pending                 ── POST approve {code,ct}
                           mint token, store ct ◄──┘
── GET poll (secret) ────► approved {token,ct,record}
── open ct → vault key
── adopt session ────────► (then #11 data + #20 addon pull happen normally)

Deferred / limitations (v1)

  • JWT revocation. PocketBase JWTs are stateless. Revoke deletes the device row (removes it from the list, stops further polling) but does not invalidate a token already held by that device. Full immediate revocation means rotating the user's tokenKey, which logs out every device — out of scope. Follow-up: a per-device token store / short-lived tokens + refresh.
  • No addon config yet on the account. If the approving account has never saved addon config, the page mints a fresh salt for the transfer. That salt only becomes canonical once a device pushes config under it; in the normal case (approving from a device that already has addons) the existing salt is used and everything lines up. Documented edge, not a blocker for the intended flow.