Skip to content

Latest commit

 

History

History
786 lines (592 loc) · 34.1 KB

File metadata and controls

786 lines (592 loc) · 34.1 KB

Server Configuration Guide

This document covers all server-side configuration for the Hofmann Dropwizard bundle (hofmann-dropwizard) and the corresponding client-side requirements that must match.

If you integrate hofmann-elimination into your own server framework instead of using the Dropwizard bundle, you should have a mechanism to provide these configurations suitable for your environment. Some facilities will use an HSM to provide these keys, or from the database.


Configuration reference

All fields below are YAML properties in HofmannConfiguration. Every field has a default; fields marked required for production will cause incorrect or insecure behaviour if left at their default in a real deployment.

OPAQUE

Field Default Required for production Description
opaqueCipherSuite P256_SHA256 No Cipher suite for OPAQUE. Accepted values: P256_SHA256, P384_SHA384, P521_SHA512. Must match the client exactly.
context hofmann-opaque-v1 Yes Application context string bound into the OPAQUE preamble. Must be unique per deployment. Shared between server and client out-of-band.
serverKeySeedHex "" (random) Yes Hex-encoded 32-byte seed that deterministically derives the server's long-term AKE key pair. Generate with openssl rand -hex 32.
oprfSeedHex "" (random) Yes Hex-encoded 32-byte seed that deterministically derives per-credential OPRF keys. Generate with openssl rand -hex 32. Must be set together with serverKeySeedHex — providing only one throws IllegalStateException on startup.
previousServerKeySeedHex "" No Previous server key seed for key rotation. Credentials registered under these keys remain authenticatable. See OPAQUE key rotation.
previousOprfSeedHex "" No Previous OPRF seed for key rotation. Must be set together with previousServerKeySeedHex or both omitted.
argon2MemoryKib 65536 Yes Argon2id memory cost in kibibytes. Set to 0 to disable Argon2 (identity KSF — for testing only). See Argon2id consistency below.
argon2Iterations 3 Yes Argon2id iteration count. Ignored when argon2MemoryKib is 0.
argon2Parallelism 1 Yes Argon2id parallelism. Ignored when argon2MemoryKib is 0.

OPRF (standalone endpoint)

Field Default Required for production Description
oprfCipherSuite P256_SHA256 No Cipher suite for the standalone /oprf endpoint. Independent of opaqueCipherSuite.
oprfMasterKeyHex "" (random) Yes Hex-encoded scalar used as the OPRF master key. Must be a valid non-zero scalar in the chosen curve group. Generate with openssl rand -hex 32. An empty value generates a random key on each startup, making OPRF outputs unstable across restarts.
oprfProcessorId hofmann-oprf-v1 No Human-readable identifier returned in every OPRF response. Useful for tracing which key produced a given output during key rotation.

JWT

Field Default Required for production Description
jwtSecretHex "" (random) Yes Hex-encoded 32-byte HMAC-SHA256 signing secret. Generate with openssl rand -hex 32. An empty value generates a random secret on each startup, invalidating all tokens after a restart.
jwtPreviousSecretHex "" No Hex-encoded previous signing secret for key rotation. Tokens signed with this key are accepted for verification while new tokens are signed with jwtSecretHex. See JWT key rotation.
jwtTtlSeconds 3600 No Token time-to-live in seconds.
jwtIssuer hofmann No Value placed in the JWT iss claim.

Security

Field Default Required for production Description
maxRequestBodyBytes 65536 No Requests with a Content-Length header exceeding this value are rejected with HTTP 413 before the body is read. The largest OPAQUE message is well under 64 KiB; raise this only if you have a specific reason.

Key material and restart behaviour

serverKeySeedHex and oprfSeedHex control whether the server's OPAQUE keys are stable across restarts.

  • Both empty — keys are randomly generated at startup. Any registered user's credentials become cryptographically invalid when the process restarts. Suitable for integration tests and the hofmann-testserver Docker image, where data loss on restart is acceptable.

  • Both set — keys are derived deterministically from the seeds. The server's long-term AKE public key stays the same across restarts, so registered credentials remain valid. Required for production.

  • Only one set — the bundle throws IllegalStateException on startup. Both seeds must be provided together or both omitted.

