Skip to content

Build, lint and test hofmann-rust in CI - #83

Merged
github-actions[bot] merged 1 commit into
mainfrom
ci-rust-job
Aug 4, 2026
Merged

Build, lint and test hofmann-rust in CI#83
github-actions[bot] merged 1 commit into
mainfrom
ci-rust-job

Conversation

@wolpert

@wolpert wolpert commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #82, closing the last CI gap: cargo test has never run in CI. Analyze (rust) is CodeQL, not a test job — which is exactly how the p256 0.14 bump in #71 broke the build on main undetected.

The job

- cargo fmt --all --check
- cargo clippy --all-targets --locked -- -D warnings
- cargo test --locked
  • No third-party toolchain action to pin. ubuntu-latest ships a stable Rust toolchain with cargo, rustfmt and clippy already installed, so the job uses it directly and prints the versions it got.
  • Caching on ~/.cargo/registry, ~/.cargo/git and hofmann-rust/target, keyed on Cargo.lock.
  • --locked on both cargo commands so a manifest edit committed without its matching lockfile update fails the build instead of silently resolving something else. That is the shape of the Bump p256 from 0.13.2 to 0.14.0 in /hofmann-rust in the dev-dependencies group #71 breakage.

Clippy warnings cleared

-D warnings required fixing the three warnings the crate already carried. All are in test code and all were machine-applicable fixes from cargo clippy --fix:

File Fix
src/elliptic_curve/expand_message_xmd.rs repeat().take()repeat_n()
tests/opaque_roundtrip.rs (×2) .expect(&format!(..)).unwrap_or_else(|_| panic!(..))

No production code changed.

Verified locally

All three commands run with the exact flags in the job, against rustc 1.96.0:

  • cargo fmt --all --check — exit 0
  • cargo clippy --all-targets --locked -- -D warnings — exit 0
  • cargo test --locked — exit 0, 82 tests passing

One tradeoff worth knowing

-D warnings means a future toolchain bump can turn newly-added clippy lints into a red build on an unrelated PR. That is the price of a lint gate that can actually fail; the alternative is a clippy step that only ever prints. If it becomes annoying, a rust-toolchain.toml would pin the version — say the word and I will add one.

🤖 Generated with Claude Code

cargo test never ran in CI -- "Analyze (rust)" is CodeQL, not a test job --
which is how the p256 0.14 bump in #71 broke the build on main undetected.

Adds a `rust` job running cargo fmt --all --check, clippy and cargo test.
ubuntu-latest ships a stable toolchain with cargo, rustfmt and clippy, so
no third-party toolchain action needs pinning; the cargo registry and
target directory are cached on Cargo.lock. Both cargo commands pass
--locked so a manifest edit committed without its lockfile update fails
rather than silently resolving something else.

Clippy runs with -D warnings, which required clearing the three warnings
the crate already had, all in test code and all machine-applicable fixes
from cargo clippy --fix:

  - expand_message_xmd.rs: repeat().take() -> repeat_n()
  - opaque_roundtrip.rs (x2): .expect(&format!(..)) ->
    .unwrap_or_else(|_| panic!(..))

Note that -D warnings makes a future toolchain bump able to turn new
clippy lints into a red build. That is the tradeoff for having a lint gate
with teeth; a rust-toolchain.toml would pin it if that becomes annoying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions
github-actions Bot merged commit 4e006d3 into main Aug 4, 2026
10 checks passed
@github-actions
github-actions Bot deleted the ci-rust-job branch August 4, 2026 15:31
github-actions Bot pushed a commit that referenced this pull request Aug 4, 2026
v3.0.0 is released: tagged, GitHub release published, and the Java
artifacts pushed to Maven Central.

Moves the working versions to 3.0.1 -- gradle.properties to
3.0.1-SNAPSHOT, hofmann-rust Cargo.toml (and Cargo.lock) and
hofmann-typescript package.json (and package-lock.json) to 3.0.1.

Note that gradle.properties only supplies the version for untagged
builds: settings.gradle.kts overrides it from an exact-match git tag on
HEAD, so the released Java version always comes from the tag rather than
from this file. The Rust and TypeScript versions are literal, so those two
are what `cargo publish` and `npm publish` would push.

