File Coverage

blib/lib/Authen/WebAuthn.pm
Criterion Covered Total %
statement 270 326 82.8
branch 72 124 58.0
condition 18 23 78.2
subroutine 36 39 92.3
pod 3 24 12.5
total 399 536 74.4


line stmt bran cond sub pod time code
1             package Authen::WebAuthn;
2             $Authen::WebAuthn::VERSION = '0.001';
3 2     2   134409 use strict;
  2         9  
  2         53  
4 2     2   10 use warnings;
  2         3  
  2         65  
5 2     2   877 use Mouse;
  2         52257  
  2         10  
6 2     2   876 use MIME::Base64 qw(encode_base64url decode_base64url);
  2         5  
  2         136  
7 2     2   1462 use JSON qw(decode_json from_json to_json);
  2         25037  
  2         14  
8 2     2   1733 use Digest::SHA qw(sha256);
  2         5301  
  2         201  
9 2     2   1193 use Crypt::PK::ECC;
  2         28910  
  2         119  
10 2     2   1202 use Crypt::PK::RSA;
  2         4617  
  2         118  
11 2     2   1098 use Crypt::OpenSSL::X509;
  2         97883  
  2         289  
12 2     2   1514 use CBOR::XS;
  2         5951  
  2         166  
13 2     2   1287 use URI;
  2         12099  
  2         139  
14 2     2   18 use Carp;
  2         6  
  2         9655  
