diff --git a/docs/adr/0019-crypto-agility-post-quantum-readiness.md b/docs/adr/0019-crypto-agility-post-quantum-readiness.md new file mode 100644 index 0000000..6052ae6 --- /dev/null +++ b/docs/adr/0019-crypto-agility-post-quantum-readiness.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 84174e0..a84e29b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 9345dc7..1007ef3 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -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. diff --git a/docs/threat-model.md b/docs/threat-model.md index 4d1c151..a235d61 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -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 diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java index de29814..4d918d2 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java @@ -20,6 +20,18 @@ public interface AdminService { /** Lists every credential the user has registered. */ AdminResult> 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> listCredentialsByAlgorithm( + UserHandle actor, UserHandle target, int coseAlgorithm); + /** Renames a credential's label. Returns NotFound when the credential is not the user's. */ AdminResult renameCredential( UserHandle actor, UserHandle target, CredentialId credentialId, String newLabel); diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java index cf99c2b..34ce89f 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java @@ -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; @@ -100,6 +101,20 @@ public AdminResult> listCredentials(UserHandle actor, Us return new AdminResult.Success<>(credentials); } + @Override + public AdminResult> 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 matching = + credentialRepository.findByUserHandle(target).stream() + .filter(record -> CredentialAlgorithms.usesAlgorithm(record, coseAlgorithm)) + .map(CredentialSummary::of) + .toList(); + return new AdminResult.Success<>(matching); + } + @Override public AdminResult renameCredential( UserHandle actor, UserHandle target, CredentialId credentialId, String newLabel) { diff --git a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java index 1700621..8dfb84b 100644 --- a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java +++ b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java @@ -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; @@ -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> es256 = admin.listCredentialsByAlgorithm(alice, alice, -7); + @SuppressWarnings("unchecked") + AdminResult.Success> hit = + (AdminResult.Success>) es256; + assertThat(hit.value()).hasSize(2); + + // RS256 (-257): no stored credential uses it → empty, ready for a re-enrollment campaign view. + AdminResult> rs256 = + admin.listCredentialsByAlgorithm(alice, alice, -257); + @SuppressWarnings("unchecked") + AdminResult.Success> miss = + (AdminResult.Success>) 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}); @@ -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)); + } } diff --git a/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java b/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java index b883da2..d5291e5 100644 --- a/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java +++ b/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java @@ -40,7 +40,14 @@ public final class BackupCodeService { /** Default count of codes generated per call. */ public static final int DEFAULT_CODE_COUNT = 10; - /** Length of each plaintext code in characters. */ + /** + * Length of each plaintext code in characters. At length 10 over the 32-symbol {@link #ALPHABET} + * each code carries ~50 bits of entropy (10 × log2(32)). This is not a quantum concern: + * the real attack cost is dominated by the Argon2id verify hash plus the per-user verify rate + * limiter ({@link #DEFAULT_RATE_LIMIT} attempts per {@link #DEFAULT_RATE_WINDOW}), not by brute + * search of the code space. {@code CODE_LENGTH} is the lever if a deployment wants more margin; + * the default is deliberately left unchanged. + */ public static final int CODE_LENGTH = 10; /** Default maximum verify attempts allowed per user within the rate-limit window. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java index 1ac7fc2..1528893 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java @@ -5,12 +5,23 @@ import com.codeheadsystems.pkauth.api.ResidentKeyRequirement; import com.codeheadsystems.pkauth.api.UserVerificationRequirement; import java.time.Duration; +import java.util.List; import java.util.Objects; import org.jspecify.annotations.Nullable; /** * Ceremony-level policy knobs. Brief §7 documents the security-relevant defaults. * + *

Crypto-agility. {@link #offeredAlgorithms()} and {@link + * #acceptedAlgorithms()} are the single source of truth for which COSE signature algorithms a + * deployment advertises in the create-options ceremony and accepts on registration verification, + * respectively. Both the create-options path and the WebAuthn4J verify path derive their algorithm + * lists from this config — there are no hardcoded algorithm lists elsewhere. The {@code accepted} + * list is authoritative (a credential whose algorithm is absent from it is rejected on verify); + * {@code offered} must be a subset of {@code accepted} and may be narrower so a deployment can + * advertise fewer algorithms than it still honors for already-registered credentials. See {@code + * docs/threat-model.md} (Post-quantum readiness) and ADR 0019. + * * @since 0.9.0 */ public record CeremonyConfig( @@ -18,11 +29,39 @@ public record CeremonyConfig( UserVerificationRequirement userVerification, ResidentKeyRequirement residentKey, AttestationConveyance attestationConveyance, - CounterRegressionPolicy counterRegression) { + CounterRegressionPolicy counterRegression, + List offeredAlgorithms, + List acceptedAlgorithms) { /** Default TTL for a ceremony challenge: 5 minutes. */ public static final Duration DEFAULT_CHALLENGE_TTL = Duration.ofMinutes(5); + /** + * Algorithms offered to the authenticator in the registration create-options ({@code + * PublicKeyCredentialParameters}). The historical offered set: ES256, EdDSA, RS256. Deliberately + * a subset of {@link #DEFAULT_ACCEPTED_ALGORITHMS}. + * + * @since 2.0.1 + */ + public static final List DEFAULT_OFFERED_ALGORITHMS = + List.of(CoseAlgorithm.ES256, CoseAlgorithm.EdDSA, CoseAlgorithm.RS256); + + /** + * Algorithms accepted on registration verification — the source of truth. The default is the + * historical {@code WebAuthn4JConverters} verify set: ES256, EdDSA, RS256, ES384, RS384. It is + * the union of everything previously accepted so no already-registered credential can + * fail verification. + * + * @since 2.0.1 + */ + public static final List DEFAULT_ACCEPTED_ALGORITHMS = + List.of( + CoseAlgorithm.ES256, + CoseAlgorithm.EdDSA, + CoseAlgorithm.RS256, + CoseAlgorithm.ES384, + CoseAlgorithm.RS384); + public CeremonyConfig { Objects.requireNonNull(challengeTtl, "challengeTtl"); if (challengeTtl.isZero() || challengeTtl.isNegative()) { @@ -32,6 +71,42 @@ public record CeremonyConfig( Objects.requireNonNull(residentKey, "residentKey"); Objects.requireNonNull(attestationConveyance, "attestationConveyance"); Objects.requireNonNull(counterRegression, "counterRegression"); + Objects.requireNonNull(offeredAlgorithms, "offeredAlgorithms"); + Objects.requireNonNull(acceptedAlgorithms, "acceptedAlgorithms"); + offeredAlgorithms = List.copyOf(offeredAlgorithms); + acceptedAlgorithms = List.copyOf(acceptedAlgorithms); + if (acceptedAlgorithms.isEmpty()) { + throw new IllegalArgumentException("acceptedAlgorithms must contain at least one algorithm"); + } + if (!acceptedAlgorithms.containsAll(offeredAlgorithms)) { + throw new IllegalArgumentException( + "offeredAlgorithms must be a subset of acceptedAlgorithms (cannot offer an algorithm that" + + " would be rejected on verify)"); + } + } + + /** + * Backward-compatible constructor that applies {@link #DEFAULT_OFFERED_ALGORITHMS} and {@link + * #DEFAULT_ACCEPTED_ALGORITHMS}. Existing call sites that predate the crypto-agility fields keep + * compiling and keep the exact historical algorithm behavior; pass the seven-argument canonical + * constructor (or {@link #from}) to override the algorithm lists. + * + * @since 0.9.0 + */ + public CeremonyConfig( + Duration challengeTtl, + UserVerificationRequirement userVerification, + ResidentKeyRequirement residentKey, + AttestationConveyance attestationConveyance, + CounterRegressionPolicy counterRegression) { + this( + challengeTtl, + userVerification, + residentKey, + attestationConveyance, + counterRegression, + DEFAULT_OFFERED_ALGORITHMS, + DEFAULT_ACCEPTED_ALGORITHMS); } /** @@ -47,7 +122,9 @@ public static CeremonyConfig defaults() { UserVerificationRequirement.REQUIRED, ResidentKeyRequirement.PREFERRED, AttestationConveyance.NONE, - CounterRegressionPolicy.REJECT); + CounterRegressionPolicy.REJECT, + DEFAULT_OFFERED_ALGORITHMS, + DEFAULT_ACCEPTED_ALGORITHMS); } /** @@ -71,12 +148,49 @@ public static CeremonyConfig from( @Nullable ResidentKeyRequirement residentKey, @Nullable AttestationConveyance attestationConveyance, @Nullable CounterRegressionPolicy counterRegression) { + return from( + challengeTtl, + userVerification, + residentKey, + attestationConveyance, + counterRegression, + null, + null); + } + + /** + * Crypto-agility-aware overload of {@link #from(Duration, UserVerificationRequirement, + * ResidentKeyRequirement, AttestationConveyance, CounterRegressionPolicy)} that also lets a host + * narrow or reorder the COSE algorithm lists. A {@code null} {@code offeredAlgorithms} / {@code + * acceptedAlgorithms} falls back to {@link #DEFAULT_OFFERED_ALGORITHMS} / {@link + * #DEFAULT_ACCEPTED_ALGORITHMS}, preserving the historical algorithm behavior. + * + * @param challengeTtl challenge TTL, or null for the default. + * @param userVerification UV requirement, or null for the default. + * @param residentKey resident-key requirement, or null for the default. + * @param attestationConveyance attestation conveyance, or null for the default. + * @param counterRegression counter-regression policy, or null for the default. + * @param offeredAlgorithms algorithms advertised in create-options, or null for the default. + * @param acceptedAlgorithms algorithms accepted on verify, or null for the default. + * @return the resolved ceremony config. + * @since 2.0.1 + */ + public static CeremonyConfig from( + @Nullable Duration challengeTtl, + @Nullable UserVerificationRequirement userVerification, + @Nullable ResidentKeyRequirement residentKey, + @Nullable AttestationConveyance attestationConveyance, + @Nullable CounterRegressionPolicy counterRegression, + @Nullable List offeredAlgorithms, + @Nullable List acceptedAlgorithms) { CeremonyConfig d = defaults(); return new CeremonyConfig( challengeTtl == null ? d.challengeTtl() : challengeTtl, userVerification == null ? d.userVerification() : userVerification, residentKey == null ? d.residentKey() : residentKey, attestationConveyance == null ? d.attestationConveyance() : attestationConveyance, - counterRegression == null ? d.counterRegression() : counterRegression); + counterRegression == null ? d.counterRegression() : counterRegression, + offeredAlgorithms == null ? d.offeredAlgorithms() : offeredAlgorithms, + acceptedAlgorithms == null ? d.acceptedAlgorithms() : acceptedAlgorithms); } } diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java new file mode 100644 index 0000000..198d3ab --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.config; + +/** + * The COSE signature algorithms pk-auth can offer to an authenticator and accept on a passkey + * registration. This is the single, framework-neutral vocabulary that both the create-options + * ceremony and the WebAuthn4J verification path map from (see {@link CeremonyConfig}); it replaces + * the two formerly-divergent hardcoded lists. + * + *

Each constant carries its IANA COSE + * algorithm identifier (the negative integer that appears as label {@code 3} in a COSE key and + * in {@code PublicKeyCredentialParameters.alg} on the wire). + * + *

Post-quantum note. Every algorithm here (ECDSA, EdDSA, RSA-PKCS1) is + * Shor-vulnerable. No post-quantum signature scheme is yet standardized in the WebAuthn/COSE/FIDO2 + * stack or implemented by WebAuthn4J, so none can be added today. This enum is the seam through + * which a quantum-safe COSE algorithm would be introduced once the ecosystem supports it — without + * touching the ceremony or verification code. See {@code docs/threat-model.md} (Post-quantum + * readiness) and ADR 0019. + * + * @since 2.0.1 + */ +public enum CoseAlgorithm { + + /** ECDSA with SHA-256 over P-256 (COSE {@code -7}). The near-universal passkey default. */ + ES256(-7), + + /** EdDSA (Ed25519) (COSE {@code -8}). */ + EdDSA(-8), + + /** ECDSA with SHA-384 over P-384 (COSE {@code -35}). */ + ES384(-35), + + /** RSASSA-PKCS1-v1_5 with SHA-256 (COSE {@code -257}). */ + RS256(-257), + + /** RSASSA-PKCS1-v1_5 with SHA-384 (COSE {@code -258}). */ + RS384(-258); + + private final int coseValue; + + CoseAlgorithm(int coseValue) { + this.coseValue = coseValue; + } + + /** + * The IANA COSE algorithm identifier for this algorithm (e.g. {@code -7} for {@link #ES256}). + * + * @return the signed COSE algorithm identifier. + * @since 2.0.1 + */ + public int coseValue() { + return coseValue; + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java new file mode 100644 index 0000000..07c7168 --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.credential; + +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; + +/** + * Read-side helpers for reasoning about the COSE signature algorithm a stored credential uses. + * + *

WebAuthn4J records the algorithm inside the COSE-encoded public key ({@link + * CredentialRecord#publicKeyCose()}), so the algorithm is already persisted on every credential — + * there is no separate column and no schema change is needed to read it. This lets an operator + * answer "which stored credentials use a (Shor-vulnerable) algorithm I want to migrate off?" and + * drive a re-enrollment campaign, without expanding the {@code CredentialRepository} SPI. + * + *

The return value is the IANA COSE algorithm identifier (e.g. {@code -7} for ES256), matching + * {@link com.codeheadsystems.pkauth.config.CoseAlgorithm#coseValue()} so callers can compare + * against the configured algorithm vocabulary directly. See {@code docs/threat-model.md} + * (Post-quantum readiness) and ADR 0019. + * + * @since 2.0.1 + */ +public final class CredentialAlgorithms { + + // ObjectConverter is documented as reusable/thread-safe; one shared instance for read-side + // decode. + private static final ObjectConverter OBJECT_CONVERTER = new ObjectConverter(); + + private CredentialAlgorithms() {} + + /** + * Decodes the COSE algorithm identifier recorded in the credential's stored public key. + * + * @param record the stored credential. + * @return the IANA COSE algorithm identifier (e.g. {@code -7} for ES256). + * @throws IllegalStateException if the stored COSE key cannot be decoded or carries no algorithm. + * @since 2.0.1 + */ + public static int coseAlgorithm(CredentialRecord record) { + COSEKey coseKey; + try { + coseKey = OBJECT_CONVERTER.getCborMapper().readValue(record.publicKeyCose(), COSEKey.class); + } catch (RuntimeException ex) { + throw new IllegalStateException( + "Unable to decode stored COSE key for credential " + record.credentialId(), ex); + } + COSEAlgorithmIdentifier alg = coseKey.getAlgorithm(); + if (alg == null) { + throw new IllegalStateException( + "Stored COSE key for credential " + record.credentialId() + " carries no algorithm"); + } + return (int) alg.getValue(); + } + + /** + * Reports whether the credential's stored public key uses the supplied COSE algorithm identifier. + * + * @param record the stored credential. + * @param coseAlgorithm the IANA COSE algorithm identifier to test for (e.g. {@code -7} for + * ES256). + * @return {@code true} if the credential uses that algorithm. + * @since 2.0.1 + */ + public static boolean usesAlgorithm(CredentialRecord record, int coseAlgorithm) { + return coseAlgorithm(record) == coseAlgorithm; + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java index e5adacb..d6d976d 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java @@ -258,10 +258,12 @@ public StartRegistrationResult startRegistration( req.username(), req.displayName() == null ? req.username() : req.displayName()), challenge, - List.of( - new PublicKeyCredentialParameters("public-key", -7), - new PublicKeyCredentialParameters("public-key", -8), - new PublicKeyCredentialParameters("public-key", -257)), + // Crypto-agility: the offered algorithm list comes from CeremonyConfig, not a hardcoded + // literal. Same source of truth the verify path reads (the accepted list); see + // WebAuthn4JConverters.pubKeyCredParams and ADR 0019. + ceremonyConfig.offeredAlgorithms().stream() + .map(a -> new PublicKeyCredentialParameters("public-key", a.coseValue())) + .toList(), DEFAULT_TIMEOUT_MS, excludeCredentials, selection, @@ -348,7 +350,7 @@ private RegistrationData verifyRegistrationWithW4j( var w4jParams = new RegistrationParameters( serverProperty, - WebAuthn4JConverters.DEFAULT_PUB_KEY_PARAMS, + WebAuthn4JConverters.pubKeyCredParams(ceremonyConfig.acceptedAlgorithms()), WebAuthn4JConverters.userVerificationRequired(ceremonyConfig.userVerification()), /* userPresenceRequired */ true); // Attestation signature verification is intentionally non-strict in the default manager; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java index 08be208..c71756f 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java @@ -7,6 +7,7 @@ import com.codeheadsystems.pkauth.api.ResidentKeyRequirement; import com.codeheadsystems.pkauth.api.Transport; import com.codeheadsystems.pkauth.api.UserVerificationRequirement; +import com.codeheadsystems.pkauth.config.CoseAlgorithm; import com.codeheadsystems.pkauth.config.RelyingPartyConfig; import com.codeheadsystems.pkauth.credential.CredentialRecord; import com.webauthn4j.converter.util.ObjectConverter; @@ -28,6 +29,7 @@ import com.webauthn4j.data.extension.authenticator.AuthenticationExtensionsAuthenticatorOutputs; import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientOutputs; import com.webauthn4j.server.ServerProperty; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -39,22 +41,35 @@ */ public final class WebAuthn4JConverters { - /** Default credential-parameter set: ES256, EdDSA, RS256, ES384, RS384 (preference order). */ - public static final List DEFAULT_PUB_KEY_PARAMS = - List.of( - new PublicKeyCredentialParameters( - PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256), - new PublicKeyCredentialParameters( - PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.EdDSA), - new PublicKeyCredentialParameters( - PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256), - new PublicKeyCredentialParameters( - PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES384), - new PublicKeyCredentialParameters( - PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS384)); - private WebAuthn4JConverters() {} + /** + * Builds the WebAuthn4J {@code pubKeyCredParams} verify list from the ceremony config's accepted + * algorithms. This is the only place the verify path learns which algorithms are allowed — there + * is no hardcoded default list. WebAuthn4J rejects a registration whose COSE key algorithm is + * absent from this list, so narrowing {@code accepted} in {@link + * com.codeheadsystems.pkauth.config.CeremonyConfig} narrows what verifies. + */ + public static List pubKeyCredParams( + List algorithms) { + List params = new ArrayList<>(algorithms.size()); + for (CoseAlgorithm alg : algorithms) { + params.add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, toW4j(alg))); + } + return List.copyOf(params); + } + + /** Maps our framework-neutral {@link CoseAlgorithm} to WebAuthn4J's COSE algorithm constant. */ + static COSEAlgorithmIdentifier toW4j(CoseAlgorithm alg) { + return switch (alg) { + case ES256 -> COSEAlgorithmIdentifier.ES256; + case EdDSA -> COSEAlgorithmIdentifier.EdDSA; + case ES384 -> COSEAlgorithmIdentifier.ES384; + case RS256 -> COSEAlgorithmIdentifier.RS256; + case RS384 -> COSEAlgorithmIdentifier.RS384; + }; + } + /** Builds a WebAuthn4J {@link ServerProperty} from our RP config and a challenge. */ public static ServerProperty serverProperty(RelyingPartyConfig rp, byte[] challenge) { Set origins = new LinkedHashSet<>(); diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithmsTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithmsTest.java new file mode 100644 index 0000000..69acf28 --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithmsTest.java @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.credential; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.Transport; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import java.time.Instant; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** Read-side decode of the COSE algorithm recorded on a stored credential (no schema change). */ +class CredentialAlgorithmsTest { + + private static final ObjectConverter OBJECT_CONVERTER = new ObjectConverter(); + + @Test + void coseAlgorithmReadsEs256FromStoredKey() { + CredentialRecord record = credentialWithEs256Key(); + + // ES256 == COSE identifier -7. + assertThat(CredentialAlgorithms.coseAlgorithm(record)).isEqualTo(-7); + assertThat(CredentialAlgorithms.usesAlgorithm(record, -7)).isTrue(); + assertThat(CredentialAlgorithms.usesAlgorithm(record, -257)).isFalse(); + } + + @Test + void undecodableKeyThrows() { + CredentialRecord broken = + new CredentialRecord( + CredentialId.of(new byte[] {1, 2, 3}), + UserHandle.of(new byte[16]), + new byte[] {0x01}, // not a valid COSE key + 0L, + "Broken", + null, + Set.of(Transport.USB), + false, + false, + Instant.EPOCH, + null); + + assertThatThrownBy(() -> CredentialAlgorithms.coseAlgorithm(broken)) + .isInstanceOf(IllegalStateException.class); + } + + private static CredentialRecord credentialWithEs256Key() { + 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 = OBJECT_CONVERTER.getCborMapper().writeValueAsBytes(key); + return new CredentialRecord( + CredentialId.of(new byte[] {9, 9, 9}), + UserHandle.of(new byte[16]), + cose, + 0L, + "ES256 key", + null, + Set.of(Transport.INTERNAL), + false, + false, + Instant.EPOCH, + null); + } +} diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java new file mode 100644 index 0000000..f01e0fe --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeheadsystems.pkauth.api.ChallengeId; +import com.codeheadsystems.pkauth.api.FinishRegistrationRequest; +import com.codeheadsystems.pkauth.api.RegistrationResponseJson; +import com.codeheadsystems.pkauth.api.RegistrationResponseJson.AuthenticatorAttestationResponseJson; +import com.codeheadsystems.pkauth.api.StartRegistrationRequest; +import com.codeheadsystems.pkauth.api.StartRegistrationResult; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.config.CeremonyConfig; +import com.codeheadsystems.pkauth.config.CoseAlgorithm; +import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.metrics.Metrics; +import com.codeheadsystems.pkauth.spi.AttestationTrustPolicy; +import com.codeheadsystems.pkauth.spi.ChallengeRecord; +import com.codeheadsystems.pkauth.spi.ChallengeStore; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.codeheadsystems.pkauth.spi.CredentialRepository; +import com.codeheadsystems.pkauth.spi.OriginValidator; +import com.codeheadsystems.pkauth.spi.UserLookup; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import tools.jackson.databind.json.JsonMapper; + +/** + * Verifies that the COSE algorithm lists flow from {@link CeremonyConfig} into both the + * create-options ceremony (offered) and the WebAuthn4J verify path (accepted) — the single source + * of truth introduced for crypto-agility / post-quantum readiness (ADR 0019). + */ +class DefaultPasskeyAuthenticationServiceAlgorithmTest { + + private static final Instant NOW = Instant.parse("2026-05-13T12:00:00Z"); + private static final byte[] CHALLENGE = filled(32, (byte) 1); + private static final ChallengeId CHALLENGE_ID = new ChallengeId(Base64Url.encode(CHALLENGE)); + private static final UserHandle USER_HANDLE = UserHandle.of(filled(16, (byte) 9)); + private static final byte[] CRED_ID = filled(20, (byte) 2); + + private final JsonMapper jsonMapper = + JsonMapper.builder() + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + private final ObjectConverter objectConverter = new ObjectConverter(); + + @Test + void defaultConfigOffersAndAcceptsHistoricalAlgorithms() { + Harness h = new Harness(CeremonyConfig.defaults()); + + // (a) create-options still offers ES256, EdDSA, RS256. + assertThat(offeredAlgs(h)).containsExactly(-7L, -8L, -257L); + + // (a) verify still accepts ES256, EdDSA, RS256, ES384, RS384 (the historical union). + assertThat(acceptedAlgs(h)).containsExactly(-7L, -8L, -257L, -35L, -258L); + } + + @Test + void customConfigFlowsIntoBothCreateOptionsAndVerifyParams() { + // (b) a narrower offered list and a custom accepted list (offered must be a subset). + CeremonyConfig custom = + CeremonyConfig.from( + null, + null, + null, + null, + null, + List.of(CoseAlgorithm.ES256), + List.of(CoseAlgorithm.ES256, CoseAlgorithm.ES384)); + Harness h = new Harness(custom); + + assertThat(offeredAlgs(h)).containsExactly(-7L); + assertThat(acceptedAlgs(h)).containsExactly(-7L, -35L); + } + + @Test + void algorithmAbsentFromConfigIsNotOfferedToTheVerifier() { + // (c) RS256 absent from the accepted list → it is not in the WebAuthn4J pubKeyCredParams, so + // WebAuthn4J rejects any registration whose COSE key uses RS256. We assert the exclusion at the + // parameter boundary (WebAuthn4J performs the actual rejection against this list). + CeremonyConfig noRs256 = + CeremonyConfig.from( + null, + null, + null, + null, + null, + List.of(CoseAlgorithm.ES256), + List.of(CoseAlgorithm.ES256, CoseAlgorithm.EdDSA)); + Harness h = new Harness(noRs256); + + assertThat(acceptedAlgs(h)).containsExactly(-7L, -8L).doesNotContain(-257L); + } + + /** Offered COSE algorithm identifiers from the create-options ceremony. */ + private List offeredAlgs(Harness h) { + StartRegistrationResult result = + h.service.startRegistration( + new StartRegistrationRequest("alice", "Alice", null, null), null); + assertThat(result).isInstanceOf(StartRegistrationResult.Started.class); + return ((StartRegistrationResult.Started) result) + .response().publicKey().pubKeyCredParams().stream().map(p -> p.alg()).toList(); + } + + /** Accepted COSE algorithm identifiers as passed to WebAuthn4J on the finish/verify path. */ + private List acceptedAlgs(Harness h) { + // The mocked manager throws after the argument is captured; we only care about the parameters. + when(h.webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenThrow(new com.webauthn4j.verifier.exception.MissingChallengeException("ignored")); + + h.service.finishRegistration(finishReg()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RegistrationParameters.class); + verify(h.webAuthnManager) + .verify(any(com.webauthn4j.data.RegistrationRequest.class), captor.capture()); + return captor.getValue().getPubKeyCredParams().stream() + .map(p -> p.getAlg()) + .map(COSEAlgorithmIdentifier::getValue) + .toList(); + } + + /** Wires a service over mocked collaborators with the supplied ceremony config. */ + private final class Harness { + final WebAuthnManager webAuthnManager = mock(WebAuthnManager.class); + final DefaultPasskeyAuthenticationService service; + + Harness(CeremonyConfig ceremonyConfig) { + CredentialRepository credentialRepository = mock(CredentialRepository.class); + UserLookup userLookup = mock(UserLookup.class); + ChallengeStore challengeStore = mock(ChallengeStore.class); + OriginValidator originValidator = mock(OriginValidator.class); + AttestationTrustPolicy attestationTrustPolicy = mock(AttestationTrustPolicy.class); + Metrics metrics = mock(Metrics.class); + + lenient().when(userLookup.getOrCreateHandle("alice")).thenReturn(USER_HANDLE); + lenient().when(credentialRepository.findByUserHandle(USER_HANDLE)).thenReturn(List.of()); + lenient().when(originValidator.isAllowed("https://example.com")).thenReturn(true); + lenient() + .when(challengeStore.takeOnce(CHALLENGE_ID)) + .thenReturn( + Optional.of( + new ChallengeRecord( + CHALLENGE, + ChallengeRecord.Purpose.REGISTRATION, + USER_HANDLE, + NOW.plusSeconds(300)))); + + RelyingPartyConfig rp = + new RelyingPartyConfig("example.com", "Example", Set.of("https://example.com")); + SecureRandom random = + new SecureRandom() { + @Override + public void nextBytes(byte[] bytes) { + System.arraycopy(CHALLENGE, 0, bytes, 0, Math.min(bytes.length, CHALLENGE.length)); + } + }; + this.service = + new DefaultPasskeyAuthenticationService( + webAuthnManager, + objectConverter, + credentialRepository, + userLookup, + challengeStore, + ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC)), + originValidator, + attestationTrustPolicy, + rp, + ceremonyConfig, + new ChallengeGenerator(random), + metrics); + } + } + + private FinishRegistrationRequest finishReg() { + return new FinishRegistrationRequest( + CHALLENGE_ID, + "alice", + "k", + new RegistrationResponseJson( + CRED_ID, + CRED_ID, + new AuthenticatorAttestationResponseJson( + cd(), new byte[] {(byte) 0xa0}, null, null, null, null), + null, + null, + "public-key")); + } + + private byte[] cd() { + var node = jsonMapper.createObjectNode(); + node.put("type", "webauthn.create"); + node.put("challenge", Base64Url.encode(CHALLENGE)); + node.put("origin", "https://example.com"); + return jsonMapper.writeValueAsString(node).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] filled(int len, byte v) { + byte[] out = new byte[len]; + java.util.Arrays.fill(out, v); + return out; + } +} diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java index 50fbf24..c8265cc 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java @@ -35,8 +35,13 @@ private JwtKeyset(JWK signingKey, JWSAlgorithm algorithm, List verification } /** - * HS256 keyset (dev / single-host). The supplied secret must be at least 256 bits per RFC 7518 - * §3.2; Nimbus rejects shorter keys. + * HS256 (symmetric) keyset. Appropriate whenever the issuer and verifier share a trust boundary + * (the common single-issuer/single-verifier deployment) — not a "dev-only" mode. The supplied + * secret must be at least 256 bits per RFC 7518 §3.2; Nimbus rejects shorter keys. With a {@code + * >= 256}-bit key HMAC-SHA256 is also the quantum-conservative choice (Shor does not apply; + * Grover leaves ~128-bit effective security). Use {@link #es256(ECKey, ECKey...)} instead only + * when an untrusted third party must verify tokens without the power to mint them. See the + * package Javadoc and {@code docs/threat-model.md} (Post-quantum readiness). */ public static JwtKeyset hs256(byte[] secret) { Objects.requireNonNull(secret, "secret"); diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java index 1d1fc79..44d26e8 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java @@ -1,5 +1,31 @@ // SPDX-License-Identifier: MIT -/** JWT issuance and validation for pk-auth-issued tokens (HS256 dev, ES256 production). */ +/** + * JWT issuance and validation for pk-auth-issued tokens. + * + *

HS256 vs ES256 is a trust-topology choice, not a dev-vs-prod one. The older + * "HS256 for dev, ES256 for production" framing was misleading. Both are production-grade; pick by + * who needs to verify the token: + * + *

    + *
  • HS256 (symmetric) — the issuer and verifier share one secret. This is the + * right default when pk-auth both mints and validates the token (the common + * single-issuer/single-verifier deployment). It is also the quantum-conservative + * choice: HMAC-SHA256 is not broken by Shor's algorithm, and with a {@code >= 256}-bit key + * (enforced by {@link com.codeheadsystems.pkauth.jwt.JwtKeyset#hs256(byte[])}) Grover's + * algorithm leaves roughly 128-bit effective security — comfortably safe. Its limitation is + * trust, not cryptography: anyone who can verify can also forge, so the secret must never + * leave the trust boundary. + *
  • ES256 (asymmetric) — exists for untrusted third-party + * verification: publish the public key so external services validate tokens without the + * power to mint them. That capability costs post-quantum exposure — ES256 is an + * elliptic-curve signature and is Shor-vulnerable. The exposure is bounded by the (short) + * token TTL: a forged signature is only useful within the lifetime of a token, and a CRQC + * does not yet exist. + *
+ * + *

Neither default nor signing behavior is changed by this note; see {@code docs/threat-model.md} + * (Post-quantum readiness) and ADR 0019. + */ @org.jspecify.annotations.NullMarked package com.codeheadsystems.pkauth.jwt;