webauthn::WebAuthn method auth assertion_verify (public)

 <instance of webauthn::WebAuthn[i]> auth assertion_verify \
    [ -st st ] [ -req req ]

Defined in packages/webauthn/tcl/webauthn-procs.tcl

Verify a WebAuthn authentication response (assertion) against stored state. This method validates the incoming assertion from navigator.credentials.get(). It checks required fields, maps the presented credential ID to a stored credential (user_id + public key), and verifies the assertion using the pending authentication state (challenge, rpId, origin, etc.). If the credential is unknown, an error is raised. When the state contains a user_id (identifier-first flow), the error message is phrased as "no passkey for this account"; otherwise it is treated as an unknown credential in discovery mode.

Switches:
-st (optional)
Authentication state dict as created by /webauthn/auth/options or auth issue_options (challenge, rpId, origin, return_url, ...).
-req (optional)
Parsed client response dict containing the assertion fields, including id, clientDataJSON, authenticatorData, and signature.

Testcases:
No testcase defined.
Source code:
set return_url        [dict get $st return_url]
set expectedRpId      [dict get $st rpId]

ns_log notice DEBUG: auth/assertion_verify st '$st' req '$req'
if {![dict exists $req id]
    || ![dict exists $req response clientDataJSON]
    || ![dict exists $req response authenticatorData]
    || ![dict exists $req response signature]} {
    throw {validation fields-missing} "missing required fields"
}

# Map credential -> user
set credential_id [dict get $req id]  ;# base64url in WebAuthn JSON

if {![::xo::dc 0or1row get_cred {
    select user_id, public_key, sign_count as old_sign_count
    from webauthn_credentials
    where credential_id = :credential_id
}]} {
    if {[dict exists $st user_id]} {
        throw {validation no-passkey} "No passkey registered for this account (or it was removed)."
    }
    throw {validation credential-unknown} "unknown credential"
}

if {[dict exists $st user_id] && $user_id != [dict get $st user_id]} {
    ns_log notice "webauthn: mismatch" selected_user [dict get $st user_id]  credential_user $user_id credential_id $credential_id
    throw {validation credential-user-mismatch} "Passkey does not match the selected account"

}

# ---- clientDataJSON
set clientData_json  [:assert_clientdata_json  -clientData_raw     [dict get $req response clientDataJSON]  -expected_type      "webauthn.get"  -expected_challenge [dict get $st challenge]  -expected_origin    [dict get $st origin]]

# ---- authenticatorData basic checks (rpIdHash, flags, signCount)
set authData_b64u [dict get $req response authenticatorData]
set sig_b64u      [dict get $req response signature]

set authData [ns_base64urldecode -binary -- $authData_b64u]
set sig      [ns_base64urldecode -binary -- $sig_b64u]

if {[string length $authData] < 37} {
    throw {validation authenicator-invalid} "authenticatorData too short"
}

set rpIdHash [string range $authData 0 31]
:assert_rpidhash -rpIdHash $rpIdHash -rpId $expectedRpId

binary scan [string range $authData 32 32] cu flags
if {($flags & 0x01) == 0} {
    throw {validation user-data-missing} "user not present"
}

binary scan [string range $authData 33 36] Iu new_sign_count

# ---- signature verification
# verify signature using stored COSE key in $public_key

# clientData_json is the *decoded* JSON string of clientDataJSON
# Hash must be over the exact bytes that were base64url-decoded.
set clientHash [ns_crypto::md string -digest sha256 -binary -encoding binary -- $clientData_json]

# signedData = authenticatorData || clientHash
set signedData "${authData}${clientHash}"

# public_key is a Tcl dict string (from DB)
if {![dict exists $public_key cose_b64u]} {
    throw {validation key-invalid} "stored public key missing cose_b64u"
}
set coseKey_bin [ns_base64urldecode -binary -- [dict get $public_key cose_b64u]]
set cose [ns_cbor decode -binary -encoding binary $coseKey_bin]

# Check alg / key type (ES256 expected)
if {![dict exists $cose 3] || [dict get $cose 3] != -7} {
    throw {validation alg-unsupported} "unsupported COSE alg (expected -7 ES256)"
}
if {![dict exists $cose 1] || [dict get $cose 1] != 2} {
    throw {validation keytype-unsupported} "unsupported COSE kty (expected 2 EC2)"
}
if {![dict exists $cose -1] || [dict get $cose -1] != 1} {
    throw {validation curve-unsupported} "unsupported COSE crv (expected 1 P-256)"
}

set x [dict get $cose -2]
set y [dict get $cose -3]
if {[string length $x] != 32 || [string length $y] != 32} {
    throw {validation key-invalid} "unexpected EC coordinate length"
}
if {[string length $sig] == 64} {
    throw {validation signature-format} "unexpected raw 64-byte signature; expected DER"
}

set pubpem [ns_crypto::eckey fromcoords -curve prime256v1 -x $x -y $y -binary -format pem]
set ok [ns_crypto::md string -digest sha256 -binary -encoding binary  -verify $pubpem -signature $sig -- $signedData]
ns_log notice "DEBUG SIGNATURE OK? $ok"
if {!$ok} {
    throw {validation signature-invalid} "signature verification failed"
}
ns_log notice DEBUG: update credential_id  $credential_id old_sign_count $old_sign_count new_sign_count $new_sign_count

db_dml update_last_used {
    update webauthn_credentials
    set last_used_at = now(),
    sign_count       = :new_sign_count
    where credential_id = :credential_id
}

return $user_id
XQL Not present:
Generic, PostgreSQL, Oracle
[ hide source ] | [ make this the default ]
Show another procedure: