diff --git a/.env.example b/.env.example index ed830cb..f8f9955 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,19 @@ # Copy to .env and fill in. Never commit the real .env. -# Long random secret shared with the userscript's API_TOKEN. Generate one: +# Secret every Reader's userscript credential is derived from (issue #24): +# the backend rebuilds install URLs from it, and only SHA-256 hashes of the +# credentials ever touch the database. Generate one: # openssl rand -hex 32 -API_TOKEN=changeme-generate-a-long-random-token +TOKEN_KEY=changeme-generate-a-long-random-token + +# Retired global credential, kept only during the cutover window so +# already-installed scripts keep working. Remove both it and +# API_TOKEN_GRACE_UNTIL once the window has passed and every device has +# reinstalled through the web UI. +# API_TOKEN= +# Moment the retired credential stops resolving to the owner (YYYY-MM-DD or +# RFC3339). Enforced in code on every request; unset means it is already dead. +# API_TOKEN_GRACE_UNTIL=2026-08-22 # The owner's Discord user ID — the one Reader every bookmark belongs to # (seeded at startup). Discord snowflake, e.g. 1046923170000000000. diff --git a/AGENTS.md b/AGENTS.md index b07f2df..f94dd25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ instantly. Existing guarantees — don't regress: -- Auth on `/bookmarks*`: require `Authorization: Bearer `, **constant-time compare**, 401 otherwise. +- Auth on `/bookmarks*`: require `Authorization: Bearer ` — the acting Reader's credential, matched by SHA-256 against `readers.token_sha256` — **constant-time compare** (via the hash, never the secret itself), 401 otherwise. - CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`. ## Secure coding rules (code you write here) @@ -83,7 +83,7 @@ Go backend: - `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on. - Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one. - Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end. -- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the API token. +- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the retired global token during its grace window. - Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `API_TOKEN`, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header. - Proxy headers are trusted only where they already are: `X-Forwarded-Proto` for the Secure cookie flag, **rightmost** `X-Forwarded-For` for client IP (leftmost is attacker-supplied). Don't read either anywhere else. - Session cookies keep `HttpOnly`, `SameSite`, `Secure`-when-HTTPS; expiry is enforced by the `sessions` table lookup, not a signature. @@ -93,8 +93,8 @@ Go backend: Userscript: - Site-derived and stored strings render via `el(..., {text})` / `textContent`. `{html}` and `innerHTML` are for author-written literal markup only (`TEMPLATE`, `CSS`) — never a title, chapter label, or API response field. The page DOM belongs to a third-party site; treat it as attacker-controlled. -- Isolated world protects the token from the site's JS. It does not protect anything from an `innerHTML` sink you add yourself. -- The `API_TOKEN` literal sits in both userscripts and must equal backend `API_TOKEN`. Never copy it into logs, docs, commit messages, issues, or a new file. Rotation touches three places: backend env plus both scripts. +- Isolated world protects the credential from the site's JS. It does not protect anything from an `innerHTML` sink you add yourself. +- The userscripts carry `__API_TOKEN__` placeholders, substituted at serve time with the requesting Reader's credential (`internal/userscript`). Never put a real credential in the repo, docs, commit messages, or issues. Rotation is a web-UI action (epoch bump, `internal/token`); `TOKEN_KEY` in backend env is what derives every credential — never log it. - `fetch()` targets `API_BASE` only — no dynamic origin, no site-supplied URL. `authHeaders()` goes nowhere but the backend. - `localStorage` is shared with the site's own JS: cache and queue live there, credentials never do. - Wrap every `localStorage` read/write and `JSON.parse` in try/catch (quota, private mode, corrupt entry), as the existing helpers do. diff --git a/DEPLOY.md b/DEPLOY.md index 0a76fc5..cedecb0 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -32,8 +32,9 @@ cp .env.example .env Edit `.env`: ```ini -# Required — long random secret, also goes in the userscript. -API_TOKEN= +# Required — secret every Reader's userscript credential is derived from. +# Only SHA-256 hashes of credentials are stored. +TOKEN_KEY= # Required — the owner's Discord user ID. Seeds the one Reader every bookmark # belongs to; the value is the snowflake in your Discord profile (Settings → @@ -66,11 +67,18 @@ BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id Generate + insert the two secrets in three lines: ```bash -sed -i "s|^API_TOKEN=.*|API_TOKEN=$(openssl rand -hex 32)|" .env +sed -i "s|^TOKEN_KEY=.*|TOKEN_KEY=$(openssl rand -hex 32)|" .env sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env -grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value +grep -E '^TOKEN_KEY=' .env ``` +`TOKEN_KEY` derives every Reader's userscript credential (issue #24); only +SHA-256 hashes of the credentials are stored, so this secret is what a +database leak alone cannot recover. If you are upgrading across the cutover, +also set `API_TOKEN` (the retired global credential) and +`API_TOKEN_GRACE_UNTIL` in `.env` so already-installed scripts keep working +for the window — see §6. + `POSTGRES_PASSWORD` is read **only while the `postgres-data` volume is empty**, which in practice means at first boot. Changing it afterwards changes the URL the backend dials but not the password the database expects, and `bookmark-api` @@ -133,7 +141,7 @@ gated by membership in one configured guild. Sessions are rows in the database: the cookie carries only an opaque id, and every request looks the row up and checks its expiry. Deleting a session row — or the whole `sessions` table — logs the browser out immediately; nothing is -signed, so rotating `API_TOKEN` does not affect browser sessions. Sessions +signed, so rotating a credential does not affect browser sessions. Sessions last 60 days. --- @@ -180,6 +188,8 @@ curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok curl -s -o /dev/null -w '%{http_code}\n' \ https://bookmark-api.violetcrown.my.id/bookmarks # -> 401 +# During the grace window the retired global credential still resolves to the +# owner; afterwards it is 401 like any other wrong credential. TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) curl -s -H "Authorization: Bearer $TOKEN" \ https://bookmark-api.violetcrown.my.id/bookmarks # -> [] @@ -199,29 +209,30 @@ a bad cert makes the browser block the userscript's `fetch()` (mixed content). ## 4. Configure the userscript -Edit the config block at the top of `userscript/manga-bookmark.user.js`: +The bindmounted `userscript/*.user.js` files carry `__API_TOKEN__` placeholders +and the deployment's `@downloadURL`/`@updateURL` lines. Check the metadata +block — it ships hardcoded to this deployment's domain, so a deployer who +copies the repo to another domain must edit the two lines or the script +auto-updates from someone else's backend: ```js -const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash -const API_TOKEN = ""; +// @downloadURL https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js +// @updateURL https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js ``` -The token sits in the userscript's isolated world — the manga sites' JS can't -read it. - -Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the -file — they ship hardcoded to this deployment's domain and token, so a -deployer who skips them ends up auto-updating from someone else's backend. -See "Installing / updating the userscript" below for how those two lines are -used. +The backend substitutes `__API_TOKEN__` with the requesting Reader's derived +credential at serve time (issue #24), so no real credential ever sits in the +file. Only the `API_BASE` constant and the metadata hostname are deployer +edits; do not put a credential in this file. --- ## 5. Install on Bromite 1. Bromite → **Settings → User scripts** → enable (accept the permission prompt). -2. Put the edited `manga-bookmark.user.js` on the device (save the file, or open - its raw URL). Bromite detects `.user.js` and offers to install. +2. Sign in to the web UI, open the **Userscripts** panel, and open the install + link — Bromite detects `.user.js` and offers to install. The script already + carries your credential; you never see or type one. 3. Confirm install — the `@match` list covers both sites. 4. Open a series on asurascans.com or demonicscans.org → a 📑 button appears bottom-right → tap → **+ Bookmark this**. @@ -229,6 +240,9 @@ used. Optional desktop test: the script is `GM_*`-free, so the same file installs in Tampermonkey/Violentmonkey for quick checks before going mobile. +Rotating the credential in the same web-UI panel invalidates every installed +copy immediately — reinstall on all devices, or they silently stop syncing. + --- ## 6. Smoke-test the full loop @@ -265,9 +279,10 @@ it; see `REDEPLOY.md` §1 for when to remove it.) | No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs `. | | 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. | | `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). | -| 401 with the right token | Trailing space/newline in `API_TOKEN`; regenerate and restart. | +| 401 with the right credential | The script's credential no longer matches the stored hash — most likely a rotation happened and the device was not reinstalled. Reinstall from the web UI. | +| 401 after rotation, even right after reinstalling | `TOKEN_KEY` changed between the rotation and the reinstall; credentials are derived from it, so changing it invalidates every credential. Keep it stable. | | Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. | -| `compose ... config` errors about `API_TOKEN`, `OWNER_DISCORD_ID` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. All three are required and none has a fallback. | +| `compose ... config` errors about `TOKEN_KEY`, `OWNER_DISCORD_ID` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. All three are required and none has a fallback. | | `bookmark-api` restarts in a loop, `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` was changed after first boot; Postgres only applies it to an empty `postgres-data`. Restore the old value, or reset the role (`REDEPLOY.md` troubleshooting). | | `bookmark-api` never logs `listening on :8080` | It is blocked on `postgres` passing `pg_isready`, or a migration failed. `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs postgres`. | @@ -278,20 +293,25 @@ Backend config reference and endpoint list: see `README.md`. ## Installing / updating the userscript The backend serves the script itself, so Violentmonkey can auto-update it. -Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your -backend; this one points `@downloadURL`/`@updateURL` at the same place so -auto-updates come from it too. +Complements §4 above — the `@downloadURL`/`@updateURL` lines point at the +credential-bearing path, so auto-updates come from the same place as the +install. -Install once, on the phone (Cromite + Violentmonkey): +Install once, on the phone (Cromite + Violentmonkey): sign in to the web UI, +open the **Userscripts** panel, and open the install link for the library — +the script is served with your credential already inside it. Its +`@downloadURL`/`@updateURL` point at the same credential-bearing path for +updates: ``` -https://bookmark-api./u//manga-bookmark.user.js +https://bookmark-api./u//manga-bookmark.user.js ``` -Open that URL in Cromite; Violentmonkey offers to install it. The token is in -the path because Violentmonkey's update poll sends no `Authorization` header, -and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A -wrong token answers 404. +Violentmonkey offers to install it. The credential is in the path because +Violentmonkey's update poll sends no `Authorization` header, and the script +embeds the credential in plain text — an open URL would leak it. A wrong +credential answers 404. The credential is derived from `TOKEN_KEY` and never +appears anywhere but this URL and the rendered script. Updating, without a redeploy: diff --git a/README.md b/README.md index b20830a..4742b15 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) | Var | Default | Notes | |-----|---------|-------| -| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. | +| `TOKEN_KEY` | *(required)* | Secret every Reader's userscript credential is derived from (issue #24); only SHA-256 hashes of credentials are stored. | +| `API_TOKEN` | *(retired)* | Global credential, honoured only until `API_TOKEN_GRACE_UNTIL` for already-installed scripts; remove both after the window. | +| `API_TOKEN_GRACE_UNTIL` | unset | Moment the retired credential stops resolving to the owner (`YYYY-MM-DD` or RFC3339), enforced in code. | | `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner; seeds the one Reader all bookmarks belong to. | | `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. | | `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. | @@ -36,11 +38,11 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) | Method | Path | Auth | Description | |--------|------|------|-------------| -| `GET` | `/bookmarks` | Bearer | All bookmarks (single-user). | +| `GET` | `/bookmarks` | Bearer | All bookmarks of the acting Reader. | | `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. | | `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. | | `GET` | `/healthz` | none | `200 ok`. | -| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. | +| `GET` | `/u/{token}/manga-bookmark.user.js` | credential in path | Serves the userscript with the requesting Reader's credential substituted in and an mtime-derived `@version`. | `key` is `:` — e.g. `asura:trash-of-the-counts-family-f886a8af`, `demonic:Infinite-Level-Up-in-Murim`, `comix:12345`, or @@ -70,7 +72,7 @@ and nothing reaches the network beyond the local Docker daemon. ```bash cp .env.example .env -# edit .env: set API_TOKEN (openssl rand -hex 32) and +# edit .env: set TOKEN_KEY (openssl rand -hex 32) and # POSTGRES_PASSWORD (openssl rand -hex 24) docker compose up -d --build # binds 127.0.0.1:8080 @@ -114,17 +116,18 @@ CORS headers. ## 2. Userscript -### Configure +### Install -Edit the config block at the top of `userscript/manga-bookmark.user.js`: +Sign in to the web UI and open the **Userscripts** panel: it offers one +install link per library. Each link serves a script rendered with your own +credential already inside it — you never see, type or copy a credential. The +served script carries `@downloadURL`/`@updateURL` pointing at its +credential-bearing path, so Violentmonkey keeps auto-updating it. -```js -const API_BASE = "https://bookmark-api."; // no trailing slash -const API_TOKEN = ""; -``` - -The token lives in the userscript's **isolated world** — the manga sites' own -JS cannot read it. +The bindmounted files carry `__API_TOKEN__` placeholders; the backend +substitutes the requesting Reader's credential at serve time, so no real +credential is ever committed. Rotating the credential (same panel) invalidates +every installed copy immediately — reinstall on all devices. ### Install on Bromite (mobile) @@ -132,8 +135,8 @@ Bromite runs Chromium's native userscript engine (no Tampermonkey needed): 1. Bromite → **Settings → User scripts** → enable user scripts (allow the permission prompt). -2. Save the configured `manga-bookmark.user.js` to the device (or open its raw - URL). Bromite detects the `.user.js` and offers to install it. +2. Open the install link from the web UI — Bromite detects the `.user.js` and + offers to install it. 3. Confirm the install; the `@match` list covers both sites. 4. Open a series on either site — a 📑 button appears bottom-right. diff --git a/REDEPLOY.md b/REDEPLOY.md index 38d1675..a2b5694 100644 --- a/REDEPLOY.md +++ b/REDEPLOY.md @@ -224,6 +224,8 @@ Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names onc ```bash API=https://bookmark-api.violetcrown.my.id WEB=https://bookmark.violetcrown.my.id +# During the grace window the retired global credential still resolves to the +# owner; afterwards it is 401 like any other wrong credential. TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) curl -s $API/healthz # -> ok diff --git a/backend/AGENTS.md b/backend/AGENTS.md index aee786b..3ddb6e4 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -21,9 +21,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN container per test binary (`TestMain` -> `pgtest.Main`) and hands each test its own database (`pgtest.URL(t)`). A package whose tests touch the store must have that `TestMain`. -- **Single-owner store, three tables.** `readers` is keyed by Discord user ID - and carries the SHA-256 of the owner's userscript token (the global - `API_TOKEN` today; issue #22). The seed creates exactly one row at startup. +- **Single-owner store, four tables.** `readers` is keyed by Discord user ID + and carries the SHA-256 of the Reader's userscript credential plus a + `token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates exactly one row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI. `series` keyed `(site, series_id)` (`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`), @@ -31,12 +31,14 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN differs between readers: progress, favourite, lifecycle bucket, `updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no surrogate id; the wire `key` is derived as `site:series_id` on read — and - every store read/write is scoped to the reader it names. `Store.OwnerID()` - is the seeded owner, which every handler passes while the global token is - still the only credential. Sync **last-write-wins**; the wire format stays - flat (ADR-0004). `Store.Upsert` decomposes one flat body across two tables - and enforces the ownership rule: client `title`/`series_url`/`cover` are - written only when the series row is new (ADR-0003). + every store read/write is scoped to the reader it names. Auth resolves the + acting Reader from the presented credential (`httpmw.Auth`), and the + reader id travels in the request context; the retired global `API_TOKEN` + additionally resolves to the owner until `API_TOKEN_GRACE_UNTIL`, with + every such acceptance logged. Sync **last-write-wins**; the wire format + stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two + tables and enforces the ownership rule: client `title`/`series_url`/`cover` + are written only when the series row is new (ADR-0003). - **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). - **Web UI:** same binary serve the browser UI on a second hostname — `GET /` (list, or login page when no session), @@ -101,7 +103,10 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `excluded.*` is post-evaluation row and default applied there would wipe bucket on every PUT from client that predates column. See `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. -- **Config via env:** `API_TOKEN`, `OWNER_DISCORD_ID` (seeds the owner Reader; +- **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential; + required), `API_TOKEN` + `API_TOKEN_GRACE_UNTIL` (retired global credential + and the moment it stops resolving to the owner — both removed after the + cutover window, enforced in code), `OWNER_DISCORD_ID` (seeds the owner Reader; required), `ALLOWED_ORIGINS` (comma list), `DATABASE_URL` (Postgres connection URL, required — no default), `PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/ @@ -113,7 +118,15 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `USERSCRIPT_PATH` and `NOVEL_USERSCRIPT_PATH` (files served at `/u/{token}/manga-bookmark.user.js` and `/u/{token}/novel-bookmark.user.js`, defaults `/userscript/manga-bookmark.user.js` and - `/userscript/novel-bookmark.user.js`, both supplied by bindmount). + `/userscript/novel-bookmark.user.js`, both supplied by bindmount; the + `__API_TOKEN__` placeholder inside them is substituted with the requesting + Reader's credential at serve time). `BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull; unset disables browser polling and leaves those sites to the userscript alone). +- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js` + (renders the bindmounted script with the acting Reader's derived credential + substituted in — the credential never appears in page markup, the address + bar, or a redirect) and `POST /rotate-token` (atomic epoch bump + hash + rewrite; invalidates every installed copy, so the panel warns to reinstall + on all devices). diff --git a/backend/api_test.go b/backend/api_test.go index 187e9f1..a4a80c4 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "crypto/sha256" "encoding/json" "fmt" "net/http" @@ -15,18 +14,36 @@ import ( "bookmarkmanager/backend/internal/pgtest" "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" ) const testToken = "s3cret-token" +// testTokenKey derives every test Reader's credential; it must match the key +// newTestStoreURL seeds the owner with, or derived credentials authenticate +// nothing. +const testTokenKey = "test-token-key" + +// testDiscordID is the owner row's discord_id (newTestStoreURL); the derived +// credential is a function of it. +const testDiscordID = "test-owner" + func testConfig() Config { return Config{ Token: testToken, + TokenKey: testTokenKey, + GraceUntil: time.Now().Add(24 * time.Hour), AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, Port: "8080", } } +// ownerCredential is the owner's epoch-0 derived credential: the string the +// install links carry and the userscript routes authenticate. +func ownerCredential() string { + return token.Token([]byte(testTokenKey), testDiscordID, 0) +} + func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } func newTestServer(t *testing.T) http.Handler { @@ -46,7 +63,7 @@ func newTestStoreURL(t *testing.T) (*store.Store, string) { t.Helper() url := pgtest.URL(t) s, err := store.Open(url, store.Owner{ - DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash")), + DiscordID: testDiscordID, TokenHash: token.Hash(ownerCredential()), }) if err != nil { t.Fatalf("store.Open: %v", err) @@ -602,10 +619,11 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { // The userscript route is registered outside the web UI's Discord auth, so it // must keep working whatever the web config — see internal/userscript for the -// handler's own behaviour. +// handler's own behaviour. The credential in the path is the owner's derived +// one, and the served script carries it substituted in. func TestUserscriptServedWithWebUIDisabled(t *testing.T) { path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") - if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } @@ -614,15 +632,18 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) { cfg.UserscriptPath = path rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil) + req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil) newRouter(s, cfg).ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } + if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) { + t.Fatalf("served script does not carry the requesting Reader's credential:\n%s", got) + } } -// Both scripts are served from the same handler on the same token, outside the -// web UI's auth — a wrong token is a 404, never a 401. +// Both scripts are served from the same handler, outside the web UI's auth — +// a wrong credential is a 404, never a 401. func TestNovelUserscriptServed(t *testing.T) { dir := t.TempDir() novelPath := filepath.Join(dir, "novel-bookmark.user.js") @@ -638,7 +659,7 @@ func TestNovelUserscriptServed(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, - "/u/"+testToken+"/novel-bookmark.user.js", nil)) + "/u/"+ownerCredential()+"/novel-bookmark.user.js", nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 7328084..ae918ea 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -7,15 +7,13 @@ import ( "strings" "time" + "bookmarkmanager/backend/internal/httpmw" "bookmarkmanager/backend/internal/store" ) // Handler serves the userscript-facing JSON bookmark API. type Handler struct { Store *store.Store - // ReaderID is the Reader this request acts as. Authentication is still the - // single global token, so that is always the seeded owner (issue #22). - ReaderID int64 } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -30,7 +28,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // List returns all bookmarks of the acting Reader. GET /bookmarks func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - items, err := h.Store.List(h.ReaderID) + items, err := h.Store.List(httpmw.ReaderID(r)) if err != nil { log.Printf("list: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -94,7 +92,7 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() - stored, err := h.Store.Upsert(h.ReaderID, b) + stored, err := h.Store.Upsert(httpmw.ReaderID(r), b) if err != nil { log.Printf("upsert: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -112,7 +110,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing key", http.StatusBadRequest) return } - if err := h.Store.Delete(h.ReaderID, key); err != nil { + if err := h.Store.Delete(httpmw.ReaderID(r), key); err != nil { log.Printf("delete: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return diff --git a/backend/internal/httpmw/middleware.go b/backend/internal/httpmw/middleware.go index e609531..a233969 100644 --- a/backend/internal/httpmw/middleware.go +++ b/backend/internal/httpmw/middleware.go @@ -2,28 +2,68 @@ package httpmw import ( "compress/gzip" + "context" "crypto/subtle" + "log" "net/http" "strings" + "time" + + "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" ) const bearerPrefix = "Bearer " -// Auth guards a handler with a constant-time bearer-token check. -func Auth(token string, next http.Handler) http.Handler { - want := []byte(token) +type ctxKey int + +// readerCtxKey is where Auth stashes the authenticated Reader id. +const readerCtxKey ctxKey = iota + +// ReaderID returns the Reader id Auth authenticated, for handlers that take +// the acting Reader from the request rather than from a fixed field. +func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(int64) } + +// ResolveReader maps a presented credential to a Reader. The credential is +// hashed and matched against readers.token_sha256 — an equality on 32-byte +// values, never a comparison of the credential itself — and, during the +// cutover window, the retired global token resolves to the owner. Every +// legacy acceptance is logged so the window can be confirmed empty before +// the token is removed. The same resolution backs the API bearer header and +// the userscript download path, so the window covers both. +func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) { + if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil { + log.Printf("auth: reader lookup: %v", err) + return 0, false + } else if ok { + return readerID, true + } + + if legacy != "" && time.Now().Before(graceUntil) && + subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 { + log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)", + s.OwnerID(), graceUntil.Format(time.RFC3339)) + return s.OwnerID(), true + } + return 0, false +} + +// Auth guards a handler with a per-Reader bearer credential. The acting +// Reader travels in the request context, so a handler scopes every store call +// to exactly the Reader that authenticated. +func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, bearerPrefix) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - got := []byte(strings.TrimPrefix(h, bearerPrefix)) - if subtle.ConstantTimeCompare(got, want) != 1 { + readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix)) + if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), readerCtxKey, readerID))) }) } diff --git a/backend/internal/store/migrations/0006_reader_token_epoch.sql b/backend/internal/store/migrations/0006_reader_token_epoch.sql new file mode 100644 index 0000000..4160eac --- /dev/null +++ b/backend/internal/store/migrations/0006_reader_token_epoch.sql @@ -0,0 +1,7 @@ +-- Rotation is an epoch bump: a Reader's credential is derived from the +-- deployment secret, their Discord id and this epoch, so bumping it issues a +-- new credential and the rewritten token_sha256 invalidates the old one the +-- moment the transaction commits. The seed's ON CONFLICT refresh (Store.Open) +-- is gated on this being 0, so a restart can never undo a rotation by +-- restoring the epoch-0 hash. +ALTER TABLE readers ADD COLUMN token_epoch bigint NOT NULL DEFAULT 0; diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index a89ada8..de2a911 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -171,12 +171,12 @@ const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, // Owner is the person running the service: the first Reader, and the only one // until registration exists. The seed makes sure exactly one readers row -// matches their Discord ID, carrying the SHA-256 of their userscript token — -// which today is the global API token. +// matches their Discord ID, carrying the SHA-256 of their epoch-0 userscript +// credential (derived by internal/token, not the retired global token). type Owner struct { DiscordID string - // TokenHash is the SHA-256 of the userscript token; the array shape makes - // it a compile error to store anything that is not a hash. + // TokenHash is the SHA-256 of the epoch-0 credential; the array shape + // makes it a compile error to store anything that is not a hash. TokenHash [32]byte } @@ -189,10 +189,69 @@ type Store struct { ownerID int64 } -// OwnerID returns the seeded owner Reader's id — the Reader every request -// acts as while the global token is still the only credential. +// OwnerID returns the seeded owner Reader's id — the Reader the retired +// global token resolves to during the grace window, and the only Reader while +// registration is closed. func (s *Store) OwnerID() int64 { return s.ownerID } +// ReaderIDForTokenHash resolves the Reader whose stored credential hash +// matches, reporting absence with ok=false. The comparison is an equality on +// the 32-byte SHA-256 of the presented credential — never on the credential +// itself — and the indexed lookup reveals only whether some Reader matches, +// which the 401/200 split has to reveal anyway. An attacker's probe is the +// hash of their guess, so even the index's prefix comparisons leak nothing +// about the real credential. +func (s *Store) ReaderIDForTokenHash(hash [32]byte) (int64, bool, error) { + var id int64 + err := s.db.QueryRow( + `SELECT id FROM readers WHERE token_sha256 = $1`, hash[:]).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("reader by token hash: %w", err) + } + return id, true, nil +} + +// ReaderTokenInfo returns the identity halves a Reader's credential is +// derived from (internal/token.Token): their Discord id and token epoch. The +// web UI needs these to rebuild the install URL — the only place a credential +// is ever produced in plaintext. +func (s *Store) ReaderTokenInfo(readerID int64) (string, int64, error) { + var ( + discordID string + epoch int64 + ) + err := s.db.QueryRow( + `SELECT discord_id, token_epoch FROM readers WHERE id = $1`, readerID). + Scan(&discordID, &epoch) + if err != nil { + return "", 0, fmt.Errorf("reader %d token info: %w", readerID, err) + } + return discordID, epoch, nil +} + +// RotateToken bumps a Reader's token epoch and rewrites the stored hash in +// one statement, so the new hash always matches the new epoch. expectedEpoch +// is the epoch the caller derived newHash for (ReaderTokenInfo + 1); a +// concurrent rotation — or an unknown reader — leaves the row untouched and +// is reported as an error rather than silently succeeding. +func (s *Store) RotateToken(readerID, expectedEpoch int64, newHash [32]byte) error { + var epoch int64 + err := s.db.QueryRow(` + UPDATE readers SET token_epoch = token_epoch + 1, token_sha256 = $3 + WHERE id = $1 AND token_epoch = $2 + RETURNING token_epoch`, readerID, expectedEpoch, newHash[:]).Scan(&epoch) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("rotate token for reader %d: concurrent rotation or unknown reader", readerID) + } + if err != nil { + return fmt.Errorf("rotate token for reader %d: %w", readerID, err) + } + return nil +} + // readersMigration is the version that creates the readers table. The owner // seed runs between two migrate passes, so that the run-once migration which // attaches existing bookmarks (0004) finds the owner row. @@ -217,6 +276,10 @@ func Open(url string, owner Owner) (*Store, error) { db.Close() return nil, fmt.Errorf("migrate schema: %w", err) } + // The owner row must exist before 0004 attaches the existing bookmarks to + // it. The hash refresh is a separate statement after all migrations: the + // token_epoch column 0006 adds does not exist yet at this point, and the + // refresh only ever concerns rows that have never been rotated. if err := seedOwner(db, owner); err != nil { db.Close() return nil, fmt.Errorf("seed owner: %w", err) @@ -225,6 +288,10 @@ func Open(url string, owner Owner) (*Store, error) { db.Close() return nil, fmt.Errorf("migrate: %w", err) } + if err := refreshOwnerToken(db, owner); err != nil { + db.Close() + return nil, fmt.Errorf("refresh owner token: %w", err) + } var ownerID int64 if err := db.QueryRow( `SELECT id FROM readers WHERE discord_id = $1`, owner.DiscordID).Scan(&ownerID); err != nil { @@ -234,19 +301,36 @@ func Open(url string, owner Owner) (*Store, error) { return &Store{db: db, ownerID: ownerID}, nil } -// seedOwner makes sure the configured owner exists as exactly one readers row, -// and keeps its token hash current on every start: rotating the userscript -// token must refresh the hash, or the stored credential goes stale. +// seedOwner makes sure the configured owner exists as exactly one readers row. +// The hash is only ever written here for a brand-new row; existing rows keep +// what they have until refreshOwnerToken decides otherwise, so the seed can +// never clobber a rotation. func seedOwner(db *sql.DB, o Owner) error { if _, err := db.Exec(` INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) - ON CONFLICT (discord_id) DO UPDATE SET token_sha256 = EXCLUDED.token_sha256`, + ON CONFLICT (discord_id) DO NOTHING`, o.DiscordID, o.TokenHash[:]); err != nil { return fmt.Errorf("seed owner: %w", err) } return nil } +// refreshOwnerToken brings a never-rotated owner row's hash current with the +// configured credential. That is the cutover path: a database seeded under +// the retired global token still carries its hash at epoch 0, and the +// epoch-0 derivation is the caller's TokenHash. A rotated row (epoch > 0) is +// left alone — a restart must not resurrect the old credential by +// overwriting the hash a rotation wrote. +func refreshOwnerToken(db *sql.DB, o Owner) error { + if _, err := db.Exec(` + UPDATE readers SET token_sha256 = $2 + WHERE discord_id = $1 AND token_epoch = 0`, + o.DiscordID, o.TokenHash[:]); err != nil { + return fmt.Errorf("refresh owner token: %w", err) + } + return nil +} + // migrate applies every embedded migration this database has not recorded, in // filename order, each in its own transaction. upto caps the highest version // applied; 0 means all. Files are named "_.sql" and are diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index e57efd5..c5bfaa6 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -75,6 +75,92 @@ func TestOpenIsIdempotent(t *testing.T) { } } +// The hash lookup is the whole authentication path: the store resolves a +// Reader from the SHA-256 of their presented credential, and nothing else. +func TestReaderIDForTokenHash(t *testing.T) { + store := newTestStore(t) + ownerHash := sha256.Sum256([]byte("owner-token-hash")) + + id, ok, err := store.ReaderIDForTokenHash(ownerHash) + if err != nil { + t.Fatalf("ReaderIDForTokenHash: %v", err) + } + if !ok || id != store.OwnerID() { + t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID()) + } + + if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil { + t.Fatalf("miss: %v", err) + } else if ok { + t.Fatal("unknown hash resolved to a Reader") + } +} + +func TestReaderTokenInfo(t *testing.T) { + store := newTestStore(t) + discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID()) + if err != nil { + t.Fatalf("ReaderTokenInfo: %v", err) + } + if discordID != testOwner.DiscordID || epoch != 0 { + t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID) + } +} + +// Rotation swaps the stored hash and bumps the epoch in one step, and the +// seed must not undo it: a restart re-runs seedOwner, which refreshes the +// epoch-0 hash only while the row has never been rotated. +func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) { + url := pgtest.URL(t) + store, err := Open(url, testOwner) + if err != nil { + t.Fatalf("Open: %v", err) + } + + oldHash := sha256.Sum256([]byte("owner-token-hash")) + newHash := sha256.Sum256([]byte("rotated-token-hash")) + if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil { + t.Fatalf("RotateToken: %v", err) + } + // A second rotation against the stale epoch is refused: the stored hash + // must never describe a different epoch than the column says. + if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil { + t.Fatal("stale-epoch rotation succeeded, want error") + } + if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil { + t.Fatalf("old lookup: %v", err) + } else if ok { + t.Fatal("old hash still resolves after rotation") + } + if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil { + t.Fatalf("new lookup: %v", err) + } else if !ok || id != store.OwnerID() { + t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok) + } + if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil { + t.Fatalf("ReaderTokenInfo: %v", err) + } else if epoch != 1 { + t.Fatalf("epoch = %d after rotation, want 1", epoch) + } + store.Close() + + reopened, err := Open(url, testOwner) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { reopened.Close() }) + if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil { + t.Fatalf("old lookup after reopen: %v", err) + } else if ok { + t.Fatal("restart resurrected the pre-rotation hash") + } + if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil { + t.Fatalf("new lookup after reopen: %v", err) + } else if !ok { + t.Fatal("restart dropped the rotated hash") + } +} + func TestStoreGet(t *testing.T) { store := newTestStore(t) if _, err := store.Upsert(store.OwnerID(), Bookmark{ diff --git a/backend/internal/token/token.go b/backend/internal/token/token.go new file mode 100644 index 0000000..5dc0b21 --- /dev/null +++ b/backend/internal/token/token.go @@ -0,0 +1,37 @@ +package token + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strconv" +) + +// Token derives one Reader's userscript credential from the deployment +// secret, the Reader's Discord id and their token epoch. +// +// The credential is deterministic rather than stored random because the +// server must be able to rebuild the install URL after a restart while the +// database holds only hashes: a random token with no plaintext copy anywhere +// would be unreconstructible, and keeping plaintext in memory would break +// every install link on restart. HMAC output is high-entropy, indistinguishable +// from random to anyone without the secret, and changes whenever the epoch +// does — which is what rotation is. The stored form is Hash of this value, +// so a database leak yields nothing but hashes of unguessable strings. +func Token(key []byte, discordID string, epoch int64) string { + mac := hmac.New(sha256.New, key) + // The separator is unambiguous: discord ids are decimal snowflakes and + // epochs are plain integers, so no two (id, epoch) pairs can collide. + mac.Write([]byte(discordID)) + mac.Write([]byte{0}) + mac.Write([]byte(strconv.FormatInt(epoch, 10))) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Hash is the SHA-256 of a credential — the only form that ever touches the +// database (readers.token_sha256). SHA-256 rather than a password hash is +// deliberate: these are unguessable values with nothing to brute-force, so a +// slow hash would only add per-request cost. +func Hash(cred string) [32]byte { + return sha256.Sum256([]byte(cred)) +} diff --git a/backend/internal/token/token_test.go b/backend/internal/token/token_test.go new file mode 100644 index 0000000..b7e4065 --- /dev/null +++ b/backend/internal/token/token_test.go @@ -0,0 +1,53 @@ +package token + +import ( + "bytes" + "crypto/sha256" + "testing" +) + +func TestTokenDeterministicPerReaderAndEpoch(t *testing.T) { + key := []byte("deployment-secret") + a := Token(key, "reader-1", 0) + b := Token(key, "reader-1", 0) + if a != b { + t.Fatal("same (reader, epoch) derived different credentials") + } + if a == Token(key, "reader-2", 0) { + t.Fatal("different readers derived the same credential") + } + if a == Token(key, "reader-1", 1) { + t.Fatal("rotation epoch derived the same credential") + } +} + +func TestTokenChangesWithSecret(t *testing.T) { + a := Token([]byte("key-1"), "reader-1", 0) + b := Token([]byte("key-2"), "reader-1", 0) + if a == b { + t.Fatal("different secrets derived the same credential") + } +} + +func TestTokenFormat(t *testing.T) { + cred := Token([]byte("key"), "reader-1", 0) + // 32 bytes of HMAC-SHA256, hex-encoded: the length the install URL and + // the committed placeholder both assume. + if len(cred) != 64 { + t.Fatalf("credential length = %d, want 64", len(cred)) + } + for _, c := range cred { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + t.Fatalf("credential contains non-hex byte %q", c) + } + } +} + +func TestHashIsSha256OfCredential(t *testing.T) { + cred := Token([]byte("key"), "reader-1", 0) + got := Hash(cred) + want := sha256.Sum256([]byte(cred)) + if !bytes.Equal(got[:], want[:]) { + t.Fatal("Hash is not the SHA-256 of the credential") + } +} diff --git a/backend/internal/userscript/userscript.go b/backend/internal/userscript/userscript.go index c714d90..6f189ba 100644 --- a/backend/internal/userscript/userscript.go +++ b/backend/internal/userscript/userscript.go @@ -1,14 +1,25 @@ package userscript import ( - "crypto/subtle" + "bytes" "log" "net/http" "os" "regexp" "time" + + "bookmarkmanager/backend/internal/httpmw" + "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" ) +// tokenPlaceholder is what the bindmounted userscript carries where the +// Reader's credential goes: in the API_TOKEN constant and in the @downloadURL +// and @updateURL metadata lines. The handler substitutes the requesting +// Reader's credential for it at serve time, so no credential literal is ever +// committed or deployed, and each Reader's copy carries exactly their own. +var tokenPlaceholder = []byte("__API_TOKEN__") + // versionLine matches the userscript metadata block's @version directive. var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`) @@ -26,35 +37,76 @@ func stampVersion(src []byte, mod time.Time) []byte { return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504"))) } -// userscriptHandler serves the userscript to Violentmonkey's updater. +// substituteToken replaces every tokenPlaceholder with the Reader's +// credential. A file without the placeholder is returned unchanged so Render +// can warn about it rather than silently serving a credential-less script. +func substituteToken(src []byte, credential string) []byte { + return bytes.ReplaceAll(src, tokenPlaceholder, []byte(credential)) +} + +// Render writes one userscript file with the credential substituted and the +// mtime-derived version stamped. Shared by the download path (Handler) and +// the web UI's install endpoints, so both serve byte-identical scripts. // -// The token lives in the path because the update poll sends no Authorization -// header, and the file embeds API_TOKEN in plain text, so an open path would -// hand that token to anyone who guessed the URL. A mismatch answers 404 rather -// than 401: a prober learns nothing about whether the route exists. +// The file is read per request — that is what lets a bindmounted copy be +// edited on the host without a restart. It is ~50 KB and polled about once a +// day. +func Render(w http.ResponseWriter, r *http.Request, path, credential string) { + info, err := os.Stat(path) + if err != nil { + log.Printf("userscript: stat %s: %v", path, err) + http.NotFound(w, r) + return + } + src, err := os.ReadFile(path) + if err != nil { + log.Printf("userscript: read %s: %v", path, err) + http.NotFound(w, r) + return + } + rendered := substituteToken(src, credential) + if bytes.Equal(rendered, src) { + // The bindmounted file was not built for per-Reader rendering. Serving + // it as written is the operator's freedom, but a credential-less copy + // is a deployment bug worth one log line — the symptom (silent 401s on + // every device) is otherwise indistinguishable from a network fault. + log.Printf("userscript: %s has no %s placeholder; serving as written", path, tokenPlaceholder) + } + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(stampVersion(rendered, info.ModTime())) +} + +// Handler serves the userscript to Violentmonkey's updater, rendered for the +// Reader whose credential is in the path. // -// The file is read per request — that is what lets a bindmounted copy be edited -// on the host without a restart. It is ~50 KB and polled about once a day. -func Handler(token, path string) http.HandlerFunc { +// The credential lives in the path because the update poll sends no +// Authorization header, and the rendered file embeds the credential in +// plaintext, so an open path would hand it to anyone who guessed the URL. A +// mismatch answers 404 rather than 401: a prober learns nothing about whether +// the route exists. The same credential authenticates the API bearer header, +// so the two are one secret with one blast radius. +// +// The credential substituted is the resolved Reader's derived one, not the +// raw path segment: while the retired global token is still accepted during +// the grace window (httpmw.ResolveReader), an already-installed script +// polling its legacy URL is served a copy carrying the Reader's own +// credential, so the next update poll migrates the device onto its per-Reader +// path — the window empties itself instead of ending in a silent 401 for +// every device that never visited the web UI. +func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 { + readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token")) + if !ok { http.NotFound(w, r) return } - info, err := os.Stat(path) + discordID, epoch, err := s.ReaderTokenInfo(readerID) if err != nil { - log.Printf("userscript: stat %s: %v", path, err) + log.Printf("userscript: reader %d token info: %v", readerID, err) http.NotFound(w, r) return } - src, err := os.ReadFile(path) - if err != nil { - log.Printf("userscript: read %s: %v", path, err) - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/javascript; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - w.Write(stampVersion(src, info.ModTime())) + Render(w, r, path, token.Token(tokenKey, discordID, epoch)) } } diff --git a/backend/internal/userscript/userscript_test.go b/backend/internal/userscript/userscript_test.go index 3196342..471d40a 100644 --- a/backend/internal/userscript/userscript_test.go +++ b/backend/internal/userscript/userscript_test.go @@ -1,115 +1,67 @@ package userscript import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" ) -const testToken = "s3cret-token" - // sampleScript is a stand-in for the real userscript: a metadata block with a -// @version line, plus a body that must survive the rewrite untouched. +// @version line, the credential placeholder in its metadata and body, plus +// content that must survive the rewrites untouched. const sampleScript = `// ==UserScript== // @name Manga Bookmark Sync // @version 1.5.0 +// @downloadURL https://api.example/u/__API_TOKEN__/manga-bookmark.user.js // @match https://asurascans.com/* // ==/UserScript== -(function () { "use strict"; })(); +(function () { "use strict"; + const API_TOKEN = "__API_TOKEN__"; +})(); ` -// writeScript drops a userscript in a temp dir with a known mtime and returns -// its path plus the version string the handler is expected to stamp. -func writeScript(t *testing.T, body string) (path, wantVersion string) { - t.Helper() - path = filepath.Join(t.TempDir(), "manga-bookmark.user.js") - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatalf("write script: %v", err) - } +func TestStampVersionReplacesVersionLineOnly(t *testing.T) { mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC) - if err := os.Chtimes(path, mod, mod); err != nil { - t.Fatalf("chtimes: %v", err) - } - return path, "2026.07.28.1642" -} + got := string(stampVersion([]byte(sampleScript), mod)) -// newTestMux registers Handler the same way main.go's router does, without -// pulling in the store or the rest of the app. -func newTestMux(token, path string) http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path)) - return mux -} - -func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { - t.Helper() - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil)) - return rr -} - -func TestUserscriptServedWithStampedVersion(t *testing.T) { - path, wantVersion := writeScript(t, sampleScript) - rr := getScript(t, newTestMux(testToken, path), testToken) - - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rr.Code) + if !strings.Contains(got, "// @version "+mod.UTC().Format("2006.01.02.1504")) { + t.Errorf("body has no stamped version:\n%s", got) } - if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { - t.Errorf("Content-Type = %q, want text/javascript", ct) + if strings.Contains(got, "1.5.0") { + t.Errorf("body still carries the file's own version:\n%s", got) } - if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { - t.Errorf("Cache-Control = %q, want no-cache", cc) - } - body := rr.Body.String() - if !strings.Contains(body, "// @version "+wantVersion) { - t.Errorf("body has no stamped version %q:\n%s", wantVersion, body) - } - if strings.Contains(body, "1.5.0") { - t.Errorf("body still carries the file's own version:\n%s", body) - } - // Everything outside the @version line is served verbatim. - if !strings.Contains(body, `(function () { "use strict"; })();`) { - t.Errorf("body was altered beyond the version line:\n%s", body) - } - if !strings.Contains(body, "// @name Manga Bookmark Sync") { - t.Errorf("metadata block was altered:\n%s", body) + // Everything outside the @version line is served verbatim, including the + // placeholder — stamping must not do the substitution's job. + if !strings.Contains(got, `const API_TOKEN = "__API_TOKEN__";`) { + t.Errorf("body was altered beyond the version line:\n%s", got) } } -// The empty-token case ("/u//manga-bookmark.user.js") is covered at the -// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to -// "/u/manga-bookmark.user.js" before this handler's own token check ever runs. -func TestUserscriptWrongTokenIs404(t *testing.T) { - path, _ := writeScript(t, sampleScript) - srv := newTestMux(testToken, path) - for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} { - if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { - t.Errorf("token %q: status = %d, want 404", tok, got) - } - } -} - -func TestUserscriptMissingFileIs404(t *testing.T) { - srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js")) - if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound { - t.Fatalf("status = %d, want 404", got) - } -} - -func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { +func TestStampVersionWithoutVersionLineServedUnmodified(t *testing.T) { const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" - path, _ := writeScript(t, noVersion) - rr := getScript(t, newTestMux(testToken, path), testToken) - - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rr.Code) - } - if rr.Body.String() != noVersion { - t.Fatalf("body = %q, want it unmodified", rr.Body.String()) + if got := string(stampVersion([]byte(noVersion), time.Now())); got != noVersion { + t.Errorf("stampVersion altered a file with no @version line:\n%s", got) + } +} + +func TestSubstituteTokenReplacesEveryPlaceholder(t *testing.T) { + got := string(substituteToken([]byte(sampleScript), "abc123")) + + if strings.Contains(got, "__API_TOKEN__") { + t.Errorf("placeholder survived substitution:\n%s", got) + } + // The credential lands in the constant and in both metadata lines. + if want := `const API_TOKEN = "abc123";`; !strings.Contains(got, want) { + t.Errorf("no substituted constant %q:\n%s", want, got) + } + if want := "https://api.example/u/abc123/manga-bookmark.user.js"; !strings.Contains(got, want) { + t.Errorf("no substituted download URL %q:\n%s", want, got) + } +} + +func TestSubstituteTokenWithoutPlaceholderServedUnmodified(t *testing.T) { + const noPlaceholder = "// ==UserScript==\n// @name x\n// ==/UserScript==\n" + if got := string(substituteToken([]byte(noPlaceholder), "abc123")); got != noPlaceholder { + t.Errorf("substituteToken altered a file without the placeholder:\n%s", got) } } diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 846e7df..2060619 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -243,6 +243,53 @@ button { cursor: pointer; } /* The label is 15px tall by design; the thumb gets 44 without moving it. */ .ghost::after { content: ""; position: absolute; inset: -15px -12px; } +/* ---- userscript setup: collapsed by default, one hairline, no card ---- */ +.setup { + margin: 0 20px; + padding: 12px 0 0; + border-bottom: 1px solid var(--rule); + color: var(--mute); +} +.setup summary { + display: flex; + align-items: center; + min-height: 44px; + padding: 0; + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); + cursor: pointer; + list-style: none; +} +.setup summary::-webkit-details-marker { display: none; } +.setup summary:hover { color: var(--paper); } +.setup[open] { padding-bottom: 16px; } +.setup-copy { + margin: 0; + padding: 4px 0 12px; + font: 14px/1.55 var(--font-body); + color: var(--mute); +} +.setup-links { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + margin: 0 0 14px; +} +.setup-links .ghost { font-size: 11px; } +.setup-rotate { margin: 0; } +/* Rotation confirmation: the one hot state the panel wears, and it is + destruction, not new-chapter signal — danger, never ember. */ +.setup-warn { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--danger); + color: var(--danger); + font: 500 12px/1.5 var(--font-mono); + letter-spacing: .04em; +} + .chrome { display: flex; flex-direction: column; } .searchbar { diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html index 760c0cd..349cc10 100644 --- a/backend/internal/web/templates/app.html +++ b/backend/internal/web/templates/app.html @@ -74,6 +74,8 @@ + {{template "setup" .}} + {{template "keyrow" .}} {{template "recent" .}} diff --git a/backend/internal/web/templates/setup.html b/backend/internal/web/templates/setup.html new file mode 100644 index 0000000..d770c6e --- /dev/null +++ b/backend/internal/web/templates/setup.html @@ -0,0 +1,27 @@ +{{/* The userscript install panel. Each link serves the script rendered + with the acting Reader's credential inside it, so the credential never + appears in this page's markup, the address bar, or a redirect. Rotation + is confirm-gated because it invalidates every installed copy at once; + the response swaps this same panel open with the reinstall warning. */}} +{{define "setup"}} +
+ Userscripts +

