@tinfoilsh/passkey-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +127 -0
  3. package/dist/codec.d.ts +12 -0
  4. package/dist/codec.d.ts.map +1 -0
  5. package/dist/codec.js +61 -0
  6. package/dist/codec.js.map +1 -0
  7. package/dist/crypto.d.ts +55 -0
  8. package/dist/crypto.d.ts.map +1 -0
  9. package/dist/crypto.js +105 -0
  10. package/dist/crypto.js.map +1 -0
  11. package/dist/errors.d.ts +23 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +33 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/kit.d.ts +63 -0
  20. package/dist/kit.d.ts.map +1 -0
  21. package/dist/kit.js +199 -0
  22. package/dist/kit.js.map +1 -0
  23. package/dist/protocol.d.ts +13 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +13 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/storage.d.ts +31 -0
  28. package/dist/storage.d.ts.map +1 -0
  29. package/dist/storage.js +68 -0
  30. package/dist/storage.js.map +1 -0
  31. package/dist/support.d.ts +15 -0
  32. package/dist/support.d.ts.map +1 -0
  33. package/dist/support.js +58 -0
  34. package/dist/support.js.map +1 -0
  35. package/dist/types.d.ts +101 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/webauthn.d.ts +38 -0
  40. package/dist/webauthn.d.ts.map +1 -0
  41. package/dist/webauthn.js +189 -0
  42. package/dist/webauthn.js.map +1 -0
  43. package/package.json +55 -0
  44. package/src/codec.ts +71 -0
  45. package/src/crypto.ts +161 -0
  46. package/src/errors.ts +41 -0
  47. package/src/index.ts +47 -0
  48. package/src/kit.ts +333 -0
  49. package/src/protocol.ts +15 -0
  50. package/src/storage.ts +69 -0
  51. package/src/support.ts +61 -0
  52. package/src/types.ts +113 -0
  53. package/src/webauthn.ts +258 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * WebAuthn PRF ceremonies: credential creation and assertion with the PRF