The same principle applies to jwtSecretHex (tokens survive restart) and oprfMasterKeyHex (OPRF outputs are stable across restarts).


Client configuration

Auto-configuration (recommended)

Both HofmannOpaqueClientManager and HofmannOprfClientManager auto-configure themselves on first use by calling the server's config endpoint (GET /opaque/config or GET /oprf/config). The response is cached per server, so the endpoint is hit exactly once regardless of how many operations follow.

// OPAQUE — no config needed; cipher suite, context, and Argon2id params
// are fetched automatically from GET /opaque/config on first use.
HofmannOpaqueClientManager opaqueManager =
    new HofmannOpaqueClientManager(opaqueAccessor);

// OPRF — cipher suite fetched from GET /oprf/config on first use.
HofmannOprfClientManager oprfManager =
    new HofmannOprfClientManager(oprfAccessor);

Both classes are @Singleton-annotated and suitable for injection by Dagger, Guice, or Spring. The accessor requires only a Map<ServerIdentifier, ServerConnectionInfo> that maps each logical server name to its base URL — no cipher suite or Argon2id parameters needed.

TypeScript / browser

The TypeScript clients have matching factory methods:

// OPAQUE — fetches /opaque/config and configures KSF automatically
const opaqueClient = await OpaqueHttpClient.create('https://api.example.com');
// opaqueClient.configResponse holds the parsed OpaqueConfigResponseDto if needed

// OPRF — fetches /oprf/config and stores it in cachedConfig
const oprfClient = await OprfHttpClient.create('https://api.example.com');
// oprfClient.cachedConfig holds { cipherSuite: string } if needed

Per-server config overrides (CLI / advanced use)

For tools that must work offline or where specific parameters must be pinned, supply a Map<ServerIdentifier, OpaqueClientConfig> (or OprfClientConfig) as the second argument. Servers present in the map skip the auto-fetch; others still auto-fetch.

// Pins exact parameters for one server; other servers are still auto-fetched.
Map<ServerIdentifier, OpaqueClientConfig> overrides = Map.of(
    myServerId,
    OpaqueClientConfig.withArgon2id("P256_SHA256", "my-app", 65536, 3, 1)
);
HofmannOpaqueClientManager manager =
    new HofmannOpaqueClientManager(accessor, overrides);

This is the pattern used by the OpaqueCli / OprfCli command-line tools.


Argon2id consistency between server and client

The Argon2id KSF runs on the client, not the server. The client calls it during both registration (finalizeRegistration) and authentication (generateKE3) to derive randomizedPwd:

randomizedPwd = HKDF-Extract("", oprfOutput || Argon2id(oprfOutput))

The server never executes Argon2id. It stores only the already-stretched output (inside the envelope and maskingKey).

When using auto-configuration (the default), the client fetches these parameters from GET /opaque/config and applies them automatically. No manual alignment is required.

When using config overrides, the parameters must match the server exactly. A mismatch causes silent authentication failures:

  • Registration appears to succeed — the server stores whatever the client sends.
  • Authentication always fails — the client derives a different randomizedPwd and therefore a different maskingKey. The OPAQUE MAC check fails with a SecurityException, indistinguishable from a wrong password.

Parameters that must match (override path only)

Server config field OpaqueClientConfig factory argument
opaqueCipherSuite withArgon2id(suiteName, ...)
context withArgon2id(..., context, ...)
argon2MemoryKib withArgon2id(..., memory, ...)
argon2Iterations withArgon2id(..., iterations, ...)
argon2Parallelism withArgon2id(..., parallelism, ...)

Test-only shortcut (identity KSF)

OpaqueClientConfig.forTesting(context) uses identity KSF (no Argon2). It only works correctly against a server with argon2MemoryKib: 0. Do not use it against the hofmann-testserver (which has Argon2 enabled) or any production server.

Changing Argon2id parameters

Changing any Argon2id parameter after users have registered invalidates all existing registrations. Every affected user must re-register from scratch. Plan parameter upgrades (e.g., increasing memory cost) as a full re-registration migration.