Verified at 3.0.1: Java clean build with 609 tests passing, Rust
cargo fmt --all --check / clippy -D warnings / cargo test all clean under
--locked, and TypeScript typecheck, build and tests passing. The --locked
runs matter here because bumping the crate version rewrites Cargo.lock,
and the new rust CI job from #83 would fail on a stale one.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Aug 6, 2026
….0) (#85)

* Open the 3.0.1 development cycle

v3.0.0 is released: tagged, GitHub release published, and the Java
artifacts pushed to Maven Central.

Moves the working versions to 3.0.1 -- gradle.properties to
3.0.1-SNAPSHOT, hofmann-rust Cargo.toml (and Cargo.lock) and
hofmann-typescript package.json (and package-lock.json) to 3.0.1.

Note that gradle.properties only supplies the version for untagged
builds: settings.gradle.kts overrides it from an exact-match git tag on
HEAD, so the released Java version always comes from the tag rather than
from this file. The Rust and TypeScript versions are literal, so those two
are what `cargo publish` and `npm publish` would push.

Verified at 3.0.1: Java clean build with 609 tests passing, Rust
cargo fmt --all --check / clippy -D warnings / cargo test all clean under
--locked, and TypeScript typecheck, build and tests passing. The --locked
runs matter here because bumping the crate version rewrites Cargo.lock,
and the new rust CI job from #83 would fail on a stale one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Record the August 2026 security review in TODO.md

Replaces the February 2026 checklist with the findings from a six-reviewer
audit covering hofmann-rfc, hofmann-server, hofmann-client, both framework
integrations, and the Rust/TypeScript ports.

Every entry on the old DONE list was re-verified against the current tree.
The claims that hold are compressed into a "Confirmed sound" section so they
are not re-litigated; four that did not hold are reopened as work items and
tagged [was marked DONE]:

  - OWASP Dependency-Check is referenced nowhere in the build. CI runs
    dependency-submission, which feeds Dependabot alerts but is not a gate.
  - The AutoCloseable zeroization on ClientAuthState/ClientRegistrationState
    is never invoked: no try-with-resources and no close() call exists in any
    src/main tree.
  - The point-validation entry credits OpaqueCrypto.deserializePoint and
    OctetStringUtils.toEcPoint, neither of which exists. The validation is
    real but lives elsewhere.
  - The constant-time work covered modInverse and scalar serialization only;
    ECPoint.multiply on the server's long-term key was never addressed.

Items demonstrated by executing code are tagged [reproduced].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reject the identity element when decoding a ristretto255 point

RFC 9497 §2.1 requires DeserializeElement to reject the group identity. The
ristretto255 suite did not, because the all-zero 32-byte string is a *valid*
ristretto255 encoding — it is the canonical encoding of the identity's
equivalence class, so every RFC 9496 §4.3.1 decode check passes for it and
none of them can catch it.

The consequence: blindInv * O = O, so OprfCipherSuite.finalize() degraded to

    H(len||input||len||0^32||"Finalize")

a function of the input alone, independent of both the blind and the server
key. A malicious, breached, or MITM'd server answering with 32 zero bytes
silently turned the OPRF into an unkeyed, unsalted hash, and mode 0x00 has no
verifiability proof for the client to detect it with. This affected the
standalone OPRF product (OprfClientManager.hashResult) as well as OPAQUE,
where the envelope MAC degraded it to an authentication failure instead.
P-256/384/521 already rejected the identity; ristretto255 was the only
affected suite, and Java was the only affected implementation — the Rust and
TypeScript ports both guard this already.

The guard goes in decodeRistretto255 rather than in finalize() because
decodeRistretto255 has exactly one caller, scalarMultiply(), which is the
shared entry point for the OPRF unblind and all six OPAQUE 3DH operations.
One check covers every caller, and it matches where the Rust port guards.

Also fixed, all found while verifying the above:

  - finalize() accepted a blind congruent to 0 mod n, which inverts to 0 and
    produces the identical key-independent collapse from the caller's side
    rather than the server's. Affects all four suites. No in-tree path can
    reach it (randomScalar samples [1, n-1]) but finalize() is public API.

  - The new guard turned a malformed group element at POST /opaque/auth/start
    from HTTP 400 into HTTP 500, because that endpoint — alone among the eight,
    in both frameworks — did not catch SecurityException. That endpoint is
    unauthenticated and the unknown-credential path reaches it identically, so
    this is now mapped to 400 in OpaqueResource and OpaqueController. Mapped to
    400 rather than 401 because nothing is authenticated at that stage.

  - ByteUtils.isAllZero's javadoc claimed decodeRistretto255 accepts the
    all-zero encoding, which this change makes false.

Adds regression coverage across all four suites for identity rejection, for
the key-independence property itself, and for blind validation, mirroring the
existing TypeScript regression in test/oprf.test.ts. hofmann-rfc: 377 tests,
0 failures. Verified by mutation — with the guard commented out, exactly the
three new ristretto255 assertions fail and no pre-existing test does, which
also confirms no legitimate path ever needed to decode the identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Canonicalize the credential identifier at the DTO boundary

The server carried two identity keys for the same user. CredentialStore keys
on the DECODED BYTES (InMemoryCredentialStore.ByteKey), while the JWT `sub`
claim, InMemorySessionStore and its credentialToJtis reverse index, and every
rate-limiter bucket key on the RAW BASE64 STRING the client sent.

Base64.getDecoder() is non-canonical: it ignores both the padding and the
unused trailing bits of the final character. So YWxpY2U=, YWxpY2U, YWxpY2V,
YWxpY2V=, YWxpY2W= and YWxpY2X= all decode to "alice", and nothing normalized
them. The alias count depends on identifier length — 1 for len%3==0, 8 for
len%3==2, 32 for len%3==1 — so roughly two thirds of a real user base was
affected.

Two consequences, both reproduced end to end against real OPAQUE crypto and
the real in-memory stores:

  - REVOCATION BYPASS. Every account-mutating operation performs a bytes-keyed
    mutation beside a string-keyed revocation (:319-320 recovery, :360-361
    delete, :414-417 change-password). A session opened under one spelling
    survived a password change or account deletion performed under another,
    staying valid until natural JWT expiry. This contradicted the Javadoc on
    both methods and the contract stated in SessionStore.

  - RATE-LIMIT MULTIPLIER. Each spelling drew from its own token bucket while
    resolving to the same account: measured 48 attempts from a capacity-6
    recovery bucket (8x) for a 17-byte identifier. That is the control
    standing between a deployment and online guessing, and it also gates the
    recovery OTP.

Fixed by canonicalizing once at the trust boundary: CredentialIdentifiers
decodes and re-encodes, and all six request models carrying an identifier
override the generated record accessor. Every downstream consumer — session
index, JWT subject, rate-limit key, log line — now sees one spelling per
identifier. Verified that all six models are the complete set, and that every
rate-limiter call site (6), revocation site (3), and the single JWT issuance
path resolve through these accessors.

Note that a record's equals/hashCode/toString still bind the raw field rather
than the accessor. Confirmed nothing security-relevant depends on that: these
DTOs are request-scoped and are never used as map keys, compared, or placed in
collections anywhere in main sources.

This also removes a pre-existing failure mode rather than adding one — a
client that varied its padding between recovery steps previously got
"Recovery token does not match credential" and was locked out. All four
spelling combinations across recoveryStart/recoveryVerify/registrationFinish
now succeed.

Adds a server-level regression test alongside the model-level ones, because
the canonicalization lives in hofmann-rfc but the vulnerability manifests in
hofmann-server: dropping the override from any single model must fail where
the property actually matters. All three assertions fail without the fix.

Also bounds canonicalize() by the same 4096-char cap the models use, so an
oversized identifier is rejected before a full decode-and-re-encode.

Upgrade note: a JWT issued before this change under a non-canonical spelling
carries the raw subject and will no longer match on delete or change-password
until the user re-authenticates. No client in the repo (Java, TypeScript, or
Rust) ever emits a non-canonical spelling, and sessions are in-memory with a
default 3600s TTL, so this should not be observable in practice.

Full build green: 667 tests, 0 failures, including 104 integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse server-supplied key-stretching parameters below a client floor

In OPAQUE the key-stretching function runs entirely on the CLIENT, so its
parameters decide how expensive an offline dictionary attack is against the
record the server stores. Both clients took those parameters from the server
over the wire with no floor:

  - Java: OpaqueClientConfig.fromServerConfig() adopted argon2MemoryKib /
    Iterations / Parallelism verbatim, and argon2MemoryKib == 0 selected
    OpaqueConfig.IdentityKsf, whose stretch() returns the input unchanged.
    Reached from the production @Inject path via clientFor().
  - TypeScript: the same in OpaqueHttpClient.create().

A malicious, breached, or MITM'd server answering GET /opaque/config with
{"argon2MemoryKib":0} at REGISTRATION time makes the client store a record
derived from an unstretched password — offline attack cost falls from 64 MiB
of Argon2id per guess to roughly one hash. The server keeps serving the same
config afterwards, so authentication continues to work and nothing looks
wrong from either side. The quieter variant (8 KiB, one iteration) is the
same attack with a smaller footprint. The server already gates this behind
allowIdentityKsf and refuses to start without it; the client had no
counterpart.

Both clients now enforce a floor of 19456 KiB / 2 iterations — the OWASP
Argon2id minimum at t=2,p=1, below the server's own 65536/3 default and equal
to the mobile target MIGRATION.md already nominates. Opting out is a local
decision only: fromServerConfig(cfg, true) in Java, { allowWeakServerKsf } in
TypeScript, or pinning a config through the client manager's overrides map,
which does not consult the server at all. Nothing remote can set it.

The TypeScript guard needed more than a magnitude comparison. Casting the
parsed JSON to the DTO type is a compile-time assertion with no runtime
force, so the fields are whatever the server sent; any non-numeric value
yields NaN, every comparison with NaN is false, and control fell through to
identityKsf. Omitting a single JSON key restored the entire vulnerability
through the guarded entry point. The parameters are now type-checked with
Number.isInteger BEFORE any magnitude check, and — more importantly —
create()'s strict path no longer contains a branch to identityKsf at all: it
passes the validated values straight to argon2idKsf, so there is nothing for
a coercion bug to land on. That structure holds even with the type check
disabled, where it fails closed at stretch time rather than silently omitting
stretching. Java was never exposed to this: Jackson refuses to bind a
non-integer to int.

Also adds memory, iteration, and parallelism ceilings in both languages.
These are DoS hardening, not security floors: Argon2id cost is linear in
iterations and was unbounded, so a server could hang the client on its first
registration just as effectively as with an absurd memory request.

Two test harnesses now opt in explicitly, both against servers that genuinely
serve sub-floor parameters: HofmannOpaqueClientManagerTest (identity KSF) and
cross-client.test.ts (real Argon2id at 1024/1 for test speed, per
hofmann-integration-tests/src/test/resources/application.yml). hofmann-demo
and hofmann-testserver both run 65536/3, so demo.ts and integration.test.ts
correctly remain strict.

Deferred deliberately, recorded rather than dropped:
  - Pinning context locally instead of adopting the server's. Real, but a
    lower-severity relay concern and an API break for every caller, so it
    needs its own change with a migration path.
  - Enforcing https on the client endpoint. SECURITY.md documents
    TLS-terminating proxies with plaintext on a private network as a
    supported topology, and this fix defends against a server reached
    legitimately over TLS that is itself malicious, so scheme-checking would
    not have stopped any of it.

BREAKING: fromServerConfig(cfg) and OpaqueHttpClient.create(url) now throw
against a server offering the identity KSF or sub-floor parameters. Warning
and proceeding was considered and rejected — a warning the attacker's own
payload triggers does not stop the client writing an unstretched record.

hofmann-client 27 tests, hofmann-typescript 80 tests, and no assertion
failures anywhere in the Java suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Throttle registration/finish and stop it revealing whether a credential exists

POST /opaque/registration/finish is unauthenticated and was, on the
non-recovery path, completely unthrottled: the rate limiter was consumed only
inside the bearerToken branch. It also branched observably on existence — an
already-registered credential threw IllegalArgumentException (HTTP 400) while
a new one returned HTTP 204. Together that is a free, unlimited, unauthenticated
user-enumeration oracle, and it defeats the enumeration resistance authStart
spends an entire fake-KE2 code path providing.

Two changes:

  - The non-recovery path now consumes a registration token BEFORE the
    existence lookup, so a probe costs the same whether or not the credential
    turns out to exist. Ordering is the security-relevant part: consuming
    afterwards would leave probing of *registered* accounts free.

  - An already-registered credential now returns normally without storing,
    instead of throwing. The property that matters is "does not overwrite",
    and that is preserved and directly asserted on the stored bytes. Signalling
    *why* nothing was written is what leaked.

Both branches now run under a 25 ms floor, mirroring the existing
RECOVERY_VERIFY_MIN_NANOS. Without it the unification is only true for
InMemoryCredentialStore: the not-exists branch performs a write the exists
branch skips, which measures ~340 ns in memory but is an INSERT of commonly
0.2-5 ms against the database-backed CredentialStore the docs recommend —
remotely measurable, and enough to reopen the oracle at the HTTP layer.

Verified by execution: both adapters now return byte-identical 204 responses
(status, body, and headers) for existing and non-existing credentials; the
victim's record is unchanged after an attacker POST and they still authenticate
with their original password; the attacker's password is rejected; base64 alias
spellings of the identifier do not bypass the guard.

WHAT THIS DOES NOT FIX. Rate limiting does not meaningfully mitigate identifier
squatting, and an earlier draft of this message claimed otherwise. The limiter
is keyed per identifier and squatting an unused identifier costs exactly one
token, so the bucket is never the constraint: 2000 of 2000 distinct identifiers
were squatted in 9 ms with the default limiter installed. An attacker can still
bind any unregistered identifier to an unusable record with a single request
and no valid OPAQUE data, after which the real owner can never register — and,
because of this change, receives no error explaining why. The only in-library
control that would touch this is an IP-dimension limiter, which this class
structurally cannot apply because it is framework-agnostic and never sees the
request context; the adapters could. Eliminating it needs proof of identifier
ownership at the deployment layer. Recorded in TODO.md rather than papered over.

Also corrects a mock in OpaqueResourceTest that threw "Credential already
registered" — a message the manager can no longer produce.

Test note: registrationFinish_isRateLimited now registers ALICE first so it
exercises the already-exists branch. As originally written it only covered the
not-exists path and passed even with the rate-limit check moved after the
existence lookup, so it asserted that a token was consumed but not that the
ordering held. It now fails against that mutation.

Full Java suite 687 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Track P0/P1-1 as done and record three findings from verification

Marks the three P0 items and P1-1 complete with their commit hashes, keeping
the analysis pointers rather than deleting the entries outright, since each
records a reproduction worth not losing.

Adds three items surfaced while verifying e63db78 rather than by the original
review:

  - Squatting needs an IP-dimension limiter, which only the adapters can apply.
    Recorded explicitly because the per-identifier limiter does NOT mitigate it,
    and saying otherwise would leave a false reassurance in the file.
  - Spring Boot flattens every error status to 401, so the new throttle is
    invisible to Spring clients. Pre-existing and reproducible on untouched
    endpoints.
  - A junk recovery bearer token drains the recovery limiter before validation,
    locking a victim out of the flow that would rescue a squatted account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Validate client-uploaded registration records before storing them

RegistrationFinishRequest base64-decodes clientPublicKey, maskingKey,
envelopeNonce and authTag and handed them straight to credentialStore.store().
Nothing checked the lengths against the suite's Npk/Nh/Nn/Nm, and the client
public key was never checked to be a group element at all.

The failure then landed at authentication time, where it is worse than a bad
request. createCredentialResponse XORs serverPublicKey || envelope against a
fixed-width pad, so a wrong-length envelope threw on the mismatch: /auth/start
answered a poisoned identifier with an error while an unknown identifier got a
fake KE2. That is an enumeration oracle, and a wrong-length public key produced
a third distinguishable outcome one step later.

Adds Server.validateRegistrationRecord, which checks every field against the
suite's sizes and forces a group-element decode of the client public key via
scalarMultiply(ONE, pk) — GroupSpec exposes no deserialize method, and
scalarMultiply routes through the same on-curve, non-identity and canonical-
encoding validation the AKE depends on.

Called from all three write paths to the credential store, each positioned so
the check cannot be turned into a weapon of its own:

  - registrationFinish, normal path: after the rate-limiter token is consumed,
    so the decode cannot be driven unthrottled. That matters most on
    ristretto255, where the Montgomery ladder runs a fixed 253 iterations
    regardless of the scalar and costs ~1.2 ms.
  - registrationFinish, recovery path: after its limiter but before
    recoveryTokenStore.remove(), so a malformed record neither burns a
    legitimate recovery token nor reaches the delete.
  - changePasswordFinish: after the JWT check but before the delete. This is
    the third write path and was missed on the first pass — leaving it
    unguarded reproduced the original defect exactly, just scoped to an
    attacker's own account.

Failures are normalised to IllegalArgumentException (HTTP 400). Left alone the
status would depend on the suite rather than the fault: BouncyCastle rejects a
bad compressed point with IllegalArgumentException on the NIST curves, while
ristretto255 raises SecurityException, which the adapters map to 401 — an
authentication challenge on an unauthenticated endpoint the caller has no
credentials to satisfy.

SCOPE, stated precisely because an earlier draft of this claimed more. This
closes the enumeration oracle. It does NOT prevent a caller storing a record
that is well-formed but cryptographically meaningless: a valid point with random
envelope bytes passes every check and still leaves the identifier
unauthenticatable. Only the password holder can produce a record that verifies,
so on an unauthenticated registration endpoint that is inherent, and the answer
is proof of identifier ownership at the deployment layer.

Tests run over all four cipher suites rather than the P-256 default, because
P-256 has Nh == Nm == Nn == 32 and hides any confusion among them: swapping the
nonce's constant from Nn to Nh passed the entire suite when the tests were
P-256 only, and now fails on P-384, P-521 and ristretto255. P-521 additionally
separates Npk (67) from Nsk (66) from Nh (64) and is covered.

The off-curve case now searches for an encoding that genuinely fails to decode.
Flipping bits in a compressed x-coordinate is not enough — roughly half of all x
values have a square root, so the flipped encoding is a valid point about half
the time. The original test failed 7 of 10 real runs; the replacement passed
5 of 5 forced reruns, and both the decode-deletion and constant-swap mutations
are now caught deterministically.

Full Java suite 732 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Validate and normalize the OPRF server key instead of trusting config

The OPRF secret key was read as new BigInteger(masterKeyHex, 16) at both
framework configuration sites and never checked. oprfMasterKeyHex: "00" on
ristretto255 makes BlindEvaluate return the identity element for every request
— and because the identity is a decodable ristretto encoding, the deployment
serves traffic normally while having no effective key at all. Together with the
client-side identity acceptance fixed in 71846ed, that was a silently keyless
OPRF at both ends.

Rejects a key congruent to zero modulo the group order, at startup and again
per request.

Does NOT reject a key at or above the group order, which was the first attempt
and was wrong. Scalar multiplication reduces modulo the order anyway, so such a
key already works and is simply a spelling of k mod n — and the recipe the docs
recommend, openssl rand -hex 32, produces a value above ristretto255's order
about 94% of the time (measured 188/200). Refusing them broke 26 integration
tests, and would have broken live deployments whose stored OPRF outputs only
that exact key reproduces. Those keys are now normalized instead. Normalization
is transparent by construction — Ristretto255GroupSpec.scalarMul reduces as its
first statement, and the NIST curves have cofactor 1 with subgroup-validated
points — and a warning is logged, since a key above the order means the
operator's generation recipe does not match their curve and nothing else would
tell them.

The per-request check exists because the supplier is a documented key-rotation
seam and can introduce a key startup never saw. Two supporting details:

  - process() now snapshots supplier.get() ONCE. It called it twice, so a
    request straddling a rotation could compute a hash with key A and label it
    key B — a stored value that can never be recomputed or verified. This was
    filed as a separate lower-priority item but is a precondition for the check
    above: without the snapshot, validate and multiply could read different
    keys and the new check would itself be racy against the seam it defends.

  - The per-request failure is raised as IllegalStateException, not the
    IllegalArgumentException validateSecretKey throws. Both HTTP adapters catch
    IllegalArgumentException on this path and rewrite it to 400 "Invalid EC
    point data" without logging, so a key gone bad under rotation would blame
    every client for a server fault, discard the reason, and leave health
    checks green — the same "looks healthy while broken" mode this change
    exists to remove, relocated rather than fixed. It now surfaces as a 5xx
    with an ERROR log naming the cause.

Cost of the per-request check is a single BigInteger mod: 57 ns against a
169 us scalar multiplication on P-256, 14 ns against 1.5 ms on ristretto255.
It adds no side-channel surface — ristretto's scalarMul performs the same
reduction as its first line regardless.

Tests cover all four suites and pin the startup behaviour at the configuration
sites themselves, which the first version did not: deleting the validation from
both config sites left the entire suite green, because the per-request check
still refused the key so no identity was ever emitted. What was lost was
failing at startup rather than on every request, which is the whole reason
those two files are touched.

Full Java suite 783 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Use a constant-time ladder for scalar multiplication on the NIST curves

scalarMultiply and scalarMultiplyGenerator called p.multiply(scalar), which
BouncyCastle resolves to WNafL2RMultiplier for all three NIST prime curves —
confirmed by reflection on the live curve objects, not assumed. Window-NAF
leaks the scalar three ways: the add/double sequence follows its digits, the
precomputed table is indexed by secret values, and the window size comes from
its bit length.

Measured on P-256 with two scalars of identical bit length and Hamming weight
2 vs 166:

  scalarMultiply          +17% to +19%
  scalarMultiplyGenerator +12.9%

Every scalar on these paths is secret: the server's long-term OPRF key, the
per-credential OPRF key, the server's long-term and ephemeral AKE keys, the
client's blind, and the client's recovered long-term private key. Server-side
blindEvaluate is the sharpest target — the attacker supplies the point, so
BouncyCastle's per-point precomputation cache misses on every request and the
signal stays clean, and evaluations against a long-lived key can be requested
without limit. P256_SHA256 is the default suite.

BouncyCastle 1.85 no longer ships MontgomeryLadderMultiplier — it exposes
WNaf, GLV, FixedPointComb and WTauNaf, none suitable for a secret scalar on a
curve without an endomorphism — so the ladder is written out over BouncyCastle's
point arithmetic. This is scheduling, not new field code: the underlying
operations remain BouncyCastle's audited point formulas.

Both paths now use it. The generator path was missed on the first pass and is
not a lesser case: OpaqueEnvelope.recover recomputes the client's long-term
private key from the same secret value on every authentication, which is
precisely the repeated-measurement pattern that makes a 13% signal dangerous.

The scalar is rescaled to a fixed width first. Adding the group order is free
arithmetically — n·P = O — but it fixes the bit length, which matters because
BouncyCastle short-circuits addition on the point at infinity: without the
rescale, iterations above the leading set bit are cheaper, and a 3-bit scalar
ran about twenty times faster than a full-width one. That channel carried only
~2 bits for a uniform key, but closing it costs nothing measurable.

After, all four channels are within noise on P-256:

  scalarMultiply hamming weight, equal width   -1.43%
  scalarMultiply 3-bit vs full width           -1.49%
  scalarMultiply 128-bit vs full width         -0.11%
  scalarMultiplyGenerator hamming weight       +0.13%

Cost is about 2.4x on the primitive. End to end the server pays roughly 0.5 ms
extra per login on P-256 (0.66 -> 1.19 ms), and on the client the production
Argon2id KSF at ~120 ms swamps it entirely — full P-256 authentication is
within noise. Deliberately no configuration switch: a "fast but leaky" toggle
on this path would be a footgun.

Residual, documented in the code rather than glossed: the two-element
accumulator array is indexed by a secret bit. Both references share a cache
line and it is a far smaller target than wNAF's precomputed table, but the
access pattern to the two heap objects stays observable to a co-located
attacker using cache probing. Removing that needs field-level constant-time
primitives BouncyCastle does not expose.

Correctness is the risk that matters here, so it is checked against
BouncyCastle as an oracle: ~70,000 comparisons across all four Weierstrass
curves with random and boundary scalars, the ladder invariant verified at every
individual step, and the RFC and cross-implementation vectors re-run rather
than accepted as UP-TO-DATE. The TypeScript client, which uses entirely
independent arithmetic, still round-trips against the Java server.

One test deliberately asserts wall-clock behaviour. A refactor that drops the
rescale leaves every functional test green — the extra iterations are provable
no-ops — so nothing else pins the property the change exists to provide. The
threshold is 2x against a pre-fix gap of about 20x, using an interleaved
median, so the margin to noise is large; an earlier structural attempt that
tested the rescale helper directly did NOT catch that mutation, because
removing the call leaves the helper's own contract intact.

Full Java suite 817 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Harden the release pipeline against supply-chain compromise

Both release workflows imported the GPG signing key and wrote
signing.gnupg.passphrase into ~/.gradle/gradle.properties, then ran unpinned
third-party actions in the SAME job with those credentials still on disk and
CENTRAL_PORTAL_USERNAME / PASSWORD / GPG_PASSPHRASE / GPG_KEY_ID reachable.
Whoever controlled a mutable tag — softprops/action-gh-release@v2 or
gradle/actions/setup-gradle@v3 — could have taken the code-signing key and the
Central Portal credentials and published signed artifacts under
com.codeheadsystems:*.

Three changes:

  - Every action is now pinned by commit SHA rather than a mutable tag, across
    all five workflows. gradle.yml already did this for gradle/actions; the
    practice simply had not reached the release path, which is the one that
    holds the credentials. Each pin keeps the SAME major version already in
    use and carries the exact version in a trailing comment — the resolved
    SHAs for the latest majors were deliberately not taken, since bumping
    setup-java to v5 or action-gh-release to v3 would be an unrequested
    upgrade riding along in a security change.

  - Signing material is scrubbed as soon as publishing completes, before the
    release action runs: gradle.properties is shredded and the GPG keyring
    removed. Pinning bounds which third-party code runs; scrubbing bounds what
    any later step can reach at all. Runs under `if: always()` so a failed
    publish does not leave the key on disk for the rest of the job.

  - manual-release.yml now refuses to publish from a ref that is not an
    ancestor of origin/main. It is a workflow_dispatch job that accepts any
    ref, creates a version tag from it and publishes — so without this gate
    anyone able to dispatch it could ship arbitrary code to Maven Central
    under this project's coordinates without it ever appearing on main.

All five workflow files re-parse as valid YAML.

Not addressed here, and recorded in TODO.md rather than folded in: the
auto-merge workflows approve and merge without a test gate, and
auto-dependabot.yml runs fetch-metadata and then ignores its outputs, so
major-version bumps merge unreviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reclaim on demand instead of denying, and fix origin rate-limit keying

Two DoS-sensitive structures were keyed entirely on attacker-chosen values and
failed closed in the worst possible way.

InMemoryRateLimiter denied any key not already resident once maxEntries was
reached, and the key is a credential identifier taken straight from an
unauthenticated body. A flood of one-shot identifiers therefore denied every
legitimate caller whose bucket was not resident. InMemoryPendingSessionStore
threw at capacity, and authStart stores an entry for every request including
the manufactured-KE2 path that exists so an unknown identifier is
indistinguishable from a known one — so unfinished handshakes 503'd everyone.

Both now reclaim before refusing: stale buckets and expired sessions are
dropped on demand rather than waiting for a background reaper. Refusing when
genuinely full is kept — admitting on overflow would let an attacker bypass
the limit outright, and evicting live entries would let them cancel other
users' handshakes.

Scope, stated plainly: this converts a persistent outage into a self-healing
one. It does NOT stop a live adversary. An attacker who touches each of 50,000
keys once every four minutes keeps every entry inside the five-minute stale
window, so the reclaim finds nothing and the outage holds at roughly the same
167 req/s as before. Properly bounding the key space — prefix aggregation and
a structure that cannot be filled — is recorded in TODO.md as the real fix.

Also fixes a genuine keying bug, and it is worth being direct that the first
version of this change introduced half of it. The origin-keyed limiter read
the request from a @Context-injected FIELD on a singleton JAX-RS resource,
which silently yields null: every caller resolved to the constant "unknown",
so the "per-origin" limiter was one global bucket that any single client could
drain at 2 req/s to deny all six OPAQUE endpoints for the whole deployment.
That is strictly worse than no limiter. The request is now threaded in as a
method parameter, which is how OprfResource.evaluate already took its
ContainerRequestContext.

The same null-field bug was already present in OprfResource, where it made the
pre-existing OPRF limiter global: in the default untrusted configuration every
client shared one bucket, capping the whole deployment's OPRF oracle at 30
req/min. Fixed the same way.

All four call sites now route through a single ClientIpResolver rather than
three near-copies that had already drifted — OprfResource omitted the blank
remote-address guard. The resolver ignores X-Forwarded-For unless the operator
declares a trusted proxy, and takes the right-most entry when it does, since
appending proxies put attacker-supplied values to the left.

The origin limiter itself now defaults to DISABLED rather than 120/min. As a
blanket default it failed both ways: one login draws two tokens, so 120/min is
60 logins/min for an entire origin, which a corporate NAT or mobile CGNAT
would hit immediately — while an attacker sidesteps it with 84 addresses, or
one IPv6 /64, since the key has no prefix aggregation. It is a useful control
for a deployment that knows its client-address distribution and a liability
switched on blindly, so it is opt-in with the reasoning recorded on the config
method.

Tests: the origin-keying regression is now pinned by passing two different
peers and asserting two different buckets — no existing test used more than
one origin, which is exactly why the null-field bug shipped. Added the full
ClientIpResolver forgeability matrix. Rewrote the pending-store test, which
passed with the fix removed: its one-second TTL let the background reaper fire
during its own sleep, so it never exercised the on-demand path; it now
backdates entries under a long TTL and fails when evictExpired() is removed.

The rate limiter now warns at construction when a config takes longer to
refill than the stale window, since eviction recreates a bucket full and would
otherwise hand back tokens the refill rate would not have granted — an
invariant every shipped config satisfies but nothing enforced.

Full Java suite 838 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop the Spring security chain competing with the host application's

Two defects in the Spring integration, both of which the repo's own tests were
structurally unable to detect.

HofmannSecurityConfig published an unconditional SecurityFilterChain matching
every URL, which globally disabled CSRF, forced STATELESS sessions, replaced
the CORS source and redefined anyRequest() as "must present a Hofmann JWT". A
consumer with a chain of their own was incompatible with it: on Spring
Security 6.2+ two any-request chains fail fast with
UnreachableFilterChainException and the application refuses to boot. (An
earlier note in this series described that as failing silently — that was
wrong on this stack, and the javadoc and USAGE.md have been corrected. The
case that did pass silently was a *scoped* consumer chain, where Hofmann's
covered the remainder.)

Separately, the autoconfiguration registered only HofmannAutoConfiguration,
which carried no @import, so the controllers, the security config and the
health indicator loaded only if the consumer's component scan happened to
reach com.codeheadsystems.hofmann.springboot. USAGE.md said autoconfiguration
"activates automatically". HofmannTestApplication sits in exactly that
package, so every existing test found them regardless.

The chain is now @ConditionalOnMissingBean(SecurityFilterChain.class). It stays
unscoped deliberately: scoping it to /opaque/** and /oprf/** would stop the JWT
filter authenticating the consumer's own endpoints, which is what the library
is for. Instead it steps aside entirely when the application has a chain, and
JwtAuthenticationFilter remains a bean so they can wire it in.

Taking over is all-or-nothing, and that is now documented rather than implied:
the condition triggers on the presence of a chain, not on what it matches, so
a consumer chain scoped with securityMatcher("/api/**") displaces this one and
leaves every URL outside that matcher with no chain at all. That is a
fail-open gap, and it is easy to reach by accident because the previous
startup crash recommended securityMatcher as the remedy.

Also fixed, all found while verifying the above:

  - The ERROR dispatch was rewritten to 401, so every 400, 429 and 503 the
    controllers raise reached the client as "unauthorized" — a throttled
    client would re-prompt for a password instead of backing off, silently
    defeating the 429s added for the registration and origin limiters. Now
    permitted, but bound to the configured error path: permitting the ERROR
    dispatch to any path would let an application that maps a custom error
    page at a protected controller serve that controller's body to
    unauthenticated callers, reachable by sending a malformed request to any
    permitted endpoint.

  - corsConfigurationSource was not conditional, and @import made it
    universally present for the first time. A consumer following the
    documented instruction to override it could not start
    (BeanDefinitionOverrideException), and one configuring CORS the ordinary
    MVC way had its policy silently overridden even when this chain had
    correctly backed off, because Spring Security prefers a bean of that exact
    name. Renamed to hofmannCorsConfigurationSource, made conditional on that
    name, and injected by name — by type is ambiguous, since
    mvcHandlerMappingIntrospector also implements CorsConfigurationSource.

  - The chain order was 100, which places a catch-all early; Spring's
    convention is that an any-request chain is published last. Now
    LOWEST_PRECEDENCE - 5, matching Spring Boot's own default.

Tests use WebApplicationContextRunner with no component scanning at all,
because the existing suite physically cannot detect this class of bug. Added
wire-level assertions that 400 responses arrive as 400: the ERROR permit could
previously be deleted with the entire build staying green. Replaced an @order
test that asserted a constant was non-zero — it passed with the annotation
removed, so it tested nothing.

Full Java suite 847 tests, 0 failures, integration suite included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Record P0/P1 completion and the follow-ups verification surfaced

Marks every P0 and P1 item done with its commit hash, keeping the analysis
rather than deleting the entries, since each records a reproduction.

Adds four items that came out of verifying the fixes rather than from the
original review. Two are scope limits on fixes that landed and are recorded so
the partial coverage is not mistaken for completeness: the rate-limiter flood
is only half-closed, and recoveryVerify still amplifies across origins. Two are
deliberate deferrals from the client KSF work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add release notes for 3.1.0 and bump the version

Versions the release 3.1.0 rather than 3.0.1: it carries breaking changes, so
a patch version would be wrong under semver. Bumps all three artifacts, which
this project keeps in lockstep — the Rust crate has no source changes this
cycle and moves only to stay aligned, as it did for 3.0.0.

The CHANGELOG entry leads with what an adopter needs to decide from: which
findings are exploitable and by whom, what breaks, and what is deliberately
still open. The known-limitations section is there on purpose — the review
this release came from began by disproving a TODO list that read as complete,
and a changelog that implies the flood is fixed would repeat that mistake.

MIGRATION.md gains a note under Argon2id parameter selection, since that is
where an operator chooses the values the new client floor checks. Its existing
recommendations already sit at or above the floor, so the guidance stands; what
was missing was that going below it is now a local decision rather than
something a server can ask for.

Version-upgrade guidance lives in the CHANGELOG rather than MIGRATION.md,
which is scoped to adopting OPAQUE from traditional password auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant