- Publicity: Public Only All
webauthn-procs.tcl
Support for WebAuthn/FIDO2
This file defines the following Objects and Classes: ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
, ::webauthn::WebAuthn
- Location:
- packages/webauthn/tcl/webauthn-procs.tcl
Procedures in this file
- Class ::webauthn::WebAuthn (public)
- Object ::webauthn::passkey (public)
- webauthn::WebAuthn instproc login_url (public)
- webauthn::WebAuthn instproc logout (public)
- webauthn::WebAuthn instproc name (public)
- webauthn::WebAuthn instproc new_challenge (public)
- webauthn::WebAuthn instproc origin (public)
- webauthn::WebAuthn instproc return_err (public)
- webauthn::WebAuthn instproc store (public)
- webauthn::WebAuthn instproc {auth assertion_verify} (public)
- webauthn::WebAuthn instproc {auth issue_options} (public)
- webauthn::WebAuthn instproc {reg attestation_verify} (public)
Detailed information
Class ::webauthn::WebAuthn (public)
::nx::Class ::webauthn::WebAuthn
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.
Object ::webauthn::passkey (public)
::webauthn::WebAuthn ::webauthn::passkey
- ::webauthn::passkey origin
- ::webauthn::passkey return_err ?-status /value/? /error/ /detail/
- ::webauthn::passkey login_url ?-return_url /value/?
- ::webauthn::passkey reg ...
- ::webauthn::passkey name
- ::webauthn::passkey auth ...
- ::webauthn::passkey logout
- ::webauthn::passkey new_challenge ?nbytes?
- ::webauthn::passkey store
- ::webauthn::passkey pp ?-list? ?-prefix /value/? /dict/
- Testcases:
- No testcase defined.
webauthn::WebAuthn method login_url (public)
<instance of webauthn::WebAuthn> login_url \ [ -return_url return_url ]
Compatibility function with other external_registry objects
- Switches:
- -return_url (optional, defaults to
"/")- Testcases:
- No testcase defined.
webauthn::WebAuthn method logout (public)
<instance of webauthn::WebAuthn> logout
Compatibility function with other external_registry objects
- Testcases:
- No testcase defined.
webauthn::WebAuthn method name (public)
<instance of webauthn::WebAuthn> name
compatibility with xo::Authorize
- Testcases:
- No testcase defined.
webauthn::WebAuthn method new_challenge (public)
<instance of webauthn::WebAuthn> new_challenge [ nbytes ]
- Parameters:
- nbytes (optional, defaults to
"32")- Testcases:
- No testcase defined.
webauthn::WebAuthn method origin (public)
<instance of webauthn::WebAuthn> origin
Returns the "origin" field provided to the attestation.
- Testcases:
- No testcase defined.
webauthn::WebAuthn method return_err (public)
<instance of webauthn::WebAuthn> return_err [ -status status ] \ error detail
- Switches:
- -status (optional, defaults to
"400")- Parameters:
- error (required)
- detail (required)
- Testcases:
- No testcase defined.
webauthn::WebAuthn method store (public)
<instance of webauthn::WebAuthn> store
- Testcases:
- No testcase defined.
webauthn::WebAuthn method auth assertion_verify (public)
<instance of webauthn::WebAuthn> auth assertion_verify \ [ -st st ] [ -req req ]
- Switches:
- -st (optional)
- -req (optional)
- Testcases:
- No testcase defined.
webauthn::WebAuthn method auth issue_options (public)
<instance of webauthn::WebAuthn> auth issue_options \ [ -return_url return_url ]
- Switches:
- -return_url (optional, defaults to
"/")- Testcases:
- No testcase defined.
webauthn::WebAuthn method reg attestation_verify (public)
<instance of webauthn::WebAuthn> reg attestation_verify \ [ -st st ] [ -req req ]
- Switches:
- -st (optional)
- -req (optional)
- Testcases:
- No testcase defined.
Content File Source
::xo::library doc { Support for WebAuthn/FIDO2 } # we need at least rest-procs, maybe authorize-procs ::xo::library require -package xooauth rest-procs #::xo::library require -package xooauth authorize-procs # # Make sure, we have the data model defined # # credential_id: base64url # public_key: whatever used verifier stores (COSE/JWK/PEM) ::xo::db::require table webauthn_credentials [subst { credential_id {varchar(512) primary key} user_id {integer not null references users(user_id) on delete cascade} rp_id {[::xo::dc map_datatype text] not null} origin {[::xo::dc map_datatype text] not null} public_key {[::xo::dc map_datatype text] not null} sign_count {bigint not null default 0} created_at {timestamptz not null default now()} last_used_at {timestamptz} label {[::xo::dc map_datatype text] not null} user_agent {[::xo::dc map_datatype text] not null} }] ::xo::db::require index -table webauthn_credentials -col credential_id -unique true namespace eval webauthn { nx::Class create ::webauthn::WebAuthn -superclasses ::xo::REST { # # 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. # :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 } } ad_proc -private validRpIdP {rpid} { Validate the provided rpid (Relying Party ID) @return boolean value } { if {$rpid eq "localhost"} { return 1 } # must contain at least a dot (heuristic for registrable domain) if {[string first "." $rpid] < 0} { return 0 } # only hostname chars (no underscore, no port/scheme/path) if {![regexp {^[a-z0-9.-]+$} $rpid]} { return 0 } # no leading/trailing dot, no empty labels if {[string match .* $rpid] || [string match *. $rpid]} { return 0 } if {[string first ".." $rpid] >= 0} { return 0 } # optional: reject labels starting/ending with '-' foreach label [split $rpid "."] { if {$label eq ""} { return 0 } if {[string match -* $label] || [string match *- $label]} { return 0 } } return 1 } ad_proc -private JQ {s} { Perform quoting for JavaScript literals. @return JSON-escaped string content (WITHOUT surrounding quotes). } { set s [string map [list \ "\\" "\\\\" \ "\"" "\\\"" \ "\b" "\\b" \ "\f" "\\f" \ "\n" "\\n" \ "\r" "\\r" \ "\t" "\\t" \ ] $s] if {[regexp {[[:cntrl:]]} $s]} { # Escape remaining control chars 0x00..0x1F set out "" set len [string length $s] for {set i 0} {$i < $len} {incr i} { set ch [string index $s $i] scan $ch %c code if {$code < 0x20} { append out [format "\\u%04X" $code] } else { append out $ch } } set s $out } return $s } } ::xo::library source_dependent # # Local variables: # mode: tcl # tcl-indent-level: 2 # indent-tabs-mode: nil # End
![[i]](/resources/acs-subsite/ZoomIn16.gif)