File Coverage

blib/lib/Authen/WebAuthn.pm
Criterion Covered Total %
statement 333 394 84.5
branch 106 176 60.2
condition 24 32 75.0
subroutine 41 44 93.1
pod 3 28 10.7
total 507 674 75.2


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