From 142eda3896bfde1dbd90d4a73b932dc4929fc6b0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 5 Sep 2026 18:42:37 -0500 Subject: [PATCH] feat(auth): pin the WebAuthn credential algorithm set and drop RS256 (BACKLOG #1166) The relying party inherited py_webauthn's default algorithm set, which carries RS256 (-257). A COSE RSA identifier fixes the padding and the hash and leaves the MODULUS unbounded, so it cannot carry the 128-bit floor ASVS 11.2.3 asks for. Re-measured by execution before building, on the pinned webauthn==3.0.0 from this worktree's own venv: an RS256 credential over a 2048-bit modulus registered and was ACCEPTED, and so did one over a 1024-bit modulus. Positive controls in the same run: ES256 accepted, garbage refused, PS256 refused (so the library's algorithm screen was running and discriminating, and the RS256 acceptance was a real allow). The item's row understates this twice, and the BACKLOG note corrects it: 1. verify_registration_response takes the SAME supported_pub_key_algs parameter and was also at the library default. Advertising is a hint an authenticator may ignore; the verify call is the only refusal. Restricting the advertised set alone would have read like a fix while accepting exactly the same credentials. 2. The accepted set had no floor at all, not merely a 2048-bit one. Built: one module constant, SUPPORTED_COSE_ALGS = (-8, -7), fed to BOTH ceremony halves through a single lazy resolver so the offer and the refusal cannot drift. EdDSA and ES256 carry no equivalent hole: the curve rides in the credential rather than the identifier, but a credential whose curve is unknown or mismatched cannot produce a verifiable assertion (measured), so no sub-floor EC2 or OKP credential is ever usable. Cost, stated rather than hidden: an authenticator offering only RS256 can no longer enrol a passkey, and TPM-backed Windows Hello is that population. Those operators keep TOTP, which ADR 0068's 2026-07-17 amendment already records as the alternative second factor. Deliberately NOT an operator setting: a knob re-admitting -257 would be the operator-supplied weak configuration this requirement is failing on. Proof: three mutations, each red, each compiled first and checked to FAIL rather than ERROR. Eight new rows, one of which fires if a future release narrows the library default to match the pin, so the file cannot pass vacuously. Instrument check: this requirement's recorded absence pattern keys on a 3072-bit RSA comparison. All 341 added lines swept with it, zero matches, against a positive control that matched a planted comparison. The guard still fires; no re-cut needed. Does NOT close #1166. Its verdict moves by a scorecard re-score, which no Builder can perform, and other named surfaces stay open. --- docs/BACKLOG.md | 20 ++ ...8-browser-webauthn-passkeys-offloopback.md | 40 +++ messagefoundry/auth/webauthn.py | 56 ++++- tests/test_webauthn_algs.py | 230 ++++++++++++++++++ 4 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 tests/test_webauthn_algs.py diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 97ea37070..efd2a34e9 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -10307,6 +10307,26 @@ Proof: nine mutations, each red, zero vacuous -- each of the three key-type chec **Still open:** the XML signature-verification algorithm policy, the WebAuthn advertised-algorithm restriction, the PKCS#12 import and inbound mTLS rows, and the verdict itself. The cell moves by a scorecard re-score, which no Builder can perform. +**A FIFTH LIMB BUILT 2026-09-05: the WebAuthn advertised-algorithm restriction -- AND THIS ITEM'S OWN ROW UNDERSTATES THE DEFECT ON TWO AXES.** The row records that `auth/webauthn.py:204` "calls the registration-options builder with no `supported_pub_key_algs` anywhere in the file, so the relying party advertises the library default set". True, and re-measured true at engine `c57903c2c` on the pinned `webauthn==3.0.0`, from this worktree's own venv rather than any other interpreter on the box (`sys.executable` printed beside every version; `cryptography` read 50.0.1, matching `requirements.lock`). It understates it twice. + +**Axis one: advertising is not the control, and a fix aimed only there would have been the shape this item exists to catch.** `verify_registration_response` takes the SAME `supported_pub_key_algs` parameter and was ALSO left at the library default. Advertisement is a hint an authenticator may ignore; the verify call is the only place a credential is refused. Restricting the advertised set alone would have made the relying party read conformant while accepting exactly the same credentials -- "the cell would read better while the product accepted exactly the same keys", in this item's own words about `jwks.py`. + +**Axis two: the row says the default set; the default set has no floor at all.** Measured by execution before building, with three positive controls in the same run: an RS256 (`-257`) credential over a **2048-bit** modulus registered and was ACCEPTED, and so did one over a **1024-bit** modulus. Controls: an ES256 credential was accepted (the fixture builds valid credentials), a garbage response was refused (the verifier is live), and a PS256 (`-37`) credential was refused (the library's algorithm screen was RUNNING and DISCRIMINATING, so the RS256 acceptance was a real allow rather than an unreached branch). The "or smaller" clause that the 2026-08-20 research measured FALSE for every TLS role is TRUE here. + +**Built:** one module constant, `SUPPORTED_COSE_ALGS = (-8, -7)` (EdDSA, ES256), passed to BOTH ceremony halves through a single lazy resolver, so the offered set and the accepted set cannot drift. RSA is dropped in full and that is the point: a COSE RSA identifier fixes the padding and the hash and leaves the MODULUS unbounded, so no RSA identifier can carry a 128-bit floor. EdDSA and ES256 carry no equivalent hole -- the curve rides in the credential rather than in the identifier, but a credential whose curve is unknown or mismatched cannot produce a verifiable assertion (measured: it registers, then every assertion against it is refused), so no sub-floor EC2 or OKP credential is ever usable. That asymmetry is why the floor is expressible here as a set of identifiers, and it is the same reasoning the 2026-08-22 signing-key limb recorded for EC. + +**The cost is real, is stated rather than hidden, and is not made an operator setting.** An authenticator that offers only RS256 can no longer enrol a passkey; TPM-backed Windows Hello is the population that registers RSA credentials. **This cost is reasoned, not measured -- no real authenticator was driven here, only the pinned library.** Those operators keep TOTP, which ADR 0068's 2026-07-17 amendment already records as the alternative second factor. A knob re-admitting `-257` was deliberately not built: it would be exactly the operator-supplied weak configuration this requirement is failing on. ADR 0068 carries a 2026-09-05 amendment recording the pin as a third policy pin beside `attestation=NONE` and `user_verification=PREFERRED`. + +**This also closes half of a row filed under a different item, which is recorded here rather than edited there.** #1168 (ASVS 11.3.1) names this same site from the padding angle: "the shipped relying party advertises COSE -257 and every enrolled credential is verified with PKCS#1 v1.5 at each login". No credential can now be enrolled under `-257`, so that sentence is stale for new enrolments as of this change. Whoever works #1168 should re-measure it rather than relay it. + +**Proof:** three mutations, each red, each checked to FAIL rather than ERROR (the mutation runner compiled the file before running, because a first cut of three mutations in this item's own history broke the syntax instead of the behaviour): the advertise-side argument removed (2 failed), the enforce-side argument removed (2 failed), and `-257` re-admitted to the pinned set (4 failed). Eight new rows in `tests/test_webauthn_algs.py`, one of which fires if a future `webauthn` release narrows its own default to match the pin, so the file cannot pass vacuously. 271 tests green across the WebAuthn, MFA, API-auth, crypto-inventory and security-static suites; `mypy --strict` clean over 268 source files. + +**The instrument trap was checked, not assumed, and again it did not fire.** This requirement's recorded absence pattern keys on a 3072-bit RSA modulus comparison. Every line this change adds (281, tracked diff plus the new test file) was swept with that pattern: zero matches, against a positive control in the same run where a planted comparison matched. So the machine guard is still firing and no re-cut was needed. The claim's own positive control still speaks in the corpus. + +**A MEASURED RESIDUAL, UNFILED AND NAMED BY SUBJECT.** The COSE `crv` field is not validated at registration, so a credential whose curve is unknown or does not match its key ENROLS and only fails at first assertion. On the mismatched-curve path the failure arrives as a raw `ValueError` from `cryptography` rather than a `WebAuthnException`, so `verify_assertion`'s `except WebAuthnException` does not catch it and the rejection would not land on the audited invalid-input path ADR 0068 decision 1 requires -- it would be a 500. Self-harm only (the enrolling user is authenticated and breaks their own credential), and this pin neither causes nor fixes it. + +**Still open after this limb:** the XML signature-verification algorithm policy, the PKCS#12 import and inbound mTLS rows, the unvalidated-`crv` residual above, and the verdict itself. The cell moves by a scorecard re-score, which no Builder can perform, and nothing in this limb re-scores it. + ## 1167. research an honest pass for ASVS 11.2.4 -- constant-time recovery-code verification without turning ten argon2id slots into an amplification target > 🔢 **Re-scored 2026-08-20 -> P3.** Value **4/10** · Difficulty **7/10** · _money pit_. The data-dependent early return survives on the shipped MFA path: _verify_second_factor walks the argon2id recovery hashes and returns on the first match, so the number of ~64 MiB verifications is a function of which code was presented. Value 4 because the leak is a wall-clock signal on an already-authenticated second factor rather than a bypass; difficulty 7 because the obvious constant-time loop multiplies a 64 MiB argon2id verification by the slot count on every attempt, converting a timing leak into a memory and CPU amplification target, and the evidentiary half has no precedent in this tree. _(was 4/10 · 7/10.)_ diff --git a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md index 619d09f68..fbf4840b3 100644 --- a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md +++ b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md @@ -438,3 +438,43 @@ password + TOTP — so 6.3.3 / 6.5.7 / 6.7.2 stay **Partial at strict L3** for t deployment. A clean Pass needs an **owner** serve gate that *requires* the `[webauthn]` extra + an enrolled passkey for admin at exposure (an owner decision; not landed here). ADR 0115 does not re-score. + +## Amendment (2026-09-05) — the credential algorithm set is pinned, and RS256 is dropped (ASVS 11.2.3, BACKLOG #1166) + +**Status:** Behaviour changed. A third policy pin joins `attestation=NONE` and +`user_verification=PREFERRED` in decision 1: `messagefoundry/auth/webauthn.py` now declares +`SUPPORTED_COSE_ALGS = (-8, -7)` (EdDSA, ES256) and passes it to **both** ceremony halves, instead +of inheriting py_webauthn's default set of EdDSA, ES256 and **RS256 (-257)**. + +**Why RS256 goes.** ASVS 11.2.3 asks every primitive for at least 128 bits of security. A COSE RSA +identifier cannot promise that: `-257` fixes the padding and the hash and leaves the modulus +unbounded, so the authenticator chooses the strength. Measured against this module at engine commit +`c57903c2c` on the pinned `webauthn==3.0.0` before the pin landed: an RS256 credential over a +2048-bit modulus registered and was accepted, and so did one over a **1024-bit** modulus. EdDSA and +ES256 have no equivalent hole. The curve rides in the credential rather than in the identifier, but +a credential whose curve is unknown or does not match its key cannot produce a verifiable +assertion, measured in the same run, so no sub-floor EC2 or OKP credential is ever usable. + +**Both halves, deliberately.** `generate_registration_options` decides what the browser is +*offered*; an authenticator may ignore it. `verify_registration_response` is the only place a +credential is *refused*. Restricting the advertisement alone would have read like a fix while +accepting exactly the same credentials, so one tuple feeds both calls and a test pins that they +cannot drift. + +**The cost, stated rather than hidden.** An authenticator that offers only RS256 can no longer +enrol a passkey; TPM-backed Windows Hello is the population that registers RSA credentials. Those +operators keep TOTP, which the 2026-07-17 amendment above already records as the alternative second +factor, and the password leg is unchanged. This is **not** an operator setting, on purpose: a knob +that re-admits `-257` would be exactly the operator-supplied weak configuration ASVS 11.2.3 is +failing on. + +**Not a re-score.** ASVS 11.2.3 stays `partial`. Its verdict moves only by a scorecard re-score, +and other surfaces named in BACKLOG #1166 are still open. + +**Measured residual, unfiled and named by subject rather than by a number.** The COSE `crv` field +is not validated at registration, so a credential whose curve is unknown or mismatched enrols and +then fails at first assertion. On the mismatched-curve path the failure arrives as a raw +`ValueError` from `cryptography` rather than a `WebAuthnException`, so `verify_assertion`'s +`except WebAuthnException` does not catch it and the rejection would not land on the audited +invalid-input path decision 1 requires. It is self-harm only (the enrolling user is authenticated +and breaks their own credential) and this pin neither causes nor fixes it. diff --git a/messagefoundry/auth/webauthn.py b/messagefoundry/auth/webauthn.py index 62d8da603..a700f6b0c 100644 --- a/messagefoundry/auth/webauthn.py +++ b/messagefoundry/auth/webauthn.py @@ -15,9 +15,9 @@ process is structural; ADR 0068 records the store-backed table as the multi-node upgrade path). Policy pins (ADR 0068 §1/§6): ``attestation=NONE`` (passkey norm — no attestation certificates are -requested or stored, keeping ASVS 6.7.1 N/A) and ``user_verification=PREFERRED`` (the knowledge +requested or stored, keeping ASVS 6.7.1 N/A), ``user_verification=PREFERRED`` (the knowledge factor is the password that accompanies every step-up; ``REQUIRED`` would brick PIN-less U2F keys -for no factor gain). +for no factor gain), and the credential algorithm set (:data:`SUPPORTED_COSE_ALGS`). """ from __future__ import annotations @@ -27,12 +27,40 @@ import time from collections.abc import Callable, Sequence from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # the [webauthn] extra is optional — the runtime import is lazy, per-call + from webauthn.helpers.cose import COSEAlgorithmIdentifier CHALLENGE_BYTES = 64 CHALLENGE_TTL_SECONDS = 120.0 PER_USER_PENDING_CAP = 16 GLOBAL_PENDING_CAP = 4096 +#: The COSE algorithm identifiers (IANA COSE Algorithms registry) this relying party will register: +#: EdDSA and ES256. Deliberately NARROWER than py_webauthn's default set, which also carries RS256 +#: (-257) — ASVS 11.2.3 asks every primitive for at least 128 bits of security, and an RSA +#: identifier cannot promise that. ``-257`` fixes the padding and the hash and leaves the MODULUS +#: unbounded, so an authenticator answers it with whatever size it holds. Measured against this +#: module before the restriction landed (BACKLOG #1166): RS256 over a 2048-bit modulus registered +#: and was accepted, and so did RS256 over a **1024**-bit one. +#: +#: EdDSA (-8) and ES256 (-7) carry no equivalent hole. The curve rides in the credential rather +#: than in the identifier, but a credential whose curve is unknown or does not match its key cannot +#: produce a verifiable assertion — measured: it registers, then every assertion against it is +#: refused — so no sub-floor EC2 or OKP credential is ever usable. That is the property the RSA +#: identifier cannot offer, and it is why the floor can be expressed here as a set of identifiers. +#: +#: **Stated rather than hidden: this refuses an authenticator that offers only RS256.** TPM-backed +#: Windows Hello is the population that registers RSA credentials. Those operators keep TOTP, which +#: ADR 0068's 2026-07-17 amendment already records as the alternative second factor. It is NOT an +#: operator setting on purpose: a knob that re-admits -257 would be exactly the operator-supplied +#: weak configuration this requirement is failing on. +#: +#: Plain ints so this module still imports without the extra; :func:`_supported_pub_key_algs` +#: resolves them to the library enum at call time. +SUPPORTED_COSE_ALGS: tuple[int, ...] = (-8, -7) + _INSTALL_HINT = ( "WebAuthn support requires the [webauthn] extra: pip install messagefoundry[webauthn]" ) @@ -70,6 +98,18 @@ class ChallengeCacheFullError(RuntimeError): """ +def _supported_pub_key_algs() -> list[COSEAlgorithmIdentifier]: + """Resolve :data:`SUPPORTED_COSE_ALGS` to the library enum (lazy — the extra is optional). + + Both ceremony halves call this, so the set the relying party ADVERTISES and the set it ACCEPTS + cannot drift apart. That is the whole control: advertisement is a hint an authenticator may + ignore, and ``verify_registration_response`` is the only place a credential is refused. + """ + from webauthn.helpers.cose import COSEAlgorithmIdentifier + + return [COSEAlgorithmIdentifier(alg) for alg in SUPPORTED_COSE_ALGS] + + def new_challenge() -> bytes: """Mint a first-party 64-byte ceremony challenge (single-use, cached with a TTL).""" return secrets.token_bytes(CHALLENGE_BYTES) @@ -187,9 +227,9 @@ def registration_options( ) -> str: """Build the browser ``navigator.credentials.create`` options as a JSON string. - ``attestation=NONE`` + ``user_verification=PREFERRED`` are pinned here (module docstring); - ``exclude_credential_ids`` carries the user's existing credentials so re-registering the same - authenticator is refused client-side. + ``attestation=NONE``, ``user_verification=PREFERRED`` and :data:`SUPPORTED_COSE_ALGS` are + pinned here (module docstring); ``exclude_credential_ids`` carries the user's existing + credentials so re-registering the same authenticator is refused client-side. """ _require_webauthn() from webauthn import generate_registration_options @@ -214,6 +254,7 @@ def registration_options( exclude_credentials=[ PublicKeyCredentialDescriptor(id=cid) for cid in exclude_credential_ids ], + supported_pub_key_algs=_supported_pub_key_algs(), ) return options_to_json(options) @@ -223,6 +264,10 @@ def verify_registration( ) -> RegistrationResult: """Verify an attestation response against the staged challenge; raise on any invalid input. + **This is where :data:`SUPPORTED_COSE_ALGS` is enforced**, not merely advertised: an + authenticator that answers with an identifier outside the set is refused here, and a refusal + lands on the same audited invalid-input path as any other bad response. + ``transports`` ride ``RegistrationCredential.response.transports`` (struct path verified against webauthn 3.0.0 at build time per ADR 0068's open item) — extracted defensively from the raw JSON so a shape drift degrades to ``None``, never a crash. @@ -237,6 +282,7 @@ def verify_registration( expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin, + supported_pub_key_algs=_supported_pub_key_algs(), ) except WebAuthnException as exc: # The BASE class, deliberately (PR-A review HIGH): structurally-malformed browser input diff --git a/tests/test_webauthn_algs.py b/tests/test_webauthn_algs.py new file mode 100644 index 000000000..33c7551bf --- /dev/null +++ b/tests/test_webauthn_algs.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The relying party's credential-algorithm floor (BACKLOG #1166, ASVS 11.2.3). + +``messagefoundry/auth/webauthn.py`` pins :data:`SUPPORTED_COSE_ALGS` instead of inheriting +py_webauthn's default set. These rows drive the REAL library on both ceremony halves, because the +two halves fail differently and only one of them refuses anything: + +* ``generate_registration_options`` decides what the browser is OFFERED. An authenticator may + ignore it, so on its own it is a hint, not a control. +* ``verify_registration_response`` decides what is ACCEPTED. This is the refusal. + +A change that restricted only the first would read like a fix and admit exactly the same +credentials, so every enforcement row here goes through ``wa.verify_registration``. + +The RSA credential builder is local rather than in ``tests/_soft_webauthn.py``: that helper exists +to produce credentials the engine ACCEPTS, and these rows need one it must refuse. ``tests/`` sits +outside ``crypto_inventory_check.py``'s ``WALK_ROOTS``, so the ``cryptography`` import here +registers nothing. +""" + +from __future__ import annotations + +import hashlib +import json +import secrets +import struct + +import pytest + +pytest.importorskip("webauthn") + +from cryptography.hazmat.primitives.asymmetric import rsa # noqa: E402 +from webauthn.helpers import bytes_to_base64url, encode_cbor # noqa: E402 +from webauthn.helpers.cose import COSEAlgorithmIdentifier # noqa: E402 + +from messagefoundry.auth import webauthn as wa # noqa: E402 +from tests._soft_webauthn import SoftAuthenticator # noqa: E402 + +RP = "t" +ORIGIN = "http://t" + +_FLAG_UP = 0x01 +_FLAG_AT = 0x40 +_AAGUID = b"\x00" * 16 + +#: Every RSA identifier the pinned library defines (PSS, PKCS#1 v1.5, and the deprecated SHA-1 +#: one). None of them constrains the modulus, which is the property this floor turns on. +_RSA_ALGS = frozenset( + { + COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, + COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_1, + } +) + +# Key generation is the slow part of these rows, so each modulus size is built once. +_rsa_keys: dict[int, rsa.RSAPrivateKey] = {} + + +def _rsa_key(bits: int) -> rsa.RSAPrivateKey: + if bits not in _rsa_keys: + _rsa_keys[bits] = rsa.generate_private_key(public_exponent=65537, key_size=bits) + return _rsa_keys[bits] + + +def _cose_rsa(public: rsa.RSAPublicKey, alg: int) -> bytes: + nums = public.public_numbers() + return encode_cbor( + { + 1: 3, # kty: RSA + 3: alg, + -1: nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big"), + -2: nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big"), + } + ) + + +def _rsa_registration_response(challenge: bytes, *, bits: int, alg: int) -> str: + """A well-formed ``navigator.credentials.create`` response carrying an RSA credential. + + Everything except the credential's key type matches what the soft authenticator produces, so a + refusal can only be the algorithm screen. + """ + key = _rsa_key(bits) + credential_id = secrets.token_bytes(32) + attested = ( + _AAGUID + + struct.pack(">H", len(credential_id)) + + credential_id + + _cose_rsa(key.public_key(), alg) + ) + auth_data = ( + hashlib.sha256(RP.encode("utf-8")).digest() + + bytes([_FLAG_UP | _FLAG_AT]) + + struct.pack(">I", 0) + + attested + ) + client_data = json.dumps( + { + "type": "webauthn.create", + "challenge": bytes_to_base64url(challenge), + "origin": ORIGIN, + } + ).encode("utf-8") + return json.dumps( + { + "id": bytes_to_base64url(credential_id), + "rawId": bytes_to_base64url(credential_id), + "response": { + "clientDataJSON": bytes_to_base64url(client_data), + "attestationObject": bytes_to_base64url( + encode_cbor({"fmt": "none", "attStmt": {}, "authData": auth_data}) + ), + }, + "type": "public-key", + "clientExtensionResults": {}, + } + ) + + +def _advertised_algs() -> list[int]: + options = json.loads( + wa.registration_options( + rp_id=RP, + rp_name="MessageFoundry", + user_id="u1", + user_name="admin", + challenge=secrets.token_bytes(wa.CHALLENGE_BYTES), + ) + ) + return [param["alg"] for param in options["pubKeyCredParams"]] + + +def test_the_advertised_set_is_the_pinned_set_not_the_library_default() -> None: + """The browser is offered exactly ``SUPPORTED_COSE_ALGS``, in order.""" + from webauthn.registration.generate_registration_options import ( + default_supported_pub_key_algs, + ) + + advertised = _advertised_algs() + assert advertised == list(wa.SUPPORTED_COSE_ALGS) + # The row only means something while the library default is WIDER than the pin. If a future + # release narrows its own default to match, this says so rather than passing vacuously. + assert set(default_supported_pub_key_algs) - set(advertised), ( + "py_webauthn's default set no longer exceeds the pinned set, so this file's premise has " + "moved; re-derive the floor against the new default rather than deleting the row" + ) + + +def test_no_rsa_identifier_is_advertised_because_none_constrains_the_modulus() -> None: + """The floor's whole reason: an RSA identifier fixes padding and hash, never key size.""" + assert _RSA_ALGS.isdisjoint(wa.SUPPORTED_COSE_ALGS) + assert COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256 not in wa.SUPPORTED_COSE_ALGS + + +def test_every_pinned_identifier_resolves_to_a_library_identifier() -> None: + """A pin the library no longer defines must fail loudly, not silently widen the set.""" + resolved = wa._supported_pub_key_algs() + assert [int(alg) for alg in resolved] == list(wa.SUPPORTED_COSE_ALGS) + assert all(isinstance(alg, COSEAlgorithmIdentifier) for alg in resolved) + + +def test_advertised_and_enforced_sets_cannot_drift() -> None: + """Both ceremony halves read one source, so the offer can never outrun the refusal.""" + assert _advertised_algs() == [int(alg) for alg in wa._supported_pub_key_algs()] + + +@pytest.mark.parametrize("bits", [2048, 1024]) +def test_an_rs256_credential_is_refused_at_any_modulus_size(bits: int) -> None: + """RSA-2048 is roughly 112 bits and RSA-1024 far less; both were ACCEPTED before this floor.""" + challenge = secrets.token_bytes(wa.CHALLENGE_BYTES) + response = _rsa_registration_response( + challenge, bits=bits, alg=int(COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256) + ) + with pytest.raises(wa.WebAuthnVerificationError) as caught: + wa.verify_registration(response_json=response, challenge=challenge, rp_id=RP, origin=ORIGIN) + # Named so the row cannot pass on some unrelated malformation of the fixture. + assert "-257" in str(caught.value) + + +def test_an_es256_credential_still_registers_and_asserts() -> None: + """The positive control: the floor refuses RSA without breaking the credentials it keeps.""" + challenge = secrets.token_bytes(wa.CHALLENGE_BYTES) + soft = SoftAuthenticator(rp_id=RP, origin=ORIGIN) + registered = wa.verify_registration( + response_json=soft.create_response(challenge), + challenge=challenge, + rp_id=RP, + origin=ORIGIN, + ) + assert registered.credential_id == soft.credential_id + + challenge = secrets.token_bytes(wa.CHALLENGE_BYTES) + assert ( + wa.verify_assertion( + response_json=soft.get_response(challenge), + challenge=challenge, + rp_id=RP, + origin=ORIGIN, + public_key=registered.public_key, + current_sign_count=0, + ) + == 0 + ) + + +def test_the_rsa_fixture_is_well_formed_apart_from_its_key_type() -> None: + """Without this, the refusal rows could be passing on a broken response rather than the floor. + + Same builder, same challenge, but carrying the identifier the library still accepts by default: + if THIS is refused, the fixture is wrong and the rows above prove nothing. + """ + challenge = secrets.token_bytes(wa.CHALLENGE_BYTES) + response = _rsa_registration_response( + challenge, bits=2048, alg=int(COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256) + ) + from webauthn import verify_registration_response + + verified = verify_registration_response( + credential=response, + expected_challenge=challenge, + expected_rp_id=RP, + expected_origin=ORIGIN, + ) + assert verified.credential_id