1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace MadWizard\WebAuthn\Server\Authentication; |
4
|
|
|
|
5
|
|
|
use MadWizard\WebAuthn\Attestation\AuthenticatorData; |
6
|
|
|
use MadWizard\WebAuthn\Credential\CredentialId; |
7
|
|
|
use MadWizard\WebAuthn\Credential\CredentialStoreInterface; |
8
|
|
|
use MadWizard\WebAuthn\Credential\UserCredentialInterface; |
9
|
|
|
use MadWizard\WebAuthn\Crypto\CoseKeyInterface; |
10
|
|
|
use MadWizard\WebAuthn\Dom\AuthenticatorAssertionResponseInterface; |
11
|
|
|
use MadWizard\WebAuthn\Dom\CollectedClientData; |
12
|
|
|
use MadWizard\WebAuthn\Dom\PublicKeyCredentialInterface; |
13
|
|
|
use MadWizard\WebAuthn\Dom\TokenBindingStatus; |
14
|
|
|
use MadWizard\WebAuthn\Exception\UnsupportedException; |
15
|
|
|
use MadWizard\WebAuthn\Exception\VerificationException; |
16
|
|
|
use MadWizard\WebAuthn\Extension\ExtensionInterface; |
17
|
|
|
use MadWizard\WebAuthn\Extension\ExtensionRegistryInterface; |
18
|
|
|
use MadWizard\WebAuthn\Format\ByteBuffer; |
19
|
|
|
use MadWizard\WebAuthn\Server\AbstractVerifier; |
20
|
|
|
|
21
|
|
|
final class AuthenticationVerifier extends AbstractVerifier |
22
|
|
|
{ |
23
|
|
|
/** |
24
|
|
|
* @var CredentialStoreInterface |
25
|
|
|
*/ |
26
|
|
|
private $credentialCollection; |
27
|
|
|
|
28
|
11 |
|
public function __construct(CredentialStoreInterface $credentialCollection, ExtensionRegistryInterface $extensionRegistry) |
29
|
|
|
{ |
30
|
11 |
|
parent::__construct($extensionRegistry); |
31
|
11 |
|
$this->credentialCollection = $credentialCollection; |
32
|
11 |
|
} |
33
|
|
|
|
34
|
11 |
|
public function verifyAuthenticatonAssertion(PublicKeyCredentialInterface $credential, AuthenticationContext $context): AuthenticationResult |
35
|
|
|
{ |
36
|
|
|
// SPEC 7.2 Verifying an authentication assertion |
37
|
|
|
|
38
|
11 |
|
$response = $credential->getResponse()->asAssertionResponse(); |
39
|
11 |
|
$authData = new AuthenticatorData($response->getAuthenticatorData()); |
40
|
11 |
|
$extensionContext = $this->processExtensions($credential, $authData, $context, ExtensionInterface::OPERATION_AUTHENTICATION); |
41
|
|
|
|
42
|
|
|
// 1. If the allowCredentials option was given when this authentication ceremony was initiated, verify that |
43
|
|
|
// credential.id identifies one of the public key credentials that were listed in allowCredentials. |
44
|
11 |
|
if (!$this->checkAllowCredentials($credential, $context->getAllowCredentialIds())) { |
45
|
1 |
|
throw new VerificationException('Credential not in list of allowed credentials.'); |
46
|
|
|
} |
47
|
|
|
|
48
|
|
|
// Note: step 2 done after 3 because credential is available then. |
49
|
|
|
// 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is inappropriate for |
50
|
|
|
// your use case), look up the corresponding credential public key. |
51
|
10 |
|
$accountCredential = $this->credentialCollection->findCredential(CredentialId::fromBinary($credential->getRawId()->getBinaryString())); |
52
|
10 |
|
if ($accountCredential === null) { |
53
|
|
|
throw new VerificationException('Account was not found'); |
54
|
|
|
} |
55
|
|
|
|
56
|
|
|
// 2. If credential.response.userHandle is present, verify that the user identified by this value is the owner |
57
|
|
|
// of the public key credential identified by credential.id. |
58
|
10 |
|
if ($response->getUserHandle() !== null && !$response->getUserHandle()->equals($accountCredential->getUserHandle()->toBuffer())) { |
59
|
1 |
|
throw new VerificationException("Credential does not belong to the user identified by the client's userHandle."); |
60
|
|
|
} |
61
|
|
|
|
62
|
|
|
// 4. Let cData, aData and sig denote the value of credential’s response's clientDataJSON, authenticatorData, |
63
|
|
|
// and signature respectively. |
64
|
|
|
// 5. Let JSONtext be the result of running UTF-8 decode on the value of cData. |
65
|
|
|
// 6. Let C, the client data claimed as used for the signature, be the result of running an |
66
|
|
|
// implementation-specific JSON parser on JSONtext. |
67
|
|
|
// 7 - 10 |
68
|
9 |
|
$clientData = $response->getParsedClientData(); |
69
|
9 |
|
$this->checkClientData($clientData, $context); |
70
|
|
|
|
71
|
|
|
// 11. Verify that the rpIdHash in aData is the SHA-256 hash of the RP ID expected by the Relying Party. |
72
|
5 |
|
if (!$this->verifyRpIdHash($authData, $context, $extensionContext)) { |
73
|
1 |
|
throw new VerificationException('rpIdHash was not correct.'); |
74
|
|
|
} |
75
|
|
|
|
76
|
|
|
// 12 and 13 |
77
|
4 |
|
if (!$this->verifyUser($authData, $context)) { |
78
|
|
|
throw new VerificationException('User verification failed'); |
79
|
|
|
} |
80
|
|
|
|
81
|
|
|
// 14. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator |
82
|
|
|
// extension outputs in the extensions in authData are as expected, considering the client extension input |
83
|
|
|
// values that were given as the extensions option in the get() call. In particular, any extension |
84
|
|
|
// identifier values in the clientExtensionResults and the extensions in authData MUST be also be present |
85
|
|
|
// as extension identifier values in the extensions member of options, i.e., no extensions are present that |
86
|
|
|
// were not requested. In the general case, the meaning of "are as expected" is specific to the Relying |
87
|
|
|
// Party and which extensions are in use. |
88
|
|
|
// Note: Since all extensions are OPTIONAL for both the client and the authenticator, the Relying Party MUST |
89
|
|
|
// be prepared to handle cases where none or not all of the requested extensions were acted upon. |
90
|
|
|
|
91
|
|
|
// -> This is already checked in processExtensions above, the extensions need to be processed earlier because |
92
|
|
|
// extensions such as appid affect the effective rpId |
93
|
|
|
|
94
|
|
|
// 15 and 16 |
95
|
4 |
|
if (!$this->verifySignature($response, $accountCredential->getPublicKey())) { |
96
|
|
|
throw new VerificationException('Invalid signature'); |
97
|
|
|
} |
98
|
|
|
|
99
|
|
|
// 17 and 18 |
100
|
4 |
|
if (!$this->verifySignatureCounter($authData, $accountCredential)) { |
101
|
|
|
throw new VerificationException('Signature counter invalid'); |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
// If all the above steps are successful, continue with the authentication ceremony as appropriate. Otherwise, fail the authentication ceremony. |
105
|
|
|
|
106
|
|
|
// Additional custom checks: |
107
|
|
|
// Ensure credential belongs to user being authenticated |
108
|
4 |
|
$userHandle = $context->getUserHandle(); |
109
|
4 |
|
if ($userHandle !== null && !$userHandle->equals($accountCredential->getUserHandle())) { |
110
|
|
|
throw new VerificationException('Credential does not belong to the user currently being authenticated.'); |
111
|
|
|
} |
112
|
|
|
|
113
|
4 |
|
return new AuthenticationResult($accountCredential, $authData); |
114
|
|
|
} |
115
|
|
|
|
116
|
|
|
/** |
117
|
|
|
* @param CredentialId[]|null $allowCredentialIds |
118
|
|
|
*/ |
119
|
11 |
|
private function checkAllowCredentials(PublicKeyCredentialInterface $credential, ?array $allowCredentialIds): bool |
120
|
|
|
{ |
121
|
11 |
|
if ($allowCredentialIds === null || \count($allowCredentialIds) === 0) { |
122
|
|
|
return true; |
123
|
|
|
} |
124
|
|
|
|
125
|
11 |
|
$credentialId = CredentialId::fromBuffer($credential->getRawId()); |
126
|
11 |
|
foreach ($allowCredentialIds as $allowCredentialId) { |
127
|
11 |
|
if ($allowCredentialId->equals($credentialId)) { |
128
|
10 |
|
return true; |
129
|
|
|
} |
130
|
|
|
} |
131
|
1 |
|
return false; |
132
|
|
|
} |
133
|
|
|
|
134
|
4 |
|
private function verifySignature(AuthenticatorAssertionResponseInterface $response, CoseKeyInterface $publicKey): bool |
135
|
|
|
{ |
136
|
|
|
// 15. Let hash be the result of computing a hash over the cData using SHA-256. |
137
|
4 |
|
$clientData = $response->getClientDataJson(); |
138
|
4 |
|
$clientDataHash = hash('sha256', $clientData, true); |
139
|
|
|
|
140
|
|
|
// 16. Using the credential public key looked up in step 3, verify that sig is a valid signature over the binary concatenation of aData and hash. |
141
|
4 |
|
$aData = $response->getAuthenticatorData()->getBinaryString(); |
142
|
|
|
|
143
|
4 |
|
$signData = $aData . $clientDataHash; |
144
|
|
|
|
145
|
4 |
|
return $publicKey->verifySignature(new ByteBuffer($signData), $response->getSignature()); |
146
|
|
|
} |
147
|
|
|
|
148
|
4 |
|
private function verifySignatureCounter(AuthenticatorData $authData, UserCredentialInterface $accountCredential): bool |
149
|
|
|
{ |
150
|
|
|
// 17. If the signature counter value adata.signCount is nonzero or the value stored in conjunction with credential’s id attribute is nonzero, then run the following sub-step: |
151
|
4 |
|
$counter = $authData->getSignCount(); |
152
|
4 |
|
if ($counter === 0) { |
153
|
|
|
return true; |
154
|
|
|
} |
155
|
|
|
|
156
|
4 |
|
$lastCounter = $this->credentialCollection->getSignatureCounter($accountCredential->getCredentialId()); |
157
|
|
|
|
158
|
4 |
|
if ($lastCounter === null) { |
159
|
|
|
// counter not known |
160
|
|
|
$this->credentialCollection->updateSignatureCounter($accountCredential->getCredentialId(), $counter); |
161
|
|
|
|
162
|
|
|
// TODO policy |
163
|
|
|
return true; |
164
|
|
|
} |
165
|
|
|
|
166
|
4 |
|
if ($lastCounter === 0) { |
167
|
|
|
return true; |
168
|
|
|
} |
169
|
4 |
|
if ($counter > $lastCounter) { |
170
|
|
|
// 18. If the signature counter value adata.signCount is |
171
|
|
|
// -> greater than the signature counter value stored in conjunction with credential’s id attribute. |
172
|
|
|
// Update the stored signature counter value, associated with credential’s id attribute, to be the value of adata.signCount. |
173
|
4 |
|
$this->credentialCollection->updateSignatureCounter($accountCredential->getCredentialId(), $counter); |
174
|
4 |
|
return true; |
175
|
|
|
} else { |
176
|
|
|
// -> less than or equal to the signature counter value stored in conjunction with credential’s id attribute. |
177
|
|
|
// This is a signal that the authenticator may be cloned, i.e. at least two copies of the credential private key may exist and are being used in |
178
|
|
|
// parallel. Relying Parties should incorporate this information into their risk scoring. Whether the Relying Party updates the stored signature counter value in this case, or not, or fails the authentication ceremony or not, is Relying Party-specific. |
179
|
|
|
|
180
|
|
|
// TODO add policy |
181
|
|
|
return false; |
182
|
|
|
} |
183
|
|
|
} |
184
|
|
|
|
185
|
9 |
|
private function checkClientData(CollectedClientData $clientData, AuthenticationContext $context): void |
186
|
|
|
{ |
187
|
|
|
// 7. Verify that the value of C.type is the string webauthn.get. |
188
|
9 |
|
if ($clientData->getType() !== 'webauthn.get') { |
189
|
1 |
|
throw new VerificationException('Expecting type in clientDataJSON to be webauthn.get.'); |
190
|
|
|
} |
191
|
|
|
|
192
|
|
|
// 8. Verify that the value of C.challenge matches the challenge that was sent to the authenticator in the |
193
|
|
|
// PublicKeyCredentialRequestOptions passed to the get() call. |
194
|
8 |
|
if (!\hash_equals($context->getChallenge()->getBase64Url(), $clientData->getChallenge())) { |
195
|
1 |
|
throw new VerificationException('Challenge in clientDataJSON does not match the challenge in the request.'); |
196
|
|
|
} |
197
|
|
|
|
198
|
|
|
// 9. Verify that the value of C.origin matches the Relying Party's origin. |
199
|
7 |
|
if (!$this->verifyOrigin($clientData->getOrigin(), $context->getOrigin())) { |
200
|
1 |
|
throw new VerificationException(sprintf("Origin '%s' does not match relying party origin.", $clientData->getOrigin())); |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
// 10. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS connection |
204
|
|
|
// over which the attestation was obtained. If Token Binding was used on that TLS connection, also verify |
205
|
|
|
// that C.tokenBinding.id matches the base64url encoding of the Token Binding ID for the connection. |
206
|
6 |
|
$tokenBinding = $clientData->getTokenBinding(); |
207
|
6 |
|
if ($tokenBinding !== null && $tokenBinding->getStatus() === TokenBindingStatus::PRESENT) { |
208
|
1 |
|
throw new UnsupportedException('Token binding is not yet supported by this library.'); |
209
|
|
|
} |
210
|
5 |
|
} |
211
|
|
|
} |
212
|
|
|
|