Files
skills/docs/identity/auth/webauthn/src/verifier/verify-assertion.md
2026-05-29 08:20:56 +01:00

4.1 KiB

Assertion verifier

Purpose

Implements verifyAssertion, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuthn L3 §7.2). The function ingests the authenticator's assertion (authenticatorData, clientDataJSON, signature) plus the relying-party's stored credential state (public key, expected origin, expected rpIdHash, sign counter) and returns a discriminated-union VerifyResult. The dispatcher routes to per-algorithm verifiers backed by @suaveplan/crypto-curve and @suaveplan/crypto-rsa — every supported COSE algorithm (ES256, ES384, ES512, EdDSA, RS256) goes through real cryptographic verification.

Features

  • Discriminated-union return: { ok: true } | { ok: false, code } — callers branch without unwrapping thrown errors.
  • Five algorithms supported: ES256 (P-256 + SHA-256), ES384 (P-384 + SHA-384), ES512 (P-521 + SHA-512), EdDSA (Ed25519), RS256 (RSA-PKCS1-v1.5 + SHA-256).
  • DER ECDSA decoding via @suaveplan/x509#decodeEcdsaSignature — WebAuthn emits DER-encoded ECDSA signatures, which we convert to the compact r||s form expected by @suaveplan/crypto-curve.
  • Constant-time rpIdHash compare via constantTimeEqual — a mismatched RP hash is the canonical phishing signal and the compare time must not leak the byte index of the first difference.
  • Sign-counter regression check via detectSignCounterRegression — { ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" } on monotonicity violation.
  • Client-data revalidation via validateClientDataJSON so type / origin / challenge are also checked.
  • verifyOverCoseKey(key, message, signature) helper exported for per-format attestation verifiers (packed self-attestation, fido-u2f, tpm).

Usage: Basic Example

import { verifyAssertion } from "@suaveplan/webauthn/verifier";

const result = await verifyAssertion({
    authenticatorData,
    clientDataJSON,
    signature,
    storedPublicKey,
    expectedChallenge,
    expectedOrigin: "https://example.com",
    expectedRpIdHash,
    signCounter: stored.signCounter,
});
if (!result.ok) {
    if (result.code === "WEBAUTHN.SIGN_COUNTER_REGRESSION") {
        revokeCredential();
    }
    throw new Error(result.code);
}

Usage: Advanced Example

import {
    verifyAssertion,
    verifyOverCoseKey,
} from "@suaveplan/webauthn/verifier";

// Direct signature check (used by per-format attestation verifiers):
const ok = await verifyOverCoseKey(
    storedKey,
    concatBytes(authData, sha256(clientDataJSON)),
    derSignatureBytes,
);

API Reference

  • VerifyResult — { ok: true } | { ok: false, code: "WEBAUTHN.SIGNATURE_INVALID" | "WEBAUTHN.RPID_HASH_MISMATCH" | "WEBAUTHN.SIGN_COUNTER_REGRESSION" }.
  • VerifyAssertionInput — { authenticatorData, clientDataJSON, signature, storedPublicKey, expectedChallenge, expectedOrigin, expectedRpIdHash, signCounter }.
  • verifyAssertion(input): Promise<VerifyResult> — the main entry point.
  • verifyOverCoseKey(key, message, signature): Promise<boolean> — per-algorithm signature check, exported for attestation verifiers.

Implementation Notes

  • WebAuthn ECDSA signatures are ASN.1 DER-encoded, not the JOSE-form r||s that @suaveplan/crypto-curve accepts. The package delegates DER decoding to decodeEcdsaSignature from @suaveplan/x509 (the same routine used in TLS / X.509) so the structural validation is shared with the rest of the security family.
  • EC2 public keys are reconstructed in uncompressed form 0x04 || x || y before passing to noble-curves; the COSE key labels x and y as separate byte strings.
  • RSA verification consumes the COSE n and e byte strings via os2ip to produce bigint modulus + exponent, then calls rsaVerifyPkcs1v15 with hash: "SHA-256". Any structural failure (wrong sig length, modulus mismatch) collapses to false — semantic failures never throw out of verifyOverCoseKey.
  • The dispatch order is: parse authData → validate client data → constant-time rpIdHash compare → sign-counter check → signature verify. The order matters: a mismatched rpIdHash is the strongest phishing signal so it surfaces before the expensive signature operation.