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 |
|
||||
|---|---|---|
|
||||
| Test website | `website/` | `https://test.locqr.dev:5173` |
|
||||
| Node server stub | `server/` | `https://api.locqr.dev:3000` |
|
||||
| Server (Java/Spring) | `server/` | `https://api.locqr.dev:3000` |
|
||||
| Web companion stub | `companion/` | `https://test.locqr.dev:5174` |
|
||||
| Browser extension | `extension/` | Loaded unpacked in browser |
|
||||
|
||||
@ -88,9 +88,11 @@ from this key.
|
||||
**6. Environment files**
|
||||
|
||||
Writes `server/.env.dev` with the dev signing key paths and local domain
|
||||
config, overwriting any previous version. Components read this file at
|
||||
startup; it is not committed (listed in `.gitignore`), as it contains paths
|
||||
that may differ per machine.
|
||||
config for the non-JVM components (companion web stub, test website), and
|
||||
prints the equivalent as environment variable exports for the server, which
|
||||
reads configuration the Spring way (environment variables / `application.yml`,
|
||||
not a `.env` file). Not committed (listed in `.gitignore`), as paths may
|
||||
differ per machine.
|
||||
|
||||
```
|
||||
ML_DSA_44_SECRET_KEY_PATH=../../dev/keys/ml-dsa-44-secret.b64
|
||||
@ -99,6 +101,10 @@ TEST_DOMAIN=test.locqr.dev
|
||||
API_PORT=3000
|
||||
```
|
||||
|
||||
The server's `application.yml` reads the same four settings from environment
|
||||
variables of the same names, with dev-friendly defaults baked in so it runs
|
||||
locally without them being set explicitly (see `server/claude.md`).
|
||||
|
||||
---
|
||||
|
||||
## Hardcoded defaults
|
||||
|
||||
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
|
||||
`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)
|
||||
|
||||
@ -7,51 +7,32 @@ pinned keys. It provides two services: run infrastructure (key bundle
|
||||
storage, token signing, relay) and account management (domain registration,
|
||||
certificates, billing).
|
||||
|
||||
The server exists in two implementations: a **Node.js stub** used during
|
||||
development, and the **Java/Spring production server** which is the long-term
|
||||
target.
|
||||
The server is a single Java/Spring Boot codebase, grown incrementally rather
|
||||
than rewritten between a throwaway prototype and a production system. It is
|
||||
scoped in two tiers by what backs it, not by what protocol logic it runs —
|
||||
signing, at-most-once bundle semantics, and relay forwarding are real from
|
||||
the first commit in both tiers; only infrastructure (storage, accounts,
|
||||
cert issuance) differs.
|
||||
|
||||
---
|
||||
| Concern | Current (PoC) scope | Production scope |
|
||||
|---|---|---|
|
||||
| Key bundle storage | In-memory `ConcurrentHashMap`, atomic `remove()` for at-most-once | Redis (`GETDEL`) |
|
||||
| Domain registrations / accounts | Hardcoded single test domain | PostgreSQL |
|
||||
| ML-DSA-44 signing | Pre-generated cert, read from file at startup (see `../dev.md`) | Live issuance |
|
||||
| WebSocket relay | Spring `TextWebSocketHandler`, in-process | Same — already the production target |
|
||||
| Account management | None | Full SaaS (registration, billing, domain admin) |
|
||||
|
||||
## Node.js stub (development tool)
|
||||
The swappable pieces sit behind narrow interfaces (`KeyBundleStore`, account
|
||||
lookup) so growing a tier means adding a new implementation of an existing
|
||||
interface, not rewriting controllers or crypto code.
|
||||
|
||||
A minimal Express server implementing only the parts the extension and companion
|
||||
actually touch. Its purpose is to allow end-to-end development and testing of
|
||||
the full run flow without the production infrastructure.
|
||||
|
||||
**What it does:**
|
||||
- Stores key bundles in memory; destroys on fetch (at-most-once)
|
||||
- Signs key exchange tokens with Ed25519 (Node built-in `crypto` module)
|
||||
- Returns server-controlled run parameters with each signed token
|
||||
- Accepts WebSocket connections from the extension for relay
|
||||
- Forwards relay messages (kem_ciphertext, encrypted credential) from companion
|
||||
to extension
|
||||
- Serves a pre-generated test domain certificate (ML-DSA-44); does not
|
||||
dynamically issue certificates
|
||||
- Accepts domain status queries; returns valid for the hardcoded test domain
|
||||
|
||||
**What it deliberately omits:**
|
||||
- No persistence — all state is in-memory
|
||||
- No ML-DSA-44 signing at runtime (test cert pre-generated by the dev setup
|
||||
script in `../dev.md`; the stub reads it from `dev/certs/test-registration.b64`
|
||||
at startup)
|
||||
- No account management
|
||||
- No billing or registration flows
|
||||
|
||||
---
|
||||
|
||||
## Production server
|
||||
|
||||
Java/Spring. Deferred. The Node stub defines the API contract that production
|
||||
must honour.
|
||||
|
||||
| Concern | Production |
|
||||
|---|---|
|
||||
| Key bundle storage | Redis (`GETDEL` for atomic at-most-once delivery) |
|
||||
| Domain registrations / accounts | PostgreSQL |
|
||||
| ML-DSA-44 signing | BouncyCastle or liboqs-java |
|
||||
| WebSocket relay | Spring WebSocket |
|
||||
| Account management | Full SaaS (registration, billing, domain admin) |
|
||||
Ed25519 signing and SHA-256 hashing are the only cryptographic operations the
|
||||
server performs at runtime, and both use the JDK's built-in `java.security`
|
||||
support — no external crypto library is needed for either. ML-KEM-768 never
|
||||
runs server-side (encapsulation/decapsulation are client-side only, per
|
||||
`../crypto.md`), and ML-DSA-44 signing only happens offline, in the dev setup
|
||||
script, not in the running server — so there is no unproven PQ library
|
||||
integration on the critical path to a running server.
|
||||
|
||||
---
|
||||
|
||||
@ -125,9 +106,11 @@ an error.
|
||||
Response: `{ x25519_pubkey_b64, kem_pubkey_b64 }` or `404` if already consumed
|
||||
or expired.
|
||||
|
||||
This is a load-bearing security property. Node stub uses `Map.get()` +
|
||||
`Map.delete()` (safe for single-process development). Production uses Redis
|
||||
`GETDEL`.
|
||||
This is a load-bearing security property. Current scope uses
|
||||
`ConcurrentHashMap.remove(runId)`, which is atomic in Java — a genuine
|
||||
at-most-once guarantee under concurrent requests, not just "safe enough for
|
||||
single-process development." Production uses Redis `GETDEL` for the same
|
||||
guarantee across multiple server instances.
|
||||
|
||||
**`GET /run/relay/:runId` (WebSocket upgrade — extension only)**
|
||||
The extension upgrades this endpoint to a WebSocket connection immediately after
|
||||
@ -148,9 +131,24 @@ and the run must be restarted.
|
||||
|
||||
### Admin / website endpoints
|
||||
|
||||
Domain registration and account management. Deferred for the stub; stub serves
|
||||
a hardcoded test domain certificate. Production endpoints are a full SaaS
|
||||
concern.
|
||||
Domain registration and account management. Deferred beyond the one endpoint
|
||||
below; current scope serves a hardcoded test domain certificate. Production
|
||||
endpoints are a full SaaS concern.
|
||||
|
||||
**`GET /domain/registration?domain=<hostname>`**
|
||||
Serves the pre-generated registration certificate for a registered domain.
|
||||
Fetched by the test website on load and passed to `locqr.init({ cert })`.
|
||||
|
||||
Response: `{ cert: "<base64url envelope>" }` (the same envelope format
|
||||
defined under *Registration certificate* in `../interfaces.md`), or `404` if
|
||||
the domain has no certificate on file.
|
||||
|
||||
Current scope serves only the one hardcoded test domain, reading
|
||||
`dev/certs/test-registration.b64` at startup — see `../dev.md`.
|
||||
|
||||
This endpoint was not fully specified in `../interfaces.md` before this
|
||||
pass; it's been added there as the normative definition. Flagging it since
|
||||
it's new, not carried over from an existing decision.
|
||||
|
||||
---
|
||||
|
||||
|
||||
66
server/pom.xml
Normal file
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