Dropwizard integration

Add the bundle in your Application.initialize():

// Dev / test — in-memory stores, ephemeral keys, logs prominent warnings
bootstrap.addBundle(new HofmannBundle<>());

// Production — persistent stores, key from config
bootstrap.addBundle(new HofmannBundle<>(myCredentialStore, mySessionStore, null));

// Production with OPRF key rotation
bootstrap.addBundle(new HofmannBundle<>(
    myCredentialStore,
    mySessionStore,
    () -> keyRotationService.currentDetail()));

// Custom SecureRandom (e.g., HSM-backed)
bootstrap.addBundle(new HofmannBundle<>().withSecureRandom(mySecureRandom));

CredentialStore and SessionStore are interfaces in hofmann-server. You must provide implementations backed by a database or distributed cache for production use. The bundle's no-arg constructor uses InMemoryCredentialStore and InMemorySessionStore, which lose all data on restart.

When using persistent stores and processorDetailSupplier = null, oprfMasterKeyHex must be set in the configuration. Omitting it throws IllegalStateException on startup.


Spring Boot integration

Add the autoconfiguration dependency:

dependencies {
    implementation 'com.codeheadsystems.hofmann:hofmann-springboot:<version>'
}

Autoconfiguration activates automatically. Every bean is @ConditionalOnMissingBean — override any by declaring your own @Bean:

// Persistent credential store backed by your database
@Bean
public CredentialStore credentialStore(MyDatabaseRepository repo) {
    return new MyDatabaseCredentialStore(repo);
}

// Persistent session store backed by Redis
@Bean
public SessionStore sessionStore(RedisTemplate<String, byte[]> redis) {
    return new RedisSessionStore(redis);
}

// HSM-backed random source
@Bean
public SecureRandom secureRandom() {
    return myHsmSecureRandom();
}

// OPRF key rotation via dynamic supplier
@Bean
public Supplier<ServerProcessorDetail> serverProcessorDetailSupplier(KeyRotationService svc) {
    return () -> new ServerProcessorDetail(svc.currentKey(), svc.currentKeyId());
}

// JWT key rotation via dynamic supplier
@Bean
public Supplier<JwtKeyDetail> jwtKeyDetailSupplier(MySecretsManager secrets) {
    return () -> new JwtKeyDetail(secrets.currentJwtKey(), secrets.previousJwtKey());
}

Configure in application.yml using the same field names as the Dropwizard YAML table above, prefixed with hofmann.:

hofmann:
  opaque-cipher-suite: P256_SHA256
  context: my-app-v1
  server-key-seed-hex: <output of openssl rand -hex 32>
  oprf-seed-hex: <output of openssl rand -hex 32>
  oprf-master-key-hex: <output of openssl rand -hex 32>
  jwt-secret-hex: <output of openssl rand -hex 32>
  jwt-previous-secret-hex: ""   # set to old jwt-secret-hex during key rotation
  argon2-memory-kib: 65536
  argon2-iterations: 3
  argon2-parallelism: 1

Custom / bare framework integration

If you are not using Dropwizard or Spring Boot, add the framework-agnostic server module:

dependencies {
    implementation 'com.codeheadsystems.hofmann:hofmann-server:<version>'
}

Then wire the protocol stack yourself:

import static java.nio.charset.StandardCharsets.UTF_8;

// 1. Choose cipher suite and build config
OpaqueConfig config = OpaqueConfig.withArgon2id(
    "my-app-v1".getBytes(UTF_8),   // context — must match every client
    65536, 3, 1                     // Argon2id memory KiB / iterations / parallelism
);

// 2. Derive the server key pair and OPRF seed from hex seeds
byte[] serverKeySeed = hexToBytes(env.getRequired("SERVER_KEY_SEED_HEX"));
byte[] oprfSeed      = hexToBytes(env.getRequired("OPRF_SEED_HEX"));
AkeKeyPair kp = config.cipherSuite().deriveAkeKeyPair(serverKeySeed);
Server server = new Server(kp.privateKeyBytes(), kp.publicKeyBytes(), oprfSeed, config);

