Authen-WebAuthn/lib/Authen/WebAuthn.pm
package Authen::WebAuthn;
$Authen::WebAuthn::VERSION = '0.005';
use strict;
use warnings;
use Mouse;
use MIME::Base64 qw(encode_base64url decode_base64url);
use JSON qw(decode_json from_json to_json);
use Digest::SHA qw(sha256);
use Crypt::PK::ECC;
use Crypt::PK::RSA;
use Crypt::OpenSSL::X509 1.808;
use CBOR::XS;
use URI;
use Carp;
use Authen::WebAuthn::SSLeayChainVerifier;
has rp_id => ( is => 'rw', required => 1 );
has origin => ( is => 'rw', required => 1 );
my $ATTESTATION_FUNCTIONS = {
none => \&attest_none,
packed => \&attest_packed,
"fido-u2f" => \&attest_u2f,
};
my $KEY_TYPES = {
ECC => {
parse_pem => \&parse_ecc_pem,
parse_cose => \&parse_ecc_cose,
make_verifier => \&make_cryptx_verifier,
},
RSA => {
parse_pem => \&parse_rsa_pem,
parse_cose => \&parse_rsa_cose,
make_verifier => \&make_cryptx_verifier,
}
};
my $COSE_ALG = {
-7 => {
name => "ES256",
key_type => "ECC",
signature_options => ["SHA256"]
},
-257 => {
name => "RS256",
key_type => "RSA",
signature_options => [ "SHA256", "v1.5" ]
},
-37 => {
name => "PS256",
key_type => "RSA",
signature_options => [ "SHA256", "pss" ]
},
-65535 => {
name => "RS1",
key_type => "RSA",
signature_options => [ "SHA1", "v1.5" ]
}
};
sub validate_registration {
my ( $self, %params ) = @_;
my (
$challenge_b64, $requested_uv,
$client_data_json_b64, $attestation_object_b64,
$token_binding_id_b64, $trust_anchors,
$allowed_attestation_types, $allow_untrusted_attestation,
$allow_unknown_attestation_format,
)
= @params{ qw(
challenge_b64 requested_uv
client_data_json_b64 attestation_object_b64
token_binding_id_b64 trust_anchors
allowed_attestation_types allow_untrusted_attestation
allow_unknown_attestation_format
)
};
my $client_data_json = decode_base64url($client_data_json_b64);
my $client_data = eval { decode_json($client_data_json) };
if ($@) {
croak("Error deserializing client data: $@");
}
# 7. Verify that the value of C.type is webauthn.create
unless ( $client_data->{type} eq "webauthn.create" ) {
croak("Type is not webauthn.create");
}
# 8. Verify that the value of C.challenge equals the base64url encoding
# of options.challenge.
unless ($challenge_b64) {
croak("Empty registration challenge");
}
unless ( $challenge_b64 eq $client_data->{challenge} ) {
croak( "Challenge received from client data "
. "($client_data->{challenge}) "
. "does not match server challenge "
. "($challenge_b64)" );
}
# 9. Verify that the value of C.origin matches the Relying Party's origin.
unless ( $client_data->{origin} ) {
croak("Empty origin in client data");
}
unless ( $client_data->{origin} eq $self->origin ) {
croak( "Origin received from client data "
. "($client_data->{origin}) "
. "does not match server origin " . "("
. $self->origin
. ")" );
}
# 10. Verify that the value of C.tokenBinding.status matches the state of
# Token Binding for the TLS connection over which the assertion was
# obtained. If Token Binding was used on that TLS connection, also verify
# that C.tokenBinding.id matches the base64url encoding of the Token
# Binding ID for the connection.
$self->check_token_binding( $client_data->{tokenBinding},
$token_binding_id_b64 );
# 11. Let hash be the result of computing a hash over
# response.clientDataJSON using SHA-256.
my $client_data_hash = sha256($client_data_json);
# 12. Perform CBOR decoding on the attestationObject field of the
# AuthenticatorAttestationResponse structure to obtain the attestation
# statement format fmt, the authenticator data authData, and the
# attestation statement attStmt.
my $attestation_object = getAttestationObject($attestation_object_b64);
my $authenticator_data = $attestation_object->{authData};
unless ($authenticator_data) {
croak("Authenticator data not found in attestation object");
}
unless ( $authenticator_data->{attestedCredentialData} ) {
croak("Attested credential data not found in authenticator data");
}
# 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID
# expected by the Relying Party.
my $hash_rp_id = sha256( $self->rp_id );
unless ( $authenticator_data->{rpIdHash} eq $hash_rp_id ) {
croak( "RP ID hash received from authenticator " . "("
. unpack( "H*", $authenticator_data->{rpIdHash} ) . ") "
. "does not match the hash of this RP ID " . "("
. unpack( "H*", $hash_rp_id )
. ")" );
}
# 14. Verify that the User Present bit of the flags in authData is set.
unless ( $authenticator_data->{flags}->{userPresent} == 1 ) {
croak("User not present during WebAuthn registration");
}
# 15. If user verification is required for this registration, verify that
# the User Verified bit of the flags in authData is set.
$requested_uv ||= "preferred";
if ( $requested_uv eq "required"
and $authenticator_data->{flags}->{userVerified} != 1 )
{
croak("User not verified during WebAuthn registration");
}
# 16. Verify that the "alg" parameter in the credential public key in
# authData matches the alg attribute of one of the items in
# options.pubKeyCredParams.
# TODO For now, allow all known key types
# 17. Verify that the values of the client extension outputs in
# clientExtensionResults and the authenticator extension outputs in the
# extensions in authData are as expected
# TODO
# 18. Determine the attestation statement format by performing a USASCII
# case-sensitive match on fmt against the set of supported WebAuthn
# Attestation Statement Format Identifier values.
my $attestation_statement_format = $attestation_object->{'fmt'};
my $attestation_function =
$ATTESTATION_FUNCTIONS->{$attestation_statement_format};
unless ( ref($attestation_function) eq "CODE" ) {
if ($allow_unknown_attestation_format) {
# Treat unknown attestation as None
$attestation_function = $ATTESTATION_FUNCTIONS->{none};
}
else {
croak(
"Unsupported attestation format during WebAuthn registration: "
. $attestation_statement_format );
}
}
# 19. Verify that attStmt is a correct attestation statement, conveying a
# valid attestation signature, by using the attestation statement format
# fmt’s verification procedure given attStmt, authData and hash.
my $attestation_statement = $attestation_object->{attStmt};
my $authenticator_data_raw = $attestation_object->{authDataRaw};
my $attestation_result = eval {
$attestation_function->(
$attestation_statement, $authenticator_data,
$authenticator_data_raw, $client_data_hash
);
};
croak( "Failed to validate attestation: " . $@ ) if ($@);
unless ( $attestation_result->{success} == 1 ) {
croak(
"Failed to validate attestation: " . $attestation_result->{error} );
}
# 20. If validation is successful, obtain a list of acceptable trust
# anchors (i.e. attestation root certificates) for that attestation type
# and attestation statement format fmt, from a trusted source or from
# policy.
if ( defined($trust_anchors) and ref($trust_anchors) eq "SUB" ) {
my $aaguid = $authenticator_data->{attestedCredentialData}->{aaguid};
$trust_anchors = $trust_anchors->(
aaguid => $aaguid,
attestation_type => $attestation_result->{type},
attestation_format => $attestation_statement_format,
);
if ( ref($trust_anchors) ne "ARRAY" ) {
croak("trust_anchors sub must return an ARRAY reference");
}
}
elsif ( defined($trust_anchors) and ref($trust_anchors) ne "ARRAY" ) {
croak("trust_anchors parameter must be a SUB or ARRAY reference");
}
# 21. Assess the attestation trustworthiness using the outputs of the
# verification procedure in step 19, as follows:
$self->check_attestation_trust( $attestation_result, $trust_anchors,
$allow_untrusted_attestation );
$self->check_attestation_type( $allowed_attestation_types,
$attestation_result->{type} );
# 22. Check that the credentialId is not yet registered to any other user
# TODO
# 23. If the attestation statement attStmt verified successfully and is
# found to be trustworthy, then register the new credential with the
# account that was denoted in options.user:
my $credential_id_bin =
$authenticator_data->{attestedCredentialData}->{credentialId};
my $credential_pubkey_cose =
$authenticator_data->{attestedCredentialData}->{credentialPublicKey};
my $signature_count = $authenticator_data->{signCount};
return {
credential_id => encode_base64url($credential_id_bin),
credential_pubkey => encode_base64url($credential_pubkey_cose),
signature_count => $signature_count,
attestation_result => $attestation_result
};
}
sub validate_assertion {
my ( $self, %params ) = @_;
my (
$challenge_b64, $credential_pubkey_b64,
$stored_sign_count, $requested_uv,
$client_data_json_b64, $authenticator_data_b64,
$signature_b64, $extension_results,
$token_binding_id_b64,
)
= @params{
qw(challenge_b64 credential_pubkey_b64
stored_sign_count requested_uv
client_data_json_b64 authenticator_data_b64
signature_b64 extension_results
token_binding_id_b64)
};
# 7. Using credential.id (or credential.rawId, if base64url encoding is
# inappropriate for your use case), look up the corresponding credential
# public key and let credentialPublicKey be that credential public key.
my $credential_verifier =
eval { getPubKeyVerifier( decode_base64url($credential_pubkey_b64) ) };
croak "Cannot get signature validator for assertion: $@" if ($@);
# 8. Let cData, authData and sig denote the value of response’s
# clientDataJSON, authenticatorData, and signature respectively.
my $client_data_json = decode_base64url($client_data_json_b64);
my $authenticator_data_raw = decode_base64url($authenticator_data_b64);
my $authenticator_data = getAuthData($authenticator_data_raw);
my $signature = decode_base64url($signature_b64);
# 9. Let JSONtext be the result of running UTF-8 decode on the value of
# cData.
# 10. Let C, the client data claimed as used for the signature, be the
# result of running an implementation-specific JSON parser on JSONtext.
my $client_data = eval { decode_json($client_data_json) };
if ($@) {
croak("Error deserializing client data: $@");
}
# 11. Verify that the value of C.type is the string webauthn.get.
unless ( $client_data->{type} eq "webauthn.get" ) {
croak("Type is not webauthn.get");
}
# 12. Verify that the value of C.challenge equals the base64url encoding of
# options.challenge.
unless ($challenge_b64) {
croak("Empty registration challenge");
}
unless ( $challenge_b64 eq $client_data->{challenge} ) {
croak( "Challenge received from client data "
. "($client_data->{challenge}) "
. "does not match server challenge "
. "($challenge_b64)" );
}
# 13. Verify that the value of C.origin matches the Relying Party's origin.
unless ( $client_data->{origin} ) {
croak("Empty origin");
}
unless ( $client_data->{origin} eq $self->origin ) {
croak( "Origin received from client data "
. "($client_data->{origin}) "
. "does not match server origin " . "("
. $self->origin
. ")" );
}
# 14. Verify that the value of C.tokenBinding.status matches the state of
# Token Binding for the TLS connection over which the attestation was
# obtained. If Token Binding was used on that TLS connection, also verify
# that C.tokenBinding.id matches the base64url encoding of the Token
# Binding ID for the connection.
$self->check_token_binding( $client_data->{tokenBinding},
$token_binding_id_b64 );
# 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID
# expected by the Relying Party.
# If using the appid extension, this step needs some special logic. See
# § 10.1 FIDO AppID Extension (appid) for details.
my $hash_rp_id;
if ( $extension_results->{appid} ) {
$hash_rp_id = sha256( $self->origin );
}
else {
$hash_rp_id = sha256( $self->rp_id );
}
unless ( $authenticator_data->{rpIdHash} eq $hash_rp_id ) {
croak( "RP ID hash received from authenticator " . "("
. unpack( "H*", $authenticator_data->{rpIdHash} ) . ") "
. "does not match the hash of this RP ID " . "("
. unpack( "H*", $hash_rp_id )
. ")" );
}
# 16. Verify that the User Present bit of the flags in authData is set.
unless ( $authenticator_data->{flags}->{userPresent} == 1 ) {
croak("User not present during WebAuthn authentication");
}
# 17. If user verification is required for this assertion, verify that the
# User Verified bit of the flags in authData is set.
$requested_uv ||= "preferred";
if ( $requested_uv eq "required"
and $authenticator_data->{flags}->{userVerified} != 1 )
{
croak("User not verified during WebAuthn authentication");
}
# 18. Verify that the values of the client extension outputs in
# clientExtensionResults and the authenticator extension outputs in the
# extensions in authData are as expected,
# TODO
# 19. Let hash be the result of computing a hash over the cData using
# SHA-256.
my $client_data_hash = sha256($client_data_json);
# 20. Using credentialPublicKey, verify that sig is a valid signature over
# the binary concatenation of authData and hash.
my $to_sign = $authenticator_data_raw . $client_data_hash;
unless ( $credential_verifier->( $signature, $to_sign ) ) {
croak("Webauthn signature was not valid");
}
# 21. Let storedSignCount be the stored signature counter value associated
# with credential.id. If authData.signCount is nonzero or storedSignCount
# is nonzero, then run the following sub-step:
$stored_sign_count //= 0;
my $signature_count = $authenticator_data->{signCount};
if ( $signature_count > 0 or $stored_sign_count > 0 ) {
if ( $signature_count <= $stored_sign_count ) {
croak( "Stored signature count $stored_sign_count "
. "higher than device signature count $signature_count" );
}
}
return { success => 1, signature_count => $signature_count, };
}
sub _ecc_obj_to_cose {
my ($key) = @_;
$key = $key->key2hash;
unless ( $key->{curve_name} eq "secp256r1" ) {
croak "Invalid ECC curve: " . $key->{curve_name};
}
# We want to be compatible with old CBOR::XS versions that don't have as_map
# The correct code should be
#return encode_cbor CBOR::XS::as_map [
# 1 => 2,
# 3 => -7,
# -1 => 1,
# -2 => pack( "H*", $key->{pub_x} ),
# -3 => pack( "H*", $key->{pub_y} ),
#];
# Manually encode the COSE key
return "\xa5" . #Map of 5 items
"\x01\x02" . # kty => EC2
"\x03\x26" . # alg => ES256
"\x20\x01" . # crv => P-256
"\x21" . # x =>
"\x58\x20" . pack( "H*", $key->{pub_x} ) . # x coordinate as a bstr
"\x22" . # y =>
"\x58\x20" . pack( "H*", $key->{pub_y} ) # y coordinate as a bstr
;
}
# This function converts public keys from U2F format to COSE format. It can be useful
# for applications who want to migrate existing U2F registrations
sub convert_raw_ecc_to_cose {
my ($raw_ecc_b64) = @_;
my $key = Crypt::PK::ECC->new;
$key->import_key_raw( decode_base64url($raw_ecc_b64), "secp256r1" );
return encode_base64url( _ecc_obj_to_cose($key) );
}
# Check Token Binding in client data against Token Binding in incoming TLS
# connection. This only works if the web server supports it.
sub check_token_binding {
my ( $self, $client_data_token_binding, $connection_tbid_b64 ) = @_;
$connection_tbid_b64 //= "";
# Token binding is not used
if ( ref($client_data_token_binding) ne "HASH" ) {
return;
}
my $token_binding_status = $client_data_token_binding->{status};
if ( $token_binding_status eq "present" ) {
my $client_data_cbid_b64 = $client_data_token_binding->{id};
# Token binding is in use: the "id" field must be present and must
# match the connection's Token Binding ID
if ($client_data_cbid_b64) {
if ( $client_data_cbid_b64 eq $connection_tbid_b64 ) {
# All is well
return;
}
else {
croak "The Token Binding ID from the current connection "
. "($connection_tbid_b64) "
. "does not match Token Binding ID in client data "
. "($client_data_cbid_b64)";
}
}
else {
croak "Missing tokenBinding.id in client data "
. "while tokenBinding.status == present";
}
}
else {
# Token binding "supported" but not used, or unknown/missing value
return;
}
}
sub check_attestation_type {
my ( $self, $allowed_attestation_types, $attestation_type ) = @_;
if ( ref($allowed_attestation_types) eq "ARRAY"
and @$allowed_attestation_types )
{
if ( !grep { lc($_) eq lc($attestation_type) }
@$allowed_attestation_types )
{
croak("Attestation type $attestation_type is not allowed");
}
}
}
sub check_attestation_trust {
my ( $self, $attestation_result, $trust_anchors,
$allow_untrusted_attestation )
= @_;
return 1 if $attestation_result->{type} eq "Self";
return 1 if $attestation_result->{type} eq "None";
#Otherwise, use the X.509 certificates returned as the attestation trust
#path from the verification procedure to verify that the attestation public
#key either correctly chains up to an acceptable root certificate, or is
#itself an acceptable certificate (i.e., it and the root certificate
#obtained in Step 20 may be the same).
my $attn_cert = $attestation_result->{trust_path}->[0];
unless ($attn_cert) {
croak("Missing attestation certificate");
}
my @trust_chain = @{ $attestation_result->{trust_path} };
shift @trust_chain;
if ( $self->matchCertificateInList( $attn_cert, $trust_anchors ) ) {
return 1;
}
my $verify_result =
Authen::WebAuthn::SSLeayChainVerifier::verify_chain( $trust_anchors,
$attn_cert, \@trust_chain );
if ( $verify_result->{result} == 1 ) {
return 1;
}
else {
# If the attestation statement attStmt successfully verified but is not
# trustworthy per step 21 above, the Relying Party SHOULD fail the
# registration ceremony.
if ( !$allow_untrusted_attestation ) {
croak( "Could not validate attestation trust: "
. $verify_result->{message} );
}
else {
# NOTE: However, if permitted by policy, the Relying Party MAY register
# the credential ID and credential public key but treat the credential
# as one with self attestation
%$attestation_result = (
success => 1,
type => "Self",
trust_path => [],
);
return 1;
}
}
}
# Try to find a DER-encoded certificate in a list of PEM-encoded certificates
sub matchCertificateInList {
my ( $self, $attn_cert, $trust_anchors ) = @_;
return if ref($trust_anchors) ne "ARRAY";
for my $candidate (@$trust_anchors) {
my $candidate_x509 = eval {
Crypt::OpenSSL::X509->new_from_string( $candidate,
Crypt::OpenSSL::X509::FORMAT_PEM );
};
next unless $candidate_x509;
if ( $attn_cert eq
$candidate_x509->as_string(Crypt::OpenSSL::X509::FORMAT_ASN1) )
{
return 1;
}
}
return;
}
# Used by u2f assertion types
sub _getU2FKeyFromCose {
my ($cose_key) = @_;
$cose_key = decode_cbor($cose_key);
# TODO: do we need to support more algs?
croak( "Unexpected COSE Alg: " . $cose_key->{3} )
unless ( $COSE_ALG->{ $cose_key->{3} }->{name} eq "ES256" );
my $pk = parse_ecc_cose($cose_key);
return $pk->export_key_raw('public');
}
sub parse_ecc_cose {
my ($cose_struct) = @_;
my $curve = $cose_struct->{-1};
my $x = $cose_struct->{-2};
my $y = $cose_struct->{-3};
my $id_to_curve = { 1 => 'secp256r1', };
my $pk = Crypt::PK::ECC->new();
my $curve_name = $id_to_curve->{$curve};
unless ($curve_name) {
croak "Unsupported curve $curve";
}
$pk->import_key( {
curve_name => $curve_name,
pub_x => unpack( "H*", $x ),
pub_y => unpack( "H*", $y ),
}
);
return $pk;
}
# This generic method generates a two-argument signature method from
# the public key (RSA, ECC, etc.) and signature options from the COSE_ALG hash
sub make_cryptx_verifier {
my ( $public_key, @signature_options ) = @_;
return sub {
my ( $signature, $message ) = @_;
return $public_key->verify_message( $signature, $message,
@signature_options );
};
}
sub parse_ecc_pem {
my ($pem) = @_;
my $pk = Crypt::PK::ECC->new();
$pk->import_key( \$pem );
return $pk;
}
sub parse_rsa_pem {
my ($pem) = @_;
my $pk = Crypt::PK::RSA->new();
$pk->import_key( \$pem );
return $pk;
}
sub parse_rsa_cose {
my ($cose_struct) = @_;
my $n = $cose_struct->{-1};
my $e = $cose_struct->{-2};
my $pk = Crypt::PK::RSA->new();
$pk->import_key( {
N => unpack( "H*", $n ),
e => unpack( "H*", $e ),
}
);
return $pk;
}
# This function returns a verification method that is used like this:
# verifier->($signature, $message) returns 1 iff the message matches the
# signature
# Arguments are the COSE alg number from
# https://www.iana.org/assignments/cose/cose.xhtml#algorithms
# some key data, and the name of the function that converts the key data into a
# CryptX key (in KEY_TYPE array)
sub get_verifier_for_alg {
my ( $alg_num, $key_data, $parse_method ) = @_;
my $alg_config = $COSE_ALG->{$alg_num};
unless ($alg_config) {
croak "Unsupported algorithm $alg_num";
}
my $key_type = $alg_config->{key_type};
my $key_type_config = $KEY_TYPES->{$key_type};
unless ($key_type_config) {
croak "Unsupported key type $key_type";
}
# Get key conversion function
my $key_function = $key_type_config->{$parse_method};
unless ( ref($key_function) eq "CODE" ) {
croak "No conversion method named $parse_method for key type $key_type";
}
# Get key
my $public_key = $key_function->($key_data);
unless ($public_key) {
croak "Could not parse public key";
}
my @signature_options = @{ $alg_config->{signature_options} };
return $key_type_config->{make_verifier}
->( $public_key, @signature_options );
}
# This function takes a Base64url encoded COSE key and returns a verification
# method
sub getPubKeyVerifier {
my ($pubkey_cose) = @_;
my $cose_key = decode_cbor($pubkey_cose);
my $alg_num = $cose_key->{3};
return get_verifier_for_alg( $alg_num, $cose_key, "parse_cose" );
}
# Same, but input is a PEM and a COSE alg name (used in assertion validation)
sub getPEMPubKeyVerifier {
my ( $pem, $alg_num ) = @_;
return get_verifier_for_alg( $alg_num, $pem, "parse_pem" );
}
sub getCoseAlgAndLength {
my ($cbor_raw) = @_;
my ( $cbor, $length ) = CBOR::XS->new->decode_prefix($cbor_raw);
my $alg_num = $cbor->{3};
my $alg = $COSE_ALG->{$alg_num}->{name};
if ($alg) {
return ( $alg, $length );
}
else {
croak "Unsupported algorithm $alg_num";
}
}
# Transform binary AAGUID into string representation
sub formatAaguid {
my ($aaguid) = @_;
if ( length($aaguid) == 16 ) {
return lc join "-",
unpack( "H*", substr( $aaguid, 0, 4 ) ),
unpack( "H*", substr( $aaguid, 4, 2 ) ),
unpack( "H*", substr( $aaguid, 6, 2 ) ),
unpack( "H*", substr( $aaguid, 8, 2 ) ),
unpack( "H*", substr( $aaguid, 10, 6 ) ),
;
}
else {
croak "Invalid AAGUID length";
}
}
sub getAttestedCredentialData {
my ($attestedCredentialData) = @_;
check_length( $attestedCredentialData, "Attested credential data", 18 );
my $res = {};
my $aaguid = formatAaguid( substr( $attestedCredentialData, 0, 16 ) );
$res->{aaguid} = $aaguid;
$res->{credentialIdLength} =
unpack( 'n', substr( $attestedCredentialData, 16, 2 ) );
$res->{credentialId} =
substr( $attestedCredentialData, 18, $res->{credentialIdLength} );
my ( $cose_alg, $length_cbor_pubkey ) = getCoseAlgAndLength(
substr( $attestedCredentialData, 18 + $res->{credentialIdLength} ) );
$res->{credentialPublicKeyAlg} = $cose_alg;
$res->{credentialPublicKey} =
substr( $attestedCredentialData, 18 + $res->{credentialIdLength},
$length_cbor_pubkey );
$res->{credentialPublicKeyLength} = $length_cbor_pubkey;
return $res;
}
sub check_length {
my ( $data, $name, $expected_len ) = @_;
my $len = length($data);
if ( $len < $expected_len ) {
croak("$name has incorrect length $len (min: $expected_len)");
}
}
sub getAuthData {
my ($ad) = @_;
my $res = {};
check_length( $ad, "Authenticator data", 37 );
$res->{rpIdHash} = substr( $ad, 0, 32 );
$res->{flags} = resolveFlags( unpack( 'C', substr( $ad, 32, 1 ) ) );
$res->{signCount} = unpack( 'N', substr( $ad, 33, 4 ) );
my $attestedCredentialDataLength = 0;
if ( $res->{flags}->{atIncluded} ) {
my $attestedCredentialData =
getAttestedCredentialData( substr( $ad, 37 ) );
$res->{attestedCredentialData} = $attestedCredentialData;
$attestedCredentialDataLength =
18 + $attestedCredentialData->{credentialIdLength} +
$attestedCredentialData->{credentialPublicKeyLength};
}
if ( $res->{flags}->{edIncluded} ) {
my $ext = substr( $ad, 37 + $attestedCredentialDataLength );
if ($ext) {
$res->{extensions} = decode_cbor($ext);
}
}
else {
# Check for trailing bytes
croak("Trailing bytes in authenticator data")
if ( length($ad) > ( 37 + $attestedCredentialDataLength ) );
}
return $res;
}
sub resolveFlags {
my ($bits) = @_;
return {
userPresent => ( ( $bits & 1 ) == 1 ),
userVerified => ( ( $bits & 4 ) == 4 ),
atIncluded => ( ( $bits & 64 ) == 64 ),
edIncluded => ( ( $bits & 128 ) == 128 ),
};
}
sub getAttestationObject {
my ($dat) = @_;
my $decoded = decode_base64url($dat);
my $res = {};
my $h = decode_cbor($decoded);
$res->{authData} = getAuthData( $h->{authData} );
$res->{authDataRaw} = $h->{authData};
$res->{attStmt} = $h->{attStmt};
$res->{fmt} = $h->{fmt};
return $res;
}
# https://www.w3.org/TR/webauthn-2/#sctn-none-attestation
sub attest_none {
my (
$attestation_statement, $auhenticator_data,
$authenticator_data_raw, $client_data_hash
) = @_;
return {
success => 1,
type => "None",
trust_path => [],
};
}
# https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation
sub attest_packed {
my (
$attestation_statement, $authenticator_data,
$authenticator_data_raw, $client_data_hash
) = @_;
# Verify that attStmt is valid CBOR conforming to the syntax defined above
# and perform CBOR decoding on it to extract the contained fields.
croak "Missing algorithm field in attestation statement"
unless ( $attestation_statement->{alg} );
croak "Missing signature field in attestation statement"
unless ( $attestation_statement->{sig} );
my $signed_value = $authenticator_data_raw . $client_data_hash;
#If x5c is present:
if ( $attestation_statement->{x5c} ) {
return attest_packed_x5c( $attestation_statement, $authenticator_data,
$signed_value );
#If x5c is not present, self attestation is in use.
}
else {
return attest_packed_self( $attestation_statement, $authenticator_data,
$signed_value );
}
}
sub attest_packed_x5c {
my ( $attestation_statement, $authenticator_data, $signed_value ) = @_;
my $x5c_der = $attestation_statement->{x5c}->[0];
my $sig_alg = $attestation_statement->{alg};
my $sig = $attestation_statement->{sig};
my ( $x5c, $key, $key_alg );
eval {
$x5c = Crypt::OpenSSL::X509->new_from_string( $x5c_der,
Crypt::OpenSSL::X509::FORMAT_ASN1 );
$key = $x5c->pubkey();
};
croak "Cannot extract public key from attestation certificate: $@" if ($@);
# Verify that sig is a valid signature over the concatenation of
# authenticatorData and clientDataHash using the attestation public key in
# attestnCert with the algorithm specified in alg.
my $attestation_verifier = eval { getPEMPubKeyVerifier( $key, $sig_alg ) };
croak "Cannot get signature validator for attestation: $@" if ($@);
# Verify that attestnCert meets the requirements in § 8.2.1 Packed
# Attestation Statement Certificate Requirements.
eval { attest_packed_check_cert_requirements($x5c) };
croak "Attestation certificate does not satisfy requirements: $@" if ($@);
# If attestnCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4
# (id-fido-gen-ce-aaguid) verify that the value of this extension matches
# the aaguid in authenticatorData.
my $aaguid_ext = $x5c->extensions_by_oid->{'1.3.6.1.4.1.45724.1.1.4'};
if ($aaguid_ext) {
my $ad_aaguid = $authenticator_data->{attestedCredentialData}->{aaguid};
my $cert_aaguid = $aaguid_ext->value;
croak "Invalid id-fido-gen-ce-aaguid extension format"
unless $cert_aaguid =~ /^#0410.{32}$/;
# Reformat aaguids so they can be compared
($cert_aaguid) = $cert_aaguid =~ /^#0410(.{32})$/;
$ad_aaguid =~ s/-//g;
$ad_aaguid = uc($ad_aaguid);
croak "AAGUID from certificate ($cert_aaguid)"
. " does not match AAGUID from authenticator data ($ad_aaguid)"
if $ad_aaguid ne $cert_aaguid;
}
# Optionally, inspect x5c and consult externally provided knowledge to
# determine whether attStmt conveys a Basic or AttCA attestation.
# TODO
# If successful, return implementation-specific values representing
# attestation type Basic, AttCA or uncertainty, and attestation trust path
# x5c.
if ( $attestation_verifier->( $sig, $signed_value ) ) {
return {
success => 1,
type => "Basic",
trust_path => $attestation_statement->{x5c},
aaguid => $authenticator_data->{attestedCredentialData}->{aaguid},
};
}
else {
croak "Invalid attestation signature";
}
}
# Implements 8.2.1. Packed Attestation Statement Certificate Requirements
sub attest_packed_check_cert_requirements {
my ($x5c) = @_;
my $version = $x5c->version;
# Version MUST be set to 3
# (which is indicated by an ASN.1 INTEGER with value 2).
croak "Invalid certificate version" unless $version eq "02";
# Subject field
croak "Missing subject C" unless $x5c->subject_name->get_entry_by_type("C");
croak "Missing subject O" unless $x5c->subject_name->get_entry_by_type("O");
croak "Missing subject CN"
unless $x5c->subject_name->get_entry_by_type("CN");
croak "Missing subject OU"
unless $x5c->subject_name->get_entry_by_type("OU");
croak "Unexpected OU"
unless $x5c->subject_name->get_entry_by_type("OU")->value eq
"Authenticator Attestation";
# The Basic Constraints extension MUST have the CA component set to false.
my $isCa = $x5c->extensions_by_oid->{"2.5.29.19"}->basicC("ca");
croak "Basic Constraints CA is true" if $isCa;
return;
}
sub attest_packed_self {
my ( $attestation_statement, $authenticator_data, $signed_value ) = @_;
my $sig = $attestation_statement->{sig};
my $sign_alg_num = $attestation_statement->{alg};
my $cose_key =
$authenticator_data->{attestedCredentialData}->{credentialPublicKey};
# Validate that alg matches the algorithm of the credentialPublicKey in
# authenticatorData.
my $cose_alg =
$authenticator_data->{attestedCredentialData}->{credentialPublicKeyAlg};
my $sign_alg = $COSE_ALG->{$sign_alg_num}->{name};
croak "Unknown key type in attestation data: $sign_alg_num"
unless ($sign_alg);
unless ( $sign_alg eq $cose_alg ) {
croak "Attestation algorithm $sign_alg does not match "
. "credential key type $cose_alg";
}
# Verify that sig is a valid signature over the concatenation of
# authenticatorData and clientDataHash using the credential public key with
# alg.
my $credential_verifier = eval { getPubKeyVerifier($cose_key) };
croak "Cannot get signature validator for attestation: $@" if ($@);
# If successful, return implementation-specific values representing
# attestation type Self and an empty attestation trust path.
if ( $credential_verifier->( $sig, $signed_value ) ) {
return {
success => 1,
type => "Self",
trust_path => [],
};
}
else {
croak "Invalid attestation signature";
}
}
# https://www.w3.org/TR/webauthn-2/#sctn-fido-u2f-attestation
sub attest_u2f {
my (
$attestation_statement, $authenticator_data,
$authenticator_data_raw, $client_data_hash
) = @_;
# 1. Verify that attStmt is valid CBOR conforming to the syntax defined above
# and perform CBOR decoding on it to extract the contained fields.
croak "Missing signature field in attestation statement"
unless ( $attestation_statement->{sig} );
my $sig = $attestation_statement->{sig};
# 2. Check that x5c has exactly one element and let attCert be that
# element. Let certificate public key be the public key conveyed by
# attCert. If certificate public key is not an Elliptic Curve (EC) public
# key over the P-256 curve, terminate this algorithm and return an
# appropriate error.
unless ($attestation_statement->{x5c}
and ref( $attestation_statement->{x5c} ) eq "ARRAY"
and $attestation_statement->{x5c}->[0] )
{
croak "Missing certificate field in attestation statement";
}
my $x5c_der = $attestation_statement->{x5c}->[0];
my $attestation_key = Crypt::PK::ECC->new();
eval {
my $x5c = Crypt::OpenSSL::X509->new_from_string( $x5c_der,
Crypt::OpenSSL::X509::FORMAT_ASN1 );
my $key_pem = $x5c->pubkey();
$attestation_key->import_key( \$key_pem );
};
croak "Could not extract ECC key from attestation certificate: $@" if ($@);
if ( $attestation_key->key2hash->{curve_name} ne "secp256r1" ) {
croak "Invalid attestation certificate curve name: "
. $attestation_key->key2hash->{curve_name};
}
# 3. Extract the claimed rpIdHash from authenticatorData, and the claimed
# credentialId and credentialPublicKey from
# authenticatorData.attestedCredentialData.
my $rp_id_hash = $authenticator_data->{rpIdHash};
my $credential_id =
$authenticator_data->{attestedCredentialData}->{credentialId};
my $credential_public_key =
$authenticator_data->{attestedCredentialData}->{credentialPublicKey};
# 4.Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of
# [RFC8152]) to Raw ANSI X9.62 public key format
my $public_u2f_key = eval { _getU2FKeyFromCose($credential_public_key) };
croak "Could not convert attested credential to U2F key: $@" if ($@);
# 5.Let verificationData be the concatenation of (0x00 || rpIdHash ||
# clientDataHash || credentialId || publicKeyU2F)
my $verification_data = "\x00"
. $rp_id_hash
. $client_data_hash
. $credential_id
. $public_u2f_key;
if (
$attestation_key->verify_message( $sig, $verification_data, "SHA256" ) )
{
return {
success => 1,
type => "Basic",
trust_path => $attestation_statement->{x5c},
};
}
else {
croak "Signature verification failed";
}
}
1;