167 lines · 6.4 KB
Raw Download
1
<?php
2
// JSON endpoint for the four WebAuthn steps. Kept apart from the pages so
3
// passkeys.php and login.php stay plain HTML and there is one place where
4
// the ceremony rules live.
5
require_once __DIR__ . '/inc/webauthn.php';
6
7
header('Content-Type: application/json; charset=UTF-8');
8
header('Cache-Control: no-store');
9
10
// No `never` return type: the README promises PHP 8.0 support and that is 8.1.
11
function say(array $data, int $code = 200)
12
{
13
    http_response_code($code);
14
    echo json_encode($data);
15
    exit;
16
}
17
18
// require_login() redirects, which is useless to fetch() — a 302 to the login
19
// page would arrive as an opaque success. Answer with JSON instead.
20
function require_login_json(): void
21
{
22
    if (!is_logged_in()) {
23
        say(['error' => 'Log in first.'], 403);
24
    }
25
}
26
27
// The browser sends JSON, so the CSRF token rides in a header rather than a
28
// form field. Same-origin is enforced by the token, not by the header alone.
29
function check_json_csrf(): void
30
{
31
    $sent = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
32
    if (!hash_equals(csrf_token(), $sent)) {
33
        say(['error' => 'Bad or missing CSRF token.'], 400);
34
    }
35
}
36
37
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
38
    say(['error' => 'POST only.'], 405);
39
}
40
check_json_csrf();
41
42
if (!webauthn_available()) {
43
    say(['error' => 'Passkeys need HTTPS (or localhost). This page is not on a secure origin.'], 400);
44
}
45
46
$body   = json_decode((string)file_get_contents('php://input'), true) ?: [];
47
$action = (string)($_GET['action'] ?? '');
48
49
switch ($action) {
50
    // ---- Registration: only someone already logged in may add a passkey.
51
    case 'register-options':
52
        require_login_json();
53
        $challenge = random_bytes(32);
54
        $_SESSION['wa_challenge'] = $challenge;
55
56
        // Existing credentials are excluded so the authenticator refuses to
57
        // enrol the same device twice instead of silently making a duplicate.
58
        $exclude = [];
59
        $st = db()->prepare('SELECT credential_id FROM passkeys WHERE rp_id = ?');
60
        $st->execute([webauthn_rp_id()]);
61
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $cid) {
62
            $exclude[] = ['type' => 'public-key', 'id' => $cid];
63
        }
64
65
        say([
66
            'challenge' => b64url_encode($challenge),
67
            'rp'        => ['id' => webauthn_rp_id(), 'name' => config()['site_name']],
68
            // A WebAuthn user handle is required but this app has exactly one
69
            // account, so it is a fixed opaque value rather than a real id.
70
            'user' => [
71
                'id'          => b64url_encode('autofeed-owner'),
72
                'name'        => 'owner',
73
                'displayName' => config()['site_name'] . ' owner',
74
            ],
75
            'pubKeyCredParams' => [
76
                ['type' => 'public-key', 'alg' => -7],    // ES256
77
                ['type' => 'public-key', 'alg' => -257],  // RS256
78
            ],
79
            'authenticatorSelection' => [
80
                'residentKey'      => 'preferred', // discoverable: lets login work without typing anything
81
                'userVerification'  => 'preferred',
82
            ],
83
            'excludeCredentials' => $exclude,
84
            'timeout'            => 60000,
85
            'attestation'        => 'none',
86
        ]);
87
        // no break — say() exits
88
89
    case 'register-verify':
90
        require_login_json();
91
        $challenge = (string)($_SESSION['wa_challenge'] ?? '');
92
        unset($_SESSION['wa_challenge']); // one challenge, one use
93
        if ($challenge === '') {
94
            say(['error' => 'That registration expired. Try again.'], 400);
95
        }
96
        try {
97
            $cred = webauthn_verify_registration(
98
                b64url_decode((string)($body['clientDataJSON'] ?? '')),
99
                b64url_decode((string)($body['attestationObject'] ?? '')),
100
                $challenge
101
            );
102
        } catch (Throwable $e) {
103
            say(['error' => 'Could not register that passkey: ' . $e->getMessage()], 400);
104
        }
105
        $label = trim((string)($body['label'] ?? ''));
106
        $label = $label === '' ? 'Passkey' : mb_substr($label, 0, 60);
107
108
        backup_db();
109
        db()->prepare(
110
            'INSERT OR REPLACE INTO passkeys (credential_id, public_key, sign_count, rp_id, label) VALUES (?,?,?,?,?)'
111
        )->execute([$cred['credential_id'], $cred['public_key'], $cred['sign_count'], webauthn_rp_id(), $label]);
112
        say(['ok' => true]);
113
114
    // ---- Login: reachable logged out, by definition.
115
    case 'login-options':
116
        $challenge = random_bytes(32);
117
        $_SESSION['wa_challenge'] = $challenge;
118
119
        $allow = [];
120
        $st = db()->prepare('SELECT credential_id FROM passkeys WHERE rp_id = ?');
121
        $st->execute([webauthn_rp_id()]);
122
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $cid) {
123
            $allow[] = ['type' => 'public-key', 'id' => $cid];
124
        }
125
        say([
126
            'challenge'        => b64url_encode($challenge),
127
            'rpId'             => webauthn_rp_id(),
128
            'allowCredentials' => $allow,
129
            'userVerification' => 'preferred',
130
            'timeout'          => 60000,
131
        ]);
132
133
    case 'login-verify':
134
        $challenge = (string)($_SESSION['wa_challenge'] ?? '');
135
        unset($_SESSION['wa_challenge']);
136
        if ($challenge === '') {
137
            say(['error' => 'That sign-in expired. Try again.'], 400);
138
        }
139
        $credId = (string)($body['id'] ?? '');
140
        $st = db()->prepare('SELECT * FROM passkeys WHERE credential_id = ? AND rp_id = ?');
141
        $st->execute([$credId, webauthn_rp_id()]);
142
        $row = $st->fetch();
143
        if (!$row) {
144
            say(['error' => 'That passkey is not registered here.'], 400);
145
        }
146
        try {
147
            $count = webauthn_verify_assertion(
148
                b64url_decode((string)($body['clientDataJSON'] ?? '')),
149
                b64url_decode((string)($body['authenticatorData'] ?? '')),
150
                b64url_decode((string)($body['signature'] ?? '')),
151
                $challenge,
152
                (string)$row['public_key'],
153
                (int)$row['sign_count']
154
            );
155
        } catch (Throwable $e) {
156
            say(['error' => 'Sign-in failed: ' . $e->getMessage()], 400);
157
        }
158
        db()->prepare("UPDATE passkeys SET sign_count = ?, last_used_at = datetime('now') WHERE id = ?")
159
            ->execute([$count, $row['id']]);
160
161
        log_in();
162
        remember_login();
163
        say(['ok' => true, 'redirect' => 'index.php']);
164
}
165
166
say(['error' => 'Unknown action.'], 400);
167