From f3084731a6902c111d71286bf5710dc32bd9a5a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:14:40 +0200 Subject: [PATCH] feat(schema): encrypted addon-config collection (issue #20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the addon_config collection so a logged-in user's addon configuration (TorBox key, Czech-dub creds, adult addon, TMDB key) can follow their account to a fresh device — encrypted client-side, so the server only ever holds ciphertext. - pb_migrations/1785200000_addon_config.js: one row per profile (unique index), OWNS access rules like the other per-profile collections. Stores blob (AES-GCM ciphertext), salt (per-account KDF salt; not secret), kdf (derivation descriptor), plus the same two-clock model as #11 (client updatedAt for LWW, server updated as the pull cursor). - README.md: data model + a section on why this one blob is encrypted. - scripts/verify.py: schema assertions + ciphertext round-trip + cross-user isolation checks for addon_config. Stacks on the #11 sync-fields backend work (PR #1). Client half is myanime-app's encrypted addon-config sync PR (issue #20). Co-Authored-By: Claude Opus 4.8 --- README.md | 39 ++++++++++---- pb_migrations/1785200000_addon_config.js | 67 ++++++++++++++++++++++++ scripts/verify.py | 35 +++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 pb_migrations/1785200000_addon_config.js diff --git a/README.md b/README.md index a829ea0..2d5a020 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,19 @@ profiles, recommendations, and gated auto-update (epic ``` users ─┬─* profiles ─┬─* watch_state (resume points; 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) └ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16) ``` -| Collection | Type | Key fields | -|---------------|------|------------| -| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) | -| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) | -| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) | -| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) | -| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) | +| Collection | Type | Key fields | +|----------------|------|------------| +| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) | +| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) | +| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) | +| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) | +| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) | +| `addon_config` | base | `profile`→profiles, `blob` (**ciphertext**), `salt`, `kdf`, `updatedAt` (client), `updated` (server) | **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 @@ -39,7 +41,26 @@ accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and update) — only a **superuser** sets it, from the admin UI. Relations `cascadeDelete`, so deleting a user removes their profiles and all child rows. Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one -`prefs` row per profile. +`prefs`/`addon_config` row per profile. + +### Encrypted addon config (issue #20) + +The `addon_config` blob is the one piece of account data that is **not** readable +by the server. The addon "tokens" are base64'd *plaintext credentials* (prehraj.to +login, TorBox API key) plus the TMDB key, so — unlike watch history — they are +encrypted **client-side** before upload. The server stores only: + +- `blob` — AES-GCM ciphertext (nonce ‖ ciphertext ‖ tag), base64; opaque here. +- `salt` — the per-account KDF salt (base64). Not secret; a salt never is. It + lives server-side so a second device can derive the **same** key from the + user's password. The key itself is never sent and never leaves the device. +- `kdf` — the key-derivation descriptor (e.g. `pbkdf2-sha256-210000`). + +Because the key is derived from the password, a **password reset** (done on +PocketBase's hosted page, off-device) leaves the blob undecryptable — expected and +acceptable for re-enterable addon config, and the client detects it, clears it, and +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. ### The two clocks (issue #11) diff --git a/pb_migrations/1785200000_addon_config.js b/pb_migrations/1785200000_addon_config.js new file mode 100644 index 0000000..8dbee4d --- /dev/null +++ b/pb_migrations/1785200000_addon_config.js @@ -0,0 +1,67 @@ +/// + +// Encrypted addon-config sync (epic #6, issue #20). +// +// #11 syncs watchlist / resume / prefs so a fresh sign-in restores your list and +// progress — but not the *addons*, so a fresh TV would have nothing to play from. +// The obvious fix is "sync the addon config too", except the addon "tokens" are +// base64'd **plaintext credentials** (prehraj.to login, TorBox API key) plus the +// TMDB key. The account is deliberately not E2E — an easy call for watch history, +// the wrong one for passwords — so this one blob is encrypted **client-side** +// before it ever reaches here. +// +// This collection therefore holds ciphertext the server can't read: +// - `blob` — AES-GCM ciphertext (nonce ‖ ciphertext ‖ tag), base64. Opaque. +// - `salt` — per-account KDF salt, base64. NOT secret (a salt never is); it +// lives here so a second device can derive the same key from the +// user's password. The key itself never leaves the device. +// - `kdf` — key-derivation descriptor (e.g. "pbkdf2-sha256-210000") so the +// params can move forward without guessing how an old blob was made. +// +// Same two-clock model as the #11 collections: client-set `updatedAt` resolves +// last-write-wins; the server autodate `updated` is the pull cursor. One row per +// profile (unique index), owned via `profile.user = auth.id` like everything else. + +migrate((app) => { + const profiles = app.findCollectionByNameOrId("profiles") + + const addonConfig = new Collection({ + type: "base", + name: "addon_config", + // Rows belong to a profile; ownership traverses profile.user, exactly as the + // watch_state / watchlist / prefs collections do. + listRule: "@request.auth.id != '' && profile.user = @request.auth.id", + viewRule: "@request.auth.id != '' && profile.user = @request.auth.id", + createRule: "@request.auth.id != '' && profile.user = @request.auth.id", + updateRule: "@request.auth.id != '' && profile.user = @request.auth.id", + deleteRule: "@request.auth.id != '' && profile.user = @request.auth.id", + fields: [ + { + type: "relation", + name: "profile", + required: true, + collectionId: profiles.id, + cascadeDelete: true, + maxSelect: 1, + minSelect: 0, + }, + // Ciphertext only. Small in practice (a few URLs + keys), but leave room. + { type: "text", name: "blob", required: true, max: 100000 }, + { type: "text", name: "salt", required: true, max: 200 }, + { type: "text", name: "kdf", required: true, max: 100 }, + // Client-set clock: when the config was actually changed (last-write-wins). + { type: "date", name: "updatedAt", required: true }, + // Server autodate: the pull cursor (monotonic in server time). + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_addon_config_profile` ON `addon_config` (`profile`)", + ], + }) + app.save(addonConfig) +}, (app) => { + // ---- Down migration ---------------------------------------------------- + try { + app.delete(app.findCollectionByNameOrId("addon_config")) + } catch (_) { /* already gone */ } +}) diff --git a/scripts/verify.py b/scripts/verify.py index 6a026cc..89a1a01 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -88,6 +88,23 @@ st, pf = req("GET", "/api/collections/prefs", admin) check("prefs.updatedAt is a client-set date", st, (field(pf, "updatedAt") or {}).get("type") == "date") +# --- encrypted addon config (issue #20) --- +st, ac = req("GET", "/api/collections/addon_config", admin) +rules_owned = all("@request.auth.id" in (ac.get(k) or "") for k in + ("listRule","viewRule","createRule","updateRule","deleteRule")) +check("addon_config exists with per-owner rules on all 5 verbs", st, rules_owned) +check("addon_config.blob is a required text field (ciphertext)", st, + (field(ac, "blob") or {}).get("type") == "text" and (field(ac, "blob") or {}).get("required")) +check("addon_config has salt + kdf text fields", st, + (field(ac, "salt") or {}).get("type") == "text" and + (field(ac, "kdf") or {}).get("type") == "text") +check("addon_config.updatedAt is a client-set date", st, + (field(ac, "updatedAt") or {}).get("type") == "date") +check("addon_config.updated is a server autodate (pull cursor)", st, + (field(ac, "updated") or {}).get("type") == "autodate") +check("addon_config has unique (profile) index", st, + any("idx_addon_config_profile" in i for i in ac.get("indexes", []))) + # --- create two users (superuser create; nsfwEnabled set by admin) --- def make_user(email, uname): st, r = req("POST", "/api/collections/users/records", admin, { @@ -153,6 +170,20 @@ st, r = req("GET", "/api/collections/watchlist/records", alice) check("exactly one watchlist row after delete+re-add", st, st == 200 and r.get("totalItems") == 1) +# --- addon_config: store ciphertext + round-trip the client clock ----------- +st, acrow = req("POST", "/api/collections/addon_config/records", alice, + {"profile": pa["id"], "blob": "bm9uY2VjaXBoZXJ0YWc=", + "salt": "c29tZXNhbHQ=", "kdf": "pbkdf2-sha256-210000", + "updatedAt": CLIENT_TS}) +check("alice stores an encrypted addon_config blob", st, st == 200) +check("addon_config.updatedAt round-trips the client's value", st, + acrow.get("updatedAt", "").startswith("2026-01-02 03:04:05")) +# One row per profile — a second create must collide, not duplicate. +st, dup = req("POST", "/api/collections/addon_config/records", alice, + {"profile": pa["id"], "blob": "b3RoZXI=", "salt": "c2FsdA==", + "kdf": "pbkdf2-sha256-210000", "updatedAt": CLIENT_TS}) +check("duplicate addon_config for a profile is rejected", st, st == 400) + # --- ISOLATION: bob must not see or touch alice's data --- st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob) check("bob CANNOT view alice's profile (404)", st, st == 404) @@ -161,6 +192,10 @@ check("bob's watch_state list is empty", st, st == 200 and r.get("totalItems") = st, r = req("PATCH", f"/api/collections/watch_state/records/{wsrow['id']}", bob, {"position": 999}) check("bob CANNOT update alice's watch_state (404)", st, st == 404) +st, r = req("GET", "/api/collections/addon_config/records", bob) +check("bob's addon_config list is empty", st, st == 200 and r.get("totalItems") == 0) +st, r = req("GET", f"/api/collections/addon_config/records/{acrow['id']}", bob) +check("bob CANNOT view alice's addon_config blob (404)", st, st == 404) # --- bob cannot create a profile owned by alice --- st, r = req("POST", "/api/collections/profiles/records", bob,