Compare commits
No commits in common. "4cc5279842be8af57e406b8a1bc798975ca94981" and "ed25627f5fb20e99cbb6835eea5bec2f30b9ece9" have entirely different histories.
4cc5279842
...
ed25627f5f
7 changed files with 1 additions and 834 deletions
|
|
@ -22,10 +22,6 @@ 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
|
||||||
|
|
@ -37,4 +33,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", "--hooksDir=/pb_hooks"]
|
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations"]
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -23,7 +23,6 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -35,7 +34,6 @@ 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
|
||||||
|
|
@ -64,24 +62,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/// <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 */ }
|
|
||||||
})
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
/// <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 {"&":"&","<":"<",">":">",'"':""","'":"'"}[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>`
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
/// <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 */ }
|
|
||||||
})
|
|
||||||
|
|
@ -105,34 +105,6 @@ 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, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue