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

147 lines
8 KiB
Markdown

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