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 | | Component | Directory | Default URL |
|---|---|---| |---|---|---|
| Test website | `website/` | `https://test.locqr.dev:5173` | | 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` | | Web companion stub | `companion/` | `https://test.locqr.dev:5174` |
| Browser extension | `extension/` | Loaded unpacked in browser | | Browser extension | `extension/` | Loaded unpacked in browser |
@ -88,9 +88,11 @@ from this key.
**6. Environment files** **6. Environment files**
Writes `server/.env.dev` with the dev signing key paths and local domain Writes `server/.env.dev` with the dev signing key paths and local domain
config, overwriting any previous version. Components read this file at config for the non-JVM components (companion web stub, test website), and
startup; it is not committed (listed in `.gitignore`), as it contains paths prints the equivalent as environment variable exports for the server, which
that may differ per machine. 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 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 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 ## 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 Subdomains are not implicitly covered — a cert for `locqr.dev` does not cover
`sub.locqr.dev`. `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) ## 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, storage, token signing, relay) and account management (domain registration,
certificates, billing). certificates, billing).
The server exists in two implementations: a **Node.js stub** used during The server is a single Java/Spring Boot codebase, grown incrementally rather
development, and the **Java/Spring production server** which is the long-term than rewritten between a throwaway prototype and a production system. It is
target. 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 Ed25519 signing and SHA-256 hashing are the only cryptographic operations the
actually touch. Its purpose is to allow end-to-end development and testing of server performs at runtime, and both use the JDK's built-in `java.security`
the full run flow without the production infrastructure. support — no external crypto library is needed for either. ML-KEM-768 never
runs server-side (encapsulation/decapsulation are client-side only, per
**What it does:** `../crypto.md`), and ML-DSA-44 signing only happens offline, in the dev setup
- Stores key bundles in memory; destroys on fetch (at-most-once) script, not in the running server — so there is no unproven PQ library
- Signs key exchange tokens with Ed25519 (Node built-in `crypto` module) integration on the critical path to a running server.
- 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) |
--- ---
@ -125,9 +106,11 @@ an error.
Response: `{ x25519_pubkey_b64, kem_pubkey_b64 }` or `404` if already consumed Response: `{ x25519_pubkey_b64, kem_pubkey_b64 }` or `404` if already consumed
or expired. or expired.
This is a load-bearing security property. Node stub uses `Map.get()` + This is a load-bearing security property. Current scope uses
`Map.delete()` (safe for single-process development). Production uses Redis `ConcurrentHashMap.remove(runId)`, which is atomic in Java — a genuine
`GETDEL`. 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)** **`GET /run/relay/:runId` (WebSocket upgrade — extension only)**
The extension upgrades this endpoint to a WebSocket connection immediately after The extension upgrades this endpoint to a WebSocket connection immediately after
@ -148,9 +131,24 @@ and the run must be restarted.
### Admin / website endpoints ### Admin / website endpoints
Domain registration and account management. Deferred for the stub; stub serves Domain registration and account management. Deferred beyond the one endpoint
a hardcoded test domain certificate. Production endpoints are a full SaaS below; current scope serves a hardcoded test domain certificate. Production
concern. 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("+", "/");
}
}