diff --git a/.codebuddy/.gitignore b/.codebuddy/.gitignore new file mode 100644 index 0000000..9f4c740 --- /dev/null +++ b/.codebuddy/.gitignore @@ -0,0 +1 @@ +db/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75bc39a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +test/test-keys.json +test/tls/ +node_modules/ +companion/dist/ +extension/dist/ +server/target/ +.idea/ diff --git a/dev.md b/dev.md index c415363..232720a 100644 --- a/dev.md +++ b/dev.md @@ -12,7 +12,7 @@ environment without regenerating any committed artifacts. | Component | Directory | Default URL | |---|---|---| | Test website | `website/` | `https://test.locqr.dev:5173` | -| Node server stub | `server/` | `https://api.locqr.dev:3000` | +| Server (Java/Spring) | `server/` | `https://api.locqr.dev:3000` | | Web companion stub | `companion/` | `https://test.locqr.dev:5174` | | Browser extension | `extension/` | Loaded unpacked in browser | @@ -88,9 +88,11 @@ from this key. **6. Environment files** Writes `server/.env.dev` with the dev signing key paths and local domain -config, overwriting any previous version. Components read this file at -startup; it is not committed (listed in `.gitignore`), as it contains paths -that may differ per machine. +config for the non-JVM components (companion web stub, test website), and +prints the equivalent as environment variable exports for the server, which +reads configuration the Spring way (environment variables / `application.yml`, +not a `.env` file). Not committed (listed in `.gitignore`), as paths may +differ per machine. ``` ML_DSA_44_SECRET_KEY_PATH=../../dev/keys/ml-dsa-44-secret.b64 @@ -99,6 +101,10 @@ TEST_DOMAIN=test.locqr.dev API_PORT=3000 ``` +The server's `application.yml` reads the same four settings from environment +variables of the same names, with dev-friendly defaults baked in so it runs +locally without them being set explicitly (see `server/claude.md`). + --- ## Hardcoded defaults diff --git a/dev/keys/ed25519-public.b64 b/dev/keys/ed25519-public.b64 new file mode 100644 index 0000000..2ee253b --- /dev/null +++ b/dev/keys/ed25519-public.b64 @@ -0,0 +1 @@ +Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4 diff --git a/dev/keys/ed25519-secret.b64 b/dev/keys/ed25519-secret.b64 new file mode 100644 index 0000000..e4803ef --- /dev/null +++ b/dev/keys/ed25519-secret.b64 @@ -0,0 +1 @@ +DETakRAJiPGTOOp0WsFZ9ez5y4rmf0pJlJtf2V1hBB0 diff --git a/interfaces.md b/interfaces.md index e47d649..e56ab42 100644 --- a/interfaces.md +++ b/interfaces.md @@ -72,6 +72,14 @@ That string is what the website operator places in `locqr.init({ cert: '...' })` Subdomains are not implicitly covered — a cert for `locqr.dev` does not cover `sub.locqr.dev`. +**`GET /domain/registration?domain=`** — fetches the envelope +above for a registered domain. Used by the test website on load, to obtain +the cert string passed to `locqr.init({ cert })`. + +Response: `{ "cert": "" }` — `cert` is exactly the +base64url-encoded envelope string described above, unmodified. `404` if +`domain` has no certificate on file. + --- ## Signed token (QR payload) diff --git a/server/claude.md b/server/claude.md index 54cb8db..085a78c 100644 --- a/server/claude.md +++ b/server/claude.md @@ -7,51 +7,32 @@ pinned keys. It provides two services: run infrastructure (key bundle storage, token signing, relay) and account management (domain registration, certificates, billing). -The server exists in two implementations: a **Node.js stub** used during -development, and the **Java/Spring production server** which is the long-term -target. +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) | -## Node.js stub (development tool) +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. -A minimal Express server implementing only the parts the extension and companion -actually touch. Its purpose is to allow end-to-end development and testing of -the full run flow without the production infrastructure. - -**What it does:** -- Stores key bundles in memory; destroys on fetch (at-most-once) -- Signs key exchange tokens with Ed25519 (Node built-in `crypto` module) -- Returns server-controlled run parameters with each signed token -- Accepts WebSocket connections from the extension for relay -- Forwards relay messages (kem_ciphertext, encrypted credential) from companion - to extension -- Serves a pre-generated test domain certificate (ML-DSA-44); does not - dynamically issue certificates -- Accepts domain status queries; returns valid for the hardcoded test domain - -**What it deliberately omits:** -- No persistence — all state is in-memory -- No ML-DSA-44 signing at runtime (test cert pre-generated by the dev setup - script in `../dev.md`; the stub reads it from `dev/certs/test-registration.b64` - at startup) -- No account management -- No billing or registration flows - ---- - -## Production server - -Java/Spring. Deferred. The Node stub defines the API contract that production -must honour. - -| Concern | Production | -|---|---| -| Key bundle storage | Redis (`GETDEL` for atomic at-most-once delivery) | -| Domain registrations / accounts | PostgreSQL | -| ML-DSA-44 signing | BouncyCastle or liboqs-java | -| WebSocket relay | Spring WebSocket | -| Account management | Full SaaS (registration, billing, domain admin) | +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. --- @@ -125,9 +106,11 @@ an error. Response: `{ x25519_pubkey_b64, kem_pubkey_b64 }` or `404` if already consumed or expired. -This is a load-bearing security property. Node stub uses `Map.get()` + -`Map.delete()` (safe for single-process development). Production uses Redis -`GETDEL`. +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 @@ -148,9 +131,24 @@ and the run must be restarted. ### Admin / website endpoints -Domain registration and account management. Deferred for the stub; stub serves -a hardcoded test domain certificate. Production endpoints are a full SaaS -concern. +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=`** +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: "" }` (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. --- diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..d2a4ca8 --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + dev.locqr + server + 0.1.0 + locqr-server + LOCQR backend: run infrastructure (key bundle storage, token signing, relay) and account management. + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + + + io.github.erdtman + java-json-canonicalization + 1.1 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + + diff --git a/server/src/main/java/dev/locqr/server/ServerApplication.java b/server/src/main/java/dev/locqr/server/ServerApplication.java new file mode 100644 index 0000000..ae8c485 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/ServerApplication.java @@ -0,0 +1,13 @@ +package dev.locqr.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class ServerApplication { + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } +} diff --git a/server/src/main/java/dev/locqr/server/config/LocqrProperties.java b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java new file mode 100644 index 0000000..b90b76f --- /dev/null +++ b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java @@ -0,0 +1,20 @@ +package dev.locqr.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Binds the {@code locqr.*} tree in application.yml. Current (PoC) scope + * config only — see server/claude.md for the current-vs-production split. + */ +@ConfigurationProperties(prefix = "locqr") +public record LocqrProperties( + String testDomain, + String ed25519SecretKeyPath, + String registrationCertPath, + Run run, + Relay relay +) { + public record Run(int qrTtlSeconds, int maxAutoRefresh, int pinTtlSeconds) {} + + public record Relay(int bufferTtlSeconds) {} +} diff --git a/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java b/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java new file mode 100644 index 0000000..3fef134 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/config/RelayEndpointConfig.java @@ -0,0 +1,27 @@ +package dev.locqr.server.config; + +import dev.locqr.server.relay.RelayHttpRequestHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; + +import java.util.Map; + +/** + * Registers {@link RelayHttpRequestHandler} as the sole owner of + * {@code /run/relay/*} — see that class's Javadoc for why it isn't split + * across {@code @RestController} + {@code WebSocketConfigurer} instead. + */ +@Configuration +public class RelayEndpointConfig { + + @Bean + public HandlerMapping relayHandlerMapping(RelayHttpRequestHandler handler) { + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setOrder(Ordered.HIGHEST_PRECEDENCE); + mapping.setUrlMap(Map.of("/run/relay/*", handler)); + return mapping; + } +} diff --git a/server/src/main/java/dev/locqr/server/controller/DomainController.java b/server/src/main/java/dev/locqr/server/controller/DomainController.java new file mode 100644 index 0000000..c3e6389 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/controller/DomainController.java @@ -0,0 +1,38 @@ +package dev.locqr.server.controller; + +import dev.locqr.server.config.LocqrProperties; +import dev.locqr.server.dto.DomainStatusResponse; +import dev.locqr.server.dto.RegistrationResponse; +import dev.locqr.server.store.RegistrationCertificateStore; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** GET /domain/status and GET /domain/registration — interfaces.md. */ +@RestController +public class DomainController { + + private final LocqrProperties properties; + private final RegistrationCertificateStore certificates; + + public DomainController(LocqrProperties properties, RegistrationCertificateStore certificates) { + this.properties = properties; + this.certificates = certificates; + } + + @GetMapping("/domain/status") + public DomainStatusResponse status(@RequestParam String domain) { + String status = properties.testDomain().equals(domain) + ? DomainStatusResponse.VALID + : DomainStatusResponse.REJECTED; + return new DomainStatusResponse(status); + } + + @GetMapping("/domain/registration") + public ResponseEntity registration(@RequestParam String domain) { + return certificates.certFor(domain) + .map(cert -> ResponseEntity.ok(new RegistrationResponse(cert))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/server/src/main/java/dev/locqr/server/controller/RunController.java b/server/src/main/java/dev/locqr/server/controller/RunController.java new file mode 100644 index 0000000..76ca076 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/controller/RunController.java @@ -0,0 +1,60 @@ +package dev.locqr.server.controller; + +import dev.locqr.server.config.LocqrProperties; +import dev.locqr.server.crypto.SigningService; +import dev.locqr.server.dto.RunBundleFetchResponse; +import dev.locqr.server.dto.RunBundleUploadRequest; +import dev.locqr.server.dto.RunBundleUploadResponse; +import dev.locqr.server.store.KeyBundle; +import dev.locqr.server.store.KeyBundleStore; +import dev.locqr.server.util.Base64Url; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +/** POST /run/bundle (Phase 1), GET /run/bundle/:runId (Phase 2) — interfaces.md. */ +@RestController +public class RunController { + + private final KeyBundleStore store; + private final SigningService signing; + private final LocqrProperties properties; + + public RunController(KeyBundleStore store, SigningService signing, LocqrProperties properties) { + this.store = store; + this.signing = signing; + this.properties = properties; + } + + @PostMapping("/run/bundle") + public RunBundleUploadResponse uploadBundle(@RequestBody RunBundleUploadRequest request) { + LocqrProperties.Run runParams = properties.run(); + Instant expiresAt = Instant.now().plusSeconds(runParams.qrTtlSeconds()); + + byte[] alphaHash = SigningService.sha256( + Base64Url.decode(request.x25519Pubkey()), + Base64Url.decode(request.kemPubkey()) + ); + + Map envelope = signing.signKeyExchangeToken( + request.runId(), request.origin(), expiresAt.getEpochSecond(), alphaHash); + String signedToken = signing.toBase64UrlJson(envelope); + + store.put(new KeyBundle( + request.runId(), request.origin(), request.x25519Pubkey(), request.kemPubkey(), expiresAt)); + + return new RunBundleUploadResponse( + signedToken, runParams.qrTtlSeconds(), runParams.maxAutoRefresh(), runParams.pinTtlSeconds()); + } + + @GetMapping("/run/bundle/{runId}") + public ResponseEntity fetchBundle(@PathVariable String runId) { + Optional bundle = store.takeIfPresent(runId); + return bundle + .map(b -> ResponseEntity.ok(new RunBundleFetchResponse(b.x25519PubkeyB64(), b.kemPubkeyB64()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/server/src/main/java/dev/locqr/server/controller/SecurityReportController.java b/server/src/main/java/dev/locqr/server/controller/SecurityReportController.java new file mode 100644 index 0000000..27712f2 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/controller/SecurityReportController.java @@ -0,0 +1,27 @@ +package dev.locqr.server.controller; + +import dev.locqr.server.dto.SecurityReportRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * POST /security/report — unauthenticated by design (interfaces.md); rate + * limiting and aggregation are explicitly deferred (server/claude.md). + * Current scope: log only. + */ +@RestController +public class SecurityReportController { + + private static final Logger log = LoggerFactory.getLogger(SecurityReportController.class); + + @PostMapping("/security/report") + public ResponseEntity report(@RequestBody SecurityReportRequest request) { + log.warn("Security report: type={} runId={} timestamp={}", + request.errorType(), request.runId(), request.timestamp()); + return ResponseEntity.ok().build(); + } +} diff --git a/server/src/main/java/dev/locqr/server/crypto/SigningService.java b/server/src/main/java/dev/locqr/server/crypto/SigningService.java new file mode 100644 index 0000000..f852899 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/crypto/SigningService.java @@ -0,0 +1,123 @@ +package dev.locqr.server.crypto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.locqr.server.config.LocqrProperties; +import dev.locqr.server.util.Base64Url; +import org.erdtman.jcs.JsonCanonicalizer; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.EdECPrivateKeySpec; +import java.security.spec.NamedParameterSpec; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The server's only runtime cryptography: Ed25519 signing and SHA-256 + * hashing, both via the JDK's built-in {@code java.security} support — no + * external crypto library needed for either. See server/claude.md. + * + *

