Compare commits

..

2 commits

Author SHA1 Message Date
4cc5279842 Merge pull request 'feat: device-auth backend for codeless sign-in (app #12)' (#3) from feat/device-auth into main
Reviewed-on: #3
2026-07-19 17:34:35 +00:00
b98b80b33b 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 <noreply@anthropic.com>
2026-07-19 14:36:15 +02:00
7 changed files with 834 additions and 1 deletions

View file

@ -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. # Schema-as-code: migrations are baked in and auto-applied on serve.
COPY pb_migrations /pb_migrations 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 # 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 # 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 # 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. # Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it.
ENTRYPOINT ["pocketbase"] 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"]

View file

@ -23,6 +23,7 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
(auth)│ ├─* watchlist (1 row per profile+item) (auth)│ ├─* watchlist (1 row per profile+item)
│ ├─1 prefs (playback/audio/subtitle blob) │ ├─1 prefs (playback/audio/subtitle blob)
│ └─1 addon_config (client-encrypted addon blob; ciphertext only) │ └─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) └ 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) | | `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) | | `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) |
| `addon_config` | base | `profile`→profiles, `blob` (**ciphertext**), `salt`, `kdf`, `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 **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 `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 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. 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) ### The two clocks (issue #11)
Sync rows carry **two** timestamps, and they are not interchangeable: Sync rows carry **two** timestamps, and they are not interchangeable:

View file

