Class ::webauthn::WebAuthn (public)

 ::nx::Class ::webauthn::WebAuthn[i]

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

rp_id: The WebAuthn Relying Party ID (domain), e.g. 'openacs.org' or 'login.example.com'; Must be a registrable domain / host that matches the site origin rules. after_successful_login_url: Where to redirect after login if no return_url exists in state. login_failure_url: Where to send users on failure if you don’t want to show debug output.

Testcases:
No testcase defined.
Source code:
        :property {rp_id:required}
        :property {after_successful_login_url /pvt/}
        :property {login_failure_url /}

        :property {pretty_name "Passkey"}
        :property {debug:switch false}
        :property {storageObj ::xo::WebAuthnStore::Cache}

        :method init {} {
            set :store ${:storageObj}
        }

        :public method return_err {{-status 400} error detail} {
            ns_return $status application/json [subst {{"error":"$error","detail":"$detail"}}]
        }

        :public method logout {} {
            #
            # Compatibility function with other external_registry objects
            #

            # The following command leads to an infinite loop.
            #
            #ad_user_logout

            # TODO: for now, this is a NOOP
            ns_log warning "[current] logout was called, but is not implemented"
        }

        :public method login_url {{-return_url /}} {
            #
            # Compatibility function with other external_registry objects
            #
            return [export_vars -base /register {return_url}]
        }


        :public method name {} {
            # compatibility with xo::Authorize
            return [expr {[info exists :pretty_name] ? ${:pretty_name} : [namespace tail [self]]}]
        }

        :public method origin {} {
            # Returns the "origin" field provided to the attestation.

            # In general, "ns_conn location" would be the right
            # thing. However, inside a container, it reports the
            # internal port.
            set proto [expr {[ad_conn behind_secure_proxy_p] ? "https" : [ns_conn proto]}]

            # Case-insensitive headers in NaviServer 5; ns_parsehostport validates.
            set hp [ns_parsehostport [ns_set get [ns_conn headers] Host]]

            set origin "${proto}://[dict get $hp host]"

            if {[dict exists $hp port]} {
                set port [dict get $hp port]
                if {($proto eq "http"  && $port != 80)
                    || ($proto eq "https" && $port != 443)} {
                    append origin ":$port"
                }
            }
            return $origin
        }

        :public method new_challenge {{nbytes 32}} {
            return [ns_crypto::randombytes -encoding base64url $nbytes]
        }

        :method state_key {purpose state} {
            return "webauthn:${purpose}:${state}"
        }

        :public method store {} {
            return ${:store}
        }

        :method assert_rpidhash {-rpIdHash -rpId {-context ""}} {
            set got_hex [binary encode hex $rpIdHash]
            set exp_hex [ns_crypto::md string -digest sha256 -encoding hex $rpId]

            if {$got_hex ne $exp_hex} {
                if {$context ne ""} {
                    throw {validation rpid-mismatch}  "$context for different rpid. Should be for: $rpId"
                } else {
                    throw {validation rpid-mismatch} "rpIdHash mismatch"
                }
            }
        }

        :method assert_clientdata_json {-clientData_raw -expected_type -expected_challenge -expected_origin} {
            #
            # clientDataJSON is bytes (as received). We decode and validate:
            #  - type
            #  - challenge
            #  - origin
            #
            set clientData_json [ns_base64urldecode -- $clientData_raw]
            if {$clientData_json eq ""} {
                throw {validation missing-clientdata} "invalid clientDataJSON"
            }
            set cd [util::json2dict $clientData_json]

            if {![dict exists $cd type]} {
                throw {validation bad-clientdata-json} "clientDataJSON missing 'type'"
            }
            if {![dict exists $cd challenge]} {
                throw {validation bad-clientdata-json} "clientDataJSON missing 'challenge'"
            }
            if {![dict exists $cd origin]} {
                throw {validation bad-clientdata-json} "clientDataJSON missing 'origin'"
            }

            set type      [dict get $cd type]
            set challenge [dict get $cd challenge]
            set origin    [dict get $cd origin]

            if {$type ne $expected_type} {
                throw {validation wrong-type} "unexpected clientDataJSON type '$type' (expected '$expected_type')"
            }
            if {$challenge ne $expected_challenge} {
                throw {validation challenge-mismatch} "challenge mismatch"
            }
            if {$origin ne $expected_origin} {
                throw {validation origin-mismatch} "origin mismatch (expected $expected_origin received $origin)"
            }

            return $clientData_json
        }

        :public method "reg attestation_verify" {-st -req} {
            set return_url        [dict get $st return_url]
            set user_id           [dict get $st user_id]

            if {![dict exists $req response clientDataJSON]
                || ![dict exists $req response attestationObject]} {
                throw {validation fields-missing} "missing required fields"
            }

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

            # ---- attestationObject (CBOR) -> authData -> credId + COSE_Key
            set attObj_b64u [dict get $req response attestationObject]
            set attObj_bin [ns_base64urldecode -binary -- $attObj_b64u]

            try {
                set ao [ns_cbor decode -binary -encoding binary $attObj_bin]
            } on error {e} {
                throw {validation attobj-cbor} "bad attestationObject CBOR: $e"
            }

            if {![dict exists $ao fmt] || ![dict exists $ao authData]} {
                throw {validation attobj-invalid} "attestationObject missing fmt/authData"
            }

            set fmt [dict get $ao fmt]
            if {$fmt ne "none"} {
                ns_log notice "registration attestation fmt=$fmt"
            }

            set authData [dict get $ao authData]
            if {[string length $authData] < 55} {
                throw {validation authdata-invalid} "authData too short"
            }

            set rpIdHash [string range $authData 0 31]
            :assert_rpidhash -rpIdHash $rpIdHash -rpId ${:rp_id} -context "attestation"

            binary scan [string range $authData 32 32] cu flags
            if {($flags & 0x40) == 0} {
                throw {validation attesteddata-missing} "no attested credential data"
            }

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

            # fixed offsets from WebAuthn authData layout
            set aaguid [string range $authData 37 37+15]
            binary scan [string range $authData 53 53+1] S credIdLen

            if {$credIdLen <= 0 || (55+$credIdLen) > [string length $authData]} {
                throw {validation credidlen-invalid} "credentialId length out of range"
            }

            set credId  [string range $authData 55 54+$credIdLen]
            set coseKey [string range $authData 55+$credIdLen end]

            try {
                set cose [ns_cbor decode -binary -encoding binary $coseKey]
            } on error {e} {
                throw {validation cose-cbor} "bad COSE_Key CBOR: $e"
            }

            # Basic COSE sanity (ES256 / P-256)
            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)"
            }

            # Build DB values
            set credential_id [ns_base64urlencode -binary -- $credId]
            set public_key [dict create  format cose  fmt $fmt  aaguid_b64u [ns_base64urlencode -binary -- $aaguid]  alg [dict get $cose 3]  crv [dict get $cose -1]  cose_b64u [ns_base64urlencode -binary -- $coseKey]  sign_count $signCount  rp_id ${:rp_id}]

            return [dict create  user_id $user_id  return_url $return_url  credential_id $credential_id  public_key $public_key  origin [dict get $st origin]  sign_count $signCount]
        }

        :public method "auth issue_options" {{-return_url "/"}} {
            set state [::xo::oauth::nonce]
            set challenge [:new_challenge 32]

            # Store ceremony state
            set key [:state_key auth $state]
            ${:store} set $key [dict create  challenge $challenge  rpId ${:rp_id}  return_url $return_url  origin [:origin]  ts [ns_time]  ]
            # Return dict the handler can serialize
            return [dict create  state $state  options [dict create  challenge $challenge  timeout 60000  rpId ${:rp_id}  userVerification preferred  ]  ]
        }

        :public method "auth assertion_verify" {-st -req} {
            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
        }

        :method lookup_user_id {-credential_id} {
            set user_id [db_string _ {
                select user_id from webauthn_credentials
                where credential_id = :credential_id
            } -default 0]
            return $user_id
        }
XQL Not present:
Generic, PostgreSQL, Oracle
[ hide source ] | [ make this the default ]
Show another procedure: