Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/adr/0019-crypto-agility-post-quantum-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 19. Crypto-agility and post-quantum readiness for passkey algorithms

Date: 2026-06-17

## Status

Accepted.

## Context

A passkey is a public-key credential. Its signature algorithm — ES256 (ECDSA P-256), EdDSA
(Ed25519), RS256/RS384 (RSA) — rests on discrete-log / factoring hardness that a
cryptographically-relevant quantum computer (CRQC) running Shor's algorithm would break. None
exists today, but stored public keys are long-lived, so the project should be honest about the
posture and remove the obstacles to a future migration.

Two concrete problems existed in the code:

1. **Two divergent, hardcoded COSE algorithm lists.** The registration *create-options* sent to the
browser hardcoded `-7` (ES256), `-8` (EdDSA), `-257` (RS256) in
`DefaultPasskeyAuthenticationService`. The registration *verify* path hardcoded a different list —
`DEFAULT_PUB_KEY_PARAMS` (ES256, EdDSA, RS256, ES384, RS384) in `WebAuthn4JConverters`. The two
could drift, and neither was operator-configurable.
2. **No way to see which stored credentials use which algorithm**, so a future "re-enroll off
algorithm X" campaign had nothing to drive it.

It is important to be precise about scope. **No post-quantum signature algorithm can be added to
pk-auth today.** The choice is gated end-to-end by the authenticator hardware, CTAP2/FIDO2, the
WebAuthn/COSE registry, and WebAuthn4J's verifier — none of which yet standardize or implement a
PQC signature (e.g. an ML-DSA / FIPS 204 COSE binding). Inventing COSE identifiers or faking
ML-DSA/Dilithium support would be dishonest and non-interoperable. The realistic goal is therefore
**crypto-agility and accurate documentation**, not new algorithms.

A related but separate question is the post-ceremony JWT. The earlier "HS256 for dev, ES256 for
production" framing was misleading: HMAC-SHA256 is *not* broken by Shor, and with the enforced
≥ 256-bit key it retains ~128-bit security under Grover — making HS256 the quantum-conservative
choice for a single-issuer/single-verifier deployment. ES256 JWTs exist for untrusted third-party
verification and *are* Shor-vulnerable, with exposure bounded by the short token TTL.

## Decision

1. **One source of truth for COSE algorithms.** Introduce a framework-neutral `CoseAlgorithm` enum
and carry two ordered lists on `CeremonyConfig`: `offeredAlgorithms` (advertised in
create-options) and `acceptedAlgorithms` (enforced on verify). Both the create-options ceremony
and the WebAuthn4J verify path derive their lists from this config; the two hardcoded lists are
removed. `acceptedAlgorithms` is authoritative; `offeredAlgorithms` must be a subset and may be
narrower. Operators can narrow either without code changes.

2. **Backward-compatible defaults.** The default `acceptedAlgorithms` is the **union** of everything
previously accepted (ES256, EdDSA, RS256, ES384, RS384), so no already-registered credential can
fail verification. The default `offeredAlgorithms` stays the historical create-options subset
(ES256, EdDSA, RS256). A new 5-arg `CeremonyConfig` convenience constructor applies these defaults
so every existing call site compiles and behaves identically.

