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