Context
Two concrete weaknesses drive this work:
The desktop daemon is wide-open on the LAN.
backend/daemon/http.go:171andbackend/daemon/daemon.go:379both bind":<port>"(0.0.0.0) with no TLS, no auth,Access-Control-Allow-Origin: *, andWithOriginFunc(...) { return true }. Any device on the same network — and any website opened in any browser on the user's machine, via DNS rebinding — can callDaemon.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.goReplace
http.port/grpc.portinteger flags withhttp.addr/grpc.addrstring flags (127.0.0.1:55001,127.0.0.1:55002). Keep legacy port flags as deprecated aliases that force127.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— swapnet.Listen("tcp", ":"+strconv.Itoa(port))fornet.Listen("tcp", cfg.GRPC.Addr).backend/daemon/http.go:171— same swap; ensureServer.Addris the loopback-scoped value.New file
backend/daemon/authtoken.go:On startup: if
<data-dir>/auth.tokenmissing, generate 32 random bytes, base64url-encode, write with0600(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
Hostheader 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 whenPublicOnly=true). Reject otherwise with 401.Drops
Access-Control-Allow-Origin: *. Allowlist onlynull(Electron file://),app://seed, and origins listed in config (empty by default).
backend/daemon/http.go:97(WithOriginFunc) — replacereturn truewith 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 readsauthorizationfrom 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-onlyListKeyscan stay without audit.
Desktop Electron changes
frontend/apps/desktop/src/daemon.tsAfter 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 toprocess.env(leaks to renderer devtools).
frontend/apps/desktop/src/app-grpc.ts:14-84— add a connectrpc interceptor (alongsideloggingInterceptor,prodInter) that fetches the token via preload IPC on first use, caches, and addsAuthorization: Bearer …header to every unary/stream call. Also add it to any plainfetch(DAEMON_HTTP_URL/...)used in the renderer.Preload (
frontend/apps/desktop/src/preload*.ts) — contextBridge exposes agetDaemonAuthToken()that calls the IPC handler.CLI (
frontend/apps/cli,backend/cmd/seed-sqlite/pingp2pthat 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
createGrpcWebTransportattaches 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-webWithOriginFuncconsults 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
createCookieSessionStoragewithhttpOnly,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 || nonceis valid (verify viacrypto.subtle.verifyin the Node runtime — already used for Ed25519 inauth-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, updateskeyPairStore.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 newDAEMON_AUTH_TOKENenv var into bothseed-daemonandseed-webcontainers, 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— ifSEED_DAEMON_AUTH_TOKENenv 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 afetchwrapper that injectsAuthorization: Bearer ${process.env.DAEMON_AUTH_TOKEN}before every call. Pass tocreateGrpcWebTransport({ fetch }).frontend/apps/web/app/entry.server.tsx:24andserver-universal-client.ts:15— same wrapper for SSR prefetch fetches..env.vars— addDAEMON_AUTH_TOKENplaceholder for dev; ensurepnpm devand./devscript 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.$.tsxand 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 existingblob.WithPublicOnlypattern — introduceblob.WithCaller(ctx, principal, caps).backend/api/documents/...— in document read RPCs, when the resource'sResourceVisibility == PRIVATE:If
caller.principalis 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 acceptsCreateDocumentChangeRequest.capability(proto/documents/v3alpha/documents.proto:190-192).Deny otherwise.
For the
PublicOnly=truegateway 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/blobWithPublicOnlycontext 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; withAuthorization: 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-webpreflight 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:writeclean infrontend/.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-daemonlogs; confirm audit entry for each sign + each private read.curlagainst staging with a wrongDAEMON_AUTH_TOKENenv forseed-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