AndroidSafetyNetAttestationVerifier::verify()   B
last analyzed

Complexity

Conditions 8
Paths 7

Size

Total Lines 47
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 8.8638

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 20
c 1
b 0
f 0
nc 7
nop 3
dl 0
loc 47
ccs 16
cts 21
cp 0.7619
crap 8.8638
rs 8.4444
1
<?php
2
3
namespace MadWizard\WebAuthn\Attestation\Verifier;
4
5
use MadWizard\WebAuthn\Attestation\Android\SafetyNetResponseParser;
6
use MadWizard\WebAuthn\Attestation\AttestationType;
7
use MadWizard\WebAuthn\Attestation\AuthenticatorData;
8
use MadWizard\WebAuthn\Attestation\Registry\AttestationFormatInterface;
9
use MadWizard\WebAuthn\Attestation\Registry\BuiltInAttestationFormat;
10
use MadWizard\WebAuthn\Attestation\Statement\AndroidSafetyNetAttestationStatement;
11
use MadWizard\WebAuthn\Attestation\Statement\AttestationStatementInterface;
12
use MadWizard\WebAuthn\Attestation\TrustPath\CertificateTrustPath;
13
use MadWizard\WebAuthn\Exception\VerificationException;
14
use MadWizard\WebAuthn\Pki\CertificateDetails;
15
use function base64_encode;
16
use function hash_equals;
17
use function microtime;
18
19
final class AndroidSafetyNetAttestationVerifier implements AttestationVerifierInterface
20
{
21
    private const ATTEST_HOSTNAME = 'attest.android.com';
22
23
    /**
24
     * @var float|null
25
     */
26
    private $fixedTimestamp;
27
28 1
    private function getMsTimestamp(): float
29
    {
30 1
        if ($this->fixedTimestamp !== null) {
31 1
            return $this->fixedTimestamp;
32
        }
33
        return microtime(true) * 1000;
34
    }
35
36
    /**
37
     * @internal Used for testing only
38
     */
39 2
    public function useFixedTimestamp(float $time): void
40
    {
41 2
        $this->fixedTimestamp = $time;
42 2
    }
43
44 2
    public function verify(AttestationStatementInterface $attStmt, AuthenticatorData $authenticatorData, string $clientDataHash): VerificationResult
45
    {
46 2
        if (!($attStmt instanceof AndroidSafetyNetAttestationStatement)) {
47
            throw new VerificationException('Expecting AndroidSafetyNetAttestationStatement');
48
        }
49
50
        // Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract the contained fields.
51
        // -> this is done in AndroidSafetyNetAttestationStatement
52
53
        // Verify that response is a valid SafetyNet response of version ver.
54 2
        $response = SafetyNetResponseParser::parse($attStmt->getResponse());
55
56
        // Verify that the nonce in the response is identical to the Base64 encoding of the SHA-256 hash of the concatenation of authenticatorData and clientDataHash.
57 2
        $expectedNonce = base64_encode(hash('sha256', $authenticatorData->getRaw()->getBinaryString() . $clientDataHash, true));
58
59 2
        if (!hash_equals($expectedNonce, $response->getNonce())) {
60
            throw new VerificationException('Nonce is invalid.');
61
        }
62
63
        // Let attestationCert be the attestation certificate.
64 2
        $x5c = $response->getCertificateChain();
65 2
        if (!isset($x5c[0])) {
66
            throw new VerificationException('No certificates in chain.');
67
        }
68 2
        $attCert = $x5c[0];
69
70
        // Verify that attestationCert is issued to the hostname "attest.android.com" (see SafetyNet online documentation).
71 2
        $certInfo = CertificateDetails::fromPem($attCert->asPem());
72 2
        $cn = $certInfo->getSubjectCommonName();
73
74 2
        if ($cn !== self::ATTEST_HOSTNAME) {
75
            throw new VerificationException(sprintf('Attestation certificate should be issued to %s.', self::ATTEST_HOSTNAME));
76
        }
77
78
        // Verify that the ctsProfileMatch attribute in the payload of response is true.
79 2
        if (!$response->isCtsProfileMatch()) {
80 1
            throw new VerificationException('Attestation should have ctsProfileMatch set to true.');
81
        }
82
83 1
        $diff = $this->getMsTimestamp() - $response->getTimestampMs();
84
85 1
        if ($diff < -60e3 || $diff > 60e3) {
86
            throw new VerificationException('Timestamp is not within margin of one minute');
87
        }
88
89
        // If successful, return implementation-specific values representing attestation type Basic and attestation trust path attestationCert.
90 1
        return new VerificationResult(AttestationType::BASIC, new CertificateTrustPath(...$x5c));
91
    }
92
93 19
    public function getSupportedFormat(): AttestationFormatInterface
94
    {
95 19
        return new BuiltInAttestationFormat(
96 19
            AndroidSafetyNetAttestationStatement::FORMAT_ID,
97 19
            AndroidSafetyNetAttestationStatement::class,
98
            $this
99
        );
100
    }
101
}
102