303 lines · 10.4 KB
Raw Download
1
<?php
2
// WebAuthn / passkey verification, hand-rolled to keep the project's "no
3
// third-party code" rule. Only what a single-user site needs: register a
4
// credential, then verify assertions against the stored public key.
5
//
6
// Attestation is deliberately NOT verified. Attestation only proves which
7
// make and model of authenticator was used, which matters when an
8
// organisation must restrict employees to approved hardware. Here the owner
9
// chooses their own device, so any authenticator they can use is acceptable,
10
// and skipping it avoids shipping a CA bundle of vendor roots.
11
require_once __DIR__ . '/auth.php';
12
13
function b64url_encode(string $bin): string
14
{
15
    return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
16
}
17
18
function b64url_decode(string $txt): string
19
{
20
    return (string)base64_decode(strtr($txt, '-_', '+/'), true);
21
}
22
23
// The Relying Party id a credential is bound to: the host without its port.
24
// Credentials are scoped to this, so a passkey made on localhost will not
25
// work on the live domain and vice versa — each host needs its own.
26
function webauthn_rp_id(): string
27
{
28
    $host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
29
    return preg_replace('/:\d+$/', '', $host);
30
}
31
32
// The exact origin the browser will report in clientDataJSON, port included.
33
function webauthn_origin(): string
34
{
35
    return (is_https() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost');
36
}
37
38
// Passkeys need a secure context. Browsers make one exception, localhost,
39
// which is what lets this work under MAMP without a certificate.
40
function webauthn_available(): bool
41
{
42
    $host = webauthn_rp_id();
43
    return is_https() || $host === 'localhost' || $host === '127.0.0.1' || $host === '::1';
44
}
45
46
/* ---------- CBOR ---------- */
47
48
// Length/value field shared by every CBOR major type.
49
function cbor_arg(string $d, int &$o, int $info): int
50
{
51
    if ($info < 24) {
52
        return $info;
53
    }
54
    if ($info === 24) {
55
        return ord($d[$o++]);
56
    }
57
    if ($info === 25) {
58
        $v = unpack('n', substr($d, $o, 2))[1];
59
        $o += 2;
60
        return $v;
61
    }
62
    if ($info === 26) {
63
        $v = unpack('N', substr($d, $o, 4))[1];
64
        $o += 4;
65
        return $v;
66
    }
67
    if ($info === 27) {
68
        $v = unpack('J', substr($d, $o, 8))[1];
69
        $o += 8;
70
        return $v;
71
    }
72
    throw new RuntimeException('Bad CBOR length marker ' . $info);
73
}
74
75
// Decodes the CBOR subset WebAuthn actually uses. $o advances past the
76
// decoded item, which is how the authenticator-data parser finds where the
77
// variable-length public key ends.
78
function cbor_decode(string $d, int &$o = 0)
79
{
80
    if ($o >= strlen($d)) {
81
        throw new RuntimeException('CBOR ended early');
82
    }
83
    $b     = ord($d[$o++]);
84
    $major = $b >> 5;
85
    $info  = $b & 0x1f;
86
87
    if ($major === 7) {
88
        // Simple values. Floats never appear in COSE keys or attestation.
89
        if ($info === 20) {
90
            return false;
91
        }
92
        if ($info === 21) {
93
            return true;
94
        }
95
        if ($info === 22 || $info === 23) {
96
            return null;
97
        }
98
        throw new RuntimeException('Unsupported CBOR simple value ' . $info);
99
    }
100
101
    $arg = cbor_arg($d, $o, $info);
102
103
    switch ($major) {
104
        case 0:
105
            return $arg;
106
        case 1:
107
            return -1 - $arg;
108
        case 2:
109
        case 3:
110
            $s  = substr($d, $o, $arg);
111
            $o += $arg;
112
            return $s;
113
        case 4:
114
            $a = [];
115
            for ($i = 0; $i < $arg; $i++) {
116
                $a[] = cbor_decode($d, $o);
117
            }
118
            return $a;
119
        case 5:
120
            $m = [];
121
            for ($i = 0; $i < $arg; $i++) {
122
                $k     = cbor_decode($d, $o);
123
                $m[$k] = cbor_decode($d, $o);
124
            }
125
            return $m;
126
    }
127
    throw new RuntimeException('Unsupported CBOR major type ' . $major);
128
}
129
130
/* ---------- DER ---------- */
131
132
function der_len(int $n): string
133
{
134
    if ($n < 128) {
135
        return chr($n);
136
    }
137
    $bytes = ltrim(pack('N', $n), "\x00");
138
    return chr(0x80 | strlen($bytes)) . $bytes;
139
}
140
141
function der_tlv(int $tag, string $value): string
142
{
143
    return chr($tag) . der_len(strlen($value)) . $value;
144
}
145
146
// DER INTEGERs are signed, so a leading bit of 1 needs a 0x00 pad or the
147
// value reads as negative.
148
function der_int(string $bytes): string
149
{
150
    $bytes = ltrim($bytes, "\x00");
151
    if ($bytes === '') {
152
        $bytes = "\x00";
153
    }
154
    if (ord($bytes[0]) & 0x80) {
155
        $bytes = "\x00" . $bytes;
156
    }
157
    return der_tlv(0x02, $bytes);
158
}
159
160
// A COSE public key (RFC 8152) as a PEM SubjectPublicKeyInfo, which is what
161
// openssl_verify() wants. ES256 and RS256 cover every mainstream passkey
162
// provider — Apple, Google, Windows Hello, and the FIDO2 keys.
163
function cose_to_pem(array $cose): string
164
{
165
    $kty = $cose[1] ?? null;
166
    $alg = $cose[3] ?? null;
167
168
    if ($kty === 2) { // Elliptic curve
169
        if ($alg !== -7) {
170
            throw new RuntimeException('Unsupported EC algorithm ' . var_export($alg, true));
171
        }
172
        if (($cose[-1] ?? null) !== 1) {
173
            throw new RuntimeException('Only the P-256 curve is supported');
174
        }
175
        $x = str_pad((string)($cose[-2] ?? ''), 32, "\x00", STR_PAD_LEFT);
176
        $y = str_pad((string)($cose[-3] ?? ''), 32, "\x00", STR_PAD_LEFT);
177
        $algId  = der_tlv(0x30, hex2bin('06072a8648ce3d0201') . hex2bin('06082a8648ce3d030107'));
178
        $point  = der_tlv(0x03, "\x00" . "\x04" . $x . $y); // 0x04 = uncompressed
179
        $spki   = der_tlv(0x30, $algId . $point);
180
    } elseif ($kty === 3) { // RSA
181
        if ($alg !== -257) {
182
            throw new RuntimeException('Unsupported RSA algorithm ' . var_export($alg, true));
183
        }
184
        $n     = (string)($cose[-1] ?? '');
185
        $e     = (string)($cose[-2] ?? '');
186
        $algId = der_tlv(0x30, hex2bin('06092a864886f70d010101') . "\x05\x00"); // rsaEncryption, NULL params
187
        $inner = der_tlv(0x30, der_int($n) . der_int($e));
188
        $spki  = der_tlv(0x30, $algId . der_tlv(0x03, "\x00" . $inner));
189
    } else {
190
        throw new RuntimeException('Unsupported key type ' . var_export($kty, true));
191
    }
192
193
    return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n";
194
}
195
196
/* ---------- authenticator data ---------- */
197
198
// authData is a packed struct: 32-byte RP id hash, 1 flag byte, 4-byte
199
// signature counter, and — when the AT flag is set — the new credential.
200
function parse_auth_data(string $ad): array
201
{
202
    if (strlen($ad) < 37) {
203
        throw new RuntimeException('Authenticator data too short');
204
    }
205
    $out = [
206
        'rp_id_hash' => substr($ad, 0, 32),
207
        'flags'      => ord($ad[32]),
208
        'sign_count' => unpack('N', substr($ad, 33, 4))[1],
209
    ];
210
    if ($out['flags'] & 0x40) { // AT: attested credential data present
211
        $len = unpack('n', substr($ad, 53, 2))[1]; // 16 bytes AAGUID first
212
        $out['credential_id'] = substr($ad, 55, $len);
213
        $offset = 55 + $len;
214
        $out['cose_key'] = cbor_decode($ad, $offset);
215
    }
216
    return $out;
217
}
218
219
// Checks the parts of clientDataJSON that are the same for registration and
220
// login: that the browser signed OUR challenge, for OUR origin, in the
221
// ceremony we asked for. Getting any of these wrong is how a WebAuthn
222
// implementation ends up accepting a replayed or cross-site assertion.
223
function verify_client_data(string $json, string $expectedType, string $challenge): void
224
{
225
    $cd = json_decode($json, true);
226
    if (!is_array($cd)) {
227
        throw new RuntimeException('clientDataJSON is not valid JSON');
228
    }
229
    if (($cd['type'] ?? '') !== $expectedType) {
230
        throw new RuntimeException('Wrong ceremony type');
231
    }
232
    if (!hash_equals(b64url_encode($challenge), (string)($cd['challenge'] ?? ''))) {
233
        throw new RuntimeException('Challenge does not match');
234
    }
235
    if (!hash_equals(webauthn_origin(), (string)($cd['origin'] ?? ''))) {
236
        throw new RuntimeException('Origin does not match');
237
    }
238
}
239
240
// Registration: verify the ceremony and return the row to store.
241
function webauthn_verify_registration(string $clientDataJSON, string $attestationObject, string $challenge): array
242
{
243
    verify_client_data($clientDataJSON, 'webauthn.create', $challenge);
244
245
    $att = cbor_decode($attestationObject);
246
    if (!isset($att['authData'])) {
247
        throw new RuntimeException('Attestation object has no authenticator data');
248
    }
249
    $ad = parse_auth_data($att['authData']);
250
251
    if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) {
252
        throw new RuntimeException('Credential is for a different site');
253
    }
254
    if (!($ad['flags'] & 0x01)) {
255
        throw new RuntimeException('User presence was not verified');
256
    }
257
    if (empty($ad['credential_id']) || empty($ad['cose_key'])) {
258
        throw new RuntimeException('No credential in the attestation');
259
    }
260
261
    return [
262
        'credential_id' => b64url_encode($ad['credential_id']),
263
        'public_key'    => cose_to_pem($ad['cose_key']),
264
        'sign_count'    => $ad['sign_count'],
265
    ];
266
}
267
268
// Login: verify the assertion signature against the stored public key.
269
// The signed message is authData concatenated with the SHA-256 of the client
270
// data — never the client data itself.
271
function webauthn_verify_assertion(
272
    string $clientDataJSON,
273
    string $authenticatorData,
274
    string $signature,
275
    string $challenge,
276
    string $pem,
277
    int $storedCount
278
): int {
279
    verify_client_data($clientDataJSON, 'webauthn.get', $challenge);
280
    $ad = parse_auth_data($authenticatorData);
281
282
    if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) {
283
        throw new RuntimeException('Assertion is for a different site');
284
    }
285
    if (!($ad['flags'] & 0x01)) {
286
        throw new RuntimeException('User presence was not verified');
287
    }
288
289
    $signed = $authenticatorData . hash('sha256', $clientDataJSON, true);
290
    if (openssl_verify($signed, $signature, $pem, OPENSSL_ALGO_SHA256) !== 1) {
291
        throw new RuntimeException('Signature does not verify');
292
    }
293
294
    // A counter that failed to advance suggests a cloned authenticator. Many
295
    // passkey providers (Apple and Google among them) always report 0 because
296
    // the credential is synced across devices by design, so the check only
297
    // applies when the authenticator actually keeps a counter.
298
    if ($storedCount > 0 && $ad['sign_count'] > 0 && $ad['sign_count'] <= $storedCount) {
299
        throw new RuntimeException('Signature counter went backwards');
300
    }
301
    return $ad['sign_count'];
302
}
303