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>
143 lines
7.7 KiB
Markdown
143 lines
7.7 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.
|
|
|
|
### 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.
|