amber-backend/docs/device-auth-contract.md
Claude 15be3bd5e5 device approve page: manual code-entry for the QR-less path (#55)
The TV's sign-in screen tells the user to open …/device and enter the code,
but the approve page only worked when opened with ?code= (what the QR encodes)
— opening it bare showed 'Chybí kód zařízení v odkazu.' with no way to type
the code.

Add a code-entry field shown when the page is opened without ?code=: the user
types the XXXX-XXXX code from the TV, it's normalized (uppercase, dash/space
stripped, capped at 8) to the dash-less form the server stores before the /info
+ /approve lookups, then the sign-in form is revealed. Input formats live to
XXXX-XXXX; Enter submits; an unknown/expired code re-shows the field prefilled
for correction instead of dead-ending. The QR/direct-link path is unchanged
(CODE now also runs through the same normalizer, so a dashed link works too).

Docs: note the optional ?code= + normalization in device-auth-contract.md.

Verified: node syntax-check of the hook + inner page script, and 19 unit
assertions over the normCode/fmtCode/length-gate logic (dashed↔dashless,
formatting, cap, validation).
2026-07-20 11:06:51 +02:00

8 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. The ?code= (QR / direct link) is optional: opened bare, the page shows a manual code-entry field so the user can type the XXXX-XXXX code shown on the TV. The page normalizes (uppercase, strips the dash) before the /info + /approve lookups, since the code is stored dash-less.

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.