@ -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 &lt;deviceName&gt;?". |
| `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.

259
pb_hooks/device_auth.pb.js Normal file
View file

@ -0,0 +1,259 @@
/// <reference path="../pb_data/types.d.ts" />
// 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 <deviceName>?"
// 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 */ }
})

279
pb_hooks/device_page.pb.js Normal file
View file

@ -0,0 +1,279 @@
/// <reference path="../pb_data/types.d.ts" />
// 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 = `<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Amber přihlásit zařízení</title>
<style>
:root { color-scheme: light dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
--line:#2a2d36; }
@media (prefers-color-scheme: light) {
:root { --bg:#f4f1ea; --card:#fff; --fg:#1b1c1f; --muted:#5b616b;
--line:#e3ddd0; } }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,
-apple-system,Segoe UI,Roboto,sans-serif; display:flex; min-height:100dvh;
align-items:center; justify-content:center; padding:20px; }
.card { width:100%; max-width:400px; background:var(--card); border-radius:16px;
padding:26px 22px; box-shadow:0 10px 40px rgba(0,0,0,.25);
border:1px solid var(--line); }
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
p.sub { margin:0 0 18px; color:var(--muted); font-size:14px; }
.dev { background:rgba(240,166,60,.10); border:1px solid var(--amber2);
border-radius:10px; padding:10px 12px; margin-bottom:18px; font-size:14px; }
.dev b { color:var(--amber); }
label { display:block; font-size:13px; color:var(--muted); margin:12px 0 5px; }
input { width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus { outline:2px solid var(--amber); border-color:transparent; }
button { width:100%; margin-top:18px; padding:13px; border:none; border-radius:10px;
font-size:15px; font-weight:600; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.primary:disabled { opacity:.55; cursor:default; }
.ghost { background:transparent; color:var(--muted); margin-top:8px; }
.msg { margin-top:14px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
vertical-align:-2px; margin-right:7px; }
@keyframes s { to { transform:rotate(360deg); } }
.hidden { display:none; }
.note { margin-top:16px; font-size:12px; color:var(--muted); line-height:1.5; }
</style>
</head>
<body>
<div class="card">
<h1>Přihlásit zařízení</h1>
<p class="sub">Amber přihlášení televize bez kódu</p>
<div id="devbox" class="dev hidden"></div>
<div id="form">
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" inputmode="email" />
<label for="pass">Heslo</label>
<input id="pass" type="password" autocomplete="current-password" />
<button id="approve" class="primary">Schválit zařízení</button>
<button id="decline" class="ghost">Odmítnout</button>
</div>
<div id="msg" class="msg"></div>
<p class="note">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í.</p>
</div>
<script>
// ── vendored X25519 (TweetNaCl scalarMult, public domain, RFC 7748) ──────────
var amberX25519 = (function () {
function gf(init){var i,r=new Float64Array(16);if(init)for(i=0;i<init.length;i++)r[i]=init[i];return r;}
var _121665=gf([0xdb41,1]);
function car(o){var i,v,c=1;for(i=0;i<16;i++){v=o[i]+c+65535;c=Math.floor(v/65536);o[i]=v-c*65536;}o[0]+=c-1+37*(c-1);}
function sel(p,q,b){var t,c=~(b-1);for(var i=0;i<16;i++){t=c&(p[i]^q[i]);p[i]^=t;q[i]^=t;}}
function pack(o,n){var i,j,b,m=gf(),t=gf();for(i=0;i<16;i++)t[i]=n[i];car(t);car(t);car(t);
for(j=0;j<2;j++){m[0]=t[0]-0xffed;for(i=1;i<15;i++){m[i]=t[i]-0xffff-((m[i-1]>>16)&1);m[i-1]&=0xffff;}
m[15]=t[15]-0x7fff-((m[14]>>16)&1);b=(m[15]>>16)&1;m[14]&=0xffff;sel(t,m,1-b);}
for(i=0;i<16;i++){o[2*i]=t[i]&0xff;o[2*i+1]=t[i]>>8;}}
function unpack(o,n){var i;for(i=0;i<16;i++)o[i]=n[2*i]+(n[2*i+1]<<8);o[15]&=0x7fff;}
function A(o,a,b){for(var i=0;i<16;i++)o[i]=a[i]+b[i];}
function Z(o,a,b){for(var i=0;i<16;i++)o[i]=a[i]-b[i];}
function M(o,a,b){var v,t=new Float64Array(31);for(var i=0;i<31;i++)t[i]=0;
for(i=0;i<16;i++){v=a[i];for(var j=0;j<16;j++)t[i+j]+=v*b[j];}
for(i=0;i<15;i++)t[i]+=38*t[i+16];for(i=0;i<16;i++)o[i]=t[i];car(o);car(o);}
function S(o,a){M(o,a,a);}
function inv(o,i){var c=gf(),a;for(a=0;a<16;a++)c[a]=i[a];
for(a=253;a>=0;a--){S(c,c);if(a!==2&&a!==4)M(c,c,i);}for(a=0;a<16;a++)o[a]=c[a];}
function smult(q,n,p){var z=new Uint8Array(32),x=new Float64Array(80),r,i,
a=gf(),b=gf(),c=gf(),d=gf(),ee=gf(),f=gf();
for(i=0;i<31;i++)z[i]=n[i];z[31]=(n[31]&127)|64;z[0]&=248;unpack(x,p);
for(i=0;i<16;i++){b[i]=x[i];d[i]=a[i]=c[i]=0;}a[0]=d[0]=1;
for(i=254;i>=0;--i){r=(z[i>>>3]>>>(i&7))&1;sel(a,b,r);sel(c,d,r);
A(ee,a,c);Z(a,a,c);A(c,b,d);Z(b,b,d);S(d,ee);S(f,a);M(a,c,a);M(c,b,ee);
A(ee,a,c);Z(a,a,c);S(b,a);Z(c,d,f);M(a,c,_121665);A(a,a,d);M(c,c,a);
M(a,d,f);M(d,b,x);S(b,ee);sel(a,b,r);sel(c,d,r);}
for(i=0;i<16;i++){x[i+16]=a[i];x[i+32]=c[i];x[i+48]=b[i];x[i+64]=d[i];}
var x32=x.subarray(32),x16=x.subarray(16);inv(x32,x32);M(x16,x16,x32);pack(q,x16);return 0;}
var _9=new Uint8Array(32);_9[0]=9;
function scalarMult(n,p){var q=new Uint8Array(32);smult(q,n,p);return q;}
function scalarMultBase(n){return scalarMult(n,_9);}
return {scalarMult:scalarMult,scalarMultBase:scalarMultBase};
})();
// ── helpers ──────────────────────────────────────────────────────────────────
var subtle = crypto.subtle;
var HKDF_INFO = new TextEncoder().encode("amber-device-key-v1");
var PBKDF2_ITERS = 210000;
function b64e(u8){var s="";for(var i=0;i<u8.length;i++)s+=String.fromCharCode(u8[i]);return btoa(s);}
function b64d(s){var bin=atob(s),u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
function qs(name){return new URLSearchParams(location.search).get(name)||"";}
async function deriveVaultKeyB64(password, saltB64){
var salt=b64d(saltB64);
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
return b64e(new Uint8Array(bits));
}
async function sealTo(tvPubB64, plaintext){
var tvPub=b64d(tvPubB64);
var seed=crypto.getRandomValues(new Uint8Array(32));
var pagePub=amberX25519.scalarMultBase(seed);
var shared=amberX25519.scalarMult(seed,tvPub);
var hk=await subtle.importKey("raw",shared,"HKDF",false,["deriveBits"]);
var bits=await subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:new Uint8Array(0),info:HKDF_INFO},hk,256);
var aes=await subtle.importKey("raw",bits,"AES-GCM",false,["encrypt"]);
var nonce=crypto.getRandomValues(new Uint8Array(12));
var ct=new Uint8Array(await subtle.encrypt({name:"AES-GCM",iv:nonce,tagLength:128},aes,new TextEncoder().encode(plaintext)));
return b64e(concat(pagePub,nonce,ct));
}
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
// ── page state ───────────────────────────────────────────────────────────────
var CODE = qs("code").toUpperCase().trim();
var info = null;
var msgEl = document.getElementById("msg");
var approveBtn = document.getElementById("approve");
var declineBtn = document.getElementById("decline");
function setMsg(text, cls){ msgEl.className = "msg " + (cls||""); msgEl.textContent = text; }
function busy(on, label){
approveBtn.disabled = on;
approveBtn.innerHTML = on ? '<span class="spin"></span>' + (label||"Pracuji…") : "Schválit zařízení";
}
async function api(method, path, body, token){
var opt = { method:method, headers:{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r = await fetch(path, opt);
var data = null; try { data = await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
async function loadInfo(){
if (!CODE){ setMsg("Chybí kód zařízení v odkazu.", "err"); document.getElementById("form").classList.add("hidden"); return; }
var r = await api("GET", "/api/device-auth/info?code=" + encodeURIComponent(CODE));
if (!r.ok){
setMsg("Požadavek nebyl nalezen nebo vypršel. Vytvořte na televizi nový.", "err");
document.getElementById("form").classList.add("hidden");
return;
}
info = r.data;
var box = document.getElementById("devbox");
box.classList.remove("hidden");
box.innerHTML = "Přihlásit zařízení: <b>" + (info.deviceName ? escapeHtml(info.deviceName) : "nové zařízení") + "</b>";
if (info.status && info.status !== "pending"){
setMsg("Tento požadavek už byl vyřízen.", "err");
document.getElementById("form").classList.add("hidden");
}
}
function escapeHtml(s){ return String(s).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];}); }
async function doApprove(){
var email = document.getElementById("email").value.trim();
var password = document.getElementById("pass").value;
if (!email || !password){ setMsg("Zadejte e-mail a heslo.", "err"); return; }
busy(true, "Přihlašuji…");
setMsg("");
try {
// 1) sign in
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: password });
if (!auth.ok || !auth.data || !auth.data.token){
setMsg("Nesprávný e-mail nebo heslo.", "err"); busy(false); return;
}
var token = auth.data.token;
var uid = auth.data.record.id;
// 2) find the account's default (earliest) profile + its addon-config salt
var prof = await api("GET",
"/api/collections/profiles/records?perPage=1&sort=created&filter=" +
encodeURIComponent("user='" + uid + "'"), null, token);
var profileId = (prof.data && prof.data.items && prof.data.items[0]) ? prof.data.items[0].id : null;
var saltB64 = null;
if (profileId){
var ac = await api("GET",
"/api/collections/addon_config/records?perPage=1&filter=" +
encodeURIComponent("profile='" + profileId + "'"), null, token);
if (ac.data && ac.data.items && ac.data.items[0]) saltB64 = ac.data.items[0].salt;
}
// No addon config yet on the account → mint a fresh salt so the TV can still
// hold a vault key (it becomes canonical once a device pushes config).
if (!saltB64) saltB64 = newSaltB64();
// 3) derive the vault key from the password and seal {salt, keyB64} to the TV
busy(true, "Šifruji klíč…");
var keyB64 = await deriveVaultKeyB64(password, saltB64);
var payload = JSON.stringify({ salt: saltB64, keyB64: keyB64 });
var keyCiphertext = "";
if (info && info.devicePubKey){
keyCiphertext = await sealTo(info.devicePubKey, payload);
}
// 4) approve (mints the TV's session token server-side)
busy(true, "Schvaluji…");
var appr = await api("POST", "/api/device-auth/approve",
{ code: CODE, keyCiphertext: keyCiphertext }, token);
if (!appr.ok){
setMsg("Schválení se nezdařilo. Zkuste to prosím znovu.", "err"); busy(false); return;
}
document.getElementById("form").classList.add("hidden");
setMsg("Hotovo — televize se za chvíli přihlásí.", "ok");
} catch (err){
setMsg("Došlo k chybě. Zkuste to prosím znovu.", "err"); busy(false);
}
}
async function doDecline(){
var email = document.getElementById("email").value.trim();
var password = document.getElementById("pass").value;
if (!email || !password){ setMsg("Pro odmítnutí se přihlaste.", "err"); return; }
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: password });
if (!auth.ok || !auth.data || !auth.data.token){ setMsg("Nesprávný e-mail nebo heslo.", "err"); return; }
await api("POST", "/api/device-auth/decline", { code: CODE }, auth.data.token);
document.getElementById("form").classList.add("hidden");
setMsg("Požadavek byl odmítnut.", "ok");
}
approveBtn.addEventListener("click", doApprove);
declineBtn.addEventListener("click", doDecline);
loadInfo();
</script>
</body>
</html>`

View file

@ -0,0 +1,100 @@
/// <reference path="../pb_data/types.d.ts" />
// 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 <deviceName>?".
// - `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 */ }
})

View file

@ -105,6 +105,34 @@ check("addon_config.updated is a server autodate (pull cursor)", st,
check("addon_config has unique (profile) index", st, check("addon_config has unique (profile) index", st,
any("idx_addon_config_profile" in i for i in ac.get("indexes", []))) 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) --- # --- create two users (superuser create; nsfwEnabled set by admin) ---
def make_user(email, uname): def make_user(email, uname):
st, r = req("POST", "/api/collections/users/records", admin, { st, r = req("POST", "/api/collections/users/records", admin, {