3. **Per-credential algorithm visibility.** `CredentialAlgorithms.coseAlgorithm(record)` decodes the
COSE algorithm already embedded in the stored public key — **no schema change** — and
`AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports which credentials
use a given algorithm, the read side a re-enrollment campaign drives off.

4. **Honest JWT framing.** Re-document HS256 vs ES256 as a trust-topology choice (symmetric, shared
trust boundary vs asymmetric, untrusted third-party verification) with the post-quantum tradeoff
spelled out. **No signing behavior or default changes.**

5. **Documentation.** Add a *Post-quantum readiness* section to the threat model and a TLS
hybrid-KEM ("harvest-now, decrypt-later") note to the operator guide, the latter framed explicitly
as an operator action at the TLS terminator, not a library change.

## Consequences

- **Positive — the divergence bug is gone.** Offered and accepted algorithms come from one config;
they can no longer silently drift, and both are operator-tunable.
- **Positive — a PQC signature becomes a small, localized change.** When the ecosystem standardizes
one, adding it is a new `CoseAlgorithm` constant plus its WebAuthn4J mapping — the ceremony and
verify code are already config-driven.
- **Positive — migration is observable.** Operators can enumerate credentials by algorithm before a
CRQC exists and stage re-enrollment.
- **Neutral — no new algorithms, by design.** This ADR deliberately ships zero new signature
algorithms; it is readiness, not a PQC implementation.
- **Negative — `CeremonyConfig` grew two fields.** Mitigated by the defaulting convenience
constructor and the `from(...)` overload, so existing construction sites and adapters are
unaffected.
- **Constraint — the symmetric story is already fine.** Random secrets (refresh 32 B, OTP pepper
≥ 16 B / 32 B recommended, challenge 32 B) and HS256 (≥ 32 B key) are 256-bit-class and
Grover-resistant; no change was needed or made there.

## Open follow-ups

- Wire `offeredAlgorithms` / `acceptedAlgorithms` through each adapter's external (YAML) host config.
Today they are overridable via `CeremonyConfig` / the `PasskeyAuthenticationServices` builder; full
per-adapter property plumbing is deferred until there is a concrete reason to narrow the lists from
configuration.
- Revisit when a COSE-registered post-quantum signature algorithm lands in WebAuthn4J; adding it is
the localized change this ADR was written to enable.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ ADR is added and the prior one is marked `Superseded by NNNN`. See
| [0016](./0016-user-deletion-fan-out.md) | Accepted | 2026-05-16 | User deletion fan-out is sequential and best-effort |
| [0017](./0017-sonarqube-cloud-static-analysis.md) | Superseded by [0018](./0018-remove-sonarqube-cloud.md) | 2026-06-11 | SonarQube Cloud for static analysis and coverage tracking |
| [0018](./0018-remove-sonarqube-cloud.md) | Accepted | 2026-06-11 | Remove SonarQube Cloud; enforce coverage with native JaCoCo line + branch gates |
| [0019](./0019-crypto-agility-post-quantum-readiness.md) | Accepted | 2026-06-17 | Crypto-agility and post-quantum readiness for passkey algorithms |
32 changes: 31 additions & 1 deletion docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,36 @@ leaves that line out.
> (`NoClassDefFoundError` on the first ceremony request). The split keeps the
> always-on factory free of any reference to the optional module.

## 8. Threat model
## 8. Transport security & post-quantum (harvest-now, decrypt-later)

pk-auth's own primitives are post-quantum aware (see `docs/threat-model.md` →
*Post-quantum readiness*), but the one genuine **harvest-now, decrypt-later** risk
in a real deployment is **outside this library**: an adversary who records your
**TLS** sessions today can decrypt them later once a cryptographically-relevant
quantum computer (CRQC) breaks the classical key exchange that protected them. Those
sessions carry pk-auth's bearer material in transit — access JWTs, refresh tokens,
magic-link URLs, OTP codes. Unlike a WebAuthn assertion (challenge–response, nothing
to harvest), a recorded ciphertext *is* harvestable.

TLS is terminated at your edge — load balancer, reverse proxy, or CDN — **not** in
pk-auth. So the mitigation is an **operator action**, not a library change:

- **Enable a hybrid post-quantum key exchange at the TLS terminator / CDN.** The
current deployable standard is the hybrid group **`X25519MLKEM768`** (X25519 +
ML-KEM-768 / FIPS 203), which stays classically secure even if the PQC half is
later faulted, and is already supported by recent OpenSSL/BoringSSL, major CDNs,
and current browsers. Configure it wherever you terminate TLS:
- Nginx/OpenSSL 3.5+: include the hybrid group in `ssl_ecdh_curve` /
`Groups`/`Curves` (e.g. `X25519MLKEM768:X25519`).
- Behind a CDN (Cloudflare, etc.): enable post-quantum / hybrid key agreement in
the edge TLS settings.
- **Keep token TTLs short.** A short access-token TTL (default 1 hour) and rotating
refresh tokens bound the value of any session an attacker does eventually decrypt.

This does not change anything pk-auth signs or stores; it hardens the channel the
tokens travel over. There is no pk-auth setting for it — it lives entirely in your
TLS-terminating layer.

## 9. Threat model

See `docs/threat-model.md` for the formal STRIDE pass.
83 changes: 83 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,89 @@ Boundaries cross-checked in this model:
- **Passkey export / migration**: not the library's concern. The user's authenticator owns the
key material; pk-auth never sees it.

## Post-quantum readiness

This section states the project's posture honestly: pk-auth is **post-quantum *aware* and
crypto-agile where it can be**, but it cannot ship a post-quantum *passkey signature* today
because none exists in the stack it sits on. The goal here is accurate framing plus the
agility hooks that make a future migration a configuration change, not a rewrite.

### What is Shor-vulnerable, and why this library can't fix it yet

The passkey signature algorithms — ES256 (ECDSA P-256), EdDSA (Ed25519), and RS256/RS384
(RSA) — all rest on problems (discrete log, factoring) that a cryptographically-relevant
quantum computer (CRQC) running Shor's algorithm would break. None exists today, and the
timeline is uncertain, but the threat is real for long-lived public keys.

The library **cannot** unilaterally fix this. The signature algorithm of a passkey is gated,
end to end, by layers pk-auth does not own:

- the **authenticator hardware / secure element** (what keypairs it can generate),
- **CTAP2 / FIDO2** (what the authenticator and platform negotiate),
- **WebAuthn / the COSE algorithm registry** (what `PublicKeyCredentialParameters.alg`
values are even defined), and
- **WebAuthn4J** (what this library's verifier can actually validate).

No post-quantum signature scheme (e.g. an ML-DSA / FIPS 204 COSE binding) is standardized
across that chain or implemented by WebAuthn4J as of this writing. pk-auth therefore does
**not** invent COSE identifiers or advertise algorithms nothing in the ecosystem can produce
or verify. When the ecosystem standardizes one, adding it is a single new constant in
`CoseAlgorithm` plus its WebAuthn4J mapping — by design (see [crypto-agility hooks](#crypto-agility-hooks-present-today)).

### Why the exposure is bounded

WebAuthn is a **challenge–response** protocol, not encryption. An assertion proves possession
of a private key *now*, against a fresh server-issued challenge; there is no long-lived
ciphertext for an attacker to harvest and decrypt later. A CRQC does **not** retroactively
break past authentications.

The real long-term liability is the **stored public key**: once a CRQC exists, an attacker who
has the public key (public by definition) could forge assertions for that credential. The
mitigation is re-enrollment onto a post-quantum algorithm *before* a CRQC arrives — which is
exactly what the per-credential algorithm visibility below is for. (The genuine
harvest-now/decrypt-later risk in a deployment is recorded **TLS** sessions carrying bearer
tokens, which is terminated outside this library — see the operator guide.)

### Crypto-agility hooks present today

Concrete seams added so a migration is configuration, not surgery (ADR 0019):

- **Single injectable COSE algorithm list.** `CeremonyConfig.offeredAlgorithms` (advertised in
create-options) and `CeremonyConfig.acceptedAlgorithms` (enforced on verify) are the one
source of truth, expressed in the framework-neutral `CoseAlgorithm` enum. Both the
create-options ceremony and the WebAuthn4J verify path derive their lists from this config —
the two formerly-divergent hardcoded lists are gone. The accepted list is authoritative;
the offered list may be a narrower subset, and an operator can narrow *either* without code
changes. The default accepted set is the **union** of everything historically accepted
(ES256, EdDSA, RS256, ES384, RS384), so no already-registered credential can fail
verification.
- **Per-credential algorithm visibility.** `CredentialAlgorithms.coseAlgorithm(record)` decodes
the COSE algorithm already stored on every credential (no schema change), and
`AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports which of a
user's credentials use a given (e.g. quantum-vulnerable) algorithm — the read side a future
re-enrollment campaign drives off.
- **A symmetric-resistant JWT path.** The HS256 post-ceremony token (with the enforced
≥ 256-bit key) is HMAC-SHA256, which Shor does not break; see [Symmetric primitives](#symmetric-primitives-already-quantum-conservative).

### Symmetric primitives already quantum-conservative

The non-signature secrets pk-auth generates are all 256-bit random values, which only Grover's
algorithm targets — and Grover merely halves the effective bit-strength, leaving ~128-bit
security at 256 bits. No action needed:

| Secret | Size | Post-quantum status |
|---|---|---|
| JWT HS256 signing secret | ≥ 32 bytes (enforced) | HMAC-SHA256 — Shor N/A; ~128-bit under Grover |
| Refresh-token secret | 32 bytes | SHA-256 hashed at rest; ~128-bit under Grover |
| OTP pepper | ≥ 16 bytes (32+ recommended) | HMAC-SHA256 keying; ~128-bit under Grover at 32 B |
| Ceremony challenge | 32 bytes | Random nonce, single-use; not a long-term liability |
| Backup codes | ~50 bits + Argon2id | Entropy + KDF + rate limit dominate; not a quantum issue |

ES256 (asymmetric) JWTs, by contrast, **are** Shor-vulnerable — they exist for untrusted
third-party verification, and their exposure is bounded by the short token TTL. For a
single-issuer/single-verifier deployment, HS256 with a strong key is the quantum-conservative
default (see the `pk-auth-jwt` package Javadoc and ADR 0019).

## Supply chain

pk-auth is a security library published to Maven Central and npm, so a compromise of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ public interface AdminService {
/** Lists every credential the user has registered. */
AdminResult<List<CredentialSummary>> listCredentials(UserHandle actor, UserHandle target);

/**
* Lists the user's credentials whose stored COSE public key uses {@code coseAlgorithm} (an IANA
* COSE algorithm identifier, e.g. {@code -7} for ES256). Read-side support for a crypto-agility /
* post-quantum re-enrollment campaign: an operator can enumerate which credentials still use a
* Shor-vulnerable algorithm and prompt those users to re-enroll. The algorithm is read from the
* already-stored COSE key — no schema change. See ADR 0019.
*
* @since 2.0.1
*/
AdminResult<List<CredentialSummary>> listCredentialsByAlgorithm(
UserHandle actor, UserHandle target, int coseAlgorithm);

/** Renames a credential's label. Returns NotFound when the credential is not the user's. */
AdminResult<CredentialSummary> renameCredential(
UserHandle actor, UserHandle target, CredentialId credentialId, String newLabel);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.codeheadsystems.pkauth.api.CredentialId;
import com.codeheadsystems.pkauth.api.UserHandle;
import com.codeheadsystems.pkauth.backupcodes.BackupCodeService;
import com.codeheadsystems.pkauth.credential.CredentialAlgorithms;
import com.codeheadsystems.pkauth.credential.CredentialRecord;
import com.codeheadsystems.pkauth.json.Base64Url;
import com.codeheadsystems.pkauth.magiclink.MagicLinkService;
Expand Down Expand Up @@ -100,6 +101,20 @@ public AdminResult<List<CredentialSummary>> listCredentials(UserHandle actor, Us
return new AdminResult.Success<>(credentials);
}

@Override
public AdminResult<List<CredentialSummary>> listCredentialsByAlgorithm(
UserHandle actor, UserHandle target, int coseAlgorithm) {
if (!authorize(actor, target)) return new AdminResult.Forbidden<>();
// Algorithm is read from each credential's already-stored COSE key (no schema change); see
// CredentialAlgorithms / ADR 0019.
List<CredentialSummary> matching =
credentialRepository.findByUserHandle(target).stream()
.filter(record -> CredentialAlgorithms.usesAlgorithm(record, coseAlgorithm))
.map(CredentialSummary::of)
.toList();
return new AdminResult.Success<>(matching);
}

@Override
public AdminResult<CredentialSummary> renameCredential(
UserHandle actor, UserHandle target, CredentialId credentialId, String newLabel) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import com.codeheadsystems.pkauth.testkit.InMemoryCredentialRepository;
import com.codeheadsystems.pkauth.testkit.InMemoryOtpRepository;
import com.codeheadsystems.pkauth.testkit.InMemoryUserLookup;
import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.attestation.authenticator.EC2COSEKey;
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
import java.security.SecureRandom;
Expand Down Expand Up @@ -148,6 +150,34 @@ void listCredentialsReturnsAllForUser() {
assertThat(success.value()).hasSize(2);
}

@Test
void listCredentialsByAlgorithmReportsOnlyMatchingCredentials() {
// Both stored credentials use ES256 (COSE -7) — the only algorithm a virtual ES2 key produces.
saveCredentialWithEs256Key(alice, new byte[] {1});
saveCredentialWithEs256Key(alice, new byte[] {2});

AdminResult<List<CredentialSummary>> es256 = admin.listCredentialsByAlgorithm(alice, alice, -7);
@SuppressWarnings("unchecked")
AdminResult.Success<List<CredentialSummary>> hit =
(AdminResult.Success<List<CredentialSummary>>) es256;
assertThat(hit.value()).hasSize(2);

// RS256 (-257): no stored credential uses it → empty, ready for a re-enrollment campaign view.
AdminResult<List<CredentialSummary>> rs256 =
admin.listCredentialsByAlgorithm(alice, alice, -257);
@SuppressWarnings("unchecked")
AdminResult.Success<List<CredentialSummary>> miss =
(AdminResult.Success<List<CredentialSummary>>) rs256;
assertThat(miss.value()).isEmpty();
}

@Test
void listCredentialsByAlgorithmRequiresAuthorization() {
UserHandle bob = users.register("bob", "Bob");
assertThat(admin.listCredentialsByAlgorithm(alice, bob, -7))
.isInstanceOf(AdminResult.Forbidden.class);
}

@Test
void renameCredentialUpdatesLabel() {
saveCredential(alice, new byte[] {1});
Expand Down Expand Up @@ -439,4 +469,28 @@ private CredentialRecord saveCredential(UserHandle user, byte[] credentialId) {
credentials.save(record);
return record;
}

/** Saves a credential whose stored COSE key is a real ES256 (COSE -7) public key. */
private void saveCredentialWithEs256Key(UserHandle user, byte[] credentialId) {
byte[] point = new byte[65];
point[0] = 0x04;
for (int i = 1; i < point.length; i++) {
point[i] = (byte) i;
}
EC2COSEKey key = EC2COSEKey.createFromUncompressedECCKey(point);
byte[] cose = new ObjectConverter().getCborMapper().writeValueAsBytes(key);
credentials.save(
new CredentialRecord(
CredentialId.of(credentialId),
user,
cose,
0L,
"ES256 key",
null,
Set.of(Transport.INTERNAL),
false,
false,
NOW,
null));
}
}
Loading
Loading