// 3. Build the standalone OPRF supplier (supports hot key rotation)
BigInteger masterKey = new BigInteger(1, hexToBytes(env.getRequired("OPRF_MASTER_KEY_HEX")));
Supplier<ServerProcessorDetail> oprfSupplier =
    () -> new ServerProcessorDetail(masterKey, "key-v1");

// 4. Provide persistent credential and session stores
CredentialStore credentialStore = new MyDatabaseCredentialStore(dataSource);
SessionStore    sessionStore    = new MyRedisSessionStore(redisClient);

// 5. Build the JWT manager (supports key rotation via Supplier<JwtKeyDetail>)
byte[] jwtSecret = hexToBytes(env.getRequired("JWT_SECRET_HEX"));
JwtManager jwt   = new JwtManager(jwtSecret, "my-app", 3600L, sessionStore);

// 6. Build the framework-agnostic protocol manager
HofmannOpaqueServerManager manager =
    new HofmannOpaqueServerManager(server, credentialStore, jwt);

// 7. Optionally build the standalone OPRF manager
OprfServerManager oprfManager = new OprfServerManager(
    OprfCipherSuite.P256_SHA256, oprfSupplier);

Expose the manager methods through your own HTTP layer. Exception mapping:

Exception thrown HTTP status
IllegalArgumentException 400 Bad Request
SecurityException 401 Unauthorized
IllegalStateException 503 Service Unavailable

Implementing CredentialStore

CredentialStore persists one RegistrationRecord per user. The key is a credentialIdentifier byte array — the user's canonical, stable identifier (see Credential identifier below).

public interface CredentialStore {
    void                         store(byte[] credentialIdentifier, RegistrationRecord record);
    Optional<RegistrationRecord> load(byte[] credentialIdentifier);
    void                         delete(byte[] credentialIdentifier);
}

All three methods must be thread-safe. A minimal JDBC implementation:

public class JdbcCredentialStore implements CredentialStore {

    public void store(byte[] id, RegistrationRecord record) {
        // UPSERT: id (BYTEA primary key), record_bytes (BYTEA)
        jdbcTemplate.update(
            "INSERT INTO credentials(id, record_bytes) VALUES (?, ?) " +
            "ON CONFLICT(id) DO UPDATE SET record_bytes = EXCLUDED.record_bytes",
            id, record.serialize());
    }

    public Optional<RegistrationRecord> load(byte[] id) {
        List<byte[]> rows = jdbcTemplate.query(
            "SELECT record_bytes FROM credentials WHERE id = ?",
            (rs, n) -> rs.getBytes(1), id);
        return rows.isEmpty()
            ? Optional.empty()
            : Optional.of(RegistrationRecord.deserialize(rows.get(0)));
    }

    public void delete(byte[] id) {
        jdbcTemplate.update("DELETE FROM credentials WHERE id = ?", id);
    }
}

Record size guide: a RegistrationRecord serializes to approximately Npk + Nh + 96 bytes. For P-256 that is roughly 160 bytes; for P-521 roughly 224 bytes. A BYTEA / BLOB column of 512 bytes is more than sufficient for all supported cipher suites.


Implementing SessionStore

SessionStore maps JWT IDs (jti, UUID strings) to SessionData and must support efficient bulk revocation per user.

public interface SessionStore {
    void                  store(String jti, SessionData sessionData);
    Optional<SessionData> load(String jti);
    void                  revoke(String jti);
    void                  revokeByCredentialIdentifier(String credentialIdentifierBase64);
}

revokeByCredentialIdentifier is called when a user deletes their credential record. Implement it efficiently: for small deployments a full scan is acceptable; for production use a secondary index keyed by user (e.g., a Redis Set of JTIs per user, or a credentialId column with an index in SQL).

Session records are short-lived (default TTL 3600 seconds), so the table or key-space stays small under normal load.


Generating and managing key material

Generate all secrets with:

openssl rand -hex 32
Secret Config field Size Purpose
Server AKE seed serverKeySeedHex 32 bytes Deterministically derives the server's long-term OPAQUE key pair
OPRF seed oprfSeedHex 32 bytes Deterministically derives the per-user OPRF evaluation key
OPRF master key oprfMasterKeyHex 32 bytes Evaluation key for the standalone /oprf endpoint
JWT signing secret jwtSecretHex 32 bytes HMAC-SHA256 signing key for session tokens