ML-KEM-768 and live ML-DSA-44 signing never happen here: KEM operations + * are client-side only, and ML-DSA-44 signing happens offline in the dev + * setup script (crypto.md). + */ +@Service +public class SigningService { + + private final PrivateKey ed25519SecretKey; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public SigningService(LocqrProperties properties) { + this.ed25519SecretKey = loadEd25519SecretKey(properties.ed25519SecretKeyPath()); + } + + /** + * Loads a raw 32-byte Ed25519 seed (base64, no padding) from disk and + * constructs a {@link PrivateKey} directly via {@link EdECPrivateKeySpec} + * — no PKCS8 wrapping needed. This is the format {@code dev/keys/} keys + * are committed in; see dev.md. + */ + private static PrivateKey loadEd25519SecretKey(String path) { + try { + byte[] seed = Base64Url.decode(Files.readString(Path.of(path)).trim()); + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + return kf.generatePrivate(new EdECPrivateKeySpec(NamedParameterSpec.ED25519, seed)); + } catch (IOException e) { + throw new UncheckedIOException( + "Could not read Ed25519 secret key from " + path + + " — see dev.md for how dev keys are generated/committed.", e); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Malformed Ed25519 secret key at " + path, e); + } + } + + /** RFC 8785 (JCS) canonicalization — mandatory, no bespoke implementation. */ + public byte[] canonicalize(Map payload) { + try { + String json = objectMapper.writeValueAsString(payload); + return new JsonCanonicalizer(json).getEncodedUTF8(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to JCS-canonicalize signed payload", e); + } + } + + public byte[] sign(byte[] canonicalPayload) { + try { + Signature signature = Signature.getInstance("Ed25519"); + signature.initSign(ed25519SecretKey); + signature.update(canonicalPayload); + return signature.sign(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Ed25519 signing failed", e); + } + } + + public static byte[] sha256(byte[]... parts) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (byte[] part : parts) { + digest.update(part); + } + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); // SHA-256 is always available on the JVM + } + } + + /** + * Signs the key-exchange token payload (interfaces.md, "Signed token + * (QR payload)") and returns the full envelope as an ordered map, ready + * for base64url(JSON.stringify(...)) encoding by the caller. + */ + public Map signKeyExchangeToken(String runId, String url, long expiresAt, byte[] alphaHash) { + Map payload = new LinkedHashMap<>(); + payload.put("v", 1); + payload.put("runId", runId); + payload.put("url", url); + payload.put("expires_at", expiresAt); + payload.put("alpha_hash", Base64Url.encode(alphaHash)); + + byte[] sig = sign(canonicalize(payload)); + + Map envelope = new LinkedHashMap<>(payload); + envelope.put("sig", Base64Url.encode(sig)); + return envelope; + } + + public String toBase64UrlJson(Map envelope) { + try { + return Base64Url.encode(objectMapper.writeValueAsBytes(envelope)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/server/src/main/java/dev/locqr/server/dto/DomainStatusResponse.java b/server/src/main/java/dev/locqr/server/dto/DomainStatusResponse.java new file mode 100644 index 0000000..ea350ca --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/DomainStatusResponse.java @@ -0,0 +1,9 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DomainStatusResponse(@JsonProperty("status") String status) { + public static final String VALID = "valid"; + public static final String REJECTED = "rejected"; + public static final String SUSPENDED = "suspended"; +} diff --git a/server/src/main/java/dev/locqr/server/dto/RegistrationResponse.java b/server/src/main/java/dev/locqr/server/dto/RegistrationResponse.java new file mode 100644 index 0000000..1a24a59 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/RegistrationResponse.java @@ -0,0 +1,5 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RegistrationResponse(@JsonProperty("cert") String cert) {} diff --git a/server/src/main/java/dev/locqr/server/dto/RelayMessage.java b/server/src/main/java/dev/locqr/server/dto/RelayMessage.java new file mode 100644 index 0000000..d0bfabe --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/RelayMessage.java @@ -0,0 +1,19 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Companion -> backend (POST) and backend -> extension (WS push) both use + * this exact shape, forwarded unchanged — interfaces.md, "Relay messages". + */ +public record RelayMessage( + @JsonProperty("type") String type, + @JsonProperty("payload_b64") String payloadB64 +) { + public static final String KEM_CIPHERTEXT = "kem_ciphertext"; + public static final String CREDENTIAL = "credential"; + + public boolean hasValidType() { + return KEM_CIPHERTEXT.equals(type) || CREDENTIAL.equals(type); + } +} diff --git a/server/src/main/java/dev/locqr/server/dto/RunBundleFetchResponse.java b/server/src/main/java/dev/locqr/server/dto/RunBundleFetchResponse.java new file mode 100644 index 0000000..e045efa --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/RunBundleFetchResponse.java @@ -0,0 +1,8 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RunBundleFetchResponse( + @JsonProperty("x25519_pubkey") String x25519Pubkey, + @JsonProperty("kem_pubkey") String kemPubkey +) {} diff --git a/server/src/main/java/dev/locqr/server/dto/RunBundleUploadRequest.java b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadRequest.java new file mode 100644 index 0000000..4e922ad --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadRequest.java @@ -0,0 +1,11 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** POST /run/bundle request — field casing matches interfaces.md exactly (mixed). */ +public record RunBundleUploadRequest( + @JsonProperty("runId") String runId, + @JsonProperty("origin") String origin, + @JsonProperty("x25519_pubkey") String x25519Pubkey, + @JsonProperty("kem_pubkey") String kemPubkey +) {} diff --git a/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java new file mode 100644 index 0000000..516c201 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/RunBundleUploadResponse.java @@ -0,0 +1,10 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RunBundleUploadResponse( + @JsonProperty("signed_token") String signedToken, + @JsonProperty("qr_ttl") int qrTtl, + @JsonProperty("max_auto_refresh") int maxAutoRefresh, + @JsonProperty("pin_ttl") int pinTtl +) {} diff --git a/server/src/main/java/dev/locqr/server/dto/SecurityReportRequest.java b/server/src/main/java/dev/locqr/server/dto/SecurityReportRequest.java new file mode 100644 index 0000000..9d5ec71 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/SecurityReportRequest.java @@ -0,0 +1,9 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SecurityReportRequest( + @JsonProperty("runId") String runId, + @JsonProperty("error_type") String errorType, + @JsonProperty("timestamp") long timestamp +) {} diff --git a/server/src/main/java/dev/locqr/server/relay/RelayHttpRequestHandler.java b/server/src/main/java/dev/locqr/server/relay/RelayHttpRequestHandler.java new file mode 100644 index 0000000..f524cb7 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/relay/RelayHttpRequestHandler.java @@ -0,0 +1,61 @@ +package dev.locqr.server.relay; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.locqr.server.dto.RelayMessage; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler; + +import java.io.IOException; + +/** + * Both roles of {@code /run/relay/:runId} live at the exact same path per + * interfaces.md — POST from the companion, WebSocket upgrade from the + * extension. Spring MVC's {@code @PostMapping} and a separately-registered + * WebSocket handler both claim the same URL via two different + * {@code HandlerMapping}s; whichever loses that race gets an unconditional + * 405 from the other, regardless of ordering (confirmed while smoke-testing + * this scaffold — see server/claude.md). One handler owning the path and + * dispatching by HTTP method itself avoids the ambiguity entirely. + */ +@Component +public class RelayHttpRequestHandler implements HttpRequestHandler { + + private final WebSocketHttpRequestHandler webSocketHandler; + private final RelaySessionRegistry registry; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public RelayHttpRequestHandler(RelayWebSocketHandler relayWebSocketHandler, RelaySessionRegistry registry) { + this.webSocketHandler = new WebSocketHttpRequestHandler(relayWebSocketHandler); + this.registry = registry; + } + + @Override + public void handleRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + switch (request.getMethod()) { + case "GET" -> webSocketHandler.handleRequest(request, response); + case "POST" -> handlePost(request, response); + default -> response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + } + + private void handlePost(HttpServletRequest request, HttpServletResponse response) throws IOException { + RelayMessage message = objectMapper.readValue(request.getInputStream(), RelayMessage.class); + if (!message.hasValidType()) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + String runId = runIdFrom(request.getRequestURI()); + registry.forward(runId, objectMapper.writeValueAsString(message)); + response.setStatus(HttpServletResponse.SC_OK); + } + + private static String runIdFrom(String path) { + String[] segments = path.split("/"); + return segments[segments.length - 1]; + } +} diff --git a/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java new file mode 100644 index 0000000..e7e7993 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java @@ -0,0 +1,111 @@ +package dev.locqr.server.relay; + +import dev.locqr.server.config.LocqrProperties; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Maps run ID to the extension's live relay WebSocket, per server/claude.md + * "WebSocket relay". A companion POST that arrives before the extension's + * socket is open (service worker suspended) is buffered briefly rather than + * dropped immediately — but only briefly: {@code locqr.relay.buffer-ttl-seconds} + * bounds how long, matching the doc's "buffer lifetime is short (seconds); + * if the extension does not reconnect within that window the message is + * discarded and the run must be restarted." + */ +@Component +public class RelaySessionRegistry { + + private static final Logger log = LoggerFactory.getLogger(RelaySessionRegistry.class); + + private record Buffered(String json, Instant receivedAt) {} + + private final Map sessions = new ConcurrentHashMap<>(); + private final Map> buffers = new ConcurrentHashMap<>(); + private final Duration bufferTtl; + private final ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "relay-buffer-cleanup"); + t.setDaemon(true); + return t; + }); + + public RelaySessionRegistry(LocqrProperties properties) { + this.bufferTtl = Duration.ofSeconds(properties.relay().bufferTtlSeconds()); + this.cleanup.scheduleAtFixedRate(this::pruneExpiredBuffers, 1, 1, TimeUnit.SECONDS); + } + + public void register(String runId, WebSocketSession session) { + sessions.put(runId, session); + flushBuffered(runId, session); + } + + public void unregister(String runId, WebSocketSession session) { + sessions.remove(runId, session); + } + + /** + * Forwards a relay message to the extension's socket if open; otherwise + * buffers it for {@link #bufferTtl}. + */ + public void forward(String runId, String json) { + WebSocketSession session = sessions.get(runId); + if (session != null && session.isOpen()) { + send(session, json); + return; + } + buffers.computeIfAbsent(runId, k -> new ConcurrentLinkedQueue<>()) + .add(new Buffered(json, Instant.now())); + } + + private void flushBuffered(String runId, WebSocketSession session) { + Queue queue = buffers.remove(runId); + if (queue == null) { + return; + } + Instant cutoff = Instant.now().minus(bufferTtl); + for (Buffered message : queue) { + if (message.receivedAt().isBefore(cutoff)) { + log.info("Discarding expired buffered relay message for run {}", runId); + continue; + } + send(session, message.json()); + } + } + + private void pruneExpiredBuffers() { + Instant cutoff = Instant.now().minus(bufferTtl); + buffers.forEach((runId, queue) -> { + queue.removeIf(m -> m.receivedAt().isBefore(cutoff)); + if (queue.isEmpty()) { + buffers.remove(runId, queue); + } + }); + } + + private void send(WebSocketSession session, String json) { + try { + session.sendMessage(new TextMessage(json)); + } catch (Exception e) { + log.warn("Failed to forward relay message over WebSocket", e); + } + } + + @PreDestroy + void shutdown() { + cleanup.shutdownNow(); + } +} diff --git a/server/src/main/java/dev/locqr/server/relay/RelayWebSocketHandler.java b/server/src/main/java/dev/locqr/server/relay/RelayWebSocketHandler.java new file mode 100644 index 0000000..0c34a99 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/relay/RelayWebSocketHandler.java @@ -0,0 +1,46 @@ +package dev.locqr.server.relay; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * The extension upgrades {@code /run/relay/:runId} to a WebSocket + * immediately after Phase 1 (server/claude.md). This handler only pushes + * server -> extension; it never expects an inbound text message. Run ID is + * the last path segment (Spring's raw WebSocket registration doesn't bind + * {@code @PathVariable}-style, so it's parsed from the handshake URI here). + */ +@Component +public class RelayWebSocketHandler extends TextWebSocketHandler { + + private static final String RUN_ID_ATTR = "runId"; + + private final RelaySessionRegistry registry; + + public RelayWebSocketHandler(RelaySessionRegistry registry) { + this.registry = registry; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + String runId = runIdFrom(session); + session.getAttributes().put(RUN_ID_ATTR, runId); + registry.register(runId, session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + String runId = (String) session.getAttributes().get(RUN_ID_ATTR); + if (runId != null) { + registry.unregister(runId, session); + } + } + + private static String runIdFrom(WebSocketSession session) { + String path = session.getUri() != null ? session.getUri().getPath() : ""; + String[] segments = path.split("/"); + return segments[segments.length - 1]; + } +} diff --git a/server/src/main/java/dev/locqr/server/store/InMemoryKeyBundleStore.java b/server/src/main/java/dev/locqr/server/store/InMemoryKeyBundleStore.java new file mode 100644 index 0000000..b2ae109 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/store/InMemoryKeyBundleStore.java @@ -0,0 +1,39 @@ +package dev.locqr.server.store; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Current (PoC) scope: {@link ConcurrentHashMap} keeps the whole store in + * one process's memory. {@link ConcurrentHashMap#remove(Object)} is atomic, + * so the at-most-once guarantee holds under concurrent requests — this is + * not just "safe for single-process development," it's a real guarantee + * within a single instance. It does not hold across multiple instances; + * that's what the production Redis {@code GETDEL} swap is for. + */ +@Component +public class InMemoryKeyBundleStore implements KeyBundleStore { + + private final Map bundles = new ConcurrentHashMap<>(); + + @Override + public void put(KeyBundle bundle) { + bundles.put(bundle.runId(), bundle); + } + + @Override + public Optional takeIfPresent(String runId) { + KeyBundle removed = bundles.remove(runId); + if (removed == null) { + return Optional.empty(); + } + if (removed.isExpired(Instant.now())) { + return Optional.empty(); + } + return Optional.of(removed); + } +} diff --git a/server/src/main/java/dev/locqr/server/store/KeyBundle.java b/server/src/main/java/dev/locqr/server/store/KeyBundle.java new file mode 100644 index 0000000..ed3cf1c --- /dev/null +++ b/server/src/main/java/dev/locqr/server/store/KeyBundle.java @@ -0,0 +1,19 @@ +package dev.locqr.server.store; + +import java.time.Instant; + +/** + * A key bundle uploaded by the extension at Phase 1, awaiting the + * companion's at-most-once fetch at Phase 2. + */ +public record KeyBundle( + String runId, + String origin, + String x25519PubkeyB64, + String kemPubkeyB64, + Instant expiresAt +) { + public boolean isExpired(Instant now) { + return now.isAfter(expiresAt); + } +} diff --git a/server/src/main/java/dev/locqr/server/store/KeyBundleStore.java b/server/src/main/java/dev/locqr/server/store/KeyBundleStore.java new file mode 100644 index 0000000..90d12e0 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/store/KeyBundleStore.java @@ -0,0 +1,21 @@ +package dev.locqr.server.store; + +import java.util.Optional; + +/** + * Storage for key bundles between upload (Phase 1) and fetch (Phase 2). + * Current scope: {@link InMemoryKeyBundleStore}. Production swaps this for a + * Redis-backed implementation ({@code GETDEL}) without touching any caller — + * see server/claude.md. + */ +public interface KeyBundleStore { + + void put(KeyBundle bundle); + + /** + * At-most-once fetch: atomically removes and returns the bundle if + * present and unexpired. This is a load-bearing security property, not + * an optimization — see server/claude.md, "Run bundle fetch". + */ + Optional takeIfPresent(String runId); +} diff --git a/server/src/main/java/dev/locqr/server/store/RegistrationCertificateStore.java b/server/src/main/java/dev/locqr/server/store/RegistrationCertificateStore.java new file mode 100644 index 0000000..6b3045d --- /dev/null +++ b/server/src/main/java/dev/locqr/server/store/RegistrationCertificateStore.java @@ -0,0 +1,50 @@ +package dev.locqr.server.store; + +import dev.locqr.server.config.LocqrProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Serves the pre-generated ML-DSA-44 registration certificate for the one + * hardcoded test domain (current scope). Unlike the Ed25519 signing key, + * a missing file here is not fatal — this is an optional dev convenience + * (`GET /domain/registration`), not the server's core signing path, so a + * missing cert just means that one endpoint 404s until the dev setup + * script (dev.md) has produced it. + */ +@Component +public class RegistrationCertificateStore { + + private static final Logger log = LoggerFactory.getLogger(RegistrationCertificateStore.class); + + private final String testDomain; + private final Optional cert; + + public RegistrationCertificateStore(LocqrProperties properties) { + this.testDomain = properties.testDomain(); + this.cert = load(properties.registrationCertPath()); + } + + private static Optional load(String path) { + try { + return Optional.of(Files.readString(Path.of(path)).trim()); + } catch (IOException e) { + log.warn("No registration certificate at {} — GET /domain/registration will 404 " + + "until the dev setup script (dev.md) generates it.", path); + return Optional.empty(); + } + } + + public Optional certFor(String domain) { + if (!testDomain.equals(domain)) { + return Optional.empty(); + } + return cert; + } +} diff --git a/server/src/main/java/dev/locqr/server/util/Base64Url.java b/server/src/main/java/dev/locqr/server/util/Base64Url.java new file mode 100644 index 0000000..f4f3259 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/util/Base64Url.java @@ -0,0 +1,25 @@ +package dev.locqr.server.util; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * base64url (RFC 4648 §5), no padding — the only binary encoding permitted + * anywhere in the protocol, per interfaces.md's shared conventions. + */ +public final class Base64Url { + + private Base64Url() {} + + public static String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static byte[] decode(String s) { + return Base64.getUrlDecoder().decode(s); + } + + public static String encodeUtf8(String s) { + return encode(s.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 0000000..38c0fb6 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,13 @@ +server: + port: ${API_PORT:3000} + +locqr: + test-domain: ${TEST_DOMAIN:test.locqr.dev} + ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64} + registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64} + run: + qr-ttl-seconds: 90 + max-auto-refresh: 3 + pin-ttl-seconds: 120 + relay: + buffer-ttl-seconds: 8 diff --git a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java new file mode 100644 index 0000000..8509cf7 --- /dev/null +++ b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java @@ -0,0 +1,202 @@ +package dev.locqr.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.locqr.server.dto.*; +import dev.locqr.server.support.TestEd25519Keys; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Exercises the same checks that were run by hand against the live server + * while scaffolding it (see server/claude.md), through the real HTTP/ + * WebSocket stack rather than by inspecting code. A hermetic, generated + * Ed25519 key is used — these tests never depend on the committed + * {@code dev/keys/} material, so they're unaffected by dev key rotation. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RunFlowIntegrationTest { + + @LocalServerPort + int port; + + @org.springframework.beans.factory.annotation.Autowired + TestRestTemplate restTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @DynamicPropertySource + static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception { + Path tempDir = Files.createTempDirectory("locqr-server-it"); + + TestEd25519Keys.Generated keys = TestEd25519Keys.generate(); + Path keyFile = TestEd25519Keys.writeSecretKeyFile(tempDir, keys.secretSeedB64()); + registry.add("locqr.ed25519-secret-key-path", keyFile::toString); + + Path certFile = tempDir.resolve("test-registration.b64"); + Files.writeString(certFile, "dummy-registration-envelope"); + registry.add("locqr.registration-cert-path", certFile::toString); + } + + // --- Run bundle: upload, at-most-once fetch (server/claude.md, interfaces.md) --- + + @Test + void uploadThenFetch_returnsTheBundleOnce_thenFourOhFoursOnASecondFetch() { + String runId = UUID.randomUUID().toString(); + var upload = new RunBundleUploadRequest( + runId, "https://test.locqr.dev", randomBase64(32), randomBase64(1184)); + + ResponseEntity uploadResponse = + restTemplate.postForEntity("/run/bundle", upload, RunBundleUploadResponse.class); + + assertThat(uploadResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + RunBundleUploadResponse body = uploadResponse.getBody(); + assertThat(body).isNotNull(); + assertThat(body.qrTtl()).isEqualTo(90); + assertThat(body.maxAutoRefresh()).isEqualTo(3); + assertThat(body.pinTtl()).isEqualTo(120); + assertThat(body.signedToken()).isNotBlank(); + + ResponseEntity firstFetch = + restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId); + assertThat(firstFetch.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(firstFetch.getBody()).isNotNull(); + assertThat(firstFetch.getBody().x25519Pubkey()).isEqualTo(upload.x25519Pubkey()); + assertThat(firstFetch.getBody().kemPubkey()).isEqualTo(upload.kemPubkey()); + + ResponseEntity secondFetch = + restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId); + assertThat(secondFetch.getStatusCode()) + .as("at-most-once: the bundle must be gone after the first fetch") + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void fetchingAnUnknownRunId_returnsNotFound() { + ResponseEntity response = restTemplate.getForEntity( + "/run/bundle/{runId}", RunBundleFetchResponse.class, "never-uploaded-" + UUID.randomUUID()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + // --- Domain status / registration (interfaces.md) --- + + @Test + void domainStatus_isValidForTheTestDomain_andRejectedForAnyOther() { + ResponseEntity valid = restTemplate.getForEntity( + "/domain/status?domain={d}", DomainStatusResponse.class, "test.locqr.dev"); + assertThat(valid.getBody().status()).isEqualTo(DomainStatusResponse.VALID); + + ResponseEntity rejected = restTemplate.getForEntity( + "/domain/status?domain={d}", DomainStatusResponse.class, "evil.example.com"); + assertThat(rejected.getBody().status()).isEqualTo(DomainStatusResponse.REJECTED); + } + + @Test + void registration_returnsTheCert_forTheTestDomain_andNotFoundForAnyOther() { + ResponseEntity found = restTemplate.getForEntity( + "/domain/registration?domain={d}", RegistrationResponse.class, "test.locqr.dev"); + assertThat(found.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(found.getBody().cert()).isEqualTo("dummy-registration-envelope"); + + ResponseEntity notFound = restTemplate.getForEntity( + "/domain/registration?domain={d}", RegistrationResponse.class, "evil.example.com"); + assertThat(notFound.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + // --- Security report (interfaces.md) --- + + @Test + void securityReport_isAccepted_unauthenticated() { + var report = new SecurityReportRequest(UUID.randomUUID().toString(), "pin_mismatch", 1748390461L); + + ResponseEntity response = restTemplate.postForEntity("/security/report", report, Void.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + // --- Relay: WebSocket + POST both live at /run/relay/:runId (server/claude.md) --- + // + // This is a regression test for a routing bug found while smoke-testing the + // scaffold: @PostMapping and a separately-registered WebSocket handler both + // claiming this exact path produced an unconditional 405 on whichever verb + // lost the HandlerMapping race. RelayHttpRequestHandler now owns the path + // and dispatches by method itself — this test would fail again if that + // regressed. + + @Test + void relay_flushesABufferedMessageOnConnect_thenForwardsLiveMessagesImmediately() throws Exception { + String runId = "ws-it-" + UUID.randomUUID(); + var buffered = new RelayMessage(RelayMessage.KEM_CIPHERTEXT, "QlVGRkVSRUQ"); + + // POSTed before any socket is open -> must be buffered, not dropped or rejected. + ResponseEntity bufferedPost = + restTemplate.postForEntity("/run/relay/{runId}", buffered, Void.class, runId); + assertThat(bufferedPost.getStatusCode()).isEqualTo(HttpStatus.OK); + + BlockingQueue received = new LinkedBlockingQueue<>(); + StandardWebSocketClient client = new StandardWebSocketClient(); + WebSocketSession session = client.execute( + new TextWebSocketHandler() { + @Override + protected void handleTextMessage(WebSocketSession s, TextMessage message) { + received.add(message.getPayload()); + } + }, + "ws://localhost:{port}/run/relay/{runId}", port, runId) + .get(5, TimeUnit.SECONDS); + + try { + String flushedJson = received.poll(5, TimeUnit.SECONDS); + assertThat(flushedJson).isNotNull(); + assertThat(objectMapper.readValue(flushedJson, RelayMessage.class)).isEqualTo(buffered); + + var live = new RelayMessage(RelayMessage.CREDENTIAL, "TElWRQ"); + ResponseEntity livePost = + restTemplate.postForEntity("/run/relay/{runId}", live, Void.class, runId); + assertThat(livePost.getStatusCode()).isEqualTo(HttpStatus.OK); + + String liveJson = received.poll(5, TimeUnit.SECONDS); + assertThat(liveJson).isNotNull(); + assertThat(objectMapper.readValue(liveJson, RelayMessage.class)).isEqualTo(live); + } finally { + session.close(); + } + } + + @Test + void relay_rejectsAMessageWithAnInvalidType() { + String runId = "ws-it-invalid-" + UUID.randomUUID(); + var invalid = new RelayMessage("not_a_real_type", "AAAA"); + + ResponseEntity response = + restTemplate.postForEntity("/run/relay/{runId}", invalid, Void.class, runId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private static String randomBase64(int numBytes) { + byte[] bytes = new byte[numBytes]; + new java.security.SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java new file mode 100644 index 0000000..6ca4856 --- /dev/null +++ b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java @@ -0,0 +1,96 @@ +package dev.locqr.server.crypto; + +import dev.locqr.server.config.LocqrProperties; +import dev.locqr.server.support.TestEd25519Keys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.security.Signature; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SigningServiceTest { + + @Test + void signKeyExchangeToken_producesAnEnvelopeWhoseSignatureVerifiesOverTheCanonicalPayload( + @TempDir Path tempDir) throws Exception { + TestEd25519Keys.Generated keys = TestEd25519Keys.generate(); + Path keyFile = TestEd25519Keys.writeSecretKeyFile(tempDir, keys.secretSeedB64()); + SigningService signing = new SigningService(properties(keyFile)); + + byte[] alphaHash = SigningService.sha256("x25519-pub".getBytes(), "kem-pub".getBytes()); + Map envelope = signing.signKeyExchangeToken( + "550e8400-e29b-41d4-a716-446655440000", "https://test.locqr.dev", 1748390460L, alphaHash); + + // Shape matches interfaces.md's "Signed token (QR payload)" full envelope exactly. + assertThat(envelope) + .containsEntry("v", 1) + .containsEntry("runId", "550e8400-e29b-41d4-a716-446655440000") + .containsEntry("url", "https://test.locqr.dev") + .containsEntry("expires_at", 1748390460L) + .containsKey("alpha_hash") + .containsKey("sig"); + + byte[] sig = Base64.getUrlDecoder().decode((String) envelope.get("sig")); + assertThat(sig).hasSize(64); // interfaces.md: 64-byte Ed25519 signature, unencoded + + Map payloadOnly = new LinkedHashMap<>(envelope); + payloadOnly.remove("sig"); + byte[] canonical = signing.canonicalize(payloadOnly); + + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(keys.publicKey()); + verifier.update(canonical); + assertThat(verifier.verify(sig)) + .as("signature must verify against the corresponding public key, over the JCS-canonicalized payload") + .isTrue(); + } + + @Test + void signKeyExchangeToken_signatureDoesNotVerify_ifAnyFieldIsTamperedWith(@TempDir Path tempDir) + throws Exception { + TestEd25519Keys.Generated keys = TestEd25519Keys.generate(); + Path keyFile = TestEd25519Keys.writeSecretKeyFile(tempDir, keys.secretSeedB64()); + SigningService signing = new SigningService(properties(keyFile)); + + byte[] alphaHash = SigningService.sha256("x25519-pub".getBytes(), "kem-pub".getBytes()); + Map envelope = signing.signKeyExchangeToken( + "run-1", "https://test.locqr.dev", 1748390460L, alphaHash); + + byte[] sig = Base64.getUrlDecoder().decode((String) envelope.get("sig")); + + Map tampered = new LinkedHashMap<>(envelope); + tampered.remove("sig"); + tampered.put("url", "https://attacker.example.com"); // substitute the bound URL + + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(keys.publicKey()); + verifier.update(signing.canonicalize(tampered)); + assertThat(verifier.verify(sig)) + .as("substituting any signed field must invalidate the signature (server/claude.md security invariants)") + .isFalse(); + } + + @Test + void sha256_hashesTheConcatenationOfItsParts_notEachPartSeparately() { + byte[] a = "classical".getBytes(); + byte[] b = "post-quantum".getBytes(); + + assertThat(SigningService.sha256(a, b)).isEqualTo(SigningService.sha256("classicalpost-quantum".getBytes())); + // Ordering matters: crypto.md's normative rule is classical-precedes-post-quantum. + assertThat(SigningService.sha256(a, b)).isNotEqualTo(SigningService.sha256(b, a)); + } + + private static LocqrProperties properties(Path keyFile) { + return new LocqrProperties( + "test.locqr.dev", + keyFile.toString(), + "/nonexistent", + new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Relay(8)); + } +} diff --git a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java new file mode 100644 index 0000000..7376964 --- /dev/null +++ b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java @@ -0,0 +1,118 @@ +package dev.locqr.server.relay; + +import dev.locqr.server.config.LocqrProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RelaySessionRegistryTest { + + private final List created = new ArrayList<>(); + + @AfterEach + void stopBackgroundCleanupThreads() { + created.forEach(RelaySessionRegistry::shutdown); + } + + @Test + void forward_sendsImmediately_whenAnOpenSessionIsRegistered() throws Exception { + RelaySessionRegistry registry = registry(30); + WebSocketSession session = openSession(); + + registry.register("run-1", session); + registry.forward("run-1", "{\"type\":\"kem_ciphertext\",\"payload_b64\":\"AAAA\"}"); + + verify(session).sendMessage(new TextMessage("{\"type\":\"kem_ciphertext\",\"payload_b64\":\"AAAA\"}")); + } + + @Test + void forward_buffersTheMessage_andFlushesItOnceASessionRegisters() throws Exception { + RelaySessionRegistry registry = registry(30); + + // Message arrives before the extension's socket is open (service worker suspended). + registry.forward("run-1", "{\"type\":\"kem_ciphertext\",\"payload_b64\":\"AAAA\"}"); + + WebSocketSession session = openSession(); + registry.register("run-1", session); + + verify(session).sendMessage(new TextMessage("{\"type\":\"kem_ciphertext\",\"payload_b64\":\"AAAA\"}")); + } + + @Test + void forward_deliversBufferedMessagesInOrder() throws Exception { + RelaySessionRegistry registry = registry(30); + + registry.forward("run-1", "first"); + registry.forward("run-1", "second"); + + WebSocketSession session = openSession(); + registry.register("run-1", session); + + var inOrder = org.mockito.Mockito.inOrder(session); + inOrder.verify(session).sendMessage(new TextMessage("first")); + inOrder.verify(session).sendMessage(new TextMessage("second")); + } + + @Test + void forward_doesNotDeliverToASessionForADifferentRunId() throws Exception { + RelaySessionRegistry registry = registry(30); + WebSocketSession session = openSession(); + registry.register("run-other", session); + + registry.forward("run-1", "should not be delivered to run-other's session"); + + verify(session, never()).sendMessage(any()); + } + + @Test + void bufferedMessages_olderThanTheConfiguredTtl_areDiscardedRatherThanDeliveredLate() throws Exception { + RelaySessionRegistry registry = registry(1); // 1s TTL — matches doc's "short (seconds)" + + registry.forward("run-1", "{\"type\":\"credential\",\"payload_b64\":\"AAAA\"}"); + Thread.sleep(1300); // TTL elapses; periodic sweep also runs at 1s intervals + + WebSocketSession session = openSession(); + registry.register("run-1", session); + + verify(session, never()) + .sendMessage(any()); + } + + @Test + void unregister_stopsFurtherDelivery_soASubsequentForwardIsBufferedInstead() throws Exception { + RelaySessionRegistry registry = registry(30); + WebSocketSession session = openSession(); + + registry.register("run-1", session); + registry.unregister("run-1", session); + registry.forward("run-1", "arrives after disconnect"); + + verify(session, never()).sendMessage(any()); + } + + private RelaySessionRegistry registry(int bufferTtlSeconds) { + RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties( + "test.locqr.dev", "unused", "unused", + new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Relay(bufferTtlSeconds))); + created.add(registry); + return registry; + } + + private static WebSocketSession openSession() { + WebSocketSession session = mock(WebSocketSession.class); + when(session.isOpen()).thenReturn(true); + return session; + } +} diff --git a/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java b/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java new file mode 100644 index 0000000..58e94ed --- /dev/null +++ b/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java @@ -0,0 +1,71 @@ +package dev.locqr.server.store; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryKeyBundleStoreTest { + + private final InMemoryKeyBundleStore store = new InMemoryKeyBundleStore(); + + @Test + void takeIfPresent_returnsTheBundleOnce_thenEmptyOnASecondFetch() { + KeyBundle bundle = bundleExpiringIn(60); + store.put(bundle); + + assertThat(store.takeIfPresent(bundle.runId())).contains(bundle); + assertThat(store.takeIfPresent(bundle.runId())) + .as("at-most-once: a second fetch for the same run must not see the bundle again") + .isEmpty(); + } + + @Test + void takeIfPresent_isEmpty_forARunIdThatWasNeverUploaded() { + assertThat(store.takeIfPresent("never-uploaded")).isEmpty(); + } + + @Test + void takeIfPresent_treatsAnExpiredBundleAsAbsent_evenThoughItWasNeverFetched() { + KeyBundle expired = new KeyBundle( + "run-1", "https://test.locqr.dev", "x25519pub", "kempub", Instant.now().minusSeconds(1)); + store.put(expired); + + assertThat(store.takeIfPresent("run-1")).isEmpty(); + } + + @Test + void concurrentFetches_onlyOneWinnerSeesTheBundle() throws Exception { + KeyBundle bundle = bundleExpiringIn(60); + store.put(bundle); + + int attempts = 50; + var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts); + var successes = new java.util.concurrent.atomic.AtomicInteger(); + var latch = new java.util.concurrent.CountDownLatch(attempts); + + for (int i = 0; i < attempts; i++) { + pool.submit(() -> { + try { + if (store.takeIfPresent(bundle.runId()).isPresent()) { + successes.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + pool.shutdown(); + + assertThat(successes.get()) + .as("ConcurrentHashMap.remove() must be atomic: exactly one concurrent fetch wins") + .isEqualTo(1); + } + + private static KeyBundle bundleExpiringIn(long seconds) { + return new KeyBundle( + "run-1", "https://test.locqr.dev", "x25519pub", "kempub", Instant.now().plusSeconds(seconds)); + } +} diff --git a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java new file mode 100644 index 0000000..6a7a02e --- /dev/null +++ b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java @@ -0,0 +1,52 @@ +package dev.locqr.server.store; + +import dev.locqr.server.config.LocqrProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class RegistrationCertificateStoreTest { + + @Test + void certFor_returnsTheFileContents_forTheConfiguredTestDomain(@TempDir Path dir) throws Exception { + Path certFile = dir.resolve("test-registration.b64"); + Files.writeString(certFile, "dummy-envelope\n"); + + RegistrationCertificateStore store = + new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString())); + + assertThat(store.certFor("test.locqr.dev")).contains("dummy-envelope"); + } + + @Test + void certFor_isEmpty_forAnyDomainOtherThanTheConfiguredTestDomain(@TempDir Path dir) throws Exception { + Path certFile = dir.resolve("test-registration.b64"); + Files.writeString(certFile, "dummy-envelope"); + + RegistrationCertificateStore store = + new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString())); + + assertThat(store.certFor("evil.example.com")).isEmpty(); + } + + @Test + void certFor_isEmpty_whenTheCertFileDoesNotExist_ratherThanThrowingAtStartup() { + RegistrationCertificateStore store = + new RegistrationCertificateStore(properties("test.locqr.dev", "/definitely/does/not/exist.b64")); + + assertThat(store.certFor("test.locqr.dev")).isEmpty(); + } + + private static LocqrProperties properties(String testDomain, String certPath) { + return new LocqrProperties( + testDomain, + "unused", + certPath, + new LocqrProperties.Run(90, 3, 120), + new LocqrProperties.Relay(8)); + } +} diff --git a/server/src/test/java/dev/locqr/server/support/TestEd25519Keys.java b/server/src/test/java/dev/locqr/server/support/TestEd25519Keys.java new file mode 100644 index 0000000..6b6484d --- /dev/null +++ b/server/src/test/java/dev/locqr/server/support/TestEd25519Keys.java @@ -0,0 +1,40 @@ +package dev.locqr.server.support; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.interfaces.EdECPrivateKey; +import java.util.Base64; + +/** + * Generates a throwaway Ed25519 keypair for tests, in the same raw-32-byte- + * seed format the real dev keys under {@code dev/keys/} are committed in + * (see dev.md). Tests never depend on the committed dev key itself, so they + * stay hermetic and unaffected by dev key rotation. + */ +public final class TestEd25519Keys { + + private TestEd25519Keys() {} + + public record Generated(String secretSeedB64, PrivateKey privateKey, PublicKey publicKey) {} + + public static Generated generate() throws GeneralSecurityException { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); + KeyPair kp = kpg.generateKeyPair(); + EdECPrivateKey priv = (EdECPrivateKey) kp.getPrivate(); + byte[] seed = priv.getBytes().orElseThrow(); + String seedB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(seed); + return new Generated(seedB64, kp.getPrivate(), kp.getPublic()); + } + + public static Path writeSecretKeyFile(Path dir, String seedB64) throws IOException { + Path file = dir.resolve("ed25519-secret.b64"); + Files.writeString(file, seedB64); + return file; + } +} diff --git a/server/src/test/java/dev/locqr/server/util/Base64UrlTest.java b/server/src/test/java/dev/locqr/server/util/Base64UrlTest.java new file mode 100644 index 0000000..afabe51 --- /dev/null +++ b/server/src/test/java/dev/locqr/server/util/Base64UrlTest.java @@ -0,0 +1,30 @@ +package dev.locqr.server.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Base64UrlTest { + + @Test + void encodeDecode_roundTrips_withNoPaddingCharacters() { + byte[] data = {1, 2, 3, 4, 5, 100, (byte) 200, (byte) 255}; + + String encoded = Base64Url.encode(data); + + assertThat(encoded).doesNotContain("="); // interfaces.md: no padding characters + assertThat(Base64Url.decode(encoded)).isEqualTo(data); + } + + @Test + void encode_usesTheUrlSafeAlphabet_notStandardBase64Characters() { + // Bytes chosen so standard base64 would emit '+' and '/'. + byte[] data = {(byte) 0xFB, (byte) 0xFF, (byte) 0xBF, (byte) 0xEF}; + + String standard = java.util.Base64.getEncoder().encodeToString(data); + assertThat(standard).containsAnyOf("+", "/"); // sanity: this input does exercise those chars + + String urlSafe = Base64Url.encode(data); + assertThat(urlSafe).doesNotContain("+", "/"); + } +}