Context

      Two concrete weaknesses drive this work:

        The desktop daemon is wide-open on the LAN. backend/daemon/http.go:171 and backend/daemon/daemon.go:379 both bind ":<port>" (0.0.0.0) with no TLS, no auth, Access-Control-Allow-Origin: *, and WithOriginFunc(...) { return true }. Any device on the same network — and any website opened in any browser on the user's machine, via DNS rebinding — can call Daemon.SignData, Documents.CreateDocumentChange, ExportKey, etc., using the user's stored signing keys.

        The Remix web server can't tell "who" is browsing, so it can't decide whether to serve a private document. Identity today lives in IndexedDB (keypairs, vault delegation blobs) on the browser side; nothing is forwarded to the server. The server talks to the site daemon anonymously via gRPC-Web (frontend/apps/web/app/client.server.ts:9).

      Goal: close the LAN/website attack surface on the desktop daemon, and give the Remix web server a way to identify the browser user and forward a per-user read capability to the daemon, without introducing new key-management primitives. Reuse what's already there: non-extractable WebCrypto keypairs (frontend/apps/web/app/auth-session.ts), signed capability blobs (proto/documents/v3alpha/access_control.proto), and the existing PublicOnly blob gate (backend/blob + backend/daemon/http.go:57).

      Single plan, three phases, shippable independently.

    Phase 1 — Loopback bind + local auth token (desktop daemon hardening)

      Outcome: a stock seed-daemon cannot be reached by anything other than a process running as the same local user that knows the token.

      Changes

        backend/config/config.go

          Replace http.port / grpc.port integer flags with http.addr / grpc.addr string flags (127.0.0.1:55001, 127.0.0.1:55002). Keep legacy port flags as deprecated aliases that force 127.0.0.1. Default = loopback, no opt-out in Phase 1.

          Add Daemon.AuthTokenPath (default <DataDir>/auth.token). No flag for "disable auth"; dev/test overrides via env only.

        backend/daemon/daemon.go:379 — swap net.Listen("tcp", ":"+strconv.Itoa(port)) for net.Listen("tcp", cfg.GRPC.Addr).

        backend/daemon/http.go:171 — same swap; ensure Server.Addr is the loopback-scoped value.

        New file backend/daemon/authtoken.go:

          On startup: if <data-dir>/auth.token missing, generate 32 random bytes, base64url-encode, write with 0600 (error if dir group/other-writable on unix). On Windows, set ACL to current SID.

          Expose Token() for in-process readers (CLI, tests).

        backend/daemon/http.go — wrap the mux with a middleware that:

          Rejects requests whose Host header is not in {127.0.0.1:<port>, localhost:<port>, [::1]:<port>} (DNS-rebinding defense).

          Requires Authorization: Bearer <token> on all RPCs except a small explicit allowlist (metrics, health, /debug/* gated by env, maybe /ipfs/{cid} read-only when PublicOnly=true). Reject otherwise with 401.

          Drops Access-Control-Allow-Origin: *. Allowlist only null (Electron file://), app://seed, and origins listed in config (empty by default).

        backend/daemon/http.go:97 (WithOriginFunc) — replace return true with the same allowlist used by the CORS middleware. Same treatment for gRPC-web.

        backend/api/apis.go:71 — add a unary + stream gRPC interceptor that reads authorization from metadata and validates against the token. Applies to all registered services. Streams too.

        backend/api/daemon/v1alpha/daemon.go (SignData, RegisterKey, ImportKey, ExportKey, DeleteKey, ChangeKeyName): keep the interceptor-level check; additionally log (structured) an audit record {time, method, key_name, remote_addr} to the existing logger. Read-only ListKeys can stay without audit.

      Desktop Electron changes

        frontend/apps/desktop/src/daemon.ts

          After spawning the child, poll <userData>/daemon/auth.token (up to ~2 s, 20 ms interval) for the file to appear, read it, cache in memory.

          Expose it over a new ipcMain.handle('daemon:authToken', …) channel. Do not write to process.env (leaks to renderer devtools).

        frontend/apps/desktop/src/app-grpc.ts:14-84 — add a connectrpc interceptor (alongside loggingInterceptor, prodInter) that fetches the token via preload IPC on first use, caches, and adds Authorization: Bearer … header to every unary/stream call. Also add it to any plain fetch(DAEMON_HTTP_URL/...) used in the renderer.

        Preload (frontend/apps/desktop/src/preload*.ts) — contextBridge exposes a getDaemonAuthToken() that calls the IPC handler.

        CLI (frontend/apps/cli, backend/cmd/seed-sqlite / pingp2p that talk to the daemon, if any) — read the token from the data dir the same way.

      Tests

        Go: backend/daemon/http_test.go — add cases: no Authorization → 401; wrong Host header → 403; correct token + Host → 200; gRPC interceptor rejects missing metadata; token file permissions (0600 on unix).

        Go e2e (backend/daemon/daemon_e2e_test.go) — pass token through the test client; ensure existing flows still pass.

        Electron: smoke test that createGrpcWebTransport attaches the header (mock the fetch, assert).

    Phase 2 — CORS/Origin allowlist + site-specific pairing

      Outcome: the daemon accepts browser requests only from the Electron app and explicitly-paired origins; everything else is 403.

      Changes

        Extend the allowlist from Phase 1 into a persisted, per-user config (<data-dir>/paired_origins.json). Editable via a new Daemon RPC:

          Daemon.PairOrigin(origin, label) — requires auth token; records consent.

          Daemon.ListPairedOrigins / UnpairOrigin.

        New desktop setting UI that lists paired origins and lets the user revoke.

        backend/daemon/http.go — CORS middleware consults the allowlist at request time; reject preflight with 403 for unknown origins. The gRPC-web WithOriginFunc consults the same list.

        Document the pairing model in docs/ (one short file).

        This phase intentionally stops short of per-origin scoped tokens — it still reuses the single loopback token for transport auth but narrows which browser contexts are permitted to hold it.

      Tests

        Unit tests for the allowlist store (add/remove/match including port, scheme, case).

        Middleware test verifying unknown origin → 403 preflight and blocked actual request.

    Phase 3 — Browser identity to web, web → daemon auth, private-doc capability forwarding

      Outcome: the Remix server knows the browser's principal (public key), the site daemon authenticates the web server via a shared env token, and private documents are served only to browsers that present a capability authorizing that principal to read them.

      Browser ↔ web server (challenge-sign login, httpOnly cookie)

        Reuse the non-extractable keypair the browser already holds (frontend/apps/web/app/auth-utils.ts:102-135, auth-session.ts). No new key material.

        New Remix session backed by createCookieSessionStorage with httpOnly, secure, sameSite: 'lax', 30-day max age, secret from env:

          frontend/apps/web/app/session.server.ts (new).

        New routes under frontend/apps/web/app/routes/:

          hm.auth.challenge.tsx (POST) — server returns {challenge: base64url(32 random bytes), nonce, expiresAt}. Nonce stored in an unauthenticated short-lived cookie or in the session pre-auth bucket.

          hm.auth.login.tsx (POST) — client sends {publicKeyMultikey, signature, nonce}; server verifies:

            principal matches multikey self-consistency (reuse frontend/apps/web/app/auth-utils.ts),

            signature over challenge || origin || nonce is valid (verify via crypto.subtle.verify in the Node runtime — already used for Ed25519 in auth-session.ts),

            nonce fresh and unused.

            On success: set session cookie containing {principal, publicKeyMultikey, issuedAt, capabilities[]}; return success.

          hm.auth.logout.tsx — destroy session.

        Client helper in frontend/apps/web/app/auth.tsx:

          loginWithLocalKey() — fetches challenge, signs via existing WebCrypto keypair, POSTs to /hm/auth/login, updates keyPairStore.

          Call automatically on first mount when a local keypair exists but the session cookie is missing.

        Remove any flows that relied on the browser telling the server its identity inline per-request — the cookie is canonical.

      Web server ↔ site daemon (shared Bearer env)

        ops/docker-compose.yml:28-72 — inject a new DAEMON_AUTH_TOKEN env var into both seed-daemon and seed-web containers, sourced from ${DAEMON_AUTH_TOKEN} at deploy time. Deployment tooling (website_deployment.sh) generates the token once per env and stores it in the deployment secret store.

        backend/daemon/authtoken.go — if SEED_DAEMON_AUTH_TOKEN env is set, prefer it over the file-on-disk (suppresses file generation). This unifies desktop (file) and server (env) models.

        frontend/apps/web/app/client.server.ts — construct a fetch wrapper that injects Authorization: Bearer ${process.env.DAEMON_AUTH_TOKEN} before every call. Pass to createGrpcWebTransport({ fetch }).

        frontend/apps/web/app/entry.server.tsx:24 and server-universal-client.ts:15 — same wrapper for SSR prefetch fetches.

        .env.vars — add DAEMON_AUTH_TOKEN placeholder for dev; ensure pnpm dev and ./dev script generate and share the token between desktop dev daemon and web dev server.

      Per-request principal + capability forwarding for private docs

        frontend/apps/web/app/routes/api.$.tsx and the SSR client: on every call to the daemon, when a session exists, forward:

          X-Seed-Principal: <publicKeyMultikey> — the logged-in principal.

          X-Seed-Capabilities: <base64url CBOR array> — the set of signed capability blobs the user already holds for the target resource (these already live in IndexedDB via vault delegation and can be PUT to the session on login).

        backend/daemon/http.go — a second middleware (runs after Bearer check) parses these headers into request context using the existing blob.WithPublicOnly pattern — introduce blob.WithCaller(ctx, principal, caps).

        backend/api/documents/... — in document read RPCs, when the resource's ResourceVisibility == PRIVATE:

          If caller.principal is the owner → allow.

          Else require a valid capability blob (signed by the owner, delegating read to this principal, for this path) in caller.caps. Validate the signature chain using the same logic that already accepts CreateDocumentChangeRequest.capability (proto/documents/v3alpha/documents.proto:190-192).

          Deny otherwise.

        For the PublicOnly=true gateway mode (public site with only public content), this whole code path is a no-op: private reads just stay blocked as today.

      Tests

        Go: table-driven tests on the document read RPC with combinations of (public/private, owner/other, with/without capability, expired capability).

        Node/Vitest: challenge-sign login round trip using a generated Ed25519 keypair; cookie set; subsequent call succeeds.

        Integration smoke: start dev stack via ./dev, run a scripted flow that logs in, reads a private doc, logs out, confirms 403.

    Files touched (quick map)

      Reused primitives (do not reinvent):

        frontend/apps/web/app/auth-utils.ts — multikey encode/decode, principal derivation.

        frontend/apps/web/app/auth-session.ts — Ed25519 sign/verify via WebCrypto.

        proto/documents/v3alpha/access_control.proto — capability signature model (already enforced on write; Phase 3 extends to read).

        backend/blob WithPublicOnly context pattern.

        backend/devicelink/devicelink.go — reference for random secret generation + multibase encoding.

    Verification

      Phase 1

        go test ./backend/... passes.

        golangci-lint run --new-from-merge-base origin/main ./backend/... clean.

        Manual: from a second machine on the LAN, curl http://<dev-box-ip>:56001/... returns connection refused. From the same box, curl http://127.0.0.1:56001/... with no Authorization returns 401; with Authorization: Bearer $(cat ~/Library/Application\ Support/.../daemon/auth.token) returns 200.

        Manual: open Electron dev app, all existing RPC flows still work.

        DNS-rebinding check: curl -H "Host: evil.example.com" http://127.0.0.1:56001/... returns 403.

      Phase 2

        curl -H "Origin: https://evil.example.com" http://127.0.0.1:56001/.../grpc-web preflight returns 403.

        Pair https://seed.hypermedia.xyz, same curl returns 200/204.

        UI round-trip: pair, revoke, request fails.

      Phase 3

        pnpm typecheck, pnpm test, pnpm audit, pnpm format:write clean in frontend/.

        Unit tests for challenge-sign login green.

        Manual: in a fresh browser profile, visit a private document URL logged out → 404/403 as today. Log in with an existing local key → same URL now renders.

        Manual: tail seed-daemon logs; confirm audit entry for each sign + each private read.

        curl against staging with a wrong DAEMON_AUTH_TOKEN env for seed-web → web server startup fails fast; with the correct token and no session cookie, private docs still 403.

    Rollout order

      Ship Phase 1 in a desktop-only release; site deployments temporarily continue without Bearer (daemon accepts env-overridden empty token in a dev-only code path).

      Ship Phase 2 once desktop Phase 1 is stable.

      Deploy Phase 3 together — site daemon + seed-web in the same release, with env token provisioned by deploy tooling, then flip private-doc enforcement on.

    Do you like what you are reading?. Subscribe to receive updates.

    Unsubscribe anytime