196 lines
8.6 KiB
Markdown
196 lines
8.6 KiB
Markdown
# 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.
|
|
|
|
### 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. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4).
|
|
4. 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.
|