Passed
Push — master ( 36255b...622d7a )
by Stefan
02:54 queued 12s
created

validateAttestationFormatPackedSelf()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 13
nc 8
nop 1
dl 0
loc 25
rs 9.8333
c 0
b 0
f 0
1
<?php
2
3
namespace SimpleSAML\Module\webauthn\WebAuthn;
4
5
use Cose\Key\Ec2Key;
6
7
include_once 'AAGUID.php';
8
9
/**
10
 * FIDO2/WebAuthn Authentication Processing filter
11
 *
12
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
13
 * having authenticated with the primary authsource.
14
 *
15
 * @author Stefan Winter <[email protected]>
16
 * @package SimpleSAMLphp
17
 */
18
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent {
19
20
    /**
21
     * Public key algorithm supported. This is -7 - ECDSA with curve P-256
22
     */
23
    const PK_ALGORITHM = -7;
24
    const AAGUID_ASSURANCE_LEVEL_NONE = 0;
25
    const AAGUID_ASSURANCE_LEVEL_SELF = 1;
26
    const AAGUID_ASSURANCE_LEVEL_BASIC = 2;
27
    const AAGUID_ASSURANCE_LEVEL_ATTCA = 3;
28
29
    /**
30
     * the AAGUID of the newly registered authenticator
31
     * @var string
32
     */
33
    public $AAGUID;
34
35
    /**
36
     * how sure are we about the AAGUID?
37
     * @var int
38
     */
39
    public $AAGUIDAssurance;
40
41
    /**
42
     * Initialize the event object.
43
     *
44
     * Validates and parses the configuration.
45
     *
46
     * @param string $pubkeyCredType  PublicKeyCredential.type
47
     * @param string $scope           the scope of the event
48
     * @param string $challenge       the challenge which was used to trigger this event
49
     * @param string $idpEntityId     the entity ID of our IdP
50
     * @param string $attestationData the attestation data CBOR blob
51
     * @param string $responseId      the response ID
52
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
53
     * @param bool $debugMode         print debugging statements?
54
     */
55
    public function __construct(
56
            string $pubkeyCredType,
57
            string $scope,
58
            string $challenge,
59
            string $idpEntityId,
60
            string $attestationData,
61
            string $responseId,
62
            string $clientDataJSON,
63
            bool $debugMode = false
64
    ) {
65
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
66
        /**
67
         * §7.1 STEP 9 : CBOR decode attestationData.
68
         */
69
        $attestationArray = $this->cborDecode($attestationData);
70
        $authData = $attestationArray['authData'];
71
        $this->eventType = "REG";
72
        parent::__construct($pubkeyCredType, $scope, $challenge, $idpEntityId, $authData, $clientDataJSON, $debugMode);
73
        // this function extracts the public key
74
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
75
        // this function may need the public key to have been previously extracted
76
        $this->validateAttestationData($attestationData, $clientDataJSON);
0 ignored issues
show
Unused Code introduced by
The call to SimpleSAML\Module\webaut...lidateAttestationData() has too many arguments starting with $clientDataJSON. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

76
        $this->/** @scrutinizer ignore-call */ 
77
               validateAttestationData($attestationData, $clientDataJSON);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
77
        // the following function sets the credential properties
78
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
79
    }
80
81
    /**
82
     * validate the incoming attestation data CBOR blob and return the embedded authData
83
     * @param string $attestationData
84
     * @return void
85
     */
86
    private function validateAttestationData(string $attestationData): void {
87
        /**
88
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
89
         */
90
        $attestationArray = $this->cborDecode($attestationData);
91
        $this->debugBuffer .= "<pre>";
92
        $this->debugBuffer .= print_r($attestationArray, true);
93
        $this->debugBuffer .= "</pre>";
94
95
        /**
96
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
97
         */
98
        switch ($attestationArray['fmt']) {
99
            case "none":
100
                $this->validateAttestationFormatNone($attestationArray);
101
                break;
102
            case "packed":
103
                $this->validateAttestationFormatPacked($attestationArray);
104
                break;
105
            case "fido-u2f":
106
                $this->validateAttestationFormatFidoU2F($attestationArray);
107
                break;
108
            case "android-safetynet":
109
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
110
                break;
111
            case "tpm":
112
            case "android-key":
113
                $this->fail("Attestation format " . $attestationArray['fmt'] . " validation not supported right now.");
114
                break;
115
            default:
116
                $this->fail("Unknown attestation format.");
117
                break;
118
        }
119
    }
120
121
    /**
122
     * @param array $attestationArray
123
     * @return void
124
     */
125
    private function validateAttestationFormatNone(array $attestationArray): void {
126
        // § 8.7 of the spec
127
        /**
128
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
129
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
130
         */
131
        if (count($attestationArray['attStmt']) == 0) {
132
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
133
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_NONE;
134
            return;
135
        } else {
136
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
137
        }
138
    }
139
140
    /**
141
     * @param array $attestationArray
142
     * @return void
143
     */
144
    private function validateAttestationFormatPacked(array $attestationArray): void {
145
        $stmtDecoded = $attestationArray['attStmt'];
146
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
147
        /**
148
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
149
         */
150
        if (isset($stmtDecoded['x5c'])) {
151
            $this->validateAttestationFormatPackedX5C($attestationArray);
152
        } elseif (isset($stmtDecoded['ecdaa'])) {
153
            $this->fail("ecdaa attestation not supported right now.");
154
        } else {
155
            // if we are still here, we are in the "self" type.
156
            $this->validateAttestationFormatPackedSelf($attestationArray);
157
        }
158
    }
159
160
    private function validateAttestationFormatPackedX5C($attestationArray) {
161
        $stmtDecoded = $attestationArray['attStmt'];
162
        /**
163
         * §8.2 Step 2: check x5c attestation
164
         */
165
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
166
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
167
        if ($keyResource === false) {
168
            $this->fail("Unable to construct public key resource from PEM.");
169
        }
170
        /**
171
         * §8.2 Step 2 Bullet 1: check signature
172
         */
173
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) != 1) {
174
            $this->fail("x5c attestation failed.");
175
        }