3
+ * extension. Pure ceremony logic — persistence of the results is handled
4
+ * by the kit through the hooks on {@link CeremonyContext}.
5
+ */
6
+
7
+ import {
8
+ base64UrlToBytes,
9
+ bufferSourceToArrayBuffer,
10
+ bytesToBase64Url,
11
+ } from "./codec.js";
12
+ import {
13
+ PasskeyKitError,
14
+ PasskeyTimeoutError,
15
+ PrfNotSupportedError,
16
+ } from "./errors.js";
17
+ import type {
18
+ PasskeyKitErrorMessages,
19
+ PasskeyKitLogger,
20
+ PasskeyUser,
21
+ PrfPasskeyResult,
22
+ } from "./types.js";
23
+
24
+ export interface CeremonyContext {
25
+ rpId: string;
26
+ rpName: string;
27
+ /** Salt passed to PRF eval.first — the client internally computes
28
+ * SHA-256("WebAuthn PRF" || 0x00 || salt). */
29
+ prfSalt: Uint8Array;
30
+ webauthnTimeoutMs: number;
31
+ stuckTimeoutMs: number;
32
+ errorMessages?: PasskeyKitErrorMessages;
33
+ logger: PasskeyKitLogger;
34
+ /** Invoked after every successful PRF ceremony so the kit can cache state. */
35
+ onPrfResult(result: PrfPasskeyResult, credential: PublicKeyCredential): void;
36
+ }
37
+
38
+ const MAX_USER_HANDLE_BYTE_COUNT = 64;
39
+
40
+ async function withStuckTimeout<T>(
41
+ promise: Promise<T>,
42
+ ctx: CeremonyContext,
43
+ ): Promise<T> {
44
+ let timer: ReturnType<typeof setTimeout> | undefined;
45
+ try {
46
+ return await Promise.race([
47
+ promise,
48
+ new Promise<T>((_, reject) => {
49
+ timer = setTimeout(
50
+ () => reject(new PasskeyTimeoutError(ctx.errorMessages?.timeout)),
51
+ ctx.stuckTimeoutMs,
52
+ );
53
+ }),
54
+ ]);
55
+ } finally {
56
+ if (timer !== undefined) clearTimeout(timer);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Create a new PRF-capable passkey for the given user.
62
+ *
63
+ * Returns the credential ID and PRF output, or null if the user cancels.
64
+ * Throws {@link PrfNotSupportedError} when the authenticator cannot supply
65
+ * PRF output and {@link PasskeyTimeoutError} when the provider hangs.
66
+ */
67
+ export async function createPrfPasskey(
68
+ ctx: CeremonyContext,
69
+ user: PasskeyUser,
70
+ ): Promise<PrfPasskeyResult | null> {
71
+ const userIdBytes = new TextEncoder().encode(user.id);
72
+ if (userIdBytes.byteLength > MAX_USER_HANDLE_BYTE_COUNT) {
73
+ throw new PasskeyKitError(
74
+ `passkey-kit: user id must be at most ${MAX_USER_HANDLE_BYTE_COUNT} UTF-8 bytes`,
75
+ );
76
+ }
77
+
78
+ try {
79
+ const credential = (await withStuckTimeout(
80
+ navigator.credentials.create({
81
+ publicKey: {
82
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
83
+ rp: { id: ctx.rpId, name: ctx.rpName },
84
+ user: {
85
+ id: userIdBytes,
86
+ name: user.name,
87
+ displayName: user.displayName || user.name,
88
+ },
89
+ pubKeyCredParams: [
90
+ { type: "public-key", alg: -7 }, // ES256
91
+ { type: "public-key", alg: -257 }, // RS256 (broader compat)
92
+ ],
93
+ authenticatorSelection: {
94
+ residentKey: "preferred",
95
+ userVerification: "required",
96
+ },
97
+ timeout: ctx.webauthnTimeoutMs,
98
+ extensions: {
99
+ prf: { eval: { first: ctx.prfSalt as BufferSource } },
100
+ },
101
+ },
102
+ }),
103
+ ctx,
104
+ )) as PublicKeyCredential | null;
105
+
106
+ if (!credential) {
107
+ return null;
108
+ }
109
+
110
+ const extensionResults = credential.getClientExtensionResults();
111
+ const prfResults = extensionResults.prf;
112
+
113
+ if (!prfResults?.enabled) {
114
+ ctx.logger.info?.("Authenticator does not support PRF", {
115
+ action: "createPrfPasskey",
116
+ });
117
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
118
+ }
119
+
120
+ const credentialId = bytesToBase64Url(new Uint8Array(credential.rawId));
121
+
122
+ // Some authenticators return PRF results during creation, others don't.
123
+ // "Not all authenticators support evaluating the PRFs during credential
124
+ // creation so outputs may, or may not, be provided."
125
+ // — https://w3c.github.io/webauthn/#prf-extension (eval description)
126
+ if (prfResults.results?.first) {
127
+ const result: PrfPasskeyResult = {
128
+ credentialId,
129
+ prfOutput: bufferSourceToArrayBuffer(prfResults.results.first),
130
+ };
131
+ ctx.onPrfResult(result, credential);
132
+ return result;
133
+ }
134
+
135
+ // PRF enabled but no results during create — do an immediate get()
136
+ ctx.logger.info?.(
137
+ "PRF enabled but no results during creation, doing immediate auth",
138
+ { action: "createPrfPasskey" },
139
+ );
140
+ // Pass throwOnCancel so a user-cancelled assertion surfaces as a
141
+ // DOMException we can handle below — otherwise a `null` return would
142
+ // be indistinguishable from "provider returned no PRF output" and we'd
143
+ // show the misleading "PRF not supported" error for a plain cancel.
144
+ const postCreateAuth = await authenticatePrfPasskey(ctx, [credentialId], {
145
+ throwOnCancel: true,
146
+ });
147
+ if (!postCreateAuth) {
148
+ // The provider claimed PRF support during creation but didn't deliver
149
+ // a PRF output on the immediately-following assertion. Treat this as
150
+ // a lack of real PRF support rather than a silent failure.
151
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
152
+ }
153
+ return postCreateAuth;
154
+ } catch (error) {
155
+ if (error instanceof PrfNotSupportedError) throw error;
156
+ if (error instanceof PasskeyTimeoutError) throw error;
157
+
158
+ // DOMException with name "NotAllowedError" means the user cancelled
159
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
160
+ ctx.logger.info?.("User cancelled passkey creation", {
161
+ action: "createPrfPasskey",
162
+ });
163
+ return null;
164
+ }
165
+
166
+ ctx.logger.error?.("Failed to create PRF passkey", error, {
167
+ action: "createPrfPasskey",
168
+ });
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Authenticate with an existing PRF passkey to derive the PRF output.
175
+ *
176
+ * @param credentialIds - base64url-encoded credential IDs to allow. Pass all
177
+ * known PRF credential IDs so the browser can select the right one.
178
+ * @returns The matched credential ID and PRF output, or null on failure/cancel.
179
+ */
180
+ export async function authenticatePrfPasskey(
181
+ ctx: CeremonyContext,
182
+ credentialIds: string[],
183
+ options: { throwOnCancel?: boolean } = {},
184
+ ): Promise<PrfPasskeyResult | null> {
185
+ const { throwOnCancel = false } = options;
186
+ const allowCredentials: PublicKeyCredentialDescriptor[] = credentialIds.map(
187
+ (id) => ({
188
+ id: base64UrlToBytes(id) as BufferSource,
189
+ type: "public-key",
190
+ }),
191
+ );
192
+
193
+ try {
194
+ const assertion = (await withStuckTimeout(
195
+ navigator.credentials.get({
196
+ publicKey: {
197
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
198
+ rpId: ctx.rpId,
199
+ allowCredentials,
200
+ userVerification: "required",
201
+ timeout: ctx.webauthnTimeoutMs,
202
+ extensions: {
203
+ prf: { eval: { first: ctx.prfSalt as BufferSource } },
204
+ },
205
+ },
206
+ }),
207
+ ctx,
208
+ )) as PublicKeyCredential | null;
209
+
210
+ if (!assertion) {
211
+ ctx.logger.info?.("passkey assertion returned no credential", {
212
+ action: "authenticatePrfPasskey",
213
+ allowedCredentials: credentialIds.length,
214
+ });
215
+ return null;
216
+ }
217
+
218
+ const extensionResults = assertion.getClientExtensionResults();
219
+ const prfOutput = extensionResults.prf?.results?.first;
220
+
221
+ if (!prfOutput) {
222
+ ctx.logger.error?.("PRF output missing from assertion", undefined, {
223
+ action: "authenticatePrfPasskey",
224
+ });
225
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
226
+ }
227
+
228
+ const result: PrfPasskeyResult = {
229
+ credentialId: bytesToBase64Url(new Uint8Array(assertion.rawId)),
230
+ prfOutput: bufferSourceToArrayBuffer(prfOutput),
231
+ };
232
+ ctx.onPrfResult(result, assertion);
233
+ return result;
234
+ } catch (error) {
235
+ if (error instanceof PasskeyTimeoutError) throw error;
236
+
237
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
238
+ // NotAllowedError covers both a user cancel and the case where the
239
+ // provider has no usable credential for any of the allowed ids
240
+ // (e.g. the passkey was created in a different browser/profile and
241
+ // never persisted on this device).
242
+ ctx.logger.info?.(
243
+ "passkey authentication not allowed (cancelled or no usable credential)",
244
+ {
245
+ action: "authenticatePrfPasskey",
246
+ allowedCredentials: credentialIds.length,
247
+ },
248
+ );
249
+ if (throwOnCancel) throw error;
250
+ return null;
251
+ }
252
+
253
+ ctx.logger.error?.("Failed to authenticate with PRF passkey", error, {
254
+ action: "authenticatePrfPasskey",
255
+ });
256
+ throw error;
257
+ }
258
+ }