locqr/server/claude.md
2026-08-16 22:00:08 +02:00

9.5 KiB

Backend Server

The backend server is the trust anchor for the entire system. Its signing keys are the only compile-time constants; all legitimacy within the system traces back to its ability to sign, and the clients' ability to verify against those pinned keys. It provides two services: run infrastructure (key bundle storage, token signing, relay) and account management (domain registration, certificates, billing).

The server is a single Java/Spring Boot codebase, grown incrementally rather than rewritten between a throwaway prototype and a production system. It is scoped in two tiers by what backs it, not by what protocol logic it runs — signing, at-most-once bundle semantics, and relay forwarding are real from the first commit in both tiers; only infrastructure (storage, accounts, cert issuance) differs.

Concern Current (PoC) scope Production scope
Key bundle storage In-memory ConcurrentHashMap, atomic remove() for at-most-once Redis (GETDEL)
Domain registrations / accounts Hardcoded single test domain PostgreSQL
ML-DSA-44 signing Pre-generated cert, read from file at startup (see ../dev.md) Live issuance
WebSocket relay Spring TextWebSocketHandler, in-process Same — already the production target
Account management None Full SaaS (registration, billing, domain admin)

The swappable pieces sit behind narrow interfaces (KeyBundleStore, account lookup) so growing a tier means adding a new implementation of an existing interface, not rewriting controllers or crypto code.

Ed25519 signing and SHA-256 hashing are the only cryptographic operations the server performs at runtime, and both use the JDK's built-in java.security support — no external crypto library is needed for either. ML-KEM-768 never runs server-side (encapsulation/decapsulation are client-side only, per ../crypto.md), and ML-DSA-44 signing only happens offline, in the dev setup script, not in the running server — so there is no unproven PQ library integration on the critical path to a running server.


Signing keys

The server holds two secret keys. Neither is ever stored in source code or committed to version control — injected via environment variables or a secrets manager at startup.

Secret key Algorithm Corresponding pinned constant Purpose
ED25519_SECRET_KEY Ed25519 ED25519_PUBLIC_KEY_B64 (extension), ED25519_PUBKEY (companion) Signs key exchange tokens
ML_DSA_44_SECRET_KEY ML-DSA-44 BACKEND_PUBLIC_KEY_B64 (extension) Signs domain registration certificates

Key regeneration patches all pinned constants in extension and companion source and requires redeployment of all three components.


API surface

All request and response body schemas are defined in ../interfaces.md. This section describes endpoint behaviour; the interfaces document is the normative source for field names, types, and encodings.

Extension endpoints

POST /run/bundle Extension uploads key bundle at Phase 1.

Request: { runId, origin, x25519_pubkey_b64, kem_pubkey_b64 }