176
        $this->pass("x5c sig check passed.");
177
        // still need to perform sanity checks on the attestation certificate
178
        /**
179
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
180
         */
181
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
182
        $this->debugBuffer .= "Attestation Certificate:" . print_r($certProps, true) . "<br/>";
183
        if ($certProps['version'] != 2 || /** §8.2.1 Bullet 1 */
184
                $certProps['subject']['OU'] != "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
185
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
186
                !isset($certProps['extensions']['basicConstraints']) ||
187
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
188
        ) {
189
            $this->fail("Attestation certificate properties are no good.");
190
        }
191
        if (isset(AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)])) {
192
            if ($certProps['subject']['O'] != AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['O'] || /** §8.2.1 Bullet 2 [Subject-O] */
193
                    $certProps['subject']['C'] != AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['C']) { /** §8.2.1 Bullet 2 [Subject-C] */
194
                $this->fail("AAGUID does not match vendor data.");
195
            }
196
            if (AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['multi'] === true) { // need to check the OID
197
                if (!isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])) { /** §8.2.1 Bullet 3 */
198
                    $this->fail("This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.");
199
                }
200
                /**
201
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
202
                 */
203
                $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
204
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
205
                if (strtolower($AAGUIDFromOid) != strtolower($this->AAGUID)) {
206
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
207
                }
208
            }
209
            // we would need to verify the attestation certificate against a known-good root CA certificate to get more than basic
210
            /*
211
             * §7.1 Step 17 is to look at AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['RootPEMs']
212
             */
213
            /*
214
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
215
             */
216
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_BASIC;
217
        } else {
218
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
219
            // unable to verify all cert properties, so this is not enough for BASIC.
220
            // but it's our own fault, we should add the device to our DB.
221
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_SELF;
222
        }
223
        $this->pass("x5c attestation passed.");
224
        return;
225
    }
226
227
    private function validateAttestationFormatPackedSelf($attestationArray) {
228
        $stmtDecoded = $attestationArray['attStmt'];
229
        /**
230
         * §8.2 Step 4 Bullet 1: check algorithm
231
         */
232
        if ($stmtDecoded['alg'] != WebAuthnRegistrationEvent::PK_ALGORITHM) {
233
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
234
        }
235
        $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
236
        $keyResource = openssl_pkey_get_public($keyObject->asPEM());
237
        if ($keyResource === false) {
238
            $this->fail("Unable to construct public key resource from PEM.");
239
        }
240
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
241
        /**
242
         * §8.2 Step 4 Bullet 2: verify signature
243
         */
244
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) == 1) {
245
            $this->pass("Self-Attestation veried.");
246
            /**
247
             * §8.2 Step 4 Bullet 3: return Self level
248
             */
249
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_SELF;
250
        } else {
251
            $this->fail("Self-Attestation failed.");
252
        }
253
    }
254
255
    /**
256
     * support legacy U2F tokens
257
     * 
258
     * @param array $attestationData the incoming attestation data
259
     */