Install each script once per device. They keep your + bookmarks in sync across every site and update themselves from here.

+ + {{if .Rotated}} +

Credential rotated — the old one no + longer works. Reinstall both scripts on every device now, or they will + silently stop syncing.

+ {{else}} +
+ +
+ {{end}} +
+{{end}} diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index eb477a9..629750d 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -16,6 +16,8 @@ import ( "bookmarkmanager/backend/internal/session" "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" + "bookmarkmanager/backend/internal/userscript" ) //go:embed templates @@ -36,10 +38,19 @@ type Handler struct { // while registration is closed (issue #23). Every session row points at // it, so it is also the Reader the UI acts as. readerID int64 - tmpl *template.Template - discord DiscordConfig - states *oauthStates - limiter *session.LoginLimiter + // tokenKey derives Readers' userscript credentials (internal/token): the + // install endpoints render the scripts with the credential inside, which + // is the one place the UI needs the secret. + tokenKey []byte + // mangaUserscriptPath / novelUserscriptPath are the bindmounted script + // files the install endpoints render — the same files the /u/ download + // paths serve. + mangaUserscriptPath string + novelUserscriptPath string + tmpl *template.Template + discord DiscordConfig + states *oauthStates + limiter *session.LoginLimiter // httpClient is the plain stdlib client that talks to Discord. It is not // an injected interface: tests point APIBase at a stub server instead. httpClient *http.Client @@ -62,6 +73,9 @@ type listView struct { // OOB marks a render of the chrome partials as an out-of-band swap rather // than the inline copy app.html lays out. OOB bool + // Rotated marks the setup panel as having just rotated the credential: + // it swaps the reinstall warning in over the button row. + Rotated bool } // PageURL and ListURL are the two link shapes every tab needs. Building them @@ -88,19 +102,22 @@ type loginView struct { // New parses every template up front so a broken one kills the process at // startup rather than the first request that touches it. -func New(s *store.Store, readerID int64, discord DiscordConfig) (*Handler, error) { +func New(s *store.Store, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err } return &Handler{ - store: s, - readerID: readerID, - tmpl: tmpl, - discord: discord, - states: newOAuthStates(), - limiter: session.NewLoginLimiter(), - httpClient: &http.Client{Timeout: discordTimeout}, + store: s, + readerID: readerID, + tokenKey: tokenKey, + mangaUserscriptPath: mangaPath, + novelUserscriptPath: novelPath, + tmpl: tmpl, + discord: discord, + states: newOAuthStates(), + limiter: session.NewLoginLimiter(), + httpClient: &http.Client{Timeout: discordTimeout}, }, nil } @@ -116,6 +133,14 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus)) mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) + + // Install endpoints render the script directly under the session: the + // credential travels inside the served bytes, never in the address bar or + // the page markup. Updates after install use the credential-bearing /u/ + // path the script embeds, which needs no session. + mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js"))) + mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js"))) + mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken)) } // staticHandler serves the embedded assets. An hour, not longer: assets are @@ -517,3 +542,48 @@ func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) { // the library got smaller. h.refreshChrome(w, r) } + +// installUserscript renders the bindmounted script with the acting Reader's +// derived credential substituted in. The credential is derived, not stored, +// so installs work after any restart; the Reader never types or copies it — +// clicking Install is the whole setup. +func (h *Handler) installUserscript(name string) http.HandlerFunc { + path := h.mangaUserscriptPath + if name == "novel-bookmark.user.js" { + path = h.novelUserscriptPath + } + return func(w http.ResponseWriter, r *http.Request) { + discordID, epoch, err := h.store.ReaderTokenInfo(readerOf(r)) + if err != nil { + log.Printf("install %s: %v", name, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch)) + } +} + +// rotateToken issues the acting Reader a new credential: the epoch bumps and +// the stored hash is rewritten, so the old credential stops authenticating +// the moment the statement commits. Every device must reinstall, or its +// script keeps failing silently — the setup panel states that warning next +// to the button, and the response repeats it as confirmation. +func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) { + readerID := readerOf(r) + discordID, epoch, err := h.store.ReaderTokenInfo(readerID) + if err != nil { + log.Printf("rotate token: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // The hash is computed for epoch+1 and guarded by it in the store, so a + // concurrent rotation cannot leave the stored hash describing another + // epoch. + if err := h.store.RotateToken(readerID, epoch, token.Hash(token.Token(h.tokenKey, discordID, epoch+1))); err != nil { + log.Printf("rotate token: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + view := listView{Lib: store.KindManga, Rotated: true} + h.render(w, http.StatusOK, "setup", view) +} diff --git a/backend/main.go b/backend/main.go index 0d65dbf..1fb7e1d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/sha256" "errors" "log" "net/http" @@ -17,13 +16,25 @@ import ( "bookmarkmanager/backend/internal/httpmw" "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" "bookmarkmanager/backend/internal/userscript" "bookmarkmanager/backend/internal/web" ) // Config holds all runtime settings, sourced from environment variables. type Config struct { - Token string + // Token is the retired global API token, kept only for the cutover grace + // window: while GraceUntil has not passed, it resolves to the owner + // Reader so already-installed scripts keep working. Unset after the + // window closes. + Token string + // TokenKey derives every Reader's userscript credential (internal/token). + // Required: without it no install URL can ever be built. + TokenKey string + // GraceUntil is the moment the retired global token stops resolving to + // the owner Reader. Zero means the token is already dead. Enforced in + // code on every request, not by a runbook note. + GraceUntil time.Time AllowedOrigins []string // DatabaseURL is the Postgres connection URL; required, no default, // because a wrong guess would silently start on an empty database. @@ -147,9 +158,29 @@ func loadLatestPoll() LatestPoll { return p } +// parseGraceUntil reads the retired-token deadline. Both a bare date and a +// full RFC3339 timestamp are accepted; an unparseable value is a +// configuration bug, not a gracefully-degraded feature — the whole point is +// that the window's end is enforced, so fail loud. +func parseGraceUntil(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw) + return time.Time{} +} + func loadConfig() Config { c := Config{ Token: os.Getenv("API_TOKEN"), + TokenKey: os.Getenv("TOKEN_KEY"), + GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")), DatabaseURL: os.Getenv("DATABASE_URL"), Port: envOr("PORT", "8080"), OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"), @@ -183,23 +214,28 @@ func newRouter(s *store.Store, cfg Config) http.Handler { // Outside httpmw.Auth (the updater sends no Authorization header) and // outside the web UI's Discord auth (the script must be installable - // without a browser session). The path segment carries the token instead. - mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath)) - mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath)) + // without a browser session). The path segment carries the credential + // instead, and the script is rendered with the resolved Reader's + // credential substituted in. + mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", + userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath)) + mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", + userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath)) - h := &api.Handler{Store: s, ReaderID: s.OwnerID()} + h := &api.Handler{Store: s} protected := http.NewServeMux() protected.HandleFunc("GET /bookmarks", h.List) protected.HandleFunc("PUT /bookmarks/{key}", h.Put) protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete) - auth := httpmw.Auth(cfg.Token, protected) + auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected) mux.Handle("/bookmarks", auth) mux.Handle("/bookmarks/", auth) // The browser UI is always registered; signing in is Discord OAuth, so // there is no password to forget and no gate to leave unset. - wh, err := web.New(s, s.OwnerID(), cfg.Discord) + wh, err := web.New(s, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey), + cfg.UserscriptPath, cfg.NovelUserscriptPath) if err != nil { log.Fatalf("web handler: %v", err) } @@ -225,8 +261,8 @@ func guardEmptyUserscriptToken(next http.Handler) http.Handler { func main() { cfg := loadConfig() - if cfg.Token == "" { - log.Fatal("API_TOKEN is required") + if cfg.TokenKey == "" { + log.Fatal("TOKEN_KEY is required") } if cfg.OwnerDiscordID == "" { log.Fatal("OWNER_DISCORD_ID is required") @@ -246,10 +282,20 @@ func main() { log.Fatalf("%s is required", key) } } + if cfg.Token == "" && !cfg.GraceUntil.IsZero() { + log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not") + } + if cfg.Token != "" && cfg.GraceUntil.IsZero() { + log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival") + } - // The owner's userscript token is the global API token today (issue #22); - // the readers row carries its SHA-256, not the token itself. - owner := store.Owner{DiscordID: cfg.OwnerDiscordID, TokenHash: sha256.Sum256([]byte(cfg.Token))} + // The owner's userscript credential is derived from TOKEN_KEY at epoch 0 + // (internal/token); the readers row carries its SHA-256, not the + // credential itself. + owner := store.Owner{ + DiscordID: cfg.OwnerDiscordID, + TokenHash: token.Hash(token.Token([]byte(cfg.TokenKey), cfg.OwnerDiscordID, 0)), + } s, err := store.Open(cfg.DatabaseURL, owner) if err != nil { diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go new file mode 100644 index 0000000..798140a --- /dev/null +++ b/backend/reader_credential_test.go @@ -0,0 +1,339 @@ +package main + +import ( + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/token" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// insertReader creates an extra reader row (registration is closed, so the +// store has no path for this — tests reach past it) and returns its id. The +// credential is derived the same way the owner's is, so it authenticates +// through the real router. +func insertReader(t *testing.T, dbURL, discordID string) int64 { + t.Helper() + db, err := sql.Open("pgx", dbURL) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0)) + var id int64 + if err := db.QueryRow( + `INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`, + discordID, hash[:]).Scan(&id); err != nil { + t.Fatalf("insert reader: %v", err) + } + return id +} + +// credRequest builds a request authenticated as the Reader whose credential +// is passed. +func credRequest(method, target, cred string) *http.Request { + req := httptest.NewRequest(method, target, nil) + req.Header.Set("Authorization", "Bearer "+cred) + return req +} + +// readerCredential is the epoch-0 derived credential of an arbitrary Reader. +func readerCredential(discordID string) string { + return token.Token([]byte(testTokenKey), discordID, 0) +} + +// withBody attaches a request body, for PUTs that carry a JSON payload. +func withBody(req *http.Request, body string) *http.Request { + req.Body = io.NopCloser(strings.NewReader(body)) + req.ContentLength = int64(len(body)) + return req +} + +// The retired global token resolves to the owner Reader only while the grace +// deadline is in the future — testConfig sets it, so the acceptance path is +// the existing auth() tests; this pins the other side of the window. +func TestLegacyTokenDeadAfterGrace(t *testing.T) { + s := newTestStore(t) + cfg := testConfig() + cfg.GraceUntil = time.Now().Add(-time.Hour) + srv := newRouter(s, cfg) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code) + } + + // The owner's own derived credential is unaffected by the window closing. + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) + if rr.Code != http.StatusOK { + t.Fatalf("derived token after grace: status = %d, want 200", rr.Code) + } +} + +// A Reader's credential authenticates exactly that Reader: rows written under +// one credential are invisible to the other, on the same key. +func TestPerReaderIsolation(t *testing.T) { + s, dbURL := newTestStoreURL(t) + insertReader(t, dbURL, "other-reader") + srv := newRouter(s, testConfig()) + + ownerKey := "asura:solo" + putBookmark(t, srv, ownerKey, store.Bookmark{ + Key: ownerKey, Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", UpdatedAt: 1, + }) + + // The other Reader's list is empty even though the owner holds the key. + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader"))) + if rr.Code != http.StatusOK { + t.Fatalf("other reader list: status = %d, want 200", rr.Code) + } + var theirs []store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &theirs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(theirs) != 0 { + t.Fatalf("other reader sees %d bookmarks, want 0 (owner's rows leaked)", len(theirs)) + } + + // The other Reader writes the same key; both rows coexist, each visible + // only to its owner. The series title is shared (ADR-0003) — the + // reader-owned fields are progress and updated_at. + req := credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader")) + req.Header.Set("Content-Type", "application/json") + body := `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Theirs","last_chapter_num":3}` + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, withBody(req, body)) + if rr.Code != http.StatusOK { + t.Fatalf("other reader put: status = %d, want 200", rr.Code) + } + + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader"))) + var theirs2 []store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &theirs2); err != nil { + t.Fatalf("decode: %v", err) + } + if len(theirs2) != 1 || theirs2[0].LastChapterNum != 3 { + t.Fatalf("other reader list = %+v, want their own row with their progress", theirs2) + } + + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) + var owners []store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil { + t.Fatalf("decode: %v", err) + } + if len(owners) != 1 || owners[0].Title != "Solo Leveling" { + t.Fatalf("owner list = %+v, want their own row", owners) + } + + // One Reader's credential cannot delete the other's row. + req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader")) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("other reader delete: status = %d, want 204", rr.Code) + } + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) + if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil { + t.Fatalf("decode: %v", err) + } + if len(owners) != 1 { + t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners) + } +} + +// The install endpoints are session-gated and render the script directly +// with the Reader's credential inside: the credential never appears in the +// address bar, the page markup, or any Location header. +func TestInstallServesScriptWithCredential(t *testing.T) { + cfg := webConfig() + dir := t.TempDir() + path := filepath.Join(dir, "manga-bookmark.user.js") + novelPath := filepath.Join(dir, "novel-bookmark.user.js") + for _, p := range []string{path, novelPath} { + if err := os.WriteFile(p, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + } + cfg.UserscriptPath = path + cfg.NovelUserscriptPath = novelPath + srv, st := newWebTestServer(t, cfg) + + for _, script := range []string{"manga-bookmark.user.js", "novel-bookmark.user.js"} { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/install/"+script, nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("%s without session: status = %d, want 401", script, rr.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/install/"+script, nil) + req.AddCookie(sessionCookie(t, st)) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("%s with session: status = %d, want 200", script, rr.Code) + } + body := rr.Body.String() + if strings.Contains(body, "__API_TOKEN__") { + t.Fatalf("%s served with an unsubstituted placeholder", script) + } + // The credential rides inside the served script — nowhere visible in + // the UI — and is the session holder's own. + if !strings.Contains(body, `API_TOKEN = "`+ownerCredential()+`"`) { + t.Fatalf("%s does not carry the owner's credential:\n%s", script, body) + } + if loc := rr.Header().Get("Location"); loc != "" { + t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc) + } + } +} + +// The retired global token also keeps the script download path working during +// the grace window — that is how already-installed scripts auto-update across +// the cutover — and dies with it. The copy served on the legacy path embeds +// the Reader's derived credential, so the next update poll migrates the +// device onto its per-Reader path: the window empties itself. +func TestLegacyTokenUserscriptPathDuringGrace(t *testing.T) { + path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") + if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + + s := newTestStore(t) + cfg := testConfig() + cfg.UserscriptPath = path + + // Within the window the legacy URL serves the script, but with the + // owner's derived credential substituted — not the legacy one. + rr := httptest.NewRecorder() + newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, + "/u/"+testToken+"/manga-bookmark.user.js", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) { + t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got) + } + + // After the deadline the same URL is a 404 like any unknown credential. + cfg.GraceUntil = time.Now().Add(-time.Hour) + rr = httptest.NewRecorder() + newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, + "/u/"+testToken+"/manga-bookmark.user.js", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code) + } +} + +// Rotation through the web UI invalidates the old credential immediately, +// mints one that authenticates the API and the script path, and warns that +// every device must reinstall. +func TestRotateCredentialViaWebUI(t *testing.T) { + s, _ := newTestStoreURL(t) + path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") + if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + cfg := webConfig() + cfg.UserscriptPath = path + srv := newRouter(s, cfg) + + oldCred := ownerCredential() + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred)) + if rr.Code != http.StatusOK { + t.Fatalf("old credential before rotation: status = %d, want 200", rr.Code) + } + + req := httptest.NewRequest(http.MethodPost, "/rotate-token", nil) + req.AddCookie(sessionCookie(t, s)) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("rotate: status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Credential rotated") { + t.Fatalf("rotation response does not warn about reinstall:\n%s", rr.Body.String()) + } + + // The old credential is dead on the API and on the script path. + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("old credential after rotation: status = %d, want 401", rr.Code) + } + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+oldCred+"/manga-bookmark.user.js", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("old credential script path after rotation: status = %d, want 404", rr.Code) + } + + // The new credential authenticates the API and the script path, and is + // substituted into the served script. + newCred := token.Token([]byte(testTokenKey), testDiscordID, 1) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", newCred)) + if rr.Code != http.StatusOK { + t.Fatalf("new credential after rotation: status = %d, want 200", rr.Code) + } + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+newCred+"/manga-bookmark.user.js", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("new credential script path: status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) { + t.Fatalf("served script does not carry the rotated credential:\n%s", got) + } + + // The install link now renders the script with the new credential. + req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil) + req.AddCookie(sessionCookie(t, s)) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("install after rotation: status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) { + t.Fatalf("install after rotation does not carry the new credential:\n%s", got) + } +} + +// The app page offers the install links; the credential never appears in its +// markup. +func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) { + srv, st := newWebTestServer(t, webConfig()) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + body := rr.Body.String() + for _, want := range []string{ + `href="/install/manga-bookmark.user.js"`, + `href="/install/novel-bookmark.user.js"`, + "Rotate credential", + } { + if !strings.Contains(body, want) { + t.Errorf("app page lacks %q", want) + } + } + if strings.Contains(body, ownerCredential()) { + t.Fatal("app page leaks the credential") + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 2449766..e30d4b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,13 @@ services: container_name: bookmark-api restart: unless-stopped environment: - # API_TOKEN is required — compose refuses to start without it. - API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} + # TOKEN_KEY derives every Reader's userscript credential (issue #24) — + # compose refuses to start without it. + TOKEN_KEY: ${TOKEN_KEY:?set TOKEN_KEY in .env} + # Retired global credential, optional: only used until the grace + # deadline, for already-installed scripts. Remove after the window. + API_TOKEN: ${API_TOKEN:-} + API_TOKEN_GRACE_UNTIL: ${API_TOKEN_GRACE_UNTIL:-} # Owner's Discord user ID — required, seeds the one Reader row. OWNER_DISCORD_ID: ${OWNER_DISCORD_ID:?set OWNER_DISCORD_ID in .env} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net} diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index bec8d4a..f2689a4 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -4,8 +4,8 @@ // @version 1.6.0 // @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you -// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js -// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js +// @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js +// @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js // @match https://asuracomic.net/* // @match https://asurascans.com/* // @match https://demonicscans.org/* @@ -22,7 +22,7 @@ // CONFIG — fill these in before installing. // ============================================================ const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash - const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN + const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24) const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips // This script owns the manga library; the novel script is a separate install diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js index df82b71..93efcdd 100644 --- a/userscript/novel-bookmark.user.js +++ b/userscript/novel-bookmark.user.js @@ -4,8 +4,8 @@ // @version 1.0.0 // @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you -// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js -// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js +// @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js +// @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js // @match https://novelfull.com/* // @match https://lightnovelworld.net/* // @run-at document-idle @@ -19,7 +19,7 @@ // CONFIG — fill these in before installing. // ============================================================ const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash - const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN + const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24) const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips // This script owns the novel library; the manga script is a separate install