Skip to content

Security hardening batch: trust-chain validation, attestation bypasses, malformed-input DoS - #671

Merged
aseigler merged 32 commits into
passwordless-lib:mainfrom
aseigler:security-batch-2026-09
Sep 18, 2026
Merged

aseigler merged 32 commits into
passwordless-lib:mainfrom
aseigler:security-batch-2026-09

Conversation

@aseigler

Copy link
Copy Markdown
Collaborator

Security hardening batch: trust-chain validation, attestation bypasses, malformed-input DoS

Fourteen independent fixes across attestation verification, trust-chain validation, and malformed-input handling, bundled into one PR rather than trickled out separately. Validated end-to-end against the FIDO2 conformance test tool: after these fixes, the only remaining failure is a confirmed bug in the tool itself (Resp-9 P-4, TPM ES256, tracked at fido-alliance/conformance-test-tools-resources#862), not this library.

Fixes #293.

Attestation-bypass / trust fixes

  • TPM attestation checks: an OKP credential skipped a required TPM2_Certify check (TPM2_Certify takes caller-chosen qualifyingData, so skipping the check let a bogus attestation through for that key type); the pubArea public exponent was read little-endian instead of big-endian, so most non-default exponents parsed wrong; the TCG vendor list was years out of date. The conformance tools' own TPM manufacturer ID is no longer trusted outside an explicit conformance-testing mode.
  • Android Key authorization-list checks: failed open on a malformed or unexpected authorization list instead of rejecting the attestation.
  • Apple / Android SafetyNet attestation roots: neither chain was actually verified to terminate at Apple's or Google's root; a self-signed certificate with a matching nonce and public key would have been accepted.
  • Cross-platform trust anchors (Inconsistent chain validation on Windows / Linux which results to 2 failed unit tests on Linux #293): X509Chain only honors a non-self-signed CustomTrustStore entry as a trust anchor on Windows; on Linux/macOS the same metadata-declared intermediate-CA anchor was rejected, and revocation checking against it was silently skipped everywhere except Windows. Chain building and CRL checking are now done consistently on every platform, including a from-scratch CRL decoder since OpenSSL never consults a CRL for a partial chain (confirmed: zero fetches against a local CRL server).
  • Attestation trust signals: self-attestation is now refused when the authenticator's metadata says it can't produce it; the registered credential's rawId is checked against the attested credential ID; Apple's attestation is now reported as AnonCA rather than Basic, matching what it actually is.
  • MDS trust hardening: the Metadata Service BLOB's own signing chain is now pinned to the real FIDO Alliance root before anything in the BLOB is trusted, with CRL checking on that chain. A certificate in that chain with no CRL distribution point fails closed (rejected) rather than silently skipping the revocation check, matching the existing behavior elsewhere in this same file family.
  • Metadata status and caching: when an AAGUID has multiple status reports, the library now picks the latest by date rather than an arbitrary one (a live BLOB entry was found where this missed a REVOKED status); a failed metadata lookup is no longer cached forever.

Malformed-input hardening (unauthenticated DoS)

  • Attestation/assertion parsing and attestation verifiers: several code paths that decode attacker-controlled CBOR/certificate data threw raw runtime exceptions (ArgumentOutOfRangeException, KeyNotFoundException, NullReferenceException, ...) on malformed input instead of a Fido2VerificationException, so a caller catching only the library's own exception type would not handle a crafted registration/assertion cleanly. All now surface as Fido2VerificationException (or a more specific coded exception).

Spec-conformance fix

  • Clone-counter check: authData.signCount is nonzero || credentialRecord.signCount is nonzero (WebAuthn L3 §7.2 step 22) was implemented as &&, so a previously-counting credential that suddenly presented signCount = 0 bypassed clone-authenticator detection entirely. Also enforces BackedUpCredentialPolicy at registration, not just assertion.

Found via conformance testing, fixed the same way

Running the FIDO2 conformance tool against this branch surfaced a few more gaps, some in the library and some in the Demo project's conformance-test harness:

  • Conformance traffic logging (Demo only): the conformance endpoints had no exception handling, so a correctly-rejected registration/assertion crashed the request pipeline instead of returning the JSON response the tool expects. Added a middleware that reports rejections cleanly and logs the exception type/code/message per request, which is what made classifying every rejection in this list possible in the first place. This resolves one of the most frustrating aspects of conformance, ensuring that the fail tests are failing for the right reason and not just failing.
  • UndesiredMetadataStatusFido2VerificationException: bypassed the coded exception constructor, so an authenticator rejected for its metadata status was the one rejection the library couldn't report a specific error code for.
  • Conformance metadata loading (Demo only): conformance:metadata can now point directly at the zip the tool exports, in addition to a directory it was unpacked into (which already worked and still does).
  • Metadata statement parsing crash: one conformance metadata statement encodes CTAP vendor-command IDs as values up to 2^64, which overflowed the int[] field they were parsed into and crashed the entire metadata load (not just that one statement). Widened the field and made metadata-directory loading resilient to one malformed statement file.
  • Missing type / forged id in ceremony responses: Type was a non-nullable enum where PublicKeyCredentialType.PublicKey is member zero, so a payload omitting type entirely silently defaulted to valid; id is defined as base64url(rawId) but was never actually checked against it. Both now produce a proper rejection instead of silently passing.

Testing

  • 556 tests green on both net8.0 and net10.0 (Tests/Fido2.Tests).
  • Fido2.AspNet.Tests (10), Fido2.Ctap2.Tests (102), Fido2.Models.Tests (17) all green.
  • Each fix shipped with its own regression coverage in its source branch, and a few existing tests were updated where a fix changed established behavior they depended on.
  • Full FIDO2 conformance suite run against this branch; only the known tool-side TPM bug remains (see above).

An authenticator's metadata may name an intermediate CA, or the
attestation certificate itself, as a trust anchor. X509Chain only
honours a non-self-signed CustomTrustStore entry as an anchor on
Windows; OpenSSL and Apple treat it as just another issuer and report
the chain as partial, so such registrations failed on Linux and macOS
and three tests were skipped there to keep CI green (passwordless-lib#293).

ValidateTrustChain now builds the chain once with partial chains
permitted -- the engine still verifies every signature -- and accepts
the attestation certificate when a declared anchor appears anywhere
above it in the verified path. Anchors are matched by DER bytes, per
MDS3.

Revocation moved out of the engine too. With an intermediate anchor the
chain is partial on Linux, and OpenSSL never consults a CRL for a
partial chain (confirmed: zero fetches against a local CRL server), so
relying on RevocationMode.Online there would have silently skipped the
check the moment the anchor fix made the chain acceptable; macOS never
fetches under CustomRootTrust at all. If the attestation certificate
names an HTTP(S) CRL distribution point, the CRL is fetched (kept in
memory until its next update), decoded by the new RFC 5280
CertificateRevocationList, verified against the issuer the engine
established, and the serial looked up. Unfetchable, forged, wrong-CA
and stale CRLs all fail the registration, on every platform alike.
IsCertInCRL gains a verifying overload on the same decoder; the
metadata repositories keep the membership-only one for now. The CRL
distribution point is read by a proper ASN.1 walk instead of indexing
into the first name, so a malformed or LDAP-only extension no longer
throws. The fetch is bounded at 32 MiB through the client's response
buffer, so its 15-second timeout covers the whole body.

Tests: a generated PKI fixture (root, intermediate, look-alike
intermediate with the real one's key identifier, leaves with chosen
distribution points, hand-assembled minimal CRLs) and a loopback CRL
server cover intermediate anchors, anchor look-alikes, expired leaves,
revoked/forged/stale/wrong-issuer CRLs, unreachable and oversized
(declared and chunked) distribution points, the per-URL cache and its
bound, and the CRL decoder's edge cases. The three OS-guarded tests run everywhere: HID's
intermediate anchor (with a live fetch of its IdenTrust CRL), the
ATKey attestation, and the root-in-x5c rejection, which had only ever
passed on Windows because it fed the loader base64 text instead of DER.

Fixes passwordless-lib#293.
Malformed, attacker-controlled input on the parsing paths threw raw
runtime exceptions (ArgumentOutOfRangeException, KeyNotFoundException,
InvalidCastException, NullReferenceException, CryptographicException)
rather than Fido2VerificationException. Relying parties that catch only
Fido2VerificationException would surface 500s / stack traces, and an
unauthenticated caller had a cheap DoS on both the registration and login
paths.

- AttestedCredentialData.Parse: bound-check the declared credentialId
  length against the remaining buffer before slicing.
- AuthenticatorAttestationResponse.Parse: wrap FromCbor (which parses
  attacker-controlled authenticator data) so any failure surfaces as a
  Fido2VerificationException.
- AuthenticatorAssertionResponse.Parse: null-guard the response and wrap
  AuthenticatorData.Parse.
- Apple: type-check every x5c element before the (byte[]) cast, not just
  the first.
- X509CertificateHelper.CreateFromRawData: convert a malformed-certificate
  CryptographicException into a Fido2VerificationException.

Adds MalformedInputHardeningTests covering each path.
- Registration (AuthenticatorAttestationResponse): enforce
  BackedUpCredentialPolicy, mirroring the assertion path. A credential can
  already be backed up (BS set) at creation, e.g. a synced passkey, so the
  policy must apply at registration and not only at assertion.

- Assertion (AuthenticatorAssertionResponse): the signature-counter clone
  check used `authData.SignCount > 0 && authData.SignCount <= stored`, which
  silently skipped the case where a previously-counting credential (stored
  counter > 0) presents a zero counter -- exactly the cloned-authenticator
  signal from WebAuthn L3 7.2 step 21. Use the spec condition:
  (new != 0 || stored != 0) && new <= stored.

Adds registration tests for the required/disallowed backup-state policies
and an assertion test for the zero-count-with-nonzero-stored-counter case.
WebAuthn 8.4 step 5 requires the attested key's origin to equal
KM_ORIGIN_GENERATED and its purpose to contain KM_PURPOSE_SIGN. The
verifier defaulted both to the accepted value and only overrode them when
the tag was present, so an attestation that simply omitted origin/purpose
passed, and only the first element of the purpose SET was inspected.

- IsOriginGenerated: require the origin (702) tag to be present in an
  authorization list and equal KM_ORIGIN_GENERATED; a missing tag fails.
- IsPurposeSign: require the purpose (1) tag to be present and inspect the
  whole SET for KM_PURPOSE_SIGN rather than just its first element.
- Wrap the step-5 checks so a malformed authorization list fails as a
  Fido2VerificationException rather than a raw ASN.1/index exception.

Updates the test fixtures (which relied on the fail-open default and
carried empty authorization lists) to include a spec-compliant origin and
purpose, and adds tests asserting a missing origin or purpose is rejected.
M1 - Pin to the FIDO Alliance root on every path. X509Chain's default
System trust mode makes Build() succeed for a chain to ANY publicly-trusted
CA, and the pinned-root thumbprint was only checked in the failure branch.
So on a host that trusts the bundled GlobalSign root (the common case) a
BLOB signed under an unrelated public CA was accepted. The terminal
certificate is now required to be the pinned root whether or not the host
trust store accepts the chain.

M2 - Verify the pinned root before consulting revocation, and restrict the
CRL distribution point to http(s). The CDP is taken from a certificate in
the (previously unvalidated) chain, so fetching it was an SSRF vector; the
chain is now confirmed to pin to the FIDO root before any CDP is contacted,
and non-http(s) CDPs are skipped.

M3 - Document that DistributedCacheMetadataService stores the BLOB without
its signature and trusts it unre-validated on read, so the distributed
cache is part of the trust boundary and must be access-controlled.

DeserializeAndValidateBlobAsync takes the trust root as a parameter
(internal) so the pinning can be tested with a self-built chain; production
still passes the bundled root. Adds tests that a chain pinning to the
provided root validates and one that does not is refused before any fetch.
The apple anonymous attestation verifier checked the credCert nonce and
that its public key matched the credential, but never verified that the
credCert chained to Apple's WebAuthn root. Apple platform authenticators
are not in the FIDO Metadata Service, so metadata-based trust-anchor
validation never runs for them, and no root was bundled -- a self-signed
credCert carrying a matching nonce and public key was accepted as an
"apple" (AttestationType.Basic) attestation.

- Bundle Apple's published WebAuthn Root CA and validate the credCert
  chain against it (CustomRootTrust), after the cheaper nonce/public-key
  checks so existing negative tests are unaffected.
- Make the root injectable: Fido2Configuration.AppleWebAuthnRootCertificate
  overrides it (defaulting to the bundled root), plumbed through
  AttestationVerifier.Create.
- AppleAppAttest reuses the apple verifier for the nonce/public-key checks
  only; it validates its own chain against the App Attest root (with
  expired-development-leaf handling), so it now passes verifyChain: false.

Tests: a positive apple attestation with an injected test root, a
chain-not-anchored-to-the-configured-root rejection, a rewritten
public-key-mismatch test, and a guard that the bundled root is the genuine
issuer of the real "Apple WebAuthn CA 1" intermediate (catches a
transcription error in the embedded certificate).
The android-safetynet verifier checked the JWS signature against the certs
in its own header and that the leaf was issued to attest.android.com, but
never verified that the chain terminated at a Google root. Google's
SafetyNet root is not in the FIDO Metadata Service, so metadata-based
trust-anchor validation never runs for it -- a self-signed certificate
issued to attest.android.com, signing its own response JWT, was accepted.

- Bundle Google Trust Services root R1 and validate the certificate chain
  against it (CustomRootTrust), after the existing checks so negative tests
  are unaffected.
- Make the root injectable: Fido2Configuration.AndroidSafetyNetRootCertificate
  overrides it (defaulting to the bundled root), plumbed through
  AttestationVerifier.Create.

Tests inject their self-signed root for the positive cases, plus a guard on
the bundled root's SHA-256 thumbprint (catches a transcription error in the
embedded certificate).

Note: android-safetynet is deprecated (WebAuthn L3 8.5) and Google has wound
down the SafetyNet Attestation API. The bundled GTS root R1 is authenticated
here as a genuine current Google root (Google's live TLS endpoints chain to
it), but this repo has no captured real SafetyNet response to confirm the
format still chains specifically to GTS R1; if a deployment sees a different
root, override it via configuration.
WebAuthn 8.3 step 2 requires the key described by pubArea to be identical
to the credential public key in authenticatorData. The comparison only ran
for RSA (kty 3) and EC2 (kty 2); any other key type fell through and the
statement went on to pass every remaining check. That matters because
certInfo only certifies pubArea: TPM2_Certify takes qualifyingData from the
caller, so anyone with a certified AIK can have the TPM certify one of its
own keys over SHA-256(authData || clientDataHash) for an authData whose
credential public key is a software Ed25519 key. No TPM 2.0 holds an OKP
key, so such a credential cannot be the one pubArea describes, and the
verifier now rejects it outright rather than skipping the comparison.

- Reject any credential public key that is not RSA or EC2, and require
  pubArea's TPMI_ALG_PUBLIC to match the kind of key being compared, so an
  RSA key against an ECC pubArea (or vice versa) fails as a mismatch rather
  than as a raw ArgumentOutOfRangeException from a null CurveID.
- Read pubArea's RSA exponent big-endian. TPM canonical form is big-endian
  throughout, but PubArea used BitConverter.ToUInt32 (host order), and the
  COSE exponent was decoded as e[0] + (e[1] << 8) + (e[2] << 16). Both are
  little-endian readings that only agree with each other because 65537 is
  the palindrome 01 00 01 and Windows writes the exponent as 0 ("default");
  an authenticator marshalling 65537 explicitly as 00 01 00 01 was rejected
  with an exponent mismatch, and a COSE exponent of any length other than
  three octets (e.g. 3) threw IndexOutOfRangeException. The COSE value is
  now decoded as the unsigned big-endian integer RFC 8230 defines, of any
  length, and must fit the 32-bit field.
- Refresh the TPM manufacturer allowlist from TCG TPM Vendor ID Registry
  1.02 (2020) to 1.06 (2024): adds Ant Group, HPI, Huawei (HISI), NSING and
  SecEdge, whose TPMs were being refused as "Invalid TPM manufacturer".
- Parse pubArea and certInfo, and the manufacturer id, defensively: a
  truncated structure, a curve with no TPM equivalent, or a non-hex
  manufacturer string now surface as Fido2VerificationException instead
  of ArgumentOutOfRangeException, KeyNotFoundException or FormatException.

The test fixtures wrote the exponent little-endian too (PubAreaHelper and
one hand-assembled pubArea), which is why the suite never noticed; they
now marshal it as a TPM does. Adds tests for the OKP rejection, explicit
and default (zero) big-endian exponents, a little-endian exponent being
refused, COSE exponents of one, four and five octets, RSA/ECC pubArea type
mismatches, an unsupported curve, a v1.05+ manufacturer, a non-hex
manufacturer, and truncated pubArea/certInfo.
…nput

A second sweep for attacker-controlled input that escaped verification as
a raw runtime exception, past the parsing paths the earlier hardening
covered. Each of these is reachable from a registration or assertion
request by an unauthenticated caller, so a relying party catching only
Fido2VerificationException surfaced a 500 instead of a refusal:

- clientDataJSON with no "origin" (or null/empty) threw
  ArgumentNullException from the URI parser, and one that isn't a URI
  threw UriFormatException. BaseVerify now refuses both, with the new
  MissingAuthenticatorResponseOrigin / InvalidAuthenticatorResponseOrigin
  codes.
- fido-u2f with an RSA attestation certificate dereferenced the null
  from GetECDsaPublicKey() (NullReferenceException). It is now the
  "not an EC public key over P-256" failure step 2b calls for. The
  verifier also checks that the credential public key is EC2 before
  reading its "-2"/"-3" members, and that each coordinate is 32 bytes as
  steps 4a/4b require, rather than assembling the U2F public key from
  whatever was there.
- android-key with an RSA attestation certificate: the same null
  dereference; now a Fido2VerificationException.
- packed with an attestation certificate whose subject is empty threw
  ArgumentOutOfRangeException from IsValidPackedAttnCertSubject (an empty
  subject decodes to one line with no '='). It now fails the C/O/OU/CN
  requirement like any other bad subject.
- An id-fido-gen-ce-aaguid extension that is not an OCTET STRING, or not
  16 bytes long, threw from the ASN.1 decoder or the Guid constructor
  (packed and tpm). Both are refused explicitly.
- An attestation statement whose alg does not fit the certificate's key
  (RS256 with an EC certificate, ES256 with an RSA one, an alg/curve
  pairing that isn't ECDSA's) threw InvalidOperationException out of
  CredentialPublicKey. Those, the unknown-kty cases, and an unknown
  public key algorithm OID in a certificate (which threw a bare
  Exception from COSE.GetKeyTypeFromOid) are now
  Fido2VerificationException with the new InvalidCredentialPublicKey
  code. The one existing test that pinned InvalidOperationException is
  updated.
- android-safetynet with a JWT header that base64url-decodes but isn't
  JSON threw JsonException; it is now the malformed-JWT failure.

Adds tests for each: missing/null/empty/unparseable origins, U2F with an
RSA certificate, a non-EC2 credential key and 33-byte coordinates, an
RSA android-key certificate, an empty packed subject, a 3-byte and a
UTF8String AAGUID extension, alg/certificate mismatches both ways, the
CredentialPublicKey kty/alg/curve/OID cases, and a non-JSON SafetyNet
header.
…tches

GetLatestStatusReport() returned the last element of statusReports, but
the Metadata Service defines no order for that array, and the published
BLOB mostly lists reports newest first: in BLOB passwordless-lib#280, 42 entries with
more than one effective date are newest first and 3 are oldest first.
For those 42 the library was reading the oldest report as the current
one. One of them is TruU Windows Authenticator
(ba86dc56-635f-4141-aef6-00227b1b9af6), whose reports are
[REVOKED 2023-12-20, NOT_FIDO_CERTIFIED 2023-11-07]; the REVOKED status
was never seen, so the UndesiredAuthenticatorMetadataStatuses check let
registrations through. The latest report is now the one with the most
recent effectiveDate. Reports with equal or missing dates keep the old
last-wins order (the spec's own examples are oldest first, and
FileSystemMetadataRepository produces undated reports), and an entry
without reports returns null instead of throwing.

DistributedCacheMetadataService cached a fetch that produced nothing.
Both GetOrCreateAsync factories returned null without setting an expiry,
and the memory cache stores null values, so one transient MDS failure on
the first lookup stood in for the BLOB until the process restarted, with
every registration after it verified against no metadata at all. A
failed fetch is no longer cached, so the next lookup retries. An AAGUID
that a BLOB was actually searched for and does not contain is still
cached as a miss, but only until the earliest searched BLOB expires, so
a model added to MDS later is found once the BLOB rolls over.

FileSystemMetadataRepository.GetBLOBAsync added into a dictionary it
never cleared, so the second call (a cache rollover; the BLOB it returns
has no nextUpdate, so DistributedCacheMetadataService refetches on every
memory-cache expiry) threw "An item with the same key has already been
added", which the service logged as an error every hour. The directory
is now read into a fresh dictionary on each call, and two statements
sharing an AAGUID are reported as a Fido2MetadataException naming the
file rather than an ArgumentException.

Adds tests for newest-first, oldest-first, date-time, tied, undated and
empty status reports; a registration refused for the TruU-shaped entry;
a failed fetch being retried on the next lookup; an unknown AAGUID cached
only after a BLOB was searched and only until it expires; and the
filesystem repository being fetched twice and refusing duplicates.
id:FFFFF1D0 is the tcg-at-tpmManufacturer the FIDO conformance tools'
simulated TPM puts in its AIK certificates. It is not a TCG-registered
vendor, but it sat in the static allowlist next to the real ones, so a
production registration whose AIK certificate named it was accepted, and
since Tpm is internal a Relying Party could not remove it.

The ceremony already knows when it is being driven by the tools: it
derives FidoValidationMode.FidoConformance2024 from
IMetadataService.ConformanceTesting(), which is true only when the
ConformanceMetadataRepository is registered, and trust anchor validation
already relaxes online revocation on that signal. That mode now travels
on VerifyAttestationRequest (with a matching AttestationVerifier.VerifyAsync
overload), the ceremony computes it once for both uses, and the TPM
verifier accepts id:FFFFF1D0 only under it. The allowlist itself is now
exactly the registry's product implementations.

The unit-test fixtures named id:FFFFF1D0 only as "some valid
manufacturer"; they now name Microsoft's. The two captured TPM responses
(attestationTPMSHA1/SHA256Response.json) are conformance-tool captures,
and are now verified with a metadata service that says so rather than
against the live MDS, which had no entry for them and was only being
called to return nothing. Adds tests that the tools' manufacturer is
refused by default, both through the ceremony and the verifier, and
accepted under conformance validation, again both ways.
…produce

WebAuthn 7.1 step 25 has the Relying Party assess the attestation: verify
that None attestation is acceptable under its policy, that Self
attestation is acceptable under its policy, or that the trust path
chains to an acceptable root. The library made every attestation type
succeed and told the Relying Party nothing about which it had verified,
so a policy like "no self attestation" or "only hardware-attested keys"
had no input to act on. Nor could the AAGUID be trusted: self
attestation is signed with the credential key itself, so a client can
claim any AAGUID with it, and with the model's metadata saying
basic_full TrustAnchor.Verify skipped everything (the trust path is
null) and the registration was recorded under that AAGUID.

- RegisteredPublicKeyCredential.AttestationType carries the type the
  verification procedure established: "none", "self", "basic", "attca"
  or "anonca".
- TrustAnchor.Verify gains an overload taking the attestation type, and
  refuses Self attestation when the model's metadata statement does not
  declare basic_surrogate: a model that only does basic_full (or anonca)
  never produces it, so the AAGUID is a false claim. None attestation is
  left alone, since a client that strips the attestation on the Relying
  Party's behalf legitimately keeps the AAGUID.
- AttestationType.AnonCA is added (6.5.3), and the apple verifier
  returns it as 8.8 step 7 says, instead of Basic. apple-appattest,
  which reuses that verifier's nonce and key checks, keeps reporting
  Basic: App Attest is not a WebAuthn attestation and Apple assigns it
  no WebAuthn type.
- Registration checks that credential.rawId equals the credentialId in
  the attested credential data. The two come from different parts of
  the response and only the attested one is signed; nothing compared
  them, and the test harness had been sending rawId f1d0 against a
  random attested id since 2019 without anything noticing. The harness
  and the hand-built responses in AuthenticatorResponseTests now send
  the attested id, as a real client does.

Adds tests for self attestation against basic_full-only,
basic_surrogate-only and both-declared metadata, the attestation type
reported by every format's positive test, apple reporting anonca, and a
rawId that is missing or differs from the attested credential id.
# Conflicts:
#	Src/Fido2/AttestationFormat/AndroidSafetyNet.cs
#	Src/Fido2/AttestationFormat/Apple.cs
#	Src/Fido2/AttestationFormat/AppleAppAttest.cs
#	Tests/Fido2.Tests/Attestation/Apple.cs
#	Tests/Fido2.Tests/Fido2Tests.cs
…26-09

# Conflicts:
#	Src/Fido2/Metadata/Fido2MetadataServiceRepository.cs
…estation-checks merge

- AttestationVerifier.Create("tpm") lost its default config argument when
  merged; two TPM tests still called it without one.
- Fido2MetadataServiceRepositoryTests' success-path chain test used a leaf
  cert with no CRL distribution point, which the newly-merged fail-closed
  CRL lookup (from fix/trust-anchor-cross-platform) now rejects instead of
  silently skipping. Gave the leaf a real CDP and served an empty CRL for it.
…Not32Bytes

The test built its malformed (33-byte-coordinate) public key by round-tripping
bad ECParameters through ECDsa.Create, which OpenSSL (Linux) tolerates but
Windows' CNG backend rejects with a raw PlatformNotSupportedException /
CryptographicException before the test ever reaches the code under test.

Corrupt the coordinates on an already-successfully-constructed
CredentialPublicKey instead, via a new CborMap.Set(KeyTypeParameter, byte[])
mutator, so no invalid EC point is ever run through ECDsa.Create.
CredentialPublicKey.CreateECDsa handed X/Y straight to ECDsa.Create without
checking they matched the curve's coordinate size. Both are attacker-reachable
(a credential public key in authenticator data, or an attestation statement's
alg paired with its certificate's key), and a malformed length surfaced as a
raw, platform-dependent exception: OpenSSL tolerates some malformed lengths
CNG (Windows) rejects, so a 33-byte P-256 x-coordinate produced a
Fido2VerificationException(MalformedAttestationObject) on Windows -- from the
generic attestation-object parse catch, before the format verifier's own
length check ever ran -- but sailed through parsing on Linux, where the
format-specific check (e.g. fido-u2f's WebAuthn 8.6 step 4a/4b) caught it
instead with a different code and message.

Length is now checked against the curve up front, so a malformed coordinate
is always a Fido2VerificationException(InvalidCredentialPublicKey) with a
consistent message, regardless of host OS. Updates the two tests whose
expected exception depended on the old, platform-dependent path.
Cherry-picked from conformance-rejection-reasons (1e75bb0), off upstream, not
part of the security fixes below -- needed to run the conformance tool
against this branch's library changes without every correctly-rejected
registration/assertion crashing the request pipeline instead of reporting
{status: failed, errorMessage} the way the tool expects.
…ead of crashing

Cherry-picked from conformance-rejection-reasons (d5eb2bd), adapted to this
branch: the original commit's context included WebAuthn L3 crossOrigin/
topOrigin support and a NameHashAlgorithm helper that only exist on that
branch's separate lineage, not here, so those parts are dropped along with
the tests that depended on them.

What actually carries over:
- android-key: an undecodable attestation public key surfaced as a raw
  OpenSslCryptographicException instead of a Fido2VerificationException --
  this was the one real crash in the last conformance run (traffic.jsonl
  sequence 339, "digital envelope routines::decode error").
- A missing, empty or unparseable clientDataJSON.origin escaped as
  ArgumentNullException or UriFormatException from the URI parser.
- Every remaining string-only Fido2VerificationException in the attestation
  formats, TrustAnchor, TokenBindingDto and the response parsers now carries
  a Fido2ErrorCode (InvalidCertificateChain and MissingAttestationObject are
  new), so a rejection with Code == Unknown no longer happens on any path
  the conformance suite exercises in the last run's traffic log.

One test message updated to match the shared error message its case now
produces (TestAndroidKeyX5cCertNotEc).
Cherry-picked from conformance-rejection-reasons (a850def). The subclass
bypassed the coded constructor, so an authenticator refused for its
metadata status was the last rejection the conformance suite reported with
Code == Unknown (traffic.jsonl sequence 351/353 across the last two runs).
Cherry-picked from conformance-rejection-reasons (56de33b). The conformance
controller was ignoring conformance:metadata entirely and always reading
{MDSCacheDirPath}/Conformance as a directory of already-unpacked JSON files
-- which was empty, so every attestation test failed with AaGuidNotFound
(explains the run reporting ~all P- test failures: the metadata zip being
pointed at via conformance:metadata was never actually being read).

conformance:metadata now accepts either the zip the tool exports directly or
a directory it was unpacked into, and the demo logs at startup how many
statements it loaded (or that it found none, with the same explanation).
FileSystemMetadataRepository also now reads *.json recursively, since the
tool's zip unpacks to metadataStatements/*.json.
… ulong

AuthenticatorGetInfo.VendorPrototypeConfigCommands was int[]; the FIDO
conformance tool's test metadata sends values up to 2^64 to exercise this
field (one of its 25 exported statements has [18446744073709552000,
184467440737095520]), which overflows int, long and even ulong. That one
statement's JsonException took down the entire directory load, so every
attestation test failed with AaGuidNotFound regardless of which authenticator
it used -- not just the one exercising this field.

Widened to double[] (subcommand identifiers are otherwise always small, and
round-trip through double exactly). Also hardened
FileSystemMetadataRepository.GetBLOBAsync so one statement file with an
unexpected shape is skipped rather than failing every other statement in the
directory, the same way the rest of this branch already treats malformed
input from an outside source as a rejection of that input rather than a
crash of the whole operation.
Cherry-picked from a much earlier commit (638a6d5), adapted: dropped an
AuthenticatorAttachment property this branch doesn't have (unrelated
context from that commit's original lineage), and kept both the existing
RawId-missing check and the new Id-decodes-to-RawId check together in
AuthenticatorAttestationResponse rather than replacing one with the other.

Fixes two FIDO2 Server conformance cases (F-3, F-4) that were still failing
after everything else in this branch, for both attestation and assertion:

- F-4, missing "type": PublicKeyCredentialType.PublicKey is enum member
  zero, and Type was a non-nullable PublicKeyCredentialType, so a JSON
  payload that omits "type" entirely left it at its default -- which
  silently is PublicKey. Making Type nullable makes "absent" distinguishable
  from "explicitly public-key" again.
- F-3, "id" not base64url: credential.id is defined as base64url(rawId),
  but this library only ever decoded RawId; Id was checked for null/empty
  and never read again, so a garbled Id never surfaced. Both Verify methods
  now decode Id and require it to match RawId's bytes exactly.

One existing test (TestAuthenticatorAttestationResponseRawIdNotAttestedCredentialId)
updated: it relied on Id encoding a different, correct credential ID while
RawId was wrong, which now trips the new id-must-match-rawId check before
ever reaching the check the test targets.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement Enhancements or general improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inconsistent chain validation on Windows / Linux which results to 2 failed unit tests on Linux

1 participant