All four must be set for a stable production deployment. Omitting any one causes either IllegalStateException on startup (seed pair) or non-deterministic output across restarts (OPRF master key, JWT secret).

Never commit secrets to source control. Use one of the patterns below to inject them at runtime.

Injecting secrets from environment variables

Both Spring Boot and Dropwizard support environment variable substitution in their config files natively. This is the simplest approach and works with any secret management system that can set environment variables (Docker, Kubernetes, systemd, CI/CD pipelines).

Spring Boot (application.yml):

hofmann:
  server-key-seed-hex: ${SERVER_KEY_SEED_HEX}
  oprf-seed-hex: ${OPRF_SEED_HEX}
  oprf-master-key-hex: ${OPRF_MASTER_KEY_HEX}
  jwt-secret-hex: ${JWT_SECRET_HEX}

Dropwizard (config.yml):

Dropwizard uses the ${ENV_VAR} syntax with the EnvironmentVariableSubstitutor:

serverKeySeedHex: ${SERVER_KEY_SEED_HEX}
oprfSeedHex: ${OPRF_SEED_HEX}
oprfMasterKeyHex: ${OPRF_MASTER_KEY_HEX}
jwtSecretHex: ${JWT_SECRET_HEX}

Docker / Docker Compose:

services:
  app:
    environment:
      SERVER_KEY_SEED_HEX: ${SERVER_KEY_SEED_HEX}
      OPRF_SEED_HEX: ${OPRF_SEED_HEX}
      OPRF_MASTER_KEY_HEX: ${OPRF_MASTER_KEY_HEX}
      JWT_SECRET_HEX: ${JWT_SECRET_HEX}

Populate from a .env file (not committed), a CI/CD secret store, or a secrets manager sidecar.

Kubernetes:

apiVersion: v1
kind: Secret
metadata:
  name: hofmann-secrets
type: Opaque
stringData:
  SERVER_KEY_SEED_HEX: "<value>"
  OPRF_SEED_HEX: "<value>"
  OPRF_MASTER_KEY_HEX: "<value>"
  JWT_SECRET_HEX: "<value>"
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          envFrom:
            - secretRef:
                name: hofmann-secrets

For managed secret stores (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager), use your platform's sidecar or init container to populate environment variables before the application starts. The Hofmann library itself does not integrate with any specific secrets manager — it reads hex strings from configuration, and the injection mechanism is an infrastructure concern.

Credential identifier

The credential identifier names the user inside your CredentialStore. Choose a value that is:

  • Stable — never changes for a given user (changing it orphans the credential record)
  • Canonical — always the same bytes for the same user (e.g., lower-case before encoding)
  • Globally unique within your deployment

Common choices:

// Lower-cased email address
byte[] credId = email.toLowerCase(Locale.ROOT).getBytes(UTF_8);

// Binary UUID (compact)
UUID uuid = UUID.fromString(userId);
ByteBuffer buf = ByteBuffer.allocate(16);
buf.putLong(uuid.getMostSignificantBits());
buf.putLong(uuid.getLeastSignificantBits());
byte[] credId = buf.array();

The identifier is never transmitted in plaintext — it is hashed into the OPRF evaluation — but you must store the mapping between it and the user record in your own database so you can look up the credential during authentication.

JWT key rotation

JWT signing keys can be rotated without invalidating in-flight sessions by using the jwtPreviousSecretHex field. During rotation, tokens signed with the previous key are still accepted for verification while all new tokens are signed with the current key.

Step-by-step rotation:

  1. Generate a new secret:

    NEW_JWT_SECRET=$(openssl rand -hex 32)
  2. Deploy with the new secret as jwtSecretHex and the old secret as jwtPreviousSecretHex:

    Spring Boot (application.yml):

    hofmann:
      jwt-secret-hex: ${NEW_JWT_SECRET_HEX}
      jwt-previous-secret-hex: ${OLD_JWT_SECRET_HEX}

    Dropwizard (config.yml):

    jwtSecretHex: ${NEW_JWT_SECRET_HEX}
    jwtPreviousSecretHex: ${OLD_JWT_SECRET_HEX}
  3. After one TTL period (default 1 hour), all tokens signed with the old key have expired. Remove jwtPreviousSecretHex on the next deploy:

    hofmann:
      jwt-secret-hex: ${NEW_JWT_SECRET_HEX}
      jwt-previous-secret-hex: ""

