@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.
- package/LICENSE +201 -0
- package/README.md +127 -0
- package/dist/codec.d.ts +12 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +61 -0
- package/dist/codec.js.map +1 -0
- package/dist/crypto.d.ts +55 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +105 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +33 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +63 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +199 -0
- package/dist/kit.js.map +1 -0
- package/dist/protocol.d.ts +13 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +13 -0
- package/dist/protocol.js.map +1 -0
- package/dist/storage.d.ts +31 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +68 -0
- package/dist/storage.js.map +1 -0
- package/dist/support.d.ts +15 -0
- package/dist/support.d.ts.map +1 -0
- package/dist/support.js +58 -0
- package/dist/support.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webauthn.d.ts +38 -0
- package/dist/webauthn.d.ts.map +1 -0
- package/dist/webauthn.js +189 -0
- package/dist/webauthn.js.map +1 -0
- package/package.json +55 -0
- package/src/codec.ts +71 -0
- package/src/crypto.ts +161 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +47 -0
- package/src/kit.ts +333 -0
- package/src/protocol.ts +15 -0
- package/src/storage.ts +69 -0
- package/src/support.ts +61 -0
- package/src/types.ts +113 -0
- package/src/webauthn.ts +258 -0
package/src/webauthn.ts
ADDED
|
@@ -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
|
+
}
|