-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPasskeyStore.php
More file actions
346 lines (299 loc) · 13.9 KB
/
Copy pathPasskeyStore.php
File metadata and controls
346 lines (299 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
<?php declare(strict_types = 1);
namespace ShipMonk\PasskeysDemo;
use PDO;
use PDOStatement;
use RuntimeException;
use ShipMonk\Passkeys\Ceremony\AuthenticationResult;
use ShipMonk\Passkeys\Ceremony\CredentialRecord;
use ShipMonk\Passkeys\Cose\CoseKey;
use ShipMonk\Passkeys\Options\PublicKeyCredentialUserEntity;
use ShipMonk\Passkeys\PasskeyStore as PasskeyStoreInterface;
use ShipMonk\Passkeys\RegisteredPasskey;
use function array_map;
use function base64_decode;
use function base64_encode;
use function date;
use function json_decode;
use function json_encode;
use function password_hash;
use function random_bytes;
use const JSON_THROW_ON_ERROR;
use const PASSWORD_DEFAULT;
/**
* The demo's database: a SQLite file (pdo_sqlite), so accounts and passkeys survive server
* restarts. It implements the library's {@see PasskeyStoreInterface PasskeyStore} — the durable
* storage a {@see \ShipMonk\Passkeys\PasskeyFlow} runs against — plus the account methods the
* demo's own endpoints need: findUserByEmail for password login, and credentialsForUser /
* deleteCredential for the manage-passkeys page. One user (identified by email) has many
* credentials (a user_id foreign key):
*
* table `users` — id (integer PK), passkey_user_handle (BLOB, unique), email (unique),
* password_hash
* table `credentials` — credential_id (PK, base64), user_id (FK),
* public_key (base64 of CoseKey::toBytes()), sign_count,
* uv_initialized, backup_eligible, backup_state, transports,
* authenticator_attachment, created_at
*
* There is no self-service signup here — real services rarely let a passkey be the *first*
* credential — so instead of an insert-on-registration path the constructor seeds two fixed
* accounts (see {@see self::DEMO_ACCOUNTS}) with bcrypt password hashes; passkeys are only ever
* added later, from an authenticated session. `password_hash` holds the output of PHP's
* {@see password_hash()} and is checked with `password_verify()` in the server's login route.
*
* The primary key is a plain integer id, as in a real schema; the WebAuthn user handle is a
* separate value — the spec-recommended 64 opaque random bytes, as
* {@see \ShipMonk\Passkeys\PasskeyFlow::generateUserHandle()} would mint — in its own unique BLOB
* column, generated once per account at seeding. Relations go through the integer id
* (credentials.user_id); the handle only crosses the wire in ceremonies and is joined back in when
* a {@see CredentialRecord} is hydrated. Handle parameters are bound as PDO::PARAM_LOB — a PHP
* string binds as text by default, and in SQLite a TEXT value never compares equal to a BLOB.
*
* The public key is a single column via {@see CoseKey::toBytes()}, rehydrated on read with
* {@see CoseKey::fromBytes()} — persistence is plain INSERT / SELECT / UPDATE, the same statements
* a production relying party would run against its own database.
*/
final class PasskeyStore implements PasskeyStoreInterface
{
/**
* The demo's fixed accounts as email => plaintext password, seeded on construction. A real
* service gets its users from normal user-management and would never hard-code a password.
*/
private const array DEMO_ACCOUNTS = [
'alice@example.com' => 'alice',
'bob@example.com' => 'bob',
];
private readonly PDO $db;
public function __construct(string $databaseFile)
{
$this->db = new PDO('sqlite:' . $databaseFile, options: [
PDO::ATTR_TIMEOUT => 5,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->exec('
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
passkey_user_handle BLOB NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS credentials (
credential_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id),
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL,
uv_initialized INTEGER NOT NULL,
backup_eligible INTEGER NOT NULL,
backup_state INTEGER NOT NULL,
transports TEXT,
authenticator_attachment TEXT,
created_at TEXT NOT NULL
);
');
$this->seedDemoAccounts();
}
/**
* Seeds {@see self::DEMO_ACCOUNTS} idempotently: INSERT OR IGNORE keys off the unique email, so
* a restart neither duplicates the accounts nor resets their handle/password. Each account is
* minted a fresh 64-byte user handle (bound as a BLOB) and a bcrypt hash of its demo password.
*/
private function seedDemoAccounts(): void
{
$statement = $this->db->prepare('
INSERT OR IGNORE INTO users (passkey_user_handle, email, password_hash)
VALUES (:handle, :email, :password_hash)
');
foreach (self::DEMO_ACCOUNTS as $email => $password) {
$this->bindParameter($statement, ':handle', random_bytes(64), PDO::PARAM_LOB);
$this->bindParameter($statement, ':email', $email);
$this->bindParameter($statement, ':password_hash', password_hash($password, PASSWORD_DEFAULT));
$statement->execute();
}
}
// -- users table --------------------------------------------------------------------------
/**
* @return array{id: int, passkey_user_handle: string, email: string, password_hash: string}|null
*/
public function findUserByEmail(string $email): ?array
{
$statement = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$this->bindParameter($statement, ':email', $email);
$statement->execute();
$row = $statement->fetch();
return $row === false ? null : $row;
}
/**
* @return array{id: int, passkey_user_handle: string, email: string, password_hash: string}|null
*/
public function findUserById(int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$this->bindParameter($statement, ':id', $id, PDO::PARAM_INT);
$statement->execute();
$row = $statement->fetch();
return $row === false ? null : $row;
}
/**
* @return array{id: int, passkey_user_handle: string, email: string, password_hash: string}|null
*/
public function findUserByHandle(string $userHandle): ?array
{
$statement = $this->db->prepare('SELECT * FROM users WHERE passkey_user_handle = :handle');
$this->bindParameter($statement, ':handle', $userHandle, PDO::PARAM_LOB);
$statement->execute();
$row = $statement->fetch();
return $row === false ? null : $row;
}
public function findUserHandleByUsername(string $username): ?string
{
// The demo's usernames are emails. Only the two-step login flow consults this; the demo's
// passkey sign-in is usernameless, so it goes unused here — but the interface requires it.
return $this->findUserByEmail($username)['passkey_user_handle'] ?? null;
}
public function findUserEntityByUserHandle(string $userHandle): ?PublicKeyCredentialUserEntity
{
$user = $this->findUserByHandle($userHandle);
if ($user === null) {
return null;
}
return new PublicKeyCredentialUserEntity(id: $user['passkey_user_handle'], name: $user['email'], displayName: $user['email']);
}
// -- credentials table --------------------------------------------------------------------
public function findCredentialByCredentialId(string $credentialId): ?CredentialRecord
{
$encodedCredentialId = base64_encode($credentialId);
$statement = $this->db->prepare('
SELECT credentials.*, users.passkey_user_handle
FROM credentials
JOIN users ON users.id = credentials.user_id
WHERE credential_id = :credential_id
');
$this->bindParameter($statement, ':credential_id', $encodedCredentialId);
$statement->execute();
$row = $statement->fetch();
return $row === false ? null : $this->recordFromRow($row);
}
/**
* @return list<CredentialRecord>
*/
public function findCredentialsByUserHandle(string $userHandle): array
{
$statement = $this->db->prepare('
SELECT credentials.*, users.passkey_user_handle
FROM credentials
JOIN users ON users.id = credentials.user_id
WHERE users.passkey_user_handle = :handle
ORDER BY created_at
');
$this->bindParameter($statement, ':handle', $userHandle, PDO::PARAM_LOB);
$statement->execute();
return array_map($this->recordFromRow(...), $statement->fetchAll());
}
public function saveCredential(RegisteredPasskey $passkey): void
{
$record = $passkey->toCredentialRecord();
$statement = $this->db->prepare('
INSERT INTO credentials (
credential_id, user_id, public_key, sign_count, uv_initialized,
backup_eligible, backup_state, transports, authenticator_attachment, created_at
) VALUES (
:credential_id, (SELECT id FROM users WHERE passkey_user_handle = :user_handle),
:public_key, :sign_count, :uv_initialized, :backup_eligible, :backup_state,
:transports, :authenticator_attachment, :created_at
)
');
$this->bindParameter($statement, ':credential_id', base64_encode($record->credentialId));
$this->bindParameter($statement, ':user_handle', $record->userHandle, PDO::PARAM_LOB);
$this->bindParameter($statement, ':public_key', base64_encode($record->publicKey->toBytes()));
$this->bindParameter($statement, ':sign_count', $record->signCount, PDO::PARAM_INT);
$this->bindParameter($statement, ':uv_initialized', (int) $record->uvInitialized, PDO::PARAM_INT);
$this->bindParameter($statement, ':backup_eligible', (int) $record->backupEligible, PDO::PARAM_INT);
$this->bindParameter($statement, ':backup_state', (int) $record->backupState, PDO::PARAM_INT);
$this->bindParameter($statement, ':transports', $record->transports === null ? null : json_encode($record->transports, JSON_THROW_ON_ERROR));
$this->bindParameter($statement, ':authenticator_attachment', $passkey->authenticatorAttachment?->value);
$this->bindParameter($statement, ':created_at', date('c'));
$statement->execute();
}
public function updateCredential(AuthenticationResult $result): void
{
// A real relying party could additionally alert on $result->possibleClone.
$credentialId = base64_encode($result->credentialId);
$signCount = $result->newSignCount;
$backupState = (int) $result->backupState;
$uvInitialized = (int) $result->userVerified;
$statement = $this->db->prepare('
UPDATE credentials
SET sign_count = :sign_count,
backup_state = :backup_state,
uv_initialized = max(uv_initialized, :uv_initialized)
WHERE credential_id = :credential_id
');
$this->bindParameter($statement, ':sign_count', $signCount, PDO::PARAM_INT);
$this->bindParameter($statement, ':backup_state', $backupState, PDO::PARAM_INT);
$this->bindParameter($statement, ':uv_initialized', $uvInitialized, PDO::PARAM_INT);
$this->bindParameter($statement, ':credential_id', $credentialId);
$statement->execute();
}
/**
* Every credential registered to a user, as raw rows — for the demo's passkey list.
*
* @return list<array<string, mixed>>
*/
public function credentialsForUser(int $userId): array
{
$statement = $this->db->prepare('SELECT * FROM credentials WHERE user_id = :user_id ORDER BY created_at');
$this->bindParameter($statement, ':user_id', $userId, PDO::PARAM_INT);
$statement->execute();
return $statement->fetchAll();
}
/**
* Removes one of a user's credentials — the "remove passkey" action on the manage page. Scoped
* to the owning user_id, so a signed-in account can only ever delete its own passkey, never one
* addressed by credential id alone.
*
* @param string $credentialId the opaque credential id handed out by {@see self::credentialsForUser()}
* (the base64 form stored in the primary-key column), echoed back verbatim by the page
*/
public function deleteCredential(
int $userId,
string $credentialId,
): void
{
$statement = $this->db->prepare('DELETE FROM credentials WHERE user_id = :user_id AND credential_id = :credential_id');
$this->bindParameter($statement, ':user_id', $userId, PDO::PARAM_INT);
$this->bindParameter($statement, ':credential_id', $credentialId);
$statement->execute();
}
/**
* @param array<string, mixed> $row
*/
private function recordFromRow(array $row): CredentialRecord
{
return new CredentialRecord(
credentialId: $this->decodeBase64($row['credential_id']),
publicKey: CoseKey::fromBytes($this->decodeBase64($row['public_key'])),
signCount: $row['sign_count'],
userHandle: $row['passkey_user_handle'],
uvInitialized: (bool) $row['uv_initialized'],
backupEligible: (bool) $row['backup_eligible'],
backupState: (bool) $row['backup_state'],
transports: $row['transports'] === null ? null : json_decode($row['transports'], flags: JSON_THROW_ON_ERROR),
);
}
private function decodeBase64(string $encoded): string
{
$decoded = base64_decode($encoded, strict: true);
if ($decoded === false) {
throw new RuntimeException('Stored value is not valid base64');
}
return $decoded;
}
private function bindParameter(
PDOStatement $statement,
string $parameterName,
mixed $value,
int $type = PDO::PARAM_STR,
): void
{
$statement->bindValue($parameterName, $value, $type);
}
}