Passed
Push — master ( 3266fb...94512e )
by Thomas
03:56 queued 01:13
created

JsonConverter::decodeAttestationResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 7
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 13
ccs 8
cts 8
cp 1
crap 1
rs 10
1
<?php
2
3
namespace MadWizard\WebAuthn\Json;
4
5
use MadWizard\WebAuthn\Dom\AuthenticatorAssertionResponse;
6
use MadWizard\WebAuthn\Dom\AuthenticatorAttestationResponse;
7
use MadWizard\WebAuthn\Dom\AuthenticatorResponseInterface;
8
use MadWizard\WebAuthn\Dom\DictionaryInterface;
9
use MadWizard\WebAuthn\Dom\PublicKeyCredential;
10
use MadWizard\WebAuthn\Dom\PublicKeyCredentialInterface;
11
use MadWizard\WebAuthn\Dom\PublicKeyCredentialType;
12
use MadWizard\WebAuthn\Exception\ParseException;
13
use MadWizard\WebAuthn\Exception\WebAuthnException;
14
use MadWizard\WebAuthn\Format\Base64UrlEncoding;
15
use MadWizard\WebAuthn\Format\ByteBuffer;
16
use MadWizard\WebAuthn\Format\DataValidator;
17
use function is_string;
18
19
final class JsonConverter
20
{
21
    /**
22
     * @codeCoverageIgnore
23
     */
24
    private function __construct()
25
    {
26
    }
27
28
    /**
29
     * Parses a JSON string containing a credential returned from the JS credential API's credentials.get or
30
     * credentials.create. The JSOn structure matches the PublicKeyCredential interface from the WebAuthn specifications
31
     * closely but since it contains ArrayBuffers it cannot be directly converted to a JSON equivalent. Fields that
32
     * are ArrayBuffers are assumed to be base64url encoded.
33
     *
34
     * Also, the response field of the PublicKeyCredential can contain either an attestation or assertion response.
35
     * To determine which one to parse the $responseType parameter must be set to 'attestation' or 'assertion'.
36
     *
37
     * The required JSON structure is:
38
     * ```
39
     * {
40
     *   "type": "public-key",
41
     *   "id": "base64url encoded ArrayBuffer",
42
     *   "response" : << authenticator response >>,
43
     *   "clientExtensionResults" : << output of credential.getClientExtensionResults() >>
44
     * }
45
     * ```
46
     *
47
     * Where the authenticator response for attestation is:
48
     * ```
49
     * {
50
     *   attestationObject: "base64url encoded ArrayBuffer"
51
     * }
52
     * ```
53
     * and for assertion response is:
54
     * ```
55
     * {
56
     *   authenticatorData : "base64url encoded ArrayBuffer",
57
     *   signature: "base64url encoded ArrayBuffer",
58
     *   userHandle: "base64url encoded ArrayBuffer"
59
     * }
60
     * ```
61
     *
62
     * @param array  $json         Json data (with objects as associative arrays)
63
     * @param string $responseType Expected type of response in the public key's response field.
64
     *                             Either 'attestation' for attestation responses or 'assertion' for assertion responses.
65
     *
66
     * @throws ParseException
67
     *
68
     * @see https://www.w3.org/TR/webauthn/#publickeycredential
69
     */
70 27
    public static function decodeCredential(array $json, string $responseType): PublicKeyCredentialInterface
71
    {
72 27
        DataValidator::checkArray(
73 27
            $json,
74
            [
75 27
                'type' => 'string',
76
                'id' => 'string',
77
                'response' => 'array',
78
                'clientExtensionResults' => '?array',
79
            ],
80 27
            false
81
        );
82 23
        if (($json['type'] ?? null) !== PublicKeyCredentialType::PUBLIC_KEY) {
83
            throw new ParseException("Expecting type 'public-key'");
84
        }
85
86 23
        if ($json['id'] === '') {
87
            throw new ParseException('Missing id in json data');
88
        }
89
90 23
        $rawId = Base64UrlEncoding::decode($json['id']);
91
92 22
        $response = self::decodeResponse($json['response'], $responseType);
93 16
        $credential = new PublicKeyCredential(new ByteBuffer($rawId), $response);
94
95 16
        if (isset($json['clientExtensionResults'])) {
96
            $credential->setClientExtensionResults($json['clientExtensionResults']);
97
        }
98
99 16
        return $credential;
100
    }
101
102 20
    private static function jsonFromString(string $json): array
103
    {
104 20
        $decoded = json_decode($json, true, 10);
105 20
        if (!is_array($decoded)) {
106 1
            throw new ParseException('Failed to decode PublicKeyCredential Json');
107
        }
108 19
        return $decoded;
109
    }
110
111
    public static function decodeAttestationString(string $json): PublicKeyCredentialInterface
112
    {
113
        return self::decodeCredential(self::jsonFromString($json), 'attestation');
114
    }
115
116 20
    public static function decodeAssertionString(string $json): PublicKeyCredentialInterface
117
    {
118 20
        return self::decodeCredential(self::jsonFromString($json), 'assertion');
119
    }
120
121 6
    public static function decodeAttestation(array $json): PublicKeyCredentialInterface
122
    {
123 6
        return self::decodeCredential($json, 'attestation');
124
    }
125
126
    public static function decodeAssertion(array $json): PublicKeyCredentialInterface
127
    {
128
        return self::decodeCredential($json, 'assertion');
129
    }
130
131 22
    private static function decodeResponse(array $response, string $responseType): AuthenticatorResponseInterface
132
    {
133 22
        $clientDataJson = $response['clientDataJSON'] ?? null;
134
135 22
        if (!is_string($clientDataJson)) {
136 1
            throw new ParseException('Expecting client data json');
137
        }
138 21
        $clientDataJson = Base64UrlEncoding::decode($clientDataJson);
139
140 21
        if ($responseType === 'assertion') {
141 15
            return self::decodeAssertionResponse($clientDataJson, $response);
142
        }
143 6
        if ($responseType === 'attestation') {
144 5
            return self::decodeAttestationResponse($clientDataJson, $response);
145
        }
146 1
        throw new WebAuthnException(sprintf('Unknown or missing type %s', $responseType));
147
    }
148
149 15
    private static function decodeAssertionResponse(string $clientDataJson, array $response): AuthenticatorAssertionResponse
150
    {
151 15
        DataValidator::checkArray(
152 15
            $response,
153
            [
154 15
                'authenticatorData' => 'string',
155
                'signature' => 'string',
156
                'userHandle' => '?:string',
157
            ],
158 15
            false
159
        );
160
161 15
        $authenticatorData = new ByteBuffer(Base64UrlEncoding::decode($response['authenticatorData']));
162 15
        $signature = new ByteBuffer(Base64UrlEncoding::decode($response['signature']));
163
164 15
        $userHandle = null;
165
166 15
        $encUserHandle = $response['userHandle'] ?? null;
167
168
        // Note: a non-null but empty userHandle is invalid according to spec but iOS incorrectly sends this instead of a real null value
169
        // To maintain compatibility an empty userHandle is seen as null here.
170
        // See https://bugs.webkit.org/show_bug.cgi?id=239737
171
        // and https://github.com/w3c/webauthn/issues/1722
172 15
        if ($encUserHandle !== null && $encUserHandle !== '') {
173 2
            $userHandle = new ByteBuffer(Base64UrlEncoding::decode($encUserHandle));
174
        }
175
176 15
        return new AuthenticatorAssertionResponse($clientDataJson, $authenticatorData, $signature, $userHandle);
177
    }
178
179 5
    private static function decodeAttestationResponse(string $clientDataJson, array $response): AuthenticatorAttestationResponse
180
    {
181 5
        DataValidator::checkArray(
182 5
            $response,
183
            [
184 5
                'attestationObject' => 'string',
185
            ],
186 5
            false
187
        );
188
189 5
        return new AuthenticatorAttestationResponse(
190 5
            $clientDataJson,
191 5
            new ByteBuffer(Base64UrlEncoding::decode($response['attestationObject']))
192
        );
193
    }
194
195 8
    public static function encodeDictionary(DictionaryInterface $dictionary): array
196
    {
197 8
        return self::encodeArray($dictionary->getAsArray());
198
    }
199
200 8
    private static function encodeArray(array $map): array
201
    {
202 8
        $converted = [];
203 8
        foreach ($map as $key => $value) {
204 8
            if ($value instanceof ByteBuffer) {
205
                // There is no direct way to store a ByteBuffer in JSON string easily.
206
                // Encode as base46url encoded string
207 7
                $converted[$key] = Base64UrlEncoding::encode($value->getBinaryString());
208 7
            } elseif ($value instanceof DictionaryInterface) {
209 5
                $converted[$key] = self::encodeDictionary($value);
210 7
            } elseif (\is_scalar($value)) {
211 7
                $converted[$key] = $value;
212 6
            } elseif (\is_array($value)) {
213 6
                $converted[$key] = self::encodeArray($value);
214
            } else {
215
                throw new WebAuthnException('Cannot convert this data to JSON format');
216
            }
217
        }
218 8
        return $converted;
219
    }
220
}
221