# 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.