260
    private function validateAttestationFormatFidoU2F($attestationData) {
261
        /**
262
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
263
         * already successfully CBOR decoded
264
         */
265
        $stmtDecoded = $attestationData['attStmt'];
266
        if (!isset($stmtDecoded['x5c'])) {
267
            fail("FIDO U2F attestation needs to have the 'x5c' key");
0 ignored issues
show
Bug introduced by
The function fail was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

267
            /** @scrutinizer ignore-call */ 
268
            fail("FIDO U2F attestation needs to have the 'x5c' key");
Loading history...
268
        }
269
        /**
270
         * §8.6 Verification Step 2: extract attCert and sanity check it
271
         */
272
        if (count($stmtDecoded['x5c']) != 1) {
273
            fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
274
        }
275
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
276
        $key = openssl_pkey_get_public($attCert);
277
        $keyProps = openssl_pkey_get_details($key);
278
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] != "prime256v1") {
279
            $this->fail("FIDO U2F attestation public key is not P-256!");
280
        }
281
        /**
282
         * §8.6 Verification Step 3 is a NOOP as these properties are already
283
         * available as class members:
284
         *
285
         * $this->rpIdHash;
286
         * $this->credentialId;
287
         * $this->credential;
288
         */
289
        /**
290
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
291
         */
292
        if (isset($this->credential[-2]) && sizeof($this->credential[-2]) == 32 &&
293
                isset($this->credential[-3]) && sizeof($this->credential[-3]) == 32) {
294
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
295
        } else {
296
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
297
        }
298
        /**
299
         * §8.6 Verification Step 5: create verificationData
300
         */
301
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $publicKeyU2F does not seem to be defined for all execution paths leading up to this point.
Loading history...
302
        /**
303
         * §8.6 Verification Step 6: verify signature
304
         */
305
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
306
            $this->fail("FIDO U2F Attestation verification failed.");
307
        } else {
308
            $this->pass("Successfully verified FIDO U2F signature.");
309
        }
310
        /**
311
         * §8.6 Verification Step 7: not performed, this is optional as per spec
312
         */
313
        /**
314
         * §8.6 Verification Step 8: so we always settle for "Basic"
315
         */
316
        $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_BASIC;
317
    }
318
319
    /**
320
     * support Android authenticators (fingerprint etc.)
321
     * 
322
     * @param array $attestationData the incoming attestation data
323
     */
324
    private function validateAttestationFormatAndroidSafetyNet($attestationData) {
0 ignored issues
show
Unused Code introduced by
The parameter $attestationData is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

324
    private function validateAttestationFormatAndroidSafetyNet(/** @scrutinizer ignore-unused */ $attestationData) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
325
        
326
    }
327
328
    /**
329
     * The registration contains the actual credential. This function parses it.
330
     * @param string $attData    the attestation data binary blob
331
     * @param string $responseId the response ID
332
     * @return void
333
     */
334
    private function validateAttestedCredentialData(string $attData, string $responseId): void {
335
        $aaguid = substr($attData, 0, 16);
336
        $credIdLenBytes = substr($attData, 16, 2);
337
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
338
        $credId = substr($attData, 18, $credIdLen);
339
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
340
        $this->AAGUID = bin2hex($aaguid);
341
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
342
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
343
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
344
        if (bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) == bin2hex($credId)) {
345
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
346
        } else {
347
            $this->fail("Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" . bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) . ").");
348
        }
349
        // so far so good. Now extract the actual public key from its COSE 
350
        // encoding.
351
        // finding out the number of bytes to CBOR decode appears non-trivial. 
352
        // The simple case is if no ED is present as the CBOR data then goes to 
353
        // the end of the byte sequence.
354
        // Since we made sure above that no ED is in the sequence, take the rest
355
        // of the sequence in its entirety.
356
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
357
        $arrayPK = $this->cborDecode($pubKeyCBOR);
358
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . print_r($arrayPK, true) . "</pre>";
359
        /**
360
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
361
         */
362
        if ($arrayPK['3'] == WebAuthnRegistrationEvent::PK_ALGORITHM) { // we requested -7, so want to see it here
363
            $this->pass("Public Key Algorithm is the expected one (-7, ECDSA).");
364
        } else {
365
            $this->fail("Public Key Algorithm mismatch!");
366
        }
367
        $this->credentialId = bin2hex($credId);
368
        $this->credential = bin2hex($pubKeyCBOR);
369
    }
370
371
    /**
372
     * transform DER formatted certificate to PEM format
373
     * 
374
     * @param string $derData blob of DER data
375
     * @return string the PEM representation of the certificate
376
     */
377
    private function der2pem(string $derData): string {
378
        $pem = chunk_split(base64_encode($derData), 64, "\n");
379
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
380
        return $pem;
381
    }
382
383
}
384