one shot server and tests
This commit is contained in:
parent
ee1b7e1667
commit
22077d40a8
1
.codebuddy/.gitignore
vendored
Normal file
1
.codebuddy/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
db/
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
test/test-keys.json
|
||||||
|
test/tls/
|
||||||
|
node_modules/
|
||||||
|
companion/dist/
|
||||||
|
extension/dist/
|
||||||
|
server/target/
|
||||||
|
.idea/
|
||||||
14
dev.md
14
dev.md
@ -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
|
||||||
|
|||||||
1
dev/keys/ed25519-public.b64
Normal file
1
dev/keys/ed25519-public.b64
Normal file
@ -0,0 +1 @@
|
|||||||
|
Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4
|
||||||
1
dev/keys/ed25519-secret.b64
Normal file
1
dev/keys/ed25519-secret.b64
Normal file
@ -0,0 +1 @@
|
|||||||
|
DETakRAJiPGTOOp0WsFZ9ez5y4rmf0pJlJtf2V1hBB0
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
66
server/pom.xml
Normal 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>
|
||||||
13
server/src/main/java/dev/locqr/server/ServerApplication.java
Normal file
13
server/src/main/java/dev/locqr/server/ServerApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
server/src/main/java/dev/locqr/server/crypto/SigningService.java
Normal file
123
server/src/main/java/dev/locqr/server/crypto/SigningService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package dev.locqr.server.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public record RegistrationResponse(@JsonProperty("cert") String cert) {}
|
||||||
19
server/src/main/java/dev/locqr/server/dto/RelayMessage.java
Normal file
19
server/src/main/java/dev/locqr/server/dto/RelayMessage.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
server/src/main/java/dev/locqr/server/store/KeyBundle.java
Normal file
19
server/src/main/java/dev/locqr/server/store/KeyBundle.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
server/src/main/java/dev/locqr/server/util/Base64Url.java
Normal file
25
server/src/main/java/dev/locqr/server/util/Base64Url.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/src/main/resources/application.yml
Normal file
13
server/src/main/resources/application.yml
Normal 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
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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("+", "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user