Dynamic rotation (without restart):

For systems that need to rotate keys without redeploying, provide a custom Supplier<JwtKeyDetail> that returns the current and previous keys from your secrets manager. The supplier is called on every sign and verify operation, so key changes take effect immediately.

Spring Boot:

@Bean
public Supplier<JwtKeyDetail> jwtKeyDetailSupplier(MySecretsManager secrets) {
    return () -> new JwtKeyDetail(
        secrets.currentJwtKey(),
        secrets.previousJwtKey());  // null when no rotation in progress
}

Dropwizard:

bootstrap.addBundle(new HofmannBundle<>(credentialStore, sessionStore, null)
    .withJwtKeyDetailSupplier(() -> new JwtKeyDetail(
        secrets.currentJwtKey(),
        secrets.previousJwtKey())));

Custom / bare framework:

Supplier<JwtKeyDetail> supplier = () -> new JwtKeyDetail(
    secrets.currentJwtKey(),
    secrets.previousJwtKey());
JwtManager jwt = new JwtManager(supplier, "my-app", 3600L, sessionStore);

When a custom Supplier<JwtKeyDetail> is provided, the jwtSecretHex and jwtPreviousSecretHex configuration fields are ignored.

OPRF key rotation (standalone endpoint)

The Supplier<ServerProcessorDetail> pattern allows hot key rotation without a restart:

public class KeyRotationService {
    private volatile ServerProcessorDetail current;

    public ServerProcessorDetail current() { return current; }

    // Called by your key management system when a new key is active
    public void rotate(BigInteger newKey, String newKeyId) {
        current = new ServerProcessorDetail(newKey, newKeyId);
    }
}

OprfServerManager oprfManager = new OprfServerManager(
    OprfCipherSuite.P256_SHA256,
    keyRotationService::current);

The processorIdentifier string (e.g., "key-v2") is returned in every /oprf response so callers can track which key version produced a given output. Keep previous key versions available until all in-flight derived values have been re-derived under the new key.

OPAQUE key rotation

Every OPAQUE registration record is cryptographically bound to the server's oprfSeed and serverPrivateKey that were active at registration time. Simply replacing serverKeySeedHex or oprfSeedHex would silently invalidate all existing registrations.

To rotate safely, keep old keys available for authentication while new registrations use the new keys. Clients automatically re-register via the change-password flow when they see the keyRotationRequired flag in the auth response.

Step-by-step rotation:

  1. Generate new seeds:

    NEW_SERVER_KEY_SEED=$(openssl rand -hex 32)
    NEW_OPRF_SEED=$(openssl rand -hex 32)
  2. Deploy with new seeds as current and old seeds as previous:

    Spring Boot (application.yml):

    hofmann:
      server-key-seed-hex: ${NEW_SERVER_KEY_SEED_HEX}
      oprf-seed-hex: ${NEW_OPRF_SEED_HEX}
      previous-server-key-seed-hex: ${OLD_SERVER_KEY_SEED_HEX}
      previous-oprf-seed-hex: ${OLD_OPRF_SEED_HEX}

    Dropwizard (config.yml):

    serverKeySeedHex: ${NEW_SERVER_KEY_SEED_HEX}
    oprfSeedHex: ${NEW_OPRF_SEED_HEX}
    previousServerKeySeedHex: ${OLD_SERVER_KEY_SEED_HEX}
    previousOprfSeedHex: ${OLD_OPRF_SEED_HEX}
  3. Users log in gradually. Each login authenticates with the old keys, then the Java and TypeScript clients automatically re-register the credential under the new keys (via the existing change-password flow). This is transparent to the user.

  4. Monitor migration progress by querying your CredentialStore for remaining version 0 records.

  5. After all credentials are migrated (or after a deadline), remove the previous seeds:

    hofmann:
      server-key-seed-hex: ${NEW_SERVER_KEY_SEED_HEX}
      oprf-seed-hex: ${NEW_OPRF_SEED_HEX}
      previous-server-key-seed-hex: ""
      previous-oprf-seed-hex: ""

    Users who never logged in during the rotation window will need to re-register via the account recovery flow.