15              
16             has rp_id => ( is => 'rw', required => 1 );
17             has origin => ( is => 'rw', required => 1 );
18              
19             my $ATTESTATION_FUNCTIONS = {
20             none => \&attest_none,
21             packed => \&attest_packed,
22             "fido-u2f" => \&attest_u2f,
23             };
24              
25             my $KEY_TYPES = {
26             ECC => {
27             parse_pem => \&parse_ecc_pem,
28             parse_cose => \&parse_ecc_cose,
29             make_verifier => \&make_cryptx_verifier,
30             },
31             RSA => {
32             parse_pem => \&parse_rsa_pem,
33             parse_cose => \&parse_rsa_cose,
34             make_verifier => \&make_cryptx_verifier,
35             }
36             };
37              
38             my $COSE_ALG = {
39             -7 => {
40             name => "ES256",
41             key_type => "ECC",
42             signature_options => ["SHA256"]
43             },
44             -257 => {
45             name => "RS256",
46             key_type => "RSA",
47             signature_options => [ "SHA256", "v1.5" ]
48             },
49             -37 => {
50             name => "PS256",
51             key_type => "RSA",
52             signature_options => [ "SHA256", "pss" ]
53             },
54             -65535 => {
55             name => "RS1",
56             key_type => "RSA",
57             signature_options => [ "SHA1", "v1.5" ]
58             }
59             };
60              
61             sub validate_registration {
62 9     9 1 3477 my ( $self, %params ) = @_;
63              
64             my (
65             $challenge_b64, $requested_uv,
66             $client_data_json_b64, $attestation_object_b64,
67             $token_binding_id_b64
68             )
69 9         28 = @params{ qw(
70             challenge_b64 requested_uv
71             client_data_json_b64 attestation_object_b64
72             token_binding_id_b64
73             )
74             };
75              
76 9         24 my $client_data_json = decode_base64url($client_data_json_b64);
77 9         85 my $client_data = eval { decode_json($client_data_json) };
  9         60  
78 9 50       21 if ($@) {
79 0         0 croak("Error deserializing client data: $@");
80             }
81              
82             # 7. Verify that the value of C.type is webauthn.create
83 9 50       24 unless ( $client_data->{type} eq "webauthn.create" ) {
84 0         0 croak("Type is not webauthn.create");
85             }
86              
87             # 8. Verify that the value of C.challenge equals the base64url encoding
88             # of options.challenge.
89 9 50       60 unless ($challenge_b64) {
90 0         0 croak("Empty registration challenge");
91             }
92              
93 9 100       21 unless ( $challenge_b64 eq $client_data->{challenge} ) {
94 1         77 croak( "Challenge received from client data "
95             . "($client_data->{challenge}) "
96             . "does not match server challenge "
97             . "($challenge_b64)" );
98             }
99              
100             # 9. Verify that the value of C.origin matches the Relying Party's origin.
101              
102 8 50       17 unless ( $client_data->{origin} ) {
103 0         0 croak("Empty origin in client data");
104             }
105              
106 8 100       28 unless ( $client_data->{origin} eq $self->origin ) {
107 1         79 croak( "Origin received from client data "
108             . "($client_data->{origin}) "
109             . "does not match server origin " . "("
110             . $self->origin
111             . ")" );
112             }
113              
114             # 10. Verify that the value of C.tokenBinding.status matches the state of
115             # Token Binding for the TLS connection over which the assertion was
116             # obtained. If Token Binding was used on that TLS connection, also verify
117             # that C.tokenBinding.id matches the base64url encoding of the Token
118             # Binding ID for the connection.
119             $self->check_token_binding( $client_data->{tokenBinding},
120 7         36 $token_binding_id_b64 );
121              
122             # 11. Let hash be the result of computing a hash over
123             # response.clientDataJSON using SHA-256.
124 7         61 my $client_data_hash = sha256($client_data_json);
125              
126             # 12. Perform CBOR decoding on the attestationObject field of the
127             # AuthenticatorAttestationResponse structure to obtain the attestation
128             # statement format fmt, the authenticator data authData, and the
129             # attestation statement attStmt.
130 7         19 my $attestation_object = getAttestationObject($attestation_object_b64);
131 7         12 my $authenticator_data = $attestation_object->{authData};
132              
133 7 50       13 unless ($authenticator_data) {
134 0         0 croak("Authenticator data not found in attestation object");
135             }
136              
137 7 50       13 unless ( $authenticator_data->{attestedCredentialData} ) {
138 0         0 croak("Attested credential data not found in authenticator data");
139             }
140              
141             # 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID
142             # expected by the Relying Party.
143 7         49 my $hash_rp_id = sha256( $self->rp_id );
144 7 100       19 unless ( $authenticator_data->{rpIdHash} eq $hash_rp_id ) {
145             croak( "RP ID hash received from authenticator " . "("
146 1         82 . unpack( "H*", $authenticator_data->{rpIdHash} ) . ") "
147             . "does not match the hash of this RP ID " . "("
148             . unpack( "H*", $hash_rp_id )
149             . ")" );
150             }
151              
152             # 14. Verify that the User Present bit of the flags in authData is set.
153 6 50       13 unless ( $authenticator_data->{flags}->{userPresent} == 1 ) {
154 0         0 croak("User not present during WebAuthn registration");
155             }
156              
157             # 15. If user verification is required for this registration, verify that
158             # the User Verified bit of the flags in authData is set.
159 6   100     24 $requested_uv ||= "preferred";
160 6 100 100     23 if ( $requested_uv eq "required"
161             and $authenticator_data->{flags}->{userVerified} != 1 )
162             {
163 1         208 croak("User not verified during WebAuthn registration");
164             }
165              
166             # 16. Verify that the "alg" parameter in the credential public key in
167             # authData matches the alg attribute of one of the items in
168             # options.pubKeyCredParams.
169             # TODO For now, allow all known key types
170              
171             # 17. Verify that the values of the client extension outputs in
172             # clientExtensionResults and the authenticator extension outputs in the
173             # extensions in authData are as expected
174             # TODO
175              
176             # 18. Determine the attestation statement format by performing a USASCII
177             # case-sensitive match on fmt against the set of supported WebAuthn
178             # Attestation Statement Format Identifier values.
179 5         11 my $attestation_statement_format = $attestation_object->{'fmt'};
180             my $attestation_function =
181 5         10 $ATTESTATION_FUNCTIONS->{$attestation_statement_format};
182 5 50       13 unless ( ref($attestation_function) eq "CODE" ) {
183 0         0 croak( "Unsupported attestation format during WebAuthn registration: "
184             . $attestation_statement_format );
185             }
186              
187             # 19. Verify that attStmt is a correct attestation statement, conveying a
188             # valid attestation signature, by using the attestation statement format
189             # fmt’s verification procedure given attStmt, authData and hash.
190 5         12 my $attestation_statement = $attestation_object->{attStmt};
191 5         10 my $authenticator_data_raw = $attestation_object->{authDataRaw};
192 5         7 my $attestation_result = eval {
193 5         15 $attestation_function->(
194             $attestation_statement, $authenticator_data,
195             $authenticator_data_raw, $client_data_hash
196             );
197             };
198 5 50       14 croak( "Failed to validate attestation: " . $@ ) if ($@);
199              
200 5 50       38 unless ( $attestation_result->{success} == 1 ) {
201             croak(
202 0         0 "Failed to validate attestation: " . $attestation_result->{error} );
203             }
204              
205             # 20. If validation is successful, obtain a list of acceptable trust
206             # anchors (i.e. attestation root certificates) for that attestation type
207             # and attestation statement format fmt, from a trusted source or from
208             # policy.
209             # TODO
210              
211             # 21. Assess the attestation trustworthiness using the outputs of the
212             # verification procedure in step 19, as follows:
213             # TODO
214              
215             # 22. Check that the credentialId is not yet registered to any other user
216             # TODO
217              
218             # 23. If the attestation statement attStmt verified successfully and is
219             # found to be trustworthy, then register the new credential with the
220             # account that was denoted in options.user:
221             my $credential_id_bin =
222 5         9 $authenticator_data->{attestedCredentialData}->{credentialId};
223             my $credential_pubkey_cose =
224 5         8 $authenticator_data->{attestedCredentialData}->{credentialPublicKey};
225 5         7 my $signature_count = $authenticator_data->{signCount};
226             return {
227 5         13 credential_id => encode_base64url($credential_id_bin),
228             credential_pubkey => encode_base64url($credential_pubkey_cose),
229             signature_count => $signature_count,
230             attestation_result => $attestation_result
231             };
232             }
233              
234             sub validate_assertion {
235 17     17 1 2840 my ( $self, %params ) = @_;
236             my (
237             $challenge_b64, $credential_pubkey_b64,
238             $stored_sign_count, $requested_uv,
239             $client_data_json_b64, $authenticator_data_b64,
240             $signature_b64, $extension_results,
241             $token_binding_id_b64,
242             )
243             = @params{
244 17         113 qw(challenge_b64 credential_pubkey_b64
245             stored_sign_count requested_uv
246             client_data_json_b64 authenticator_data_b64
247             signature_b64 extension_results
248             token_binding_id_b64)
249             };
250              
251             # 7. Using credential.id (or credential.rawId, if base64url encoding is
252             # inappropriate for your use case), look up the corresponding credential
253             # public key and let credentialPublicKey be that credential public key.
254             my $credential_verifier =
255 17         47 eval { getPubKeyVerifier( decode_base64url($credential_pubkey_b64) ) };
  17         76  
256 17 50       77 croak "Cannot get signature validator for assertion: $@" if ($@);
257              
258             # 8. Let cData, authData and sig denote the value of response’s
259             # clientDataJSON, authenticatorData, and signature respectively.
260 17         132 my $client_data_json = decode_base64url($client_data_json_b64);
261 17         316 my $authenticator_data_raw = decode_base64url($authenticator_data_b64);
262 17         176 my $authenticator_data = getAuthData($authenticator_data_raw);
263 17         38 my $signature = decode_base64url($signature_b64);
264              
265             # 9. Let JSONtext be the result of running UTF-8 decode on the value of
266             # cData.
267             # 10. Let C, the client data claimed as used for the signature, be the
268             # result of running an implementation-specific JSON parser on JSONtext.
269 17         149 my $client_data = eval { decode_json($client_data_json) };
  17         290  
270 17 100       54 if ($@) {
271 1         306 croak("Error deserializing client data: $@");
272             }
273              
274             # 11. Verify that the value of C.type is the string webauthn.get.
275 16 50       62 unless ( $client_data->{type} eq "webauthn.get" ) {
276 0         0 croak("Type is not webauthn.get");
277             }
278              
279             # 12. Verify that the value of C.challenge equals the base64url encoding of
280             # options.challenge.
281 16 50       50 unless ($challenge_b64) {
282 0         0 croak("Empty registration challenge");
283             }
284              
285 16 100       68 unless ( $challenge_b64 eq $client_data->{challenge} ) {
286 1         247 croak( "Challenge received from client data "
287             . "($client_data->{challenge}) "
288             . "does not match server challenge "
289             . "($challenge_b64)" );
290             }
291              
292             # 13. Verify that the value of C.origin matches the Relying Party's origin.
293 15 50       45 unless ( $client_data->{origin} ) {
294 0         0 croak("Empty origin");
295             }
296              
297 15 100       137 unless ( $client_data->{origin} eq $self->origin ) {
298 1         264 croak( "Origin received from client data "
299             . "($client_data->{origin}) "
300             . "does not match server origin " . "("
301             . $self->origin
302             . ")" );
303             }
304              
305             # 14. Verify that the value of C.tokenBinding.status matches the state of
306             # Token Binding for the TLS connection over which the attestation was
307             # obtained. If Token Binding was used on that TLS connection, also verify
308             # that C.tokenBinding.id matches the base64url encoding of the Token
309             # Binding ID for the connection.
310             $self->check_token_binding( $client_data->{tokenBinding},
311 14         86 $token_binding_id_b64 );
312              
313             # 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID
314             # expected by the Relying Party.
315             # If using the appid extension, this step needs some special logic. See
316             # § 10.1 FIDO AppID Extension (appid) for details.
317              
318 12         29 my $hash_rp_id;
319 12 50       45 if ( $extension_results->{appid} ) {
320 0         0 $hash_rp_id = sha256( $self->origin );
321             }
322             else {
323 12         174 $hash_rp_id = sha256( $self->rp_id );
324             }
325              
326 12 100       40 unless ( $authenticator_data->{rpIdHash} eq $hash_rp_id ) {
327             croak( "RP ID hash received from authenticator " . "("
328 1         289 . unpack( "H*", $authenticator_data->{rpIdHash} ) . ") "
329             . "does not match the hash of this RP ID " . "("
330             . unpack( "H*", $hash_rp_id )
331             . ")" );
332             }
333              
334             # 16. Verify that the User Present bit of the flags in authData is set.
335 11 50       37 unless ( $authenticator_data->{flags}->{userPresent} == 1 ) {
336 0         0 croak("User not present during WebAuthn authentication");
337             }
338              
339             # 17. If user verification is required for this assertion, verify that the
340             # User Verified bit of the flags in authData is set.
341 11   100     65 $requested_uv ||= "preferred";
342 11 100 100     40 if ( $requested_uv eq "required"
343             and $authenticator_data->{flags}->{userVerified} != 1 )
344             {
345 1         146 croak("User not verified during WebAuthn authentication");
346             }
347              
348             # 18. Verify that the values of the client extension outputs in
349             # clientExtensionResults and the authenticator extension outputs in the
350             # extensions in authData are as expected,
351             # TODO
352              
353             # 19. Let hash be the result of computing a hash over the cData using
354             # SHA-256.
355 10         49 my $client_data_hash = sha256($client_data_json);
356              
357             # 20. Using credentialPublicKey, verify that sig is a valid signature over
358             # the binary concatenation of authData and hash.
359 10         36 my $to_sign = $authenticator_data_raw . $client_data_hash;
360              
361 10 100       30 unless ( $credential_verifier->( $signature, $to_sign ) ) {
362 2         502 croak("Webauthn signature was not valid");
363             }
364              
365             # 21. Let storedSignCount be the stored signature counter value associated
366             # with credential.id. If authData.signCount is nonzero or storedSignCount
367             # is nonzero, then run the following sub-step:
368 8   100     54 $stored_sign_count //= 0;
369 8         38 my $signature_count = $authenticator_data->{signCount};
370 8 100 66     37 if ( $signature_count > 0 or $stored_sign_count > 0 ) {
371 5 100       18 if ( $signature_count <= $stored_sign_count ) {
372 2         509 croak( "Stored signature count $stored_sign_count "
373             . "higher than device signature count $signature_count" );
374             }
375             }
376              
377 6         167 return { success => 1, signature_count => $signature_count, };
378             }
379              
380             sub _ecc_obj_to_cose {
381 21     21   93 my ($key) = @_;
382              
383 21         1402 $key = $key->key2hash;
384 21 50       174 unless ( $key->{curve_name} eq "secp256r1" ) {
385 0         0 croak "Invalid ECC curve: " . $key->{curve_name};
386             }
387              
388             # We want to be compatible with old CBOR::XS versions that don't have as_map
389             # The correct code should be
390             #return encode_cbor CBOR::XS::as_map [
391             # 1 => 2,
392             # 3 => -7,
393             # -1 => 1,
394             # -2 => pack( "H*", $key->{pub_x} ),
395             # -3 => pack( "H*", $key->{pub_y} ),
396             #];
397              
398             # Manually encode the COSE key
399             return "\xa5" . #Map of 5 items
400             "\x01\x02" . # kty => EC2
401             "\x03\x26" . # alg => ES256
402             "\x20\x01" . # crv => P-256
403             "\x21" . # x =>
404             "\x58\x20" . pack( "H*", $key->{pub_x} ) . # x coordinate as a bstr
405             "\x22" . # y =>
406             "\x58\x20" . pack( "H*", $key->{pub_y} ) # y coordinate as a bstr
407 21         765 ;
408              
409             }
410              
411             # This function converts public keys from U2F format to COSE format. It can be useful
412             # for applications who want to migrate existing U2F registrations
413             sub convert_raw_ecc_to_cose {
414 0     0 1 0 my ($raw_ecc_b64) = @_;
415              
416 0         0 my $key = Crypt::PK::ECC->new;
417 0         0 $key->import_key_raw( decode_base64url($raw_ecc_b64), "secp256r1" );
418 0         0 return encode_base64url( _ecc_obj_to_cose($key) );
419             }
420              
421             # Check Token Binding in client data against Token Binding in incoming TLS
422             # connection. This only works if the web server supports it.
423             sub check_token_binding {
424 21     21 0 86 my ( $self, $client_data_token_binding, $connection_tbid_b64 ) = @_;
425 21   100     115 $connection_tbid_b64 //= "";
426              
427             # Token binding is not used
428 21 100       70 if ( ref($client_data_token_binding) ne "HASH" ) {
429 18         37 return;
430             }
431              
432 3         10 my $token_binding_status = $client_data_token_binding->{status};
433              
434 3 50       13 if ( $token_binding_status eq "present" ) {
435 3         8 my $client_data_cbid_b64 = $client_data_token_binding->{id};
436              
437             # Token binding is in use: the "id" field must be present and must
438             # match the connection's Token Binding ID
439 3 50       10 if ($client_data_cbid_b64) {
440 3 100       10 if ( $client_data_cbid_b64 eq $connection_tbid_b64 ) {
441              
442             # All is well
443 1         3 return;
444             }
445             else {
446 2         512 croak "The Token Binding ID from the current connection "
447             . "($connection_tbid_b64) "
448             . "does not match Token Binding ID in client data "
449             . "($client_data_cbid_b64)";
450             }
451              
452             }
453             else {
454 0         0 croak "Missing tokenBinding.id in client data "
455             . "while tokenBinding.status == present";
456             }
457              
458             }
459             else {
460             # Token binding "supported" but not used, or unknown/missing value
461 0         0 return;
462             }
463             }
464              
465             # Used by u2f assertion types
466             sub _getU2FKeyFromCose {
467 1     1   3 my ($cose_key) = @_;
468 1         6 $cose_key = decode_cbor($cose_key);
469              
470             # TODO: do we need to support more algs?
471             croak( "Unexpected COSE Alg: " . $cose_key->{3} )
472 1 50       6 unless ( $COSE_ALG->{ $cose_key->{3} }->{name} eq "ES256" );
473              
474 1         2 my $pk = parse_ecc_cose($cose_key);
475 1         14 return $pk->export_key_raw('public');
476             }
477              
478             sub parse_ecc_cose {
479 17     17 0 40 my ($cose_struct) = @_;
480              
481 17         52 my $curve = $cose_struct->{-1};
482 17         45 my $x = $cose_struct->{-2};
483 17         27 my $y = $cose_struct->{-3};
484 17         42 my $id_to_curve = { 1 => 'secp256r1', };
485              
486 17         93 my $pk = Crypt::PK::ECC->new();
487 17         1774 my $curve_name = $id_to_curve->{$curve};
488 17 50       78 unless ($curve_name) {
489 0         0 croak "Unsupported curve $curve";
490             }
491              
492             $pk->import_key( {
493 17         221 curve_name => $curve_name,
494             pub_x => unpack( "H*", $x ),
495             pub_y => unpack( "H*", $y ),
496             }
497             );
498 17         41511 return $pk;
499             }
500              
501             # This generic method generates a two-argument signature method from
502             # the public key (RSA, ECC, etc.) and signature options from the COSE_ALG hash
503             sub make_cryptx_verifier {
504 18     18 0 79 my ( $public_key, @signature_options ) = @_;
505              
506             return sub {
507 11     11   25 my ( $signature, $message ) = @_;
508 11         12168 return $public_key->verify_message( $signature, $message,
509             @signature_options );
510 18         356 };
511             }
512              
513             sub parse_ecc_pem {
514 1     1 0 3 my ($pem) = @_;
515 1         9 my $pk = Crypt::PK::ECC->new();
516 1         101 $pk->import_key( \$pem );
517 1         2505 return $pk;
518             }
519              
520             sub parse_rsa_pem {
521 0     0 0 0 my ($pem) = @_;
522 0         0 my $pk = Crypt::PK::RSA->new();
523 0         0 $pk->import_key( \$pem );
524 0         0 return $pk;
525             }
526              
527             sub parse_rsa_cose {
528 1     1 0 2 my ($cose_struct) = @_;
529 1         3 my $n = $cose_struct->{-1};
530 1         2 my $e = $cose_struct->{-2};
531              
532 1         8 my $pk = Crypt::PK::RSA->new();
533              
534 1         58 $pk->import_key( {
535             N => unpack( "H*", $n ),
536             e => unpack( "H*", $e ),
537             }
538             );
539              
540 1         113 return $pk;
541             }
542              
543             # This function returns a verification method that is used like this:
544             # verifier->($signature, $message) returns 1 iff the message matches the
545             # signature
546             # Arguments are the COSE alg number from
547             # https://www.iana.org/assignments/cose/cose.xhtml#algorithms
548             # some key data, and the name of the function that converts the key data into a
549             # CryptX key (in KEY_TYPE array)
550             sub get_verifier_for_alg {
551 18     18 0 57 my ( $alg_num, $key_data, $parse_method ) = @_;
552              
553 18         52 my $alg_config = $COSE_ALG->{$alg_num};
554 18 50       58 unless ($alg_config) {
555 0         0 croak "Unsupported algorithm $alg_num";
556             }
557              
558 18         66 my $key_type = $alg_config->{key_type};
559 18         52 my $key_type_config = $KEY_TYPES->{$key_type};
560 18 50       55 unless ($key_type_config) {
561 0         0 croak "Unsupported key type $key_type";
562             }
563              
564             # Get key conversion function
565 18         52 my $key_function = $key_type_config->{$parse_method};
566 18 50       85 unless ( ref($key_function) eq "CODE" ) {
567 0         0 croak "No conversion method named $parse_method for key type $key_type";
568             }
569              
570             # Get key
571 18         70 my $public_key = $key_function->($key_data);
572 18 50       119 unless ($public_key) {
573 0         0 croak "Could not parse public key";
574             }
575              
576 18         43 my @signature_options = @{ $alg_config->{signature_options} };
  18         146  
577             return $key_type_config->{make_verifier}
578 18         100 ->( $public_key, @signature_options );
579             }
580              
581             # This function takes a Base64url encoded COSE key and returns a verification
582             # method
583              
584             sub getPubKeyVerifier {
585 17     17 0 809 my ($pubkey_cose) = @_;
586 17         251 my $cose_key = decode_cbor($pubkey_cose);
587              
588 17         52 my $alg_num = $cose_key->{3};
589 17         74 return get_verifier_for_alg( $alg_num, $cose_key, "parse_cose" );
590             }
591              
592             # Same, but input is a PEM and a COSE alg name (used in assertion validation)
593             sub getPEMPubKeyVerifier {
594 1     1 0 7 my ( $pem, $alg_num ) = @_;
595              
596 1         9 return get_verifier_for_alg( $alg_num, $pem, "parse_pem" );
597             }
598              
599             sub getCoseAlgAndLength {
600 7     7 0 16 my ($cbor_raw) = @_;
601              
602 7         55 my ( $cbor, $length ) = CBOR::XS->new->decode_prefix($cbor_raw);
603              
604 7         25 my $alg_num = $cbor->{3};
605 7         16 my $alg = $COSE_ALG->{$alg_num}->{name};
606              
607 7 50       12 if ($alg) {
608 7         22 return ( $alg, $length );
609             }
610             else {
611 0         0 croak "Unsupported algorithm $alg_num";
612             }
613             }
614              
615             # Transform binary AAGUID into string representation
616             sub formatAaguid {
617 7     7 0 16 my ($aaguid) = @_;
618 7 50       15 if ( length($aaguid) == 16 ) {
619 7         50 return lc join "-",
620             unpack( "H*", substr( $aaguid, 0, 4 ) ),
621             unpack( "H*", substr( $aaguid, 4, 2 ) ),
622             unpack( "H*", substr( $aaguid, 6, 2 ) ),
623             unpack( "H*", substr( $aaguid, 8, 2 ) ),
624             unpack( "H*", substr( $aaguid, 10, 6 ) ),
625             ;
626             }
627             else {
628 0         0 croak "Invalid AAGUID length";
629             }
630             }
631              
632             sub getAttestedCredentialData {
633 7     7 0 28 my ($attestedCredentialData) = @_;
634              
635 7         34 check_length( $attestedCredentialData, "Attested credential data", 18 );
636              
637 7         9 my $res = {};
638 7         19 my $aaguid = formatAaguid( substr( $attestedCredentialData, 0, 16 ) );
639 7         17 $res->{aaguid} = $aaguid;
640             $res->{credentialIdLength} =
641 7         18 unpack( 'n', substr( $attestedCredentialData, 16, 2 ) );
642             $res->{credentialId} =
643 7         15 substr( $attestedCredentialData, 18, $res->{credentialIdLength} );
644             my ( $cose_alg, $length_cbor_pubkey ) = getCoseAlgAndLength(
645 7         26 substr( $attestedCredentialData, 18 + $res->{credentialIdLength} ) );
646              
647 7         12 $res->{credentialPublicKeyAlg} = $cose_alg;
648             $res->{credentialPublicKey} =
649             substr( $attestedCredentialData, 18 + $res->{credentialIdLength},
650 7         17 $length_cbor_pubkey );
651 7         9 $res->{credentialPublicKeyLength} = $length_cbor_pubkey;
652 7         11 return $res;
653             }
654              
655             sub check_length {
656 31     31 0 75 my ( $data, $name, $expected_len ) = @_;
657              
658 31         59 my $len = length($data);
659 31 50       106 if ( $len < $expected_len ) {
660 0         0 croak("$name has incorrect length $len (min: $expected_len)");
661             }
662             }
663              
664             sub getAuthData {
665 24     24 0 68 my ($ad) = @_;
666 24         62 my $res = {};
667              
668 24         106 check_length( $ad, "Authenticator data", 37 );
669              
670 24         112 $res->{rpIdHash} = substr( $ad, 0, 32 );
671 24         154 $res->{flags} = resolveFlags( unpack( 'C', substr( $ad, 32, 1 ) ) );
672 24         117 $res->{signCount} = unpack( 'N', substr( $ad, 33, 4 ) );
673              
674 24         43 my $attestedCredentialDataLength = 0;
675 24 100       69 if ( $res->{flags}->{atIncluded} ) {
676 7         17 my $attestedCredentialData =
677             getAttestedCredentialData( substr( $ad, 37 ) );
678 7         15 $res->{attestedCredentialData} = $attestedCredentialData;
679             $attestedCredentialDataLength =
680             18 + $attestedCredentialData->{credentialIdLength} +
681 7         11 $attestedCredentialData->{credentialPublicKeyLength};
682             }
683              
684 24 50       82 if ( $res->{flags}->{edIncluded} ) {
685 0         0 my $ext = substr( $ad, 37 + $attestedCredentialDataLength );
686              
687 0 0       0 if ($ext) {
688 0         0 $res->{extensions} = decode_cbor($ext);
689             }
690             }
691             else {
692             # Check for trailing bytes
693 24 50       86 croak("Trailing bytes in authenticator data")
694             if ( length($ad) > ( 37 + $attestedCredentialDataLength ) );
695             }
696              
697 24         51 return $res;
698             }
699              
700             sub resolveFlags {
701 24     24 0 42 my ($bits) = @_;
702             return {
703 24         240 userPresent => ( ( $bits & 1 ) == 1 ),
704             userVerified => ( ( $bits & 4 ) == 4 ),
705             atIncluded => ( ( $bits & 64 ) == 64 ),
706             edIncluded => ( ( $bits & 128 ) == 128 ),
707             };
708             }
709              
710             sub getAttestationObject {
711 7     7 0 11 my ($dat) = @_;
712 7         16 my $decoded = decode_base64url($dat);
713 7         78 my $res = {};
714 7         49 my $h = decode_cbor($decoded);
715 7         19 $res->{authData} = getAuthData( $h->{authData} );
716 7         12 $res->{authDataRaw} = $h->{authData};
717 7         14 $res->{attStmt} = $h->{attStmt};
718 7         12 $res->{fmt} = $h->{fmt};
719 7         15 return $res;
720             }
721              
722             # https://www.w3.org/TR/webauthn-2/#sctn-none-attestation
723             sub attest_none {
724             my (
725 3     3 0 8 $attestation_statement, $auhenticator_data,
726             $authenticator_data_raw, $client_data_hash
727             ) = @_;
728             return {
729 3         12 success => 1,
730             type => "None",
731             trust_path => [],
732             };
733              
734             }
735              
736             # https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation
737             sub attest_packed {
738             my (
739 1     1 0 7 $attestation_statement, $authenticator_data,
740             $authenticator_data_raw, $client_data_hash
741             ) = @_;
742              
743             # Verify that attStmt is valid CBOR conforming to the syntax defined above
744             # and perform CBOR decoding on it to extract the contained fields.
745             croak "Missing algorithm field in attestation statement"
746 1 50       4 unless ( $attestation_statement->{alg} );
747              
748             croak "Missing signature field in attestation statement"
749 1 50       5 unless ( $attestation_statement->{sig} );
750              
751 1         21 my $signed_value = $authenticator_data_raw . $client_data_hash;
752              
753             #If x5c is present:
754 1 50       7 if ( $attestation_statement->{x5c} ) {
755 1         4 return attest_packed_x5c( $attestation_statement, $authenticator_data,
756             $signed_value );
757              
758             #If x5c is not present, self attestation is in use.
759             }
760             else {
761 0         0 return attest_packed_self( $attestation_statement, $authenticator_data,
762             $signed_value );
763             }
764             }
765              
766             sub attest_packed_x5c {
767 1     1 0 3 my ( $attestation_statement, $authenticator_data, $signed_value ) = @_;
768              
769 1         3 my $x5c_der = $attestation_statement->{x5c}->[0];
770 1         2 my $sig_alg = $attestation_statement->{alg};
771 1         2 my $sig = $attestation_statement->{sig};
772              
773 1         2 my ( $x5c, $key, $key_alg );
774 1         1 eval {
775 1         212 $x5c = Crypt::OpenSSL::X509->new_from_string( $x5c_der,
776             Crypt::OpenSSL::X509::FORMAT_ASN1 );
777 1         157 $key = $x5c->pubkey();
778             };
779              
780 1 50       5 croak "Cannot extract public key from attestation certificate: $@" if ($@);
781              
782             # Verify that sig is a valid signature over the concatenation of
783             # authenticatorData and clientDataHash using the attestation public key in
784             # attestnCert with the algorithm specified in alg.
785 1         3 my $attestation_verifier = eval { getPEMPubKeyVerifier( $key, $sig_alg ) };
  1         4  
786 1 50       3 croak "Cannot get signature validator for attestation: $@" if ($@);
787              
788             # Verify that attestnCert meets the requirements in § 8.2.1 Packed
789             # Attestation Statement Certificate Requirements.
790             # TODO
791             # If attestnCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4
792             # (id-fido-gen-ce-aaguid) verify that the value of this extension matches
793             # the aaguid in authenticatorData.
794             # TODO
795             # Optionally, inspect x5c and consult externally provided knowledge to
796             # determine whether attStmt conveys a Basic or AttCA attestation.
797             # TODO
798              
799             # If successful, return implementation-specific values representing
800             # attestation type Basic, AttCA or uncertainty, and attestation trust path
801             # x5c.
802 1 50       3 if ( $attestation_verifier->( $sig, $signed_value ) ) {
803             return {
804             success => 1,
805             type => "Unsure",
806             trust_path => $attestation_statement->{x5c},
807 1         32 };
808             }
809             else {
810 0         0 croak "Invalid attestation signature";
811             }
812             }
813              
814             sub attest_packed_self {
815 0     0 0 0 my ( $attestation_statement, $authenticator_data, $signed_value ) = @_;
816              
817 0         0 my $sig = $attestation_statement->{sig};
818 0         0 my $sign_alg_num = $attestation_statement->{alg};
819             my $cose_key =
820 0         0 $authenticator_data->{attestedCredentialData}->{credentialPublicKey};
821              
822             # Validate that alg matches the algorithm of the credentialPublicKey in
823             # authenticatorData.
824             my $cose_alg =
825 0         0 $authenticator_data->{attestedCredentialData}->{credentialPublicKeyAlg};
826 0         0 my $sign_alg = $COSE_ALG->{$sign_alg_num}->{name};
827 0 0       0 croak "Unknown key type in attestation data: $sign_alg_num"
828             unless ($sign_alg);
829              
830 0 0       0 unless ( $sign_alg eq $cose_alg ) {
831 0         0 croak "Attestation algorithm $sign_alg does not match "
832             . "credential key type $cose_alg";
833             }
834              
835             # Verify that sig is a valid signature over the concatenation of
836             # authenticatorData and clientDataHash using the credential public key with
837             # alg.
838 0         0 my $credential_verifier = eval { getPubKeyVerifier($cose_key) };
  0         0  
839 0 0       0 croak "Cannot get signature validator for attestation: $@" if ($@);
840              
841             # If successful, return implementation-specific values representing
842             # attestation type Self and an empty attestation trust path.
843 0 0       0 if ( $credential_verifier->( $sig, $signed_value ) ) {
844             return {
845 0         0 success => 1,
846             type => "Self",
847             trust_path => [],
848             };
849             }
850             else {
851 0         0 croak "Invalid attestation signature";
852             }
853             }
854              
855             # https://www.w3.org/TR/webauthn-2/#sctn-fido-u2f-attestation
856             sub attest_u2f {
857             my (
858 1     1 0 3 $attestation_statement, $authenticator_data,
859             $authenticator_data_raw, $client_data_hash
860             ) = @_;
861              
862             # 1. Verify that attStmt is valid CBOR conforming to the syntax defined above
863             # and perform CBOR decoding on it to extract the contained fields.
864             croak "Missing signature field in attestation statement"
865 1 50       3 unless ( $attestation_statement->{sig} );
866              
867 1         3 my $sig = $attestation_statement->{sig};
868              
869             # 2. Check that x5c has exactly one element and let attCert be that
870             # element. Let certificate public key be the public key conveyed by
871             # attCert. If certificate public key is not an Elliptic Curve (EC) public
872             # key over the P-256 curve, terminate this algorithm and return an
873             # appropriate error.
874 1 50 33     9 unless ($attestation_statement->{x5c}
      33        
875             and ref( $attestation_statement->{x5c} ) eq "ARRAY"
876             and $attestation_statement->{x5c}->[0] )
877             {
878 0         0 croak "Missing certificate field in attestation statement";
879             }
880              
881 1         2 my $x5c_der = $attestation_statement->{x5c}->[0];
882 1         4 my $attestation_key = Crypt::PK::ECC->new();
883 1         42 eval {
884 1         65 my $x5c = Crypt::OpenSSL::X509->new_from_string( $x5c_der,
885             Crypt::OpenSSL::X509::FORMAT_ASN1 );
886 1         79 my $key_pem = $x5c->pubkey();
887 1         6 $attestation_key->import_key( \$key_pem );
888             };
889 1 50       2435 croak "Could not extract ECC key from attestation certificate: $@" if ($@);
890              
891 1 50       46 if ( $attestation_key->key2hash->{curve_name} ne "secp256r1" ) {
892             croak "Invalid attestation certificate curve name: "
893 0         0 . $attestation_key->key2hash->{curve_name};
894             }
895              
896             # 3. Extract the claimed rpIdHash from authenticatorData, and the claimed
897             # credentialId and credentialPublicKey from
898             # authenticatorData.attestedCredentialData.
899 1         5 my $rp_id_hash = $authenticator_data->{rpIdHash};
900             my $credential_id =
901 1         3 $authenticator_data->{attestedCredentialData}->{credentialId};
902             my $credential_public_key =
903 1         2 $authenticator_data->{attestedCredentialData}->{credentialPublicKey};
904              
905             # 4.Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of
906             # [RFC8152]) to Raw ANSI X9.62 public key format
907 1         2 my $public_u2f_key = eval { _getU2FKeyFromCose($credential_public_key) };
  1         3  
908 1 50       5 croak "Could not convert attested credential to U2F key: $@" if ($@);
909              
910             # 5.Let verificationData be the concatenation of (0x00 || rpIdHash ||
911             # clientDataHash || credentialId || publicKeyU2F)
912 1         4 my $verification_data = "\x00"
913             . $rp_id_hash
914             . $client_data_hash
915             . $credential_id
916             . $public_u2f_key;
917              
918 1 50       1465 if (
919             $attestation_key->verify_message( $sig, $verification_data, "SHA256" ) )
920             {
921             return {
922             success => 1,
923             type => "Unsure",
924             trust_path => $attestation_statement->{x5c},
925 1         13 };
926             }
927             else {
928 0           croak "Signature verification failed";
929             }
930             }
931              
932             1;
933              
934             __END__