Passed
Push — master ( 7e7b4a...ba62bd )
by Stefan
03:10 queued 11s
created

validateAttestationFormatAndroidSafetyNet()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 1
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

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

255
    private function validateAttestationFormatFidoU2F(/** @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...
256
        
257
    }
258
    
259
    /**
260
     * support Android authenticators (fingerprint etc.)
261
     * 
262
     * @param array $attestationData the incoming attestation data
263
     */
264
    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

264
    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...
265
        
266
    }
267
    
268
    /**
269
     * The registration contains the actual credential. This function parses it.
270
     * @param string $attData    the attestation data binary blob
271
     * @param string $responseId the response ID
272
     * @return void
273
     */
274
    private function validateAttestedCredentialData(string $attData, string $responseId) : void
275
    {
276
        $aaguid = substr($attData, 0, 16);
277
        $credIdLenBytes = substr($attData, 16, 2);
278
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
279
        $credId = substr($attData, 18, $credIdLen);
280
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
281
        $this->AAGUID = bin2hex($aaguid);
282
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
283
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
284
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
285
        if (bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) == bin2hex($credId)) {
286
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
287
        } else {
288
            $this->fail("Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" . bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) . ").");
289
        }
290
        // so far so good. Now extract the actual public key from its COSE 
291
        // encoding.
292
        // finding out the number of bytes to CBOR decode appears non-trivial. 
293
        // The simple case is if no ED is present as the CBOR data then goes to 
294
        // the end of the byte sequence.
295
        // Since we made sure above that no ED is in the sequence, take the rest
296
        // of the sequence in its entirety.
297
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
298
        $arrayPK = $this->cborDecode($pubKeyCBOR);
299
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . print_r($arrayPK, true) . "</pre>";
300
        /**
301
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
302
         */
303
        if ($arrayPK['3'] == WebAuthnRegistrationEvent::PK_ALGORITHM) { // we requested -7, so want to see it here
304
            $this->pass("Public Key Algorithm is the expected one (-7, ECDSA).");
305
        } else {
306
            $this->fail("Public Key Algorithm mismatch!");
307
        }
308
        $this->credentialId = bin2hex($credId);
309
        $this->credential = bin2hex($pubKeyCBOR);
310
    }
311
312
    /**
313
     * transform DER formatted certificate to PEM format
314
     * 
315
     * @param string $derData blob of DER data
316
     * @return string the PEM representation of the certificate
317
     */
318
    private function der2pem(string $derData) : string
319
    {
320
        $pem = chunk_split(base64_encode($derData), 64, "\n");
321
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
322
        return $pem;
323
    }
324
325
}
326