one shot server and tests

This commit is contained in:
Rasmus Neikes 2026-08-16 13:34:51 +02:00
parent ee1b7e1667
commit 22077d40a8
38 changed files with 1513 additions and 52 deletions

1
.codebuddy/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
db/

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
test/test-keys.json
test/tls/
node_modules/
companion/dist/
extension/dist/
server/target/
.idea/

14
dev.md
View File

@ -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

View File

@ -0,0 +1 @@
Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4

View File

@ -0,0 +1 @@
DETakRAJiPGTOOp0WsFZ9ez5y4rmf0pJlJtf2V1hBB0

View File

@ -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=<hostname>`** — 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": "<base64url envelope>" }``cert` is exactly the
base64url-encoded envelope string described above, unmodified. `404` if
`domain` has no certificate on file.
---
## Signed token (QR payload)

View File

@ -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=<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.
---

66
server/pom.xml Normal file
View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>dev.locqr</groupId>
<artifactId>server</artifactId>
<version>0.1.0</version>
<name>locqr-server</name>
<description>LOCQR backend: run infrastructure (key bundle storage, token signing, relay) and account management.</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RFC 8785 (JCS) canonical JSON — pinned in ../crypto.md. Same author's
library across every platform (Node, browser, Android, Java/Spring)
for maximum byte-identical-output confidence. Hand-rolled JCS is not
permitted. -->
<dependency>
<groupId>io.github.erdtman</groupId>
<artifactId>java-json-canonicalization</artifactId>
<version>1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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) {}
}

View File

@ -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;
}
}

View File

@ -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<RegistrationResponse> registration(@RequestParam String domain) {
return certificates.certFor(domain)
.map(cert -> ResponseEntity.ok(new RegistrationResponse(cert)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@ -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<String, Object> 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<RunBundleFetchResponse> fetchBundle(@PathVariable String runId) {
Optional<KeyBundle> bundle = store.takeIfPresent(runId);
return bundle
.map(b -> ResponseEntity.ok(new RunBundleFetchResponse(b.x25519PubkeyB64(), b.kemPubkeyB64())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@ -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<Void> report(@RequestBody SecurityReportRequest request) {
log.warn("Security report: type={} runId={} timestamp={}",
request.errorType(), request.runId(), request.timestamp());
return ResponseEntity.ok().build();
}
}

View File

@ -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.
*
* <p>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<String, Object> 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<String, Object> signKeyExchangeToken(String runId, String url, long expiresAt, byte[] alphaHash) {
Map<String, Object> 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<String, Object> envelope = new LinkedHashMap<>(payload);
envelope.put("sig", Base64Url.encode(sig));
return envelope;
}
public String toBase64UrlJson(Map<String, Object> envelope) {
try {
return Base64Url.encode(objectMapper.writeValueAsBytes(envelope));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

@ -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";
}

View File

@ -0,0 +1,5 @@
package dev.locqr.server.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
public record RegistrationResponse(@JsonProperty("cert") String cert) {}

View File

@ -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);
}
}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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];
}
}

View File

@ -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<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Map<String, Queue<Buffered>> 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<Buffered> 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();
}
}

View File

@ -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];
}
}

View File

@ -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<String, KeyBundle> bundles = new ConcurrentHashMap<>();
@Override
public void put(KeyBundle bundle) {
bundles.put(bundle.runId(), bundle);
}
@Override
public Optional<KeyBundle> 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);
}
}

View File

@ -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);
}
}

View File

@ -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<KeyBundle> takeIfPresent(String runId);
}

View File

@ -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<String> cert;
public RegistrationCertificateStore(LocqrProperties properties) {
this.testDomain = properties.testDomain();
this.cert = load(properties.registrationCertPath());
}
private static Optional<String> 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<String> certFor(String domain) {
if (!testDomain.equals(domain)) {
return Optional.empty();
}
return cert;
}
}

View File

@ -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));
}
}

View File

@ -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

View File

@ -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<RunBundleUploadResponse> 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<RunBundleFetchResponse> 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<RunBundleFetchResponse> 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<RunBundleFetchResponse> 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<DomainStatusResponse> valid = restTemplate.getForEntity(
"/domain/status?domain={d}", DomainStatusResponse.class, "test.locqr.dev");
assertThat(valid.getBody().status()).isEqualTo(DomainStatusResponse.VALID);
ResponseEntity<DomainStatusResponse> 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<RegistrationResponse> 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<RegistrationResponse> 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<Void> 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<Void> bufferedPost =
restTemplate.postForEntity("/run/relay/{runId}", buffered, Void.class, runId);
assertThat(bufferedPost.getStatusCode()).isEqualTo(HttpStatus.OK);
BlockingQueue<String> 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<Void> 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<Void> 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);
}
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> envelope = signing.signKeyExchangeToken(
"run-1", "https://test.locqr.dev", 1748390460L, alphaHash);
byte[] sig = Base64.getUrlDecoder().decode((String) envelope.get("sig"));
Map<String, Object> 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));
}
}

View File

@ -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<RelaySessionRegistry> 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;
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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("+", "/");
}
}