|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
declare(strict_types=1); |
|
4
|
|
|
|
|
5
|
|
|
namespace SimpleSAML\Module\webauthn\WebAuthn; |
|
6
|
|
|
|
|
7
|
|
|
use Cose\Key\Ec2Key; |
|
8
|
|
|
use Exception; |
|
9
|
|
|
use SimpleSAML\Error\Error; |
|
10
|
|
|
use SimpleSAML\Error\InvalidCredential; |
|
11
|
|
|
use SimpleSAML\Module\webauthn\WebAuthn\AAGUID; |
|
12
|
|
|
use SimpleSAML\Utils; |
|
13
|
|
|
use SpomkyLabs\Pki\ASN1\Type\UnspecifiedType; |
|
14
|
|
|
|
|
15
|
|
|
use function print_r; |
|
16
|
|
|
use function sprintf; |
|
17
|
|
|
|
|
18
|
|
|
/** |
|
19
|
|
|
* FIDO2/WebAuthn Authentication Processing filter |
|
20
|
|
|
* |
|
21
|
|
|
* Filter for registering or authenticating with a FIDO2/WebAuthn token after |
|
22
|
|
|
* having authenticated with the primary authsource. |
|
23
|
|
|
* |
|
24
|
|
|
* @author Stefan Winter <[email protected]> |
|
25
|
|
|
* @package SimpleSAMLphp |
|
26
|
|
|
*/ |
|
27
|
|
|
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent |
|
28
|
|
|
{ |
|
29
|
|
|
/** |
|
30
|
|
|
* Public key algorithm supported. This is -7 - ECDSA with curve P-256, or -275 (RS256) |
|
31
|
|
|
*/ |
|
32
|
|
|
public const PK_ALGORITHM_ECDSA = "-7"; |
|
33
|
|
|
|
|
34
|
|
|
public const PK_ALGORITHM_RSA = "-257"; |
|
35
|
|
|
|
|
36
|
|
|
public const PK_ALGORITHM = [self::PK_ALGORITHM_ECDSA, self::PK_ALGORITHM_RSA]; |
|
37
|
|
|
|
|
38
|
|
|
public const AAGUID_ASSURANCE_LEVEL_NONE = 'None'; |
|
39
|
|
|
|
|
40
|
|
|
public const AAGUID_ASSURANCE_LEVEL_SELF = 'Self'; |
|
41
|
|
|
|
|
42
|
|
|
public const AAGUID_ASSURANCE_LEVEL_BASIC = 'Basic'; |
|
43
|
|
|
|
|
44
|
|
|
public const AAGUID_ASSURANCE_LEVEL_ATTCA = 'AttCA'; |
|
45
|
|
|
|
|
46
|
|
|
// nomenclature from the MDS3 spec |
|
47
|
|
|
public const FIDO_REVOKED = "REVOKED"; |
|
48
|
|
|
|
|
49
|
|
|
public const CERTIFICATION_NOT_REQUIRED = "CERTIFICATION_NOT_REQUIRED"; |
|
50
|
|
|
|
|
51
|
|
|
public const FIDO_CERTIFIED_L1 = "FIDO_CERTIFIED_L1"; |
|
52
|
|
|
|
|
53
|
|
|
public const FIDO_CERTIFIED_L1PLUS = "FIDO_CERTIFIED_L1plus"; |
|
54
|
|
|
|
|
55
|
|
|
public const FIDO_CERTIFIED_L2 = "FIDO_CERTIFIED_L2"; |
|
56
|
|
|
|
|
57
|
|
|
public const FIDO_CERTIFIED_L3 = "FIDO_CERTIFIED_L3"; |
|
58
|
|
|
|
|
59
|
|
|
public const FIDO_CERTIFIED_L3PLUS = "FIDO_CERTIFIED_L3plus"; |
|
60
|
|
|
|
|
61
|
|
|
// Keymaster 3 - KeyMint ??? |
|
62
|
|
|
private const ORIGINS_3 = [ // https://source.android.com/docs/security/features/keystore/tags#origin |
|
63
|
|
|
0 => "GENERATED", |
|
64
|
|
|
1 => "DERIVED", |
|
65
|
|
|
2 => "IMPORTED", |
|
66
|
|
|
3 => "UNKNOWN", |
|
67
|
|
|
]; |
|
68
|
|
|
|
|
69
|
|
|
private const PURPOSE_3 = [ |
|
70
|
|
|
0 => "ENCRYPT", |
|
71
|
|
|
1 => "DECRYPT", |
|
72
|
|
|
2 => "SIGN", |
|
73
|
|
|
3 => "VERIFY", |
|
74
|
|
|
4 => "DERIVE_KEY", |
|
75
|
|
|
5 => "WRAP_KEY", |
|
76
|
|
|
]; |
|
77
|
|
|
|
|
78
|
|
|
private const MIN_SUPPORTED_KEYMASTER_VERSION = 3; |
|
79
|
|
|
|
|
80
|
|
|
|
|
81
|
|
|
/** |
|
82
|
|
|
* the AAGUID of the newly registered authenticator |
|
83
|
|
|
* @var string |
|
84
|
|
|
*/ |
|
85
|
|
|
protected string $AAGUID; |
|
86
|
|
|
|
|
87
|
|
|
/** |
|
88
|
|
|
* how sure are we about the AAGUID? |
|
89
|
|
|
* @var string |
|
90
|
|
|
*/ |
|
91
|
|
|
protected string $AAGUIDAssurance; |
|
92
|
|
|
|
|
93
|
|
|
/** |
|
94
|
|
|
* An array of known hardware tokens |
|
95
|
|
|
* |
|
96
|
|
|
* @var \SimpleSAML\Module\webauthn\WebAuthn\AAGUID |
|
97
|
|
|
*/ |
|
98
|
|
|
protected AAGUID $AAGUIDDictionary; |
|
99
|
|
|
|
|
100
|
|
|
protected string $AttFmt; |
|
101
|
|
|
|
|
102
|
|
|
|
|
103
|
|
|
/** |
|
104
|
|
|
* Initialize the event object. |
|
105
|
|
|
* |
|
106
|
|
|
* Validates and parses the configuration. |
|
107
|
|
|
* |
|
108
|
|
|
* @param string $pubkeyCredType PublicKeyCredential.type |
|
109
|
|
|
* @param string $scope the scope of the event |
|
110
|
|
|
* @param string $challenge the challenge which was used to trigger this event |
|
111
|
|
|
* @param string $attestationData the attestation data CBOR blob |
|
112
|
|
|
* @param string $responseId the response ID |
|
113
|
|
|
* @param string $clientDataJSON the client data JSON string which is present in all types of events |
|
114
|
|
|
* @param bool $debugMode print debugging statements? |
|
115
|
|
|
*/ |
|
116
|
|
|
public function __construct( |
|
117
|
|
|
string $pubkeyCredType, |
|
118
|
|
|
string $scope, |
|
119
|
|
|
string $challenge, |
|
120
|
|
|
string $attestationData, |
|
121
|
|
|
string $responseId, |
|
122
|
|
|
string $clientDataJSON, |
|
123
|
|
|
array $acceptabilityPolicy, |
|
124
|
|
|
bool $debugMode = false, |
|
125
|
|
|
) { |
|
126
|
|
|
$this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>"; |
|
127
|
|
|
/** |
|
128
|
|
|
* §7.1 STEP 9 : CBOR decode attestationData. |
|
129
|
|
|
*/ |
|
130
|
|
|
$attestationArray = $this->cborDecode($attestationData); |
|
131
|
|
|
$authData = $attestationArray['authData']; |
|
132
|
|
|
$this->eventType = "REG"; |
|
133
|
|
|
parent::__construct($pubkeyCredType, $scope, $challenge, $authData, $clientDataJSON, $debugMode); |
|
134
|
|
|
|
|
135
|
|
|
$this->AAGUIDDictionary = AAGUID::getInstance(); |
|
136
|
|
|
|
|
137
|
|
|
// this function extracts the public key |
|
138
|
|
|
$this->validateAttestedCredentialData(substr($authData, 37), $responseId); |
|
139
|
|
|
// this function may need the public key to have been previously extracted |
|
140
|
|
|
$this->validateAttestationData($attestationData); |
|
141
|
|
|
// the following function sets the credential properties |
|
142
|
|
|
$this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>"; |
|
143
|
|
|
// now check if the authenticator is acceptable as per policy |
|
144
|
|
|
$this->verifyAcceptability($acceptabilityPolicy); |
|
145
|
|
|
} |
|
146
|
|
|
|
|
147
|
|
|
|
|
148
|
|
|
private function verifyAcceptability($acceptabilityPolicy) |
|
149
|
|
|
{ |
|
150
|
|
|
if ($acceptabilityPolicy['minCertLevel'] == self::CERTIFICATION_NOT_REQUIRED) { // all is accepted |
|
151
|
|
|
return; |
|
152
|
|
|
} |
|
153
|
|
|
|
|
154
|
|
|
// if we care about the content of the attestation at all, make sure we |
|
155
|
|
|
// have a confidence level beyond "None". |
|
156
|
|
|
if ($this->AAGUIDAssurance == self::AAGUID_ASSURANCE_LEVEL_NONE) { |
|
157
|
|
|
throw new Exception("Authenticator did not provide a useful attestation level."); |
|
158
|
|
|
} |
|
159
|
|
|
if (in_array($this->AAGUID, $acceptabilityPolicy['aaguidWhitelist'])) { |
|
160
|
|
|
return; |
|
161
|
|
|
} |
|
162
|
|
|
if (in_array($this->AttFmt, $acceptabilityPolicy['attFmtWhitelist'])) { |
|
163
|
|
|
return; |
|
164
|
|
|
} |
|
165
|
|
|
|
|
166
|
|
|
$aaguidDb = AAGUID::getInstance(); |
|
167
|
|
|
if (!$aaguidDb->hasToken($this->AAGUID)) { |
|
168
|
|
|
throw new Exception("Authenticator with AAGUID " . |
|
169
|
|
|
$this->AAGUID . |
|
170
|
|
|
" is not known to the FIDO MDS3 database."); |
|
171
|
|
|
} |
|
172
|
|
|
$authenticatorData = $aaguidDb->get($this->AAGUID); |
|
173
|
|
|
$certification = $authenticatorData['statusReports'][0]['status']; |
|
174
|
|
|
|
|
175
|
|
|
if ($certification == self::FIDO_REVOKED) { |
|
176
|
|
|
// phpcs:ignore Generic.Files.LineLength.TooLong |
|
177
|
|
|
throw new InvalidCredential("FIDO Alliance has REVOKED certification of this device. It cannot be registered."); |
|
178
|
|
|
} |
|
179
|
|
|
|
|
180
|
|
|
switch ($acceptabilityPolicy['minCertLevel']) { |
|
181
|
|
|
case self::FIDO_CERTIFIED_L1: |
|
182
|
|
|
// note: always full string match - there is also a level NOT_FIDO_CERTIFIED ! |
|
183
|
|
|
if ($certification == "FIDO_CERTIFIED" || $certification == self::FIDO_CERTIFIED_L1) { |
|
184
|
|
|
return; |
|
185
|
|
|
} |
|
186
|
|
|
// intentional fall-through, higher levels are also okay |
|
187
|
|
|
case self::FIDO_CERTIFIED_L1PLUS: |
|
188
|
|
|
if ($certification == self::FIDO_CERTIFIED_L1PLUS) { |
|
189
|
|
|
return; |
|
190
|
|
|
} |
|
191
|
|
|
// intentional fall-through, higher levels are also okay |
|
192
|
|
|
case self::FIDO_CERTIFIED_L2: |
|
193
|
|
|
if ($certification == self::FIDO_CERTIFIED_L2) { |
|
194
|
|
|
return; |
|
195
|
|
|
} |
|
196
|
|
|
// intentional fall-through, higher levels are also okay |
|
197
|
|
|
case self::FIDO_CERTIFIED_L3: |
|
198
|
|
|
if ($certification == self::FIDO_CERTIFIED_L3) { |
|
199
|
|
|
return; |
|
200
|
|
|
} |
|
201
|
|
|
// intentional fall-through, higher levels are also okay |
|
202
|
|
|
case self::FIDO_CERTIFIED_L3PLUS: |
|
203
|
|
|
if ($certification == self::FIDO_CERTIFIED_L3PLUS) { |
|
204
|
|
|
return; |
|
205
|
|
|
} |
|
206
|
|
|
throw new Error("FIDO_CERTIFICATION_TOO_LOW"); |
|
207
|
|
|
default: |
|
208
|
|
|
throw new Exception("Configuration error: unknown minimum certification level " . |
|
209
|
|
|
$acceptabilityPolicy['minCertLevel']); |
|
210
|
|
|
} |
|
211
|
|
|
} |
|
212
|
|
|
|
|
213
|
|
|
|
|
214
|
|
|
/** |
|
215
|
|
|
* Validate the incoming attestation data CBOR blob and return the embedded authData |
|
216
|
|
|
* @param string $attestationData |
|
217
|
|
|
* @return void |
|
218
|
|
|
*/ |
|
219
|
|
|
private function validateAttestationData(string $attestationData): void |
|
220
|
|
|
{ |
|
221
|
|
|
/** |
|
222
|
|
|
* STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject |
|
223
|
|
|
*/ |
|
224
|
|
|
$attestationArray = $this->cborDecode($attestationData); |
|
225
|
|
|
$this->debugBuffer .= "<pre>"; |
|
226
|
|
|
$this->debugBuffer .= print_r($attestationArray, true); |
|
227
|
|
|
$this->debugBuffer .= "</pre>"; |
|
228
|
|
|
|
|
229
|
|
|
/** |
|
230
|
|
|
* STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values |
|
231
|
|
|
*/ |
|
232
|
|
|
$this->AttFmt = $attestationArray['fmt']; |
|
233
|
|
|
switch ($attestationArray['fmt']) { |
|
234
|
|
|
case "none": |
|
235
|
|
|
$this->validateAttestationFormatNone($attestationArray); |
|
236
|
|
|
break; |
|
237
|
|
|
case "packed": |
|
238
|
|
|
$this->validateAttestationFormatPacked($attestationArray); |
|
239
|
|
|
break; |
|
240
|
|
|
case "fido-u2f": |
|
241
|
|
|
$this->validateAttestationFormatFidoU2F($attestationArray); |
|
242
|
|
|
break; |
|
243
|
|
|
case "android-safetynet": |
|
244
|
|
|
$this->validateAttestationFormatAndroidSafetyNet($attestationArray); |
|
245
|
|
|
break; |
|
246
|
|
|
case "apple": |
|
247
|
|
|
$this->validateAttestationFormatApple($attestationArray); |
|
248
|
|
|
break; |
|
249
|
|
|
case "tpm": |
|
250
|
|
|
$this->fail("TPM attestation format not supported right now."); |
|
251
|
|
|
break; |
|
252
|
|
|
case "android-key": |
|
253
|
|
|
$this->validateAttestationFormatAndroidKey($attestationArray); |
|
254
|
|
|
break; |
|
255
|
|
|
default: |
|
256
|
|
|
$this->fail("Unknown attestation format."); |
|
257
|
|
|
break; |
|
258
|
|
|
} |
|
259
|
|
|
$this->AttFmt = $attestationArray['fmt']; |
|
260
|
|
|
} |
|
261
|
|
|
|
|
262
|
|
|
|
|
263
|
|
|
/** |
|
264
|
|
|
* @param array $attestationArray |
|
265
|
|
|
* @return void |
|
266
|
|
|
*/ |
|
267
|
|
|
private function validateAttestationFormatNone(array $attestationArray): void |
|
268
|
|
|
{ |
|
269
|
|
|
// § 8.7 of the spec |
|
270
|
|
|
/** |
|
271
|
|
|
* § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array |
|
272
|
|
|
* § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy) |
|
273
|
|
|
*/ |
|
274
|
|
|
if (count($attestationArray['attStmt']) === 0) { |
|
275
|
|
|
$this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve."); |
|
276
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE; |
|
277
|
|
|
return; |
|
278
|
|
|
} else { |
|
279
|
|
|
$this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'."); |
|
280
|
|
|
} |
|
281
|
|
|
} |
|
282
|
|
|
|
|
283
|
|
|
|
|
284
|
|
|
/** |
|
285
|
|
|
* @param array $attestationArray |
|
286
|
|
|
*/ |
|
287
|
|
|
private function validateAttestationFormatApple(array $attestationArray): void |
|
288
|
|
|
{ |
|
289
|
|
|
// found at: https://www.apple.com/certificateauthority/private/ |
|
290
|
|
|
|
|
291
|
|
|
$APPLE_WEBAUTHN_ROOT_CA = "-----BEGIN CERTIFICATE----- |
|
292
|
|
|
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w |
|
293
|
|
|
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ |
|
294
|
|
|
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx |
|
295
|
|
|
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG |
|
296
|
|
|
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49 |
|
297
|
|
|
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k |
|
298
|
|
|
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/ |
|
299
|
|
|
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk |
|
300
|
|
|
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA |
|
301
|
|
|
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3 |
|
302
|
|
|
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B |
|
303
|
|
|
1bWeT0vT |
|
304
|
|
|
-----END CERTIFICATE-----"; |
|
305
|
|
|
// § 8.8 Bullet 1 of the draft spec at |
|
306
|
|
|
// https://pr-preview.s3.amazonaws.com/alanwaketan/webauthn/pull/1491.html#sctn-apple-anonymous-attestation |
|
307
|
|
|
// draft implemented in state of 11 Feb 2021 |
|
308
|
|
|
// I can't help but notice that the verification procedure does NOTHING with CA certs from the chain, |
|
309
|
|
|
// nor is there a root to validate to! |
|
310
|
|
|
// Found the root CA with Google, see above, and will perform chain validation even if the spec doesn't say so. |
|
311
|
|
|
// first, clear the openssl error backlog. We might need error data in case things go sideways. |
|
312
|
|
|
while (openssl_error_string() !== false); |
|
313
|
|
|
|
|
314
|
|
|
$stmtDecoded = $attestationArray['attStmt']; |
|
315
|
|
|
if (!isset($stmtDecoded['x5c'])) { |
|
316
|
|
|
$this->fail("Apple attestation statement does not contain an x5c attestation statement!"); |
|
317
|
|
|
} |
|
318
|
|
|
// § 8.8 Bullet 2 |
|
319
|
|
|
$nonceToHash = $attestationArray['authData'] . $this->clientDataHash; |
|
320
|
|
|
// § 8.8 Bullet 3 |
|
321
|
|
|
$cryptoUtils = new Utils\Crypto(); |
|
322
|
|
|
$nonce = hash("sha256", $nonceToHash, true); // does raw_output have to be FALSE or TRUE? |
|
323
|
|
|
$certProps = openssl_x509_parse($cryptoUtils->der2pem($stmtDecoded['x5c'][0])); |
|
324
|
|
|
// § 8.8 Bullet 4 |
|
325
|
|
|
if ( |
|
326
|
|
|
!isset($certProps['extensions']['1.2.840.113635.100.8.2']) || |
|
327
|
|
|
empty($certProps['extensions']['1.2.840.113635.100.8.2']) |
|
328
|
|
|
) { |
|
329
|
|
|
$this->fail("The required nonce value is not present in the OID."); |
|
330
|
|
|
} |
|
331
|
|
|
$toCompare = substr($certProps['extensions']['1.2.840.113635.100.8.2'], 6); |
|
332
|
|
|
if ($nonce != $toCompare) { |
|
333
|
|
|
$this->fail("There is a mismatch between the nonce and the OID (XXX $nonce XXX , XXX $toCompare XXX )."); |
|
334
|
|
|
} |
|
335
|
|
|
|
|
336
|
|
|
// chain validation first |
|
337
|
|
|
foreach ($stmtDecoded['x5c'] as $runIndex => $runCert) { |
|
338
|
|
|
if (isset($stmtDecoded['x5c'][$runIndex + 1])) { // there is a next cert, so follow the chain |
|
339
|
|
|
$certResource = openssl_x509_read($cryptoUtils->der2pem($runCert)); |
|
340
|
|
|
$signerPubKey = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1])); |
|
341
|
|
|
if (openssl_x509_verify($certResource, $signerPubKey) != 1) { |
|
342
|
|
|
// phpcs:ignore Generic.Files.LineLength.TooLong |
|
343
|
|
|
$this->fail("Error during chain validation of the attestation certificate (while validating cert #$runIndex, which is " |
|
344
|
|
|
. $cryptoUtils->der2pem($runCert) |
|
345
|
|
|
. "; next cert was " |
|
346
|
|
|
. $cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1])); |
|
347
|
|
|
} |
|
348
|
|
|
} else { // last cert, compare to the root |
|
349
|
|
|
$certResource = openssl_x509_read($cryptoUtils->der2pem($runCert)); |
|
350
|
|
|
$signerPubKey = openssl_pkey_get_public($APPLE_WEBAUTHN_ROOT_CA); |
|
351
|
|
|
if (openssl_x509_verify($certResource, $signerPubKey) != 1) { |
|
352
|
|
|
$this->fail(sprintf( |
|
353
|
|
|
"Error during root CA validation of the attestation chain certificate, which is %s", |
|
354
|
|
|
$cryptoUtils->der2pem($runCert), |
|
355
|
|
|
)); |
|
356
|
|
|
} |
|
357
|
|
|
} |
|
358
|
|
|
} |
|
359
|
|
|
|
|
360
|
|
|
$keyResource = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][0])); |
|
361
|
|
|
if ($keyResource === false) { |
|
362
|
|
|
$this->fail( |
|
363
|
|
|
// phpcs:ignore Generic.Files.LineLength.TooLong |
|
364
|
|
|
"Did not get a parseable X.509 structure out of the Apple attestation statement - x5c nr. 0 statement was: XXX " |
|
365
|
|
|
. $stmtDecoded['x5c'][0] |
|
366
|
|
|
. " XXX; PEM equivalent is " |
|
367
|
|
|
. $cryptoUtils->der2pem($stmtDecoded['x5c'][0]) |
|
368
|
|
|
. ". OpenSSL error: " |
|
369
|
|
|
. openssl_error_string(), |
|
370
|
|
|
); |
|
371
|
|
|
} |
|
372
|
|
|
|
|
373
|
|
|
// $this->credential is a public key in CBOR, not "PEM". We need to convert it first. |
|
374
|
|
|
$keyArray = $this->cborDecode(hex2bin($this->credential)); |
|
375
|
|
|
$keyObject = new Ec2Key($keyArray); |
|
376
|
|
|
$credentialResource = openssl_pkey_get_public($keyObject->asPEM()); |
|
377
|
|
|
|
|
378
|
|
|
if ($credentialResource === false) { |
|
379
|
|
|
$this->fail( |
|
380
|
|
|
"Could not create a public key from CBOR credential. XXX " |
|
381
|
|
|
. $this->credential |
|
382
|
|
|
. " XXX; PEM equivalent is " |
|
383
|
|
|
. $keyObject->asPEM() |
|
384
|
|
|
. ". OpenSSL error: " |
|
385
|
|
|
. openssl_error_string(), |
|
386
|
|
|
); |
|
387
|
|
|
} |
|
388
|
|
|
|
|
389
|
|
|
// § 8.8 Bullet 5 |
|
390
|
|
|
$credentialDetails = openssl_pkey_get_details($credentialResource); |
|
391
|
|
|
$keyDetails = openssl_pkey_get_details($keyResource); |
|
392
|
|
|
if ( |
|
393
|
|
|
$credentialDetails['bits'] != $keyDetails['bits'] || |
|
394
|
|
|
$credentialDetails['key'] != $keyDetails['key'] || |
|
395
|
|
|
$credentialDetails['type'] != $keyDetails['type'] |
|
396
|
|
|
) { |
|
397
|
|
|
$this->fail( |
|
398
|
|
|
"The credential public key does not match the certificate public key in attestationData. (" |
|
399
|
|
|
. $credentialDetails['key'] |
|
400
|
|
|
. " - " |
|
401
|
|
|
. $keyDetails['key'] |
|
402
|
|
|
. ")", |
|
403
|
|
|
); |
|
404
|
|
|
} |
|
405
|
|
|
$this->pass("Apple attestation format verification passed."); |
|
406
|
|
|
return; |
|
407
|
|
|
} |
|
408
|
|
|
|
|
409
|
|
|
|
|
410
|
|
|
private function commonX5cSignatureChecks(array $attestationArray): void |
|
411
|
|
|
{ |
|
412
|
|
|
$stmtDecoded = $attestationArray['attStmt']; |
|
413
|
|
|
/** |
|
414
|
|
|
* §8.2 Step 4 Bullet 1: check algorithm |
|
415
|
|
|
*/ |
|
416
|
|
|
if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) { |
|
417
|
|
|
$this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . "."); |
|
418
|
|
|
} |
|
419
|
|
|
$keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0])); |
|
420
|
|
|
if ($keyResource === false) { |
|
421
|
|
|
$this->fail("Unable to construct public key resource from PEM."); |
|
422
|
|
|
} |
|
423
|
|
|
/** |
|
424
|
|
|
* §8.2 Step 2: check x5c attestation |
|
425
|
|
|
*/ |
|
426
|
|
|
$sigdata = $attestationArray['authData'] . $this->clientDataHash; |
|
427
|
|
|
/** |
|
428
|
|
|
* §8.2 Step 2 Bullet 1: check signature |
|
429
|
|
|
*/ |
|
430
|
|
|
$retCode = openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, "sha256"); |
|
431
|
|
|
if ($retCode !== 1) { |
|
432
|
|
|
$this->fail(sprintf( |
|
433
|
|
|
// phpcs:ignore Generic.Files.LineLength.TooLong |
|
434
|
|
|
"Packed signature mismatch (return code $retCode, for :authdata:%s - :clientDataHash:%s - :signature:%s), attestation failed.", |
|
435
|
|
|
$attestationArray['authData'], |
|
436
|
|
|
$this->clientDataHash, |
|
437
|
|
|
$stmtDecoded['sig'], |
|
438
|
|
|
)); |
|
439
|
|
|
} |
|
440
|
|
|
$this->pass("x5c sig check passed."); |
|
441
|
|
|
} |
|
442
|
|
|
|
|
443
|
|
|
|
|
444
|
|
|
/** |
|
445
|
|
|
* @param array $attestationArray |
|
446
|
|
|
*/ |
|
447
|
|
|
private function validateAttestationFormatPacked(array $attestationArray): void |
|
448
|
|
|
{ |
|
449
|
|
|
$stmtDecoded = $attestationArray['attStmt']; |
|
450
|
|
|
$this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>"; |
|
451
|
|
|
/** |
|
452
|
|
|
* §7.1 Step 16: attestation is either done with x5c or ecdaa. |
|
453
|
|
|
*/ |
|
454
|
|
|
if (isset($stmtDecoded['x5c'])) { |
|
455
|
|
|
$this->commonX5cSignatureChecks($attestationArray); |
|
456
|
|
|
$this->validateAttestationFormatPackedX5C($attestationArray); |
|
457
|
|
|
} elseif (isset($stmtDecoded['ecdaa'])) { |
|
458
|
|
|
$this->fail("ecdaa attestation not supported right now."); |
|
459
|
|
|
} else { |
|
460
|
|
|
// if we are still here, we are in the "self" type. |
|
461
|
|
|
// signature checks already done, nothing more to do |
|
462
|
|
|
$this->pass("Self-Attestation verified."); |
|
463
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF; |
|
464
|
|
|
} |
|
465
|
|
|
} |
|
466
|
|
|
|
|
467
|
|
|
|
|
468
|
|
|
/** |
|
469
|
|
|
* @param array $attestationArray |
|
470
|
|
|
* @return void |
|
471
|
|
|
*/ |
|
472
|
|
|
private function validateAttestationFormatPackedX5C(array $attestationArray): void |
|
473
|
|
|
{ |
|
474
|
|
|
$stmtDecoded = $attestationArray['attStmt']; |
|
475
|
|
|
// still need to perform sanity checks on the attestation certificate |
|
476
|
|
|
/** |
|
477
|
|
|
* §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1 |
|
478
|
|
|
*/ |
|
479
|
|
|
$certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0])); |
|
480
|
|
|
$this->debugBuffer .= "Attestation Certificate:" . |
|
481
|
|
|
/** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>"; |
|
482
|
|
|
if ( |
|
483
|
|
|
$certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */ |
|
484
|
|
|
$certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */ |
|
485
|
|
|
!isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */ |
|
486
|
|
|
!isset($certProps['extensions']['basicConstraints']) || |
|
487
|
|
|
strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */ |
|
488
|
|
|
) { |
|
489
|
|
|
$this->fail("Attestation certificate properties are no good."); |
|
490
|
|
|
} |
|
491
|
|
|
|
|
492
|
|
|
if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) { |
|
493
|
|
|
$token = $this->AAGUIDDictionary->get($this->AAGUID); |
|
494
|
|
|
/** |
|
495
|
|
|
* Checking the OID is not programmatically possible. Text per spec: |
|
496
|
|
|
* "If the related attetation root certificate is used for multiple |
|
497
|
|
|
* authenticator models, the Extension OID ... MUST be present." |
|
498
|
|
|
* |
|
499
|
|
|
* FIDO MDS3 metadata does not disclose whether the root CAs are |
|
500
|
|
|
* used for multiple models. |
|
501
|
|
|
*/ |
|
502
|
|
|
/* if ($token['multi'] === true) { // need to check the OID |
|
503
|
|
|
if ( |
|
504
|
|
|
!isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']) || |
|
505
|
|
|
empty($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']) |
|
506
|
|
|
) { // §8.2.1 Bullet 3 |
|
507
|
|
|
$this->fail( |
|
508
|
|
|
// phpcs:ignore Generic.Files.LineLength.TooLong |
|
509
|
|
|
"This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.", |
|
510
|
|
|
); |
|
511
|
|
|
} |
|
512
|
|
|
/** |
|
513
|
|
|
* §8.2 Step 2 Bullet 3: compare AAGUID values |
|
514
|
|
|
*/ |
|
515
|
|
|
/* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4); |
|
516
|
|
|
$this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>"; |
|
517
|
|
|
if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) { |
|
518
|
|
|
$this->fail("AAGUID mismatch between attestation certificate and attestation statement."); |
|
519
|
|
|
} |
|
520
|
|
|
}*/ |
|
521
|
|
|
// we would need to verify the attestation certificate against a known-good |
|
522
|
|
|
// root CA certificate to get more than basic |
|
523
|
|
|
/* |
|
524
|
|
|
* §7.1 Step 17 is to look at $token['RootPEMs'] |
|
525
|
|
|
*/ |
|
526
|
|
|
foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) { |
|
527
|
|
|
openssl_x509_parse("-----BEGIN CERTIFICATE-----\n$oneRoot\n-----END CERTIFICATE-----", true); |
|
528
|
|
|
} |
|
529
|
|
|
/* |
|
530
|
|
|
* §7.1 Step 18 is skipped, and we unconditionally return "only" Basic. |
|
531
|
|
|
*/ |
|
532
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC; |
|
533
|
|
|
} else { |
|
534
|
|
|
$this->warn("Unknown authenticator model found: " . $this->AAGUID . "."); |
|
535
|
|
|
// unable to verify all cert properties, so this is not enough for BASIC. |
|
536
|
|
|
// but it's our own fault, we should add the device to our DB. |
|
537
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF; |
|
538
|
|
|
} |
|
539
|
|
|
$this->pass("x5c attestation passed."); |
|
540
|
|
|
return; |
|
541
|
|
|
} |
|
542
|
|
|
|
|
543
|
|
|
|
|
544
|
|
|
private function validateAttestationFormatAndroidKey(array $attestationArray): void |
|
545
|
|
|
{ |
|
546
|
|
|
$stmtDecoded = $attestationArray['attStmt']; |
|
547
|
|
|
$this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>"; |
|
548
|
|
|
$this->commonX5cSignatureChecks($attestationArray); |
|
549
|
|
|
// first certificate's properties |
|
550
|
|
|
$certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0])); |
|
551
|
|
|
$keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0])); |
|
552
|
|
|
$keyDetails = openssl_pkey_get_details($keyResource); |
|
553
|
|
|
switch ($keyDetails['type']) { |
|
554
|
|
|
case OPENSSL_KEYTYPE_EC: |
|
555
|
|
|
$certPubkey = $keyDetails['ec']; |
|
556
|
|
|
break; |
|
557
|
|
|
case OPENSSL_KEYTYPE_RSA: |
|
558
|
|
|
$certPubkey = $keyDetails['rsa']; |
|
559
|
|
|
break; |
|
560
|
|
|
default: |
|
561
|
|
|
throw new Exception("Public key was neither a RSA nor EC key."); |
|
562
|
|
|
} |
|
563
|
|
|
$statementKeyData = $this->cborDecode(hex2bin($this->credential)); |
|
564
|
|
|
// this will only work for ECDSA keys, screw RSA |
|
565
|
|
|
if ( |
|
566
|
|
|
$statementKeyData['x'] != $certPubkey[-2] || $statementKeyData['y'] != $certPubkey[-3] |
|
567
|
|
|
) { |
|
568
|
|
|
$this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . |
|
569
|
|
|
/** @scrutinizer ignore-type */ print_r($certPubkey, true) . |
|
570
|
|
|
"###" . |
|
571
|
|
|
/** @scrutinizer ignore-type */ print_r($statementKeyData, true) . |
|
572
|
|
|
")."); |
|
573
|
|
|
} |
|
574
|
|
|
// throw new Exception(print_r($certProps, true)); |
|
575
|
|
|
$rawAsn1Oid = $certProps['extensions']['1.3.6.1.4.1.11129.2.1.17']; |
|
576
|
|
|
$keyDescription = UnspecifiedType::fromDER($rawAsn1Oid)->asSequence(); |
|
577
|
|
|
$attestationVersion = $keyDescription->at(0)->asInteger()->intNumber(); |
|
578
|
|
|
$attestationChallenge = $keyDescription->at(4)->asOctetString()->string(); |
|
579
|
|
|
$softwareEnforced = $keyDescription->at(6)->asSequence(); |
|
580
|
|
|
$teeEnforced = $keyDescription->at(7)->asSequence(); |
|
581
|
|
|
|
|
582
|
|
|
if ($this->clientDataHash !== $attestationChallenge) { |
|
583
|
|
|
$this->fail("ClientDataHash is not in certificate's extension data (attestationChallenge)."); |
|
584
|
|
|
} |
|
585
|
|
|
|
|
586
|
|
|
if ($attestationVersion < self::MIN_SUPPORTED_KEYMASTER_VERSION) { |
|
587
|
|
|
$this->fail("Attestation versions below " . |
|
588
|
|
|
self::MIN_SUPPORTED_KEYMASTER_VERSION . |
|
589
|
|
|
" not supported, found $attestationVersion."); |
|
590
|
|
|
} |
|
591
|
|
|
|
|
592
|
|
|
if ($softwareEnforced->hasTagged(600) || $teeEnforced->hasTagged(600)) { |
|
593
|
|
|
$this->fail("Tag allApplications found!"); |
|
594
|
|
|
} |
|
595
|
|
|
// need to go through both software and TEE and check origins and purpose |
|
596
|
|
|
|
|
597
|
|
|
// phpcs:disable Generic.Files.LineLength.TooLong |
|
598
|
|
|
if ( |
|
599
|
|
|
($softwareEnforced->hasTagged(702) && ($softwareEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) || |
|
600
|
|
|
($teeEnforced->hasTagged(702) && ($teeEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) |
|
601
|
|
|
) { |
|
602
|
|
|
$this->fail("Incorrect value for ORIGIN!"); |
|
603
|
|
|
} |
|
604
|
|
|
// phpcs:enable Generic.Files.LineLength.TooLong |
|
605
|
|
|
|
|
606
|
|
|
if ($softwareEnforced->hasTagged(1)) { |
|
607
|
|
|
$purposesSoftware = $softwareEnforced->getTagged(1)->asExplicit()->asSet(); |
|
608
|
|
|
foreach ($purposesSoftware->elements() as $onePurpose) { |
|
609
|
|
|
if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) { |
|
610
|
|
|
$this->fail("Incorrect value for PURPOSE (softwareEnforced)!"); |
|
611
|
|
|
} |
|
612
|
|
|
} |
|
613
|
|
|
} |
|
614
|
|
|
if ($teeEnforced->hasTagged(1)) { |
|
615
|
|
|
$purposesTee = $teeEnforced->getTagged(1)->asExplicit()->asSet(); |
|
616
|
|
|
foreach ($purposesTee->elements() as $onePurpose) { |
|
617
|
|
|
if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) { |
|
618
|
|
|
$this->fail("Incorrect value for PURPOSE (teeEnforced)!"); |
|
619
|
|
|
} |
|
620
|
|
|
} |
|
621
|
|
|
} |
|
622
|
|
|
|
|
623
|
|
|
$this->pass("Android Key attestation passed."); |
|
624
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC; |
|
625
|
|
|
} |
|
626
|
|
|
|
|
627
|
|
|
|
|
628
|
|
|
/** |
|
629
|
|
|
* support legacy U2F tokens |
|
630
|
|
|
* |
|
631
|
|
|
* @param array $attestationData the incoming attestation data |
|
632
|
|
|
* @return void |
|
633
|
|
|
*/ |
|
634
|
|
|
private function validateAttestationFormatFidoU2F(array $attestationData): void |
|
635
|
|
|
{ |
|
636
|
|
|
/** |
|
637
|
|
|
* §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was |
|
638
|
|
|
* already successfully CBOR decoded |
|
639
|
|
|
*/ |
|
640
|
|
|
$stmtDecoded = $attestationData['attStmt']; |
|
641
|
|
|
if (!isset($stmtDecoded['x5c'])) { |
|
642
|
|
|
$this->fail("FIDO U2F attestation needs to have the 'x5c' key"); |
|
643
|
|
|
} |
|
644
|
|
|
/** |
|
645
|
|
|
* §8.6 Verification Step 2: extract attCert and sanity check it |
|
646
|
|
|
*/ |
|
647
|
|
|
if (count($stmtDecoded['x5c']) !== 1) { |
|
648
|
|
|
$this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key."); |
|
649
|
|
|
} |
|
650
|
|
|
$attCert = $this->der2pem($stmtDecoded['x5c'][0]); |
|
651
|
|
|
$key = openssl_pkey_get_public($attCert); |
|
652
|
|
|
$keyProps = openssl_pkey_get_details($key); |
|
653
|
|
|
if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") { |
|
654
|
|
|
$this->fail("FIDO U2F attestation public key is not P-256!"); |
|
655
|
|
|
} |
|
656
|
|
|
/** |
|
657
|
|
|
* §8.6 Verification Step 3 is a NOOP as these properties are already |
|
658
|
|
|
* available as class members: |
|
659
|
|
|
* |
|
660
|
|
|
* $this->rpIdHash; |
|
661
|
|
|
* $this->credentialId; |
|
662
|
|
|
* $this->credential; |
|
663
|
|
|
*/ |
|
664
|
|
|
/** |
|
665
|
|
|
* §8.6 Verification Step 4: encode the public key in ANSI X9.62 format |
|
666
|
|
|
*/ |
|
667
|
|
|
if ( |
|
668
|
|
|
isset($this->credential[-2]) && |
|
669
|
|
|
strlen($this->credential[-2]) === 32 && |
|
670
|
|
|
isset($this->credential[-3]) && |
|
671
|
|
|
strlen($this->credential[-3]) === 32 |
|
672
|
|
|
) { |
|
673
|
|
|
$publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3]; |
|
674
|
|
|
} else { |
|
675
|
|
|
$publicKeyU2F = false; |
|
676
|
|
|
$this->fail("FIDO U2F attestation: the public key is not as expected."); |
|
677
|
|
|
} |
|
678
|
|
|
/** |
|
679
|
|
|
* §8.6 Verification Step 5: create verificationData |
|
680
|
|
|
*/ |
|
681
|
|
|
$verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F; |
|
682
|
|
|
/** |
|
683
|
|
|
* §8.6 Verification Step 6: verify signature |
|
684
|
|
|
*/ |
|
685
|
|
|
if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) { |
|
686
|
|
|
$this->fail("FIDO U2F Attestation verification failed."); |
|
687
|
|
|
} else { |
|
688
|
|
|
$this->pass("Successfully verified FIDO U2F signature."); |
|
689
|
|
|
} |
|
690
|
|
|
/** |
|
691
|
|
|
* §8.6 Verification Step 7: not performed, this is optional as per spec |
|
692
|
|
|
*/ |
|
693
|
|
|
/** |
|
694
|
|
|
* §8.6 Verification Step 8: so we always settle for "Basic" |
|
695
|
|
|
*/ |
|
696
|
|
|
$this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC; |
|
697
|
|
|
} |
|
698
|
|
|
|
|
699
|
|
|
|
|
700
|
|
|
/** |
|
701
|
|
|
* support Android authenticators (fingerprint etc.) |
|
702
|
|
|
* |
|
703
|
|
|
* @param array $attestationData the incoming attestation data |
|
704
|
|
|
* @return void |
|
705
|
|
|
*/ |
|
706
|
|
|
private function validateAttestationFormatAndroidSafetyNet(array $attestationData): void |
|
707
|
|
|
{ |
|
708
|
|
|
$this->fail(sprintf( |
|
709
|
|
|
"Android SafetyNet attestation is historic and not supported (%s).", |
|
710
|
|
|
print_r($attestationData, true), |
|
|
|
|
|
|
711
|
|
|
)); |
|
712
|
|
|
// be sure to end execution even if the Exception is caught |
|
713
|
|
|
exit(1); |
|
|
|
|
|
|
714
|
|
|
} |
|
715
|
|
|
|
|
716
|
|
|
|
|
717
|
|
|
/** |
|
718
|
|
|
* The registration contains the actual credential. This function parses it. |
|
719
|
|
|
* @param string $attData the attestation data binary blob |
|
720
|
|
|
* @param string $responseId the response ID |
|
721
|
|
|
* @return void |
|
722
|
|
|
*/ |
|
723
|
|
|
private function validateAttestedCredentialData(string $attData, string $responseId): void |
|
724
|
|
|
{ |
|
725
|
|
|
$aaguid = substr($attData, 0, 16); |
|
726
|
|
|
$credIdLenBytes = substr($attData, 16, 2); |
|
727
|
|
|
$credIdLen = intval(bin2hex($credIdLenBytes), 16); |
|
728
|
|
|
$credId = substr($attData, 18, $credIdLen); |
|
729
|
|
|
$this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>"; |
|
730
|
|
|
$this->AAGUID = bin2hex($aaguid); |
|
731
|
|
|
$this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>"; |
|
732
|
|
|
$this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>"; |
|
733
|
|
|
$this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>"; |
|
734
|
|
|
if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) { |
|
735
|
|
|
$this->pass("Credential IDs in authenticator response and in attestation data match."); |
|
736
|
|
|
} else { |
|
737
|
|
|
$this->fail( |
|
738
|
|
|
"Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" . |
|
739
|
|
|
bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ").", |
|
740
|
|
|
); |
|
741
|
|
|
} |
|
742
|
|
|
// so far so good. Now extract the actual public key from its COSE |
|
743
|
|
|
// encoding. |
|
744
|
|
|
// finding out the number of bytes to CBOR decode appears non-trivial. |
|
745
|
|
|
// The simple case is if no ED is present as the CBOR data then goes to |
|
746
|
|
|
// the end of the byte sequence. |
|
747
|
|
|
// since we don't know the algoritm yet, we don't know how many bytes |
|
748
|
|
|
// of credential CBOR follow. Let's read to the end; the CBOR decoder |
|
749
|
|
|
// silently ignores trailing extensions (if any) |
|
750
|
|
|
$pubKeyCBOR = substr($attData, 18 + $credIdLen); |
|
751
|
|
|
$arrayPK = $this->cborDecode($pubKeyCBOR); |
|
752
|
|
|
$this->debugBuffer .= "pubKey in canonical form: <pre>" . |
|
753
|
|
|
/** @scrutinizer ignore-type */ print_r($arrayPK, true) . |
|
754
|
|
|
"</pre>"; |
|
755
|
|
|
/** |
|
756
|
|
|
* STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one? |
|
757
|
|
|
*/ |
|
758
|
|
|
if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here |
|
759
|
|
|
$this->algo = (int)$arrayPK['3']; |
|
760
|
|
|
$this->pass("Public Key Algorithm is expected (" . |
|
761
|
|
|
implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . |
|
762
|
|
|
")."); |
|
763
|
|
|
} else { |
|
764
|
|
|
$this->fail("Public Key Algorithm mismatch!"); |
|
765
|
|
|
} |
|
766
|
|
|
$this->credentialId = bin2hex($credId); |
|
767
|
|
|
$this->credential = bin2hex($pubKeyCBOR); |
|
768
|
|
|
|
|
769
|
|
|
// now that we know credential and its length, we can CBOR-decode the |
|
770
|
|
|
// trailing extensions |
|
771
|
|
|
switch ($this->algo) { |
|
772
|
|
|
case self::PK_ALGORITHM_ECDSA: |
|
773
|
|
|
$credentialLength = 77; |
|
774
|
|
|
break; |
|
775
|
|
|
case self::PK_ALGORITHM_RSA: |
|
776
|
|
|
$credentialLength = 272; |
|
777
|
|
|
break; |
|
778
|
|
|
default: |
|
779
|
|
|
$this->fail("No credential length information for $this->algo"); |
|
780
|
|
|
// be sure to end execution even if the Exception is caught |
|
781
|
|
|
exit(1); |
|
|
|
|
|
|
782
|
|
|
} |
|
783
|
|
|
$extensions = substr($attData, 18 + $credIdLen + $credentialLength); |
|
784
|
|
|
if (strlen($extensions) !== 0) { |
|
785
|
|
|
$this->pass("Found the following extensions (" . |
|
786
|
|
|
strlen($extensions) . |
|
787
|
|
|
" bytes) during registration ceremony: "); |
|
788
|
|
|
} |
|
789
|
|
|
} |
|
790
|
|
|
|
|
791
|
|
|
|
|
792
|
|
|
/** |
|
793
|
|
|
* transform DER formatted certificate to PEM format |
|
794
|
|
|
* |
|
795
|
|
|
* @param string $derData blob of DER data |
|
796
|
|
|
* @return string the PEM representation of the certificate |
|
797
|
|
|
*/ |
|
798
|
|
|
private function der2pem(string $derData): string |
|
799
|
|
|
{ |
|
800
|
|
|
$pem = chunk_split(base64_encode($derData), 64, "\n"); |
|
801
|
|
|
$pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n"; |
|
802
|
|
|
return $pem; |
|
803
|
|
|
} |
|
804
|
|
|
|
|
805
|
|
|
|
|
806
|
|
|
/** |
|
807
|
|
|
* @return string |
|
808
|
|
|
*/ |
|
809
|
|
|
public function getAAGUID(): string |
|
810
|
|
|
{ |
|
811
|
|
|
return $this->AAGUID; |
|
812
|
|
|
} |
|
813
|
|
|
|
|
814
|
|
|
|
|
815
|
|
|
/** |
|
816
|
|
|
* @return string |
|
817
|
|
|
*/ |
|
818
|
|
|
public function getAttestationLevel(): string |
|
819
|
|
|
{ |
|
820
|
|
|
return $this->AAGUIDAssurance; |
|
821
|
|
|
} |
|
822
|
|
|
} |
|
823
|
|
|
|