origin is the full HTTPS origin of the site (https://host:port), taken from the registration certificate the extension verified. The server bakes it verbatim into the signed token as url. The extension must reject any origin whose scheme is not https: before the bundle upload is ever attempted.

The server:

  1. Stores the key bundle associated with the run ID.
  2. Computes alpha_hash = SHA-256(x25519_pub_bytes || kem_pub_bytes).
  3. Signs { alpha_hash, url, runId, expires_at } with Ed25519 → signed token.
  4. Returns signed token and run parameters.

Response: { signed_token, qr_ttl, max_auto_refresh, pin_ttl }

The server should eventually verify that the origin is registered to an active account before signing — this ownership verification mechanism is deferred for the stub. TTL values in run parameters are server-controlled; clients must not apply local defaults.

GET /domain/status?domain=<hostname> Extension queries account status during site verification.

Response: { status: "valid" | "rejected" | "suspended" } or error if unreachable.

POST /security/report Extension or companion reports a security-class error.

Request: { runId, error_type, timestamp }. Server logs and aggregates. Site owner notification mechanism deferred — see ../extension/claude.md, Error escalation.

Companion endpoints

GET /run/bundle/:runId Companion fetches key bundle at Phase 2. At-most-once: the bundle is atomically destroyed on this fetch. A second request for the same run ID returns an error.

Response: { x25519_pubkey_b64, kem_pubkey_b64 } or 404 if already consumed or expired.

This is a load-bearing security property. Current scope uses ConcurrentHashMap.remove(runId), which is atomic in Java — a genuine at-most-once guarantee under concurrent requests, not just "safe enough for single-process development." Production uses Redis GETDEL for the same guarantee across multiple server instances.

GET /run/relay/:runId (WebSocket upgrade — extension only) The extension upgrades this endpoint to a WebSocket connection immediately after Phase 1 completes. The server maps the run ID to the live socket and uses it to push relay messages to the extension. If the service worker suspends and the socket drops, the extension is responsible for reconnecting before any relay message arrives.

POST /run/relay/:runId (HTTP — companion only) Companion sends a relay message (kem_ciphertext in Phase 2, encrypted credential in Phase 4) addressed to the run ID.

Request: { type, payload_b64 }. Server forwards the complete message object over the extension's WebSocket for that run ID. If the WebSocket is not currently open (service worker suspended), the message is buffered briefly. Buffer lifetime is short (seconds); if the extension does not reconnect within that window the message is discarded and the run must be restarted.

POST /run/relay/:runId/confirm (extension only) and GET /run/relay/:runId/confirm (polled — companion only) PIN confirmation handshake (../interfaces.md, "PIN confirmation"). The extension POSTs when its user clicks Confirm; the companion polls the GET until it sees { confirmed: true } before proceeding to select/send a credential. Current scope: a plain in-memory marker in RelaySessionRegistry (same component as the relay message buffer above, not a separate store), pruned on its own longer TTL — see that class's Javadoc. Both requests are unauthenticated, same as the rest of this surface at current scope; runId itself is the only addressing key, same trust model as the relay endpoints above.

Admin / website endpoints

Domain registration and account management. Deferred beyond the one endpoint below; current scope serves a hardcoded test domain certificate. Production endpoints are a full SaaS concern.

GET /domain/registration?domain=<hostname> Serves the pre-generated registration certificate for a registered domain. Fetched by the test website on load and passed to locqr.init({ cert }).

Response: { cert: "<base64url envelope>" } (the same envelope format defined under Registration certificate in ../interfaces.md), or 404 if the domain has no certificate on file.

Current scope serves only the one hardcoded test domain, reading dev/certs/test-registration.b64 at startup — see ../dev.md.

This endpoint was not fully specified in ../interfaces.md before this pass; it's been added there as the normative definition. Flagging it since it's new, not carried over from an existing decision.


WebSocket relay

The extension opens a WebSocket connection to /run/relay/:runId after completing Phase 1. The server maintains a mapping of run ID → active connection.

Message flow:

  1. Extension opens WS for run ID (after Phase 1).
  2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2).
  3. Extension user clicks Confirm → extension POSTs /run/relay/:runId/confirm; companion polls the same path (GET) and unblocks once it sees confirmed.
  4. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4).
  5. WS closes on run end (delivered, error, or TTL expiry).

The extension is responsible for keeping its WS connection alive across service worker suspensions using chrome.alarms or equivalent. The server-side buffer provides a short grace window but is not a reliable recovery mechanism.


State model

Data Storage Lifetime
Key bundles In-memory / Redis qr_ttl; destroyed on fetch or expiry
Active WS connections (runId → socket) In-memory Run lifetime
Relay message buffer (undelivered) In-memory Seconds; discarded if extension does not reconnect
Domain registration certificates Static file / DB Until expiry or renewal
Account records Hardcoded / DB Persistent
Signing keys Environment / secrets manager Permanent

Security invariants

  • Signing keys are never stored in source code or version control.
  • The key bundle is atomically destroyed on first fetch. No partial reads; no second deliveries.
  • The server cannot decrypt relay messages. It forwards opaque blobs; the run key is never transmitted through it.
  • The signed token binds alpha_hash, url, runId, and expires_at together. Substituting any field invalidates the signature.
  • Security-class error reports are accepted from any client without authentication — rate-limiting and aggregation are the server's responsibility.