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
20 changes: 20 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10547,6 +10547,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.)_
Expand Down
40 changes: 40 additions & 0 deletions docs/adr/0068-browser-webauthn-passkeys-offloopback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
56 changes: 51 additions & 5 deletions messagefoundry/auth/webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading