From b98b80b33b26f768742f0dde280d83a6130d8405 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:36:15 +0200 Subject: [PATCH] feat(device-auth): codeless device sign-in backend (#12) 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 --- Dockerfile | 6 +- README.md | 20 ++ docs/device-auth-contract.md | 143 ++++++++++++ pb_hooks/device_auth.pb.js | 259 ++++++++++++++++++++++ pb_hooks/device_page.pb.js | 279 ++++++++++++++++++++++++ pb_migrations/1786000000_device_auth.js | 100 +++++++++ scripts/verify.py | 28 +++ 7 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 docs/device-auth-contract.md create mode 100644 pb_hooks/device_auth.pb.js create mode 100644 pb_hooks/device_page.pb.js create mode 100644 pb_migrations/1786000000_device_auth.js diff --git a/Dockerfile b/Dockerfile index 8f02c95..00bc305 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,10 @@ COPY --from=fetch /pb/pocketbase /usr/local/bin/pocketbase # Schema-as-code: migrations are baked in and auto-applied on serve. COPY pb_migrations /pb_migrations +# JS hooks (custom routes + the device sign-in approve page, issue #12). Loaded +# from --hooksDir on serve. +COPY pb_hooks /pb_hooks + # Persist DB, uploaded files, and any admin-created migrations here. Runs as root # so it can always write the volume Coolify attaches — Coolify's persistent mount # is root-owned, and a non-root user hitting it fails with SQLite "unable to open @@ -33,4 +37,4 @@ EXPOSE 8090 # Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it. ENTRYPOINT ["pocketbase"] -CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations"] +CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks"] diff --git a/README.md b/README.md index 2d5a020..1e1d7ac 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro (auth)│ ├─* watchlist (1 row per profile+item) │ ├─1 prefs (playback/audio/subtitle blob) │ └─1 addon_config (client-encrypted addon blob; ciphertext only) + ├─* device_auth (codeless sign-in requests / approved devices, issue #12) └ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16) ``` @@ -34,6 +35,7 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro | `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) | | `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) | | `addon_config` | base | `profile`→profiles, `blob` (**ciphertext**), `salt`, `kdf`, `updatedAt` (client), `updated` (server) | +| `device_auth` | base | `code` (unique), `deviceName`, `status`, `user`→users, `expiresAt`, + hidden `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` — codeless device sign-in (#12) | **Access rules.** A user only ever reads/writes their own `users` row, their own `profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never @@ -62,6 +64,24 @@ acceptable for re-enterable addon config, and the client detects it, clears it, prompts a re-entry. A normal in-app *change password* re-encrypts the blob, so only reset loses it. This does **not** change the no-E2E stance for the rest of the data. +### Codeless device sign-in (issue #12) + +A new device (a TV) signs into an account with **no password typing and no code +transcription**: it shows a QR + short code, and an already-logged-in phone/PC +approves it from `GET /device`. On approval the TV gets a minted session token and +— **end-to-end, the server never sees it** — the addon-config vault key, so it can +decrypt the `addon_config` blob it then pulls. This lives in the `device_auth` +collection (`pb_migrations/1786000000_device_auth.js`) plus two JS hooks +(`pb_hooks/device_auth.pb.js` routes + cron, `pb_hooks/device_page.pb.js` approve +page). All create/poll/approve logic is in the hooks (the TV is unauthenticated +when it requests and polls); the collection exposes only the owner-scoped device +**list + revoke**. **JWT revocation caveat:** deleting a device row removes it from +the list but can't invalidate a token already minted (stateless JWTs) — a +documented v1 limitation. + +**Full contract** (endpoints, record shape, the exact crypto — PBKDF2 params + +the X25519/HKDF/AES-GCM key transfer): [`docs/device-auth-contract.md`](docs/device-auth-contract.md). + ### The two clocks (issue #11) Sync rows carry **two** timestamps, and they are not interchangeable: diff --git a/docs/device-auth-contract.md b/docs/device-auth-contract.md new file mode 100644 index 0000000..435f1c6 --- /dev/null +++ b/docs/device-auth-contract.md @@ -0,0 +1,143 @@ +# 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. diff --git a/pb_hooks/device_auth.pb.js b/pb_hooks/device_auth.pb.js new file mode 100644 index 0000000..4f94483 --- /dev/null +++ b/pb_hooks/device_auth.pb.js @@ -0,0 +1,259 @@ +/// + +// Codeless device sign-in — server routes (epic #6, issue #12). +// +// The TV is unauthenticated when it creates a request and when it polls, so the +// whole handshake lives here in hooks (superuser context) rather than in the +// `device_auth` collection's REST rules — those expose only the owner-scoped +// device list + revoke. Every route hands back exactly the fields its caller may +// see; the minted session token and the E2E-encrypted vault key are delivered to +// the TV once, over a per-request `pollSecret`, then scrubbed. +// +// Crypto note: `keyCiphertext` is the addon-config vault key (issue #20) +// encrypted by the approve page to the TV's ephemeral public key. The server +// stores and forwards it as an opaque blob and never holds the key — see +// `pb_hooks/device_page.pb.js` and the app's `device_key_transfer.dart`. + +const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request +const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch + +// Human code: 8 chars, unambiguous alphabet (Crockford minus I L O U), shown as +// XXXX-XXXX. Stored without the dash; the app + page normalize before lookup. +const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +function nowSeconds() { + return Math.floor(Date.now() / 1000) +} + +function genCode() { + return $security.randomStringWithAlphabet(8, CODE_ALPHABET) +} + +// ── POST /api/device-auth/request ───────────────────────────────────────────── +// Public. The TV creates a sign-in request and gets back the code to show, the +// pollSecret only it will hold, and the id + expiry. Body: { deviceName, +// devicePubKey }. +routerAdd("POST", "/api/device-auth/request", (e) => { + const data = new DynamicModel({ deviceName: "", devicePubKey: "" }) + e.bindBody(data) + + const collection = $app.findCollectionByNameOrId("device_auth") + const pollSecret = $security.randomString(40) + + // Retry on the (astronomically unlikely) unique-code collision. + let record = null + let lastErr = null + for (let i = 0; i < 5; i++) { + const rec = new Record(collection) + rec.set("code", genCode()) + rec.set("deviceName", (data.deviceName || "").substring(0, 100)) + rec.set("status", "pending") + rec.set("devicePubKey", (data.devicePubKey || "").substring(0, 500)) + rec.set("pollSecret", pollSecret) + rec.set("expiresAt", nowSeconds() + DEVICE_TTL_SECONDS) + try { + $app.save(rec) + record = rec + break + } catch (err) { + lastErr = err + } + } + if (!record) { + throw new BadRequestError("could not create device request", lastErr) + } + + return e.json(200, { + id: record.id, + code: record.getString("code"), + pollSecret: pollSecret, + expiresAt: record.getInt("expiresAt"), + ttl: DEVICE_TTL_SECONDS, + }) +}) + +// ── GET /api/device-auth/info?code=CODE ─────────────────────────────────────── +// Public, non-secret. The approve page uses it to show "Approve ?" +// and to read the TV's ephemeral public key it encrypts the vault key to. Returns +// nothing sensitive (no pollSecret, no token). +routerAdd("GET", "/api/device-auth/info", (e) => { + const code = (e.request.url.query().get("code") || "").toUpperCase().trim() + if (!code) throw new BadRequestError("missing code") + + let record + try { + record = $app.findFirstRecordByFilter( + "device_auth", "code = {:code}", { code: code }) + } catch (_) { + throw new NotFoundError("unknown code") + } + if (record.getInt("expiresAt") < nowSeconds() && + record.getString("status") === "pending") { + throw new NotFoundError("request expired") + } + return e.json(200, { + deviceName: record.getString("deviceName"), + devicePubKey: record.getString("devicePubKey"), + status: record.getString("status"), + }) +}) + +// ── GET /api/device-auth/poll?id=..&secret=.. ───────────────────────────────── +// Public but pollSecret-gated. The TV polls this until approved/denied/expired. +// The minted token + the E2E vault-key ciphertext are only ever returned here, to +// a caller holding the matching pollSecret. +routerAdd("GET", "/api/device-auth/poll", (e) => { + const id = e.request.url.query().get("id") || "" + const secret = e.request.url.query().get("secret") || "" + if (!id || !secret) throw new BadRequestError("missing id/secret") + + let record + try { + record = $app.findRecordById("device_auth", id) + } catch (_) { + throw new NotFoundError("unknown request") + } + // Constant-ish comparison; secret is a 40-char random string. + if (record.getString("pollSecret") !== secret) { + throw new ForbiddenError("bad secret") + } + + const status = record.getString("status") + const expiresAt = record.getInt("expiresAt") + + if (status === "pending") { + if (expiresAt < nowSeconds()) return e.json(200, { status: "expired" }) + return e.json(200, { status: "pending", expiresAt: expiresAt }) + } + if (status === "denied") { + return e.json(200, { status: "denied" }) + } + + // approved. + const token = record.getString("authToken") + if (!token) { + // Already picked up (token scrubbed) — the TV has its session. Nothing more. + return e.json(200, { status: "approved" }) + } + if (expiresAt < nowSeconds()) { + // Pickup window elapsed before the TV fetched — treat as expired so it + // restarts cleanly rather than adopting a token about to be scrubbed. + return e.json(200, { status: "expired" }) + } + + const keyCiphertext = record.getString("keyCiphertext") + const uid = record.getString("user") + let userRecord = null + if (uid) { + try { + const u = $app.findRecordById("users", uid) + userRecord = { + id: u.id, + email: u.getString("email"), + username: u.getString("username"), + verified: u.getBool("verified"), + } + } catch (_) { /* user gone; deliver token anyway */ } + } + + return e.json(200, { + status: "approved", + token: token, + keyCiphertext: keyCiphertext, + record: userRecord, + }) +}) + +// ── POST /api/device-auth/approve ───────────────────────────────────────────── +// Auth required (a logged-in user). Authorizes a pending request: mints a session +// token for the caller and stores the E2E vault-key ciphertext the page produced. +// Body: { code, keyCiphertext }. +routerAdd("POST", "/api/device-auth/approve", (e) => { + const data = new DynamicModel({ code: "", keyCiphertext: "" }) + e.bindBody(data) + const code = (data.code || "").toUpperCase().trim() + if (!code) throw new BadRequestError("missing code") + + let record + try { + record = $app.findFirstRecordByFilter( + "device_auth", "code = {:code}", { code: code }) + } catch (_) { + throw new NotFoundError("unknown code") + } + if (record.getString("status") !== "pending") { + throw new BadRequestError("request is not pending") + } + if (record.getInt("expiresAt") < nowSeconds()) { + throw new BadRequestError("request expired") + } + + // e.auth is the authenticated users record — mint a real session token for it. + const token = e.auth.newAuthToken() + + record.set("status", "approved") + record.set("user", e.auth.id) + record.set("authToken", token) + record.set("keyCiphertext", (data.keyCiphertext || "").substring(0, 4000)) + // Reset the clock to the shorter pickup window for the TV to fetch the token. + record.set("expiresAt", nowSeconds() + PICKUP_TTL_SECONDS) + $app.save(record) + + return e.json(200, { ok: true, deviceName: record.getString("deviceName") }) +}, $apis.requireAuth("users")) + +// ── POST /api/device-auth/decline ───────────────────────────────────────────── +// Auth required. The user declines a pending request. +routerAdd("POST", "/api/device-auth/decline", (e) => { + const data = new DynamicModel({ code: "" }) + e.bindBody(data) + const code = (data.code || "").toUpperCase().trim() + if (!code) throw new BadRequestError("missing code") + + let record + try { + record = $app.findFirstRecordByFilter( + "device_auth", "code = {:code}", { code: code }) + } catch (_) { + throw new NotFoundError("unknown code") + } + if (record.getString("status") === "pending") { + record.set("status", "denied") + // Scrub the pending secrets immediately; a declined request keeps nothing. + record.set("pollSecret", "") + record.set("devicePubKey", "") + $app.save(record) + } + return e.json(200, { ok: true }) +}, $apis.requireAuth("users")) + +// ── Cleanup cron ────────────────────────────────────────────────────────────── +// Scrub the minted token + vault ciphertext from approved rows whose pickup +// window elapsed (they've been delivered, or the TV gave up), and delete stale +// pending/denied rows. The device list keeps the approved rows (status + user + +// created), just without any live secret at rest. +cronAdd("device_auth_cleanup", "*/5 * * * *", () => { + const now = nowSeconds() + try { + const stale = $app.findRecordsByFilter( + "device_auth", "status = 'approved' && authToken != '' && expiresAt < {:now}", + "created", 200, 0, { now: now }) + for (const r of stale) { + r.set("authToken", "") + r.set("keyCiphertext", "") + r.set("pollSecret", "") + r.set("devicePubKey", "") + $app.save(r) + } + } catch (_) { /* best effort */ } + try { + // Pending that never got approved, and declined rows: drop them a day after + // expiry so they don't accumulate. (Approved rows stay — they're the device + // list.) + const dead = $app.findRecordsByFilter( + "device_auth", + "(status = 'pending' || status = 'denied') && expiresAt < {:cutoff}", + "created", 200, 0, { cutoff: now - 86400 }) + for (const r of dead) $app.delete(r) + } catch (_) { /* best effort */ } +}) diff --git a/pb_hooks/device_page.pb.js b/pb_hooks/device_page.pb.js new file mode 100644 index 0000000..9c26169 --- /dev/null +++ b/pb_hooks/device_page.pb.js @@ -0,0 +1,279 @@ +/// + +// Approve page for codeless device sign-in (epic #6, issue #12). +// +// Served at GET /device?code=CODE — the QR the TV shows encodes exactly this URL. +// The page is self-contained (no external assets, no CDN): the user signs in with +// email + password, the page derives the addon-config vault key (#20) from the +// password, seals it to the TV's ephemeral public key, and approves the request. +// +// Crypto here MUST match the app byte-for-byte (verified by the app's +// device_key_transfer_test + addon_config_crypto_interop_test against fixed +// vectors this same code produced under Node): +// - vault key: PBKDF2-HMAC-SHA256, 210000 iters, 256-bit, salt = account salt +// (addon_config_crypto.dart). +// - key transfer: X25519 (vendored TweetNaCl scalarMult, RFC 7748) → HKDF-SHA256 +// (salt="", info="amber-device-key-v1") → AES-256-GCM (12B nonce, 128b tag), +// blob = ephPub[32] ‖ nonce[12] ‖ ct ‖ tag[16], base64 (device_key_transfer.dart). +// +// The password never leaves the page; the vault key is sealed to the TV and never +// reaches the server (E2E for this one blob). The minted token is delivered by the +// poll route, not here. + +routerAdd("GET", "/device", (e) => { + return e.html(200, DEVICE_APPROVE_HTML) +}) + +const DEVICE_APPROVE_HTML = ` + + + + + +Amber — přihlásit zařízení + + + +
+

Přihlásit zařízení

+

Amber — přihlášení televize bez kódu

+ + + +
+ + + + + + +
+ +
+ +

Heslo se použije jen ve vašem prohlížeči k odemčení nastavení + doplňků a odešle se televizi zašifrovaně. Na server se heslo ani klíč nikdy + neposílají.

+
+ + + +` diff --git a/pb_migrations/1786000000_device_auth.js b/pb_migrations/1786000000_device_auth.js new file mode 100644 index 0000000..2df7f03 --- /dev/null +++ b/pb_migrations/1786000000_device_auth.js @@ -0,0 +1,100 @@ +/// + +// Codeless device sign-in / phone-approve (epic #6, issue #12). +// +// A new device (a TV) signs into an account with **no password typing and no +// code transcription**: it shows a QR + short human code, and an already-logged-in +// phone/PC opens the approve page, authenticates, and authorizes the device. The +// TV then receives a minted session token and pulls its data (#11) + its +// client-encrypted addon config (#20) — no pairing code, nothing more to type. +// +// This collection is the request/handshake record. It is **never** driven through +// the normal collection REST rules by the TV — the TV is unauthenticated when it +// creates the request and when it polls — so all of the create/poll/approve/ +// decline logic lives in the `pb_hooks/device_auth.pb.js` routes, which run with +// superuser context and hand back only the fields a given caller may see. The +// collection rules here therefore expose **only** the owner-scoped list/view/ +// delete (the device list + revoke, #12's "device list / revoke"): create/update +// are locked to superusers (i.e. the hooks), and every sensitive field is `hidden` +// so it can never leak through the record API even to the owner. +// +// Fields: +// - `code` short human-readable code (also the QR's `?code=`), unique. +// - `deviceName` what the phone shows in "Approve ?". +// - `status` pending | approved | denied. +// - `user` rel→users, set on approval (the account that authorized it). +// - `devicePubKey` the TV's ephemeral X25519 public key (base64), used by the +// approve page to end-to-end encrypt the addon-config vault key +// to the TV. Public by nature; hidden only to keep the record +// API tidy. +// - `authToken` (hidden) the minted user session token, delivered to the TV +// exactly once via the poll route then cleared. +// - `keyCiphertext` (hidden) the addon-config vault key, encrypted to +// `devicePubKey` by the approve page — the server never sees the +// key itself (E2E). Delivered once then cleared. +// - `pollSecret` (hidden) a secret only the requesting TV holds; the poll route +// returns the sensitive fields only to a caller presenting it. +// - `expiresAt` unix seconds; a request past it is rejected (~5 min TTL). A +// plain number (not a date) so the hooks can compare it against +// the wall clock without any datetime parsing in the JS VM. + +migrate((app) => { + const users = app.findCollectionByNameOrId("users") + + const deviceAuth = new Collection({ + type: "base", + name: "device_auth", + // Owner-scoped read + delete only: this is the device list (approved rows) + // and revoke. Create/update happen in the hooks under superuser context, so + // they are intentionally locked here (null == superuser-only). + listRule: "@request.auth.id != '' && user = @request.auth.id", + viewRule: "@request.auth.id != '' && user = @request.auth.id", + createRule: null, + updateRule: null, + deleteRule: "@request.auth.id != '' && user = @request.auth.id", + fields: [ + { type: "text", name: "code", required: true, max: 40 }, + { type: "text", name: "deviceName", max: 100 }, + { + type: "select", + name: "status", + required: true, + maxSelect: 1, + values: ["pending", "approved", "denied"], + }, + { + type: "relation", + name: "user", + required: false, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + minSelect: 0, + }, + // The TV's ephemeral public key (base64). Not secret. + { type: "text", name: "devicePubKey", hidden: true, max: 500 }, + // Minted session token — delivered once, then cleared. Never returned by + // the record API (hidden); only the poll hook hands it back, over the + // matching pollSecret. + { type: "text", name: "authToken", hidden: true, max: 2000 }, + // Vault key encrypted to devicePubKey (E2E; opaque to the server). + { type: "text", name: "keyCiphertext", hidden: true, max: 4000 }, + // Only the requesting device holds this; gates the poll route. + { type: "text", name: "pollSecret", hidden: true, max: 200 }, + // Unix seconds. Past it → the request is dead. + { type: "number", name: "expiresAt", required: true }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_device_auth_code` ON `device_auth` (`code`)", + "CREATE INDEX `idx_device_auth_user` ON `device_auth` (`user`)", + ], + }) + app.save(deviceAuth) +}, (app) => { + // ---- Down migration ---------------------------------------------------- + try { + app.delete(app.findCollectionByNameOrId("device_auth")) + } catch (_) { /* already gone */ } +}) diff --git a/scripts/verify.py b/scripts/verify.py index 89a1a01..fee8dfa 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -105,6 +105,34 @@ check("addon_config.updated is a server autodate (pull cursor)", st, check("addon_config has unique (profile) index", st, any("idx_addon_config_profile" in i for i in ac.get("indexes", []))) +# --- codeless device sign-in (issue #12) --- +st, da = req("GET", "/api/collections/device_auth", admin) +check("device_auth collection exists", st, st == 200) +# Owner-scoped read + delete (the device list + revoke); create/update locked to +# the hooks (superuser), so those rules are empty. +check("device_auth list/view/delete are owner-scoped", st, + all("user = @request.auth.id" in (da.get(k) or "") for k in + ("listRule", "viewRule", "deleteRule"))) +check("device_auth create/update are locked (hooks-only)", st, + not (da.get("createRule") or "") and not (da.get("updateRule") or "")) +da_fields = {f["name"]: f for f in da.get("fields", [])} +check("device_auth has code/status/expiresAt", st, + {"code", "status", "expiresAt"} <= set(da_fields)) +check("device_auth hides authToken/keyCiphertext/pollSecret/devicePubKey", st, + all(da_fields.get(n, {}).get("hidden") for n in + ("authToken", "keyCiphertext", "pollSecret", "devicePubKey"))) +check("device_auth.code has a unique index", st, + any("idx_device_auth_code" in i for i in da.get("indexes", []))) + +# The custom routes are registered (hooks loaded). A bad request still proves the +# route exists (400/404, not the 404-with-no-such-route body). The approve page +# GET /device should return HTML. +st, _ = req("POST", "/api/device-auth/request", body={"deviceName": "verify.py", + "devicePubKey": "AAAA"}) +check("POST /api/device-auth/request is served (hook loaded)", st, st == 200) +st, _ = req("GET", "/api/device-auth/poll?id=nope&secret=nope") +check("GET /api/device-auth/poll is served", st, st in (200, 400, 403, 404)) + # --- create two users (superuser create; nsfwEnabled set by admin) --- def make_user(email, uname): st, r = req("POST", "/api/collections/users/records", admin, { -- 2.45.2