Versioned credential storage:

The CredentialStore interface has default store(id, record, keyVersion) and loadVersioned(id) methods. The defaults delegate to the unversioned methods with version 0, so existing implementations continue to work. For production, override these to persist a key_version column:

@Override
public void store(byte[] id, RegistrationRecord record, int keyVersion) {
    jdbcTemplate.update(
        "INSERT INTO credentials(id, record_bytes, key_version) VALUES (?, ?, ?) " +
        "ON CONFLICT(id) DO UPDATE SET record_bytes = EXCLUDED.record_bytes, " +
        "key_version = EXCLUDED.key_version",
        id, record.serialize(), keyVersion);
}

@Override
public Optional<VersionedCredential> loadVersioned(byte[] id) {
    List<VersionedCredential> rows = jdbcTemplate.query(
        "SELECT record_bytes, key_version FROM credentials WHERE id = ?",
        (rs, n) -> new VersionedCredential(
            rs.getInt("key_version"),
            RegistrationRecord.deserialize(rs.getBytes("record_bytes"))),
        id);
    return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}

Dynamic rotation (without restart):

Provide a custom Supplier<OpaqueServerKeyDetail> that returns the current and previous servers from your key management system:

Spring Boot:

@Bean
public Supplier<OpaqueServerKeyDetail> opaqueServerKeyDetailSupplier(KeyVaultService vault) {
    return () -> vault.currentOpaqueKeyDetail();
}

Dropwizard:

bootstrap.addBundle(new HofmannBundle<>(credentialStore, sessionStore, null)
    .withOpaqueServerKeyDetailSupplier(() -> vault.currentOpaqueKeyDetail()));

When a custom supplier is provided, the seed configuration fields are ignored.


Endpoints registered by the bundle

Method Path Auth required Description
GET /opaque/config No Returns OPAQUE cipher suite, context, and Argon2id params
POST /opaque/registration/start No Begin OPAQUE registration (returns blinded OPRF evaluation)
POST /opaque/registration/finish No Complete registration (stores credential record)
DELETE /opaque/registration Bearer token Delete a credential record
POST /opaque/auth/start No Begin OPAQUE authentication (KE1 → KE2)
POST /opaque/auth/finish No Complete authentication (KE3 → JWT)
GET /oprf/config No Returns OPRF cipher suite name
POST /oprf No Standalone OPRF evaluation

The bundle also registers:

  • A health check at /admin/healthcheck named opaque-server that verifies the server public key is a valid compressed EC point.
  • A Bearer token authentication filter. Protect your own routes with @Auth HofmannPrincipal.
  • A request body size filter (HTTP 413 for oversized payloads).

Local test server

hofmann-testserver/ provides a Docker Compose setup backed by HofmannApplication with in-memory stores. Start it with:

cd hofmann-testserver
docker compose up

The default config.yml uses Argon2id (65536 KiB / 3 / 1) and stable pre-generated test keys so the server public key is consistent across container restarts within the same image. Override individual keys by setting environment variables before running Compose:

export SERVER_KEY_SEED_HEX=$(openssl rand -hex 32)
export OPRF_SEED_HEX=$(openssl rand -hex 32)
export OPRF_MASTER_KEY_HEX=$(openssl rand -hex 32)
export JWT_SECRET_HEX=$(openssl rand -hex 32)
docker compose up

Clients connecting to the testserver can use HofmannOpaqueClientManager(accessor) without any manual config — the manager fetches the cipher suite, context, and Argon2id parameters automatically from GET /opaque/config on first use. If you are constructing OpaqueClientConfig directly (e.g. for a CLI override), use withArgon2id(...) — not forTesting(...), which uses the identity KSF and will fail against a server with Argon2 enabled.