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).
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— thedevice_authcollection.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 usescryptography'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.