@tinfoilsh/passkey-kit 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +138 -81
  2. package/dist/crypto.d.ts +11 -54
  3. package/dist/crypto.d.ts.map +1 -1
  4. package/dist/crypto.js +146 -97
  5. package/dist/crypto.js.map +1 -1
  6. package/dist/errors.d.ts +11 -21
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +10 -27
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +7 -10
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -7
  13. package/dist/index.js.map +1 -1
  14. package/dist/kit.d.ts +2 -62
  15. package/dist/kit.d.ts.map +1 -1
  16. package/dist/kit.js +305 -159
  17. package/dist/kit.js.map +1 -1
  18. package/dist/storage.d.ts +15 -29
  19. package/dist/storage.d.ts.map +1 -1
  20. package/dist/storage.js +113 -62
  21. package/dist/storage.js.map +1 -1
  22. package/dist/support.d.ts +2 -14
  23. package/dist/support.d.ts.map +1 -1
  24. package/dist/support.js +32 -47
  25. package/dist/support.js.map +1 -1
  26. package/dist/types.d.ts +85 -89
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/webauthn.d.ts +10 -33
  29. package/dist/webauthn.d.ts.map +1 -1
  30. package/dist/webauthn.js +89 -177
  31. package/dist/webauthn.js.map +1 -1
  32. package/dist/wrapped-key-record-codec.d.ts +4 -0
  33. package/dist/wrapped-key-record-codec.d.ts.map +1 -0
  34. package/dist/wrapped-key-record-codec.js +89 -0
  35. package/dist/wrapped-key-record-codec.js.map +1 -0
  36. package/package.json +4 -2
  37. package/src/crypto.ts +171 -137
  38. package/src/errors.ts +29 -33
  39. package/src/index.ts +26 -41
  40. package/src/kit.ts +405 -263
  41. package/src/storage.ts +126 -60
  42. package/src/support.ts +36 -50
  43. package/src/types.ts +99 -99
  44. package/src/webauthn.ts +121 -237
  45. package/src/wrapped-key-record-codec.ts +93 -0
  46. package/dist/protocol.d.ts +0 -13
  47. package/dist/protocol.d.ts.map +0 -1
  48. package/dist/protocol.js +0 -13
  49. package/dist/protocol.js.map +0 -1
  50. package/src/protocol.ts +0 -15
package/src/kit.ts CHANGED
@@ -1,333 +1,475 @@
1
- /**
2
- * The main SDK entry point. `createPasskeyKit(config)` binds relying-party
3
- * configuration, protocol constants, and local persistence into a single
4
- * object exposing both high-level flows (enroll / unlock / rewrap) and the
5
- * lower-level ceremony + crypto building blocks.
6
- */
7
-
8
- import { base64ToBytes, bytesToBase64 } from "./codec.js";
9
1
  import {
10
- deriveKeyEncryptionKey as deriveKekFromPrf,
11
- unwrapCek,
12
- wrapCek,
2
+ copyAndValidateProfile,
3
+ profilesEqual,
4
+ unwrapKey,
5
+ validateCredentialId,
6
+ validateKey,
7
+ validateWrappedKey,
8
+ wrapKey,
13
9
  } from "./crypto.js";
14
- import { TINFOIL_HKDF_INFO_V1, TINFOIL_PRF_SALT_INPUT_V1 } from "./protocol.js";
15
- import { browserLocalStorageAdapter, type StorageAdapter } from "./storage.js";
16
- import { detectPrfSupport } from "./support.js";
10
+ import { invalidInput, PasskeyKeyError } from "./errors.js";
11
+ import { capability as detectCapability } from "./support.js";
12
+ import type { CachedPRFResult } from "./storage.js";
17
13
  import type {
18
- EnrollResult,
19
- PasskeyKitConfig,
20
- PasskeyKitStorageKeys,
14
+ CreateAndWrapKeyInput,
15
+ EvaluateCredentialInput,
16
+ PasskeyKeyManager,
17
+ PasskeyKeyManagerConfig,
21
18
  PasskeyUser,
22
- PrfPasskeyResult,
23
- UnlockResult,
24
- WrappedCek,
19
+ RecoverKeyInput,
20
+ UnwrapKeyWithPRFResultInput,
21
+ WrapKeyWithPRFResultInput,
22
+ WrappedKey,
25
23
  } from "./types.js";
26
24
  import {
27
- authenticatePrfPasskey,
28
- createPrfPasskey,
25
+ createPrfCredential,
26
+ evaluatePrfCredential,
29
27
  type CeremonyContext,
28
+ type InternalPrfResult,
30
29
  } from "./webauthn.js";
31
30
 
32
- const DEFAULT_STORAGE_KEYS: PasskeyKitStorageKeys = {
33
- prfResult: "tinfoil-secret-passkey-prf-output",
34
- localCredentialId: "tinfoil-local-passkey-credential-id",
35
- };
36
-
37
- const DEFAULT_WEBAUTHN_TIMEOUT_MS = 30_000;
38
-
39
- // Internal hard timeout to guard against providers (e.g. some
40
- // password-manager browser extensions) that never resolve the
41
- // credentials.create/get promise. Kept tight so users aren't left staring
42
- // at an indefinite spinner; the WebAuthn flow itself should complete well
43
- // within this window once the provider actually prompts.
44
- const DEFAULT_STUCK_TIMEOUT_MS = 60_000;
31
+ const DEFAULT_TIMEOUT_MS = 60_000;
32
+ const MIN_TIMEOUT_MS = 1;
33
+ const MAX_TIMEOUT_MS = 2_147_483_647;
45
34
 
46
- interface PrfCacheEntry {
47
- credentialId: string;
48
- prfOutput: string; // base64-encoded
35
+ interface ActiveCeremony {
36
+ token: symbol;
37
+ operation: string;
38
+ cancel(error: PasskeyKeyError): void;
49
39
  }
50
40
 
51
- export interface PasskeyKit {
52
- /** Optimistic device/browser PRF support check (cached per kit). */
53
- isPrfSupported(): Promise<boolean>;
54
- resetPrfSupportCache(): void;
41
+ function mapCeremonyError(error: unknown, operation: string): PasskeyKeyError {
42
+ if (error instanceof PasskeyKeyError) {
43
+ if (error.operation) return error;
44
+ return new PasskeyKeyError(error.category, error.message, {
45
+ cause: error.cause,
46
+ operation,
47
+ });
48
+ }
49
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
50
+ return new PasskeyKeyError(
51
+ "cancelled",
52
+ "the prompt was dismissed or no eligible credential was available",
53
+ { cause: error, operation },
54
+ );
55
+ }
56
+ if (error instanceof DOMException && error.name === "AbortError") {
57
+ return new PasskeyKeyError("cancelled", "the passkey operation was cancelled", {
58
+ cause: error,
59
+ operation,
60
+ });
61
+ }
62
+ return new PasskeyKeyError("operation_failed", "the passkey operation failed", {
63
+ cause: error,
64
+ operation,
65
+ });
66
+ }
55
67
 
56
- /**
57
- * Create a new PRF-capable passkey. Returns null when the user cancels;
58
- * throws PrfNotSupportedError / PasskeyTimeoutError otherwise on failure.
59
- */
60
- createPasskey(user: PasskeyUser): Promise<PrfPasskeyResult | null>;
61
- /**
62
- * Prompt for an assertion against any of the given credential ids and
63
- * return the matched credential's PRF output. Null on cancel/failure.
64
- */
65
- authenticate(
66
- credentialIds: string[],
67
- options?: { throwOnCancel?: boolean },
68
- ): Promise<PrfPasskeyResult | null>;
69
- /** Derive the AES-256-GCM KEK from a PRF output (HKDF-SHA-256). */
70
- deriveKek(prfOutput: ArrayBuffer | Uint8Array): Promise<CryptoKey>;
68
+ function context(
69
+ profile: PasskeyKeyProfileSnapshot,
70
+ relyingPartyName: string,
71
+ timeoutMs: number,
72
+ ): CeremonyContext {
73
+ return {
74
+ rpId: profile.relyingPartyId,
75
+ rpName: relyingPartyName,
76
+ prfInput: profile.prfSalt,
77
+ timeoutMs,
78
+ };
79
+ }
71
80
 
72
- /** Create a passkey and wrap the given CEK under it in one flow. */
73
- enroll(opts: {
74
- user: PasskeyUser;
75
- cek: Uint8Array;
76
- }): Promise<EnrollResult | null>;
77
- /** Authenticate against the given wrapped CEKs and unwrap the matching one. */
78
- unlock(wrappedCeks: WrappedCek[]): Promise<UnlockResult | null>;
79
- /**
80
- * Unwrap the wrapped CEK matching the cached PRF output without a
81
- * biometric prompt. Returns null when nothing is cached, no wrapped CEK
82
- * matches the cached credential, or the cached output fails to unwrap;
83
- * fall back to `unlock()` in that case.
84
- */
85
- unlockWithCachedPrf(wrappedCeks: WrappedCek[]): Promise<UnlockResult | null>;
86
- /**
87
- * Re-wrap a CEK using the cached PRF output (no biometric prompt).
88
- * Returns null when nothing is cached.
89
- */
90
- rewrapWithCachedPrf(cek: Uint8Array): Promise<WrappedCek | null>;
91
- /** Wrap a CEK under the KEK of an explicit PRF result. */
92
- wrapWithPrfResult(
93
- prfResult: PrfPasskeyResult,
94
- cek: Uint8Array,
95
- ): Promise<WrappedCek>;
96
- /** Unwrap a CEK with the KEK of an explicit PRF result. */
97
- unwrapWithPrfResult(
98
- prfResult: PrfPasskeyResult,
99
- wrapped: Pick<WrappedCek, "kekIvHex" | "wrappedKeyHex">,
100
- ): Promise<Uint8Array>;
81
+ type PasskeyKeyProfileSnapshot = ReturnType<typeof copyAndValidateProfile>;
101
82
 
102
- /**
103
- * Cached PRF result from local storage, if any. The PRF output is
104
- * deterministic for a given passkey, so it can be reused to avoid
105
- * re-prompting biometrics on key updates.
106
- */
107
- getCachedPrfResult(): PrfPasskeyResult | null;
108
- clearCachedPrfResult(): void;
109
- /** Credential id owned by this device (platform attachment), if known. */
110
- getLocalCredentialId(): string | null;
111
- setLocalCredentialId(credentialId: string): void;
112
- /** Clear all device-local state (e.g. on sign-out). */
113
- clearLocalState(): void;
83
+ function copyUser(user: PasskeyUser): PasskeyUser {
84
+ if (!user || typeof user !== "object") throw invalidInput("user is required");
85
+ return {
86
+ id: user.id instanceof Uint8Array ? user.id.slice() : user.id,
87
+ name: user.name,
88
+ displayName: user.displayName,
89
+ };
114
90
  }
115
91
 
116
- export function createPasskeyKit(config: PasskeyKitConfig): PasskeyKit {
117
- const logger = config.logger ?? {};
118
- const storage: StorageAdapter | null =
119
- config.storage === undefined ? browserLocalStorageAdapter : config.storage;
120
- const storageKeys: PasskeyKitStorageKeys = {
121
- ...DEFAULT_STORAGE_KEYS,
122
- ...config.storageKeys,
123
- };
124
- // Byte inputs are snapshotted so later caller-side buffer reuse cannot
125
- // silently change the PRF domain or KEK derivation between ceremonies.
126
- const prfSalt =
127
- typeof config.prfSaltInput === "string" || config.prfSaltInput === undefined
128
- ? new TextEncoder().encode(
129
- config.prfSaltInput ?? TINFOIL_PRF_SALT_INPUT_V1,
130
- )
131
- : config.prfSaltInput.slice();
132
- const hkdfInfo =
133
- config.hkdfInfo === undefined
134
- ? TINFOIL_HKDF_INFO_V1
135
- : typeof config.hkdfInfo === "string"
136
- ? config.hkdfInfo
137
- : config.hkdfInfo.slice();
92
+ function copyWrappedKeys(wrappedKeys: WrappedKey[]): WrappedKey[] {
93
+ if (!Array.isArray(wrappedKeys) || wrappedKeys.length === 0) {
94
+ throw invalidInput("at least one wrapped key is required");
95
+ }
96
+ return wrappedKeys.map((wrapped) => {
97
+ if (!wrapped || typeof wrapped !== "object") {
98
+ throw invalidInput("wrapped key is required");
99
+ }
100
+ return {
101
+ ...wrapped,
102
+ profile: copyAndValidateProfile(wrapped.profile),
103
+ };
104
+ });
105
+ }
138
106
 
139
- let prfSupportCache: boolean | null = null;
107
+ export function createPasskeyKeyManager(
108
+ config: PasskeyKeyManagerConfig,
109
+ ): PasskeyKeyManager {
110
+ if (!config || typeof config !== "object") throw invalidInput("manager config is required");
111
+ const profile = copyAndValidateProfile(config.profile);
112
+ if (
113
+ typeof config.relyingPartyName !== "string" ||
114
+ config.relyingPartyName.length === 0
115
+ ) {
116
+ throw invalidInput("relyingPartyName must be a non-empty string");
117
+ }
118
+ const relyingPartyName = config.relyingPartyName;
119
+ if (
120
+ config.timeoutMs !== undefined &&
121
+ (!Number.isFinite(config.timeoutMs) ||
122
+ config.timeoutMs < MIN_TIMEOUT_MS ||
123
+ config.timeoutMs > MAX_TIMEOUT_MS)
124
+ ) {
125
+ throw invalidInput(
126
+ `timeoutMs must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}`,
127
+ );
128
+ }
129
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
130
+ let activeCeremony: ActiveCeremony | null = null;
131
+
132
+ async function runCeremony(
133
+ operation: string,
134
+ callerSignal: AbortSignal | undefined,
135
+ perform: (signal: AbortSignal) => Promise<InternalPrfResult>,
136
+ ): Promise<InternalPrfResult> {
137
+ if (activeCeremony) {
138
+ throw new PasskeyKeyError(
139
+ "operation_in_progress",
140
+ "another passkey ceremony is in progress",
141
+ { operation },
142
+ );
143
+ }
144
+ if (callerSignal?.aborted) {
145
+ throw new PasskeyKeyError("cancelled", "the passkey operation was cancelled", {
146
+ cause: callerSignal.reason,
147
+ operation,
148
+ });
149
+ }
140
150
 
141
- function cachePrfResult(result: PrfPasskeyResult): void {
142
- if (!storage) return;
143
- const entry: PrfCacheEntry = {
144
- credentialId: result.credentialId,
145
- prfOutput: bytesToBase64(new Uint8Array(result.prfOutput)),
151
+ const token = Symbol(operation);
152
+ const controller = new AbortController();
153
+ let rejectInterruption: ((error: PasskeyKeyError) => void) | undefined;
154
+ const interrupted = new Promise<InternalPrfResult>((_, reject) => {
155
+ rejectInterruption = reject;
156
+ });
157
+ const cancel = (error: PasskeyKeyError) => {
158
+ rejectInterruption?.(error);
159
+ controller.abort(error);
146
160
  };
161
+ activeCeremony = { token, operation, cancel };
162
+ const cancelFromCaller = () =>
163
+ cancel(
164
+ new PasskeyKeyError("cancelled", "the passkey operation was cancelled", {
165
+ cause: callerSignal?.reason,
166
+ operation,
167
+ }),
168
+ );
169
+ callerSignal?.addEventListener("abort", cancelFromCaller, { once: true });
170
+ const timeout = setTimeout(
171
+ () =>
172
+ cancel(
173
+ new PasskeyKeyError("timeout", "the passkey operation timed out", {
174
+ operation,
175
+ }),
176
+ ),
177
+ timeoutMs,
178
+ );
179
+
147
180
  try {
148
- storage.setItem(storageKeys.prfResult, JSON.stringify(entry));
181
+ return await Promise.race([perform(controller.signal), interrupted]);
182
+ } catch (error) {
183
+ throw mapCeremonyError(error, operation);
184
+ } finally {
185
+ clearTimeout(timeout);
186
+ callerSignal?.removeEventListener("abort", cancelFromCaller);
187
+ if (activeCeremony?.token === token) activeCeremony = null;
188
+ }
189
+ }
190
+
191
+ function recordSuccessfulCredential(result: InternalPrfResult): void {
192
+ try {
193
+ config.storage?.saveCachedPRFResult({
194
+ profile: copyAndValidateProfile(profile),
195
+ credentialId: result.credentialId,
196
+ prfOutput: result.prfOutput.slice(),
197
+ });
149
198
  } catch {
150
- // best-effort
199
+ // Storage is best-effort and cannot invalidate a successful ceremony.
200
+ }
201
+ if (result.isPlatformAuthenticator) {
202
+ try {
203
+ config.storage?.saveLocalCredentialId(result.credentialId);
204
+ } catch {
205
+ // Storage is best-effort and cannot invalidate a successful ceremony.
206
+ }
151
207
  }
152
208
  }
153
209
 
154
- function getCachedPrfResult(): PrfPasskeyResult | null {
155
- if (!storage) return null;
210
+ function loadCachedResult(): CachedPRFResult | null {
211
+ let result: CachedPRFResult | null;
156
212
  try {
157
- const raw = storage.getItem(storageKeys.prfResult);
158
- if (!raw) return null;
159
- const entry = JSON.parse(raw) as PrfCacheEntry;
160
- return {
161
- credentialId: entry.credentialId,
162
- prfOutput: base64ToBytes(entry.prfOutput).buffer as ArrayBuffer,
163
- };
213
+ result = config.storage?.loadCachedPRFResult() ?? null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ if (!result) return null;
218
+ let cachedProfile: PasskeyKeyProfileSnapshot;
219
+ try {
220
+ cachedProfile = copyAndValidateProfile(result.profile);
221
+ validateCredentialId(result.credentialId);
222
+ } catch {
223
+ return null;
224
+ }
225
+ if (
226
+ !profilesEqual(cachedProfile, profile) ||
227
+ !(result.prfOutput instanceof Uint8Array) ||
228
+ result.prfOutput.length !== 32
229
+ ) return null;
230
+ return {
231
+ profile: cachedProfile,
232
+ credentialId: result.credentialId,
233
+ prfOutput: result.prfOutput.slice(),
234
+ };
235
+ }
236
+
237
+ function loadPreferredCredentialId(): string | null {
238
+ try {
239
+ return config.storage?.loadLocalCredentialId() ?? null;
164
240
  } catch {
165
241
  return null;
166
242
  }
167
243
  }
168
244
 
169
- function setLocalCredentialId(credentialId: string): void {
170
- storage?.setItem(storageKeys.localCredentialId, credentialId);
245
+ function orderedCredentialIds(
246
+ credentialIds: string[],
247
+ preferredCredentialId?: string,
248
+ ): string[] {
249
+ if (!Array.isArray(credentialIds) || credentialIds.length === 0) {
250
+ throw invalidInput("at least one credentialId is required");
251
+ }
252
+ const unique = [...new Set(credentialIds)];
253
+ for (const credentialId of unique) {
254
+ validateCredentialId(credentialId);
255
+ }
256
+ const preferred = preferredCredentialId ?? loadPreferredCredentialId();
257
+ if (!preferred || !unique.includes(preferred)) return unique;
258
+ return [preferred, ...unique.filter((credentialId) => credentialId !== preferred)];
171
259
  }
172
260
 
173
- // Cross-device hybrid (QR-paired phone, etc.) reports
174
- // `authenticatorAttachment === 'cross-platform'` on the resulting
175
- // credential. Caching the cred id in that case would make this
176
- // device look like it has its own passkey when in reality the user
177
- // just borrowed another device's passkey for a one-shot unlock.
178
- // Per WebAuthn L3 §5.2.1, we only treat `authenticatorAttachment`
179
- // of `'platform'` as "this device truly owns this credential".
180
- function onPrfResult(
181
- result: PrfPasskeyResult,
182
- credential: PublicKeyCredential,
183
- ): void {
184
- cachePrfResult(result);
185
- if (credential.authenticatorAttachment === "platform") {
186
- try {
187
- setLocalCredentialId(result.credentialId);
188
- } catch {
189
- // best-effort: a throwing custom adapter must not discard the
190
- // ceremony result
191
- }
261
+ function copyPRFOutput(
262
+ prfResult: { output: Uint8Array },
263
+ operation: string,
264
+ ): Uint8Array {
265
+ if (
266
+ !prfResult ||
267
+ typeof prfResult !== "object" ||
268
+ !(prfResult.output instanceof Uint8Array) ||
269
+ prfResult.output.length !== 32
270
+ ) {
271
+ throw invalidInput("prfResult.output must be exactly 32 bytes", operation);
192
272
  }
273
+ return prfResult.output.slice();
193
274
  }
194
275
 
195
- const ceremonyContext: CeremonyContext = {
196
- rpId: config.rpId,
197
- rpName: config.rpName,
198
- prfSalt,
199
- webauthnTimeoutMs: config.webauthnTimeoutMs ?? DEFAULT_WEBAUTHN_TIMEOUT_MS,
200
- stuckTimeoutMs: config.stuckTimeoutMs ?? DEFAULT_STUCK_TIMEOUT_MS,
201
- errorMessages: config.errorMessages,
202
- logger,
203
- onPrfResult,
204
- };
276
+ async function wrapKeyWithPRFResult(
277
+ input: WrapKeyWithPRFResultInput,
278
+ operation = "wrapKeyWithPRFResult",
279
+ ): Promise<WrappedKey> {
280
+ if (!input || typeof input !== "object") throw invalidInput("input is required", operation);
281
+ validateKey(input.keyMaterial, operation);
282
+ validateCredentialId(input.credentialId);
283
+ const keyMaterial = input.keyMaterial.slice();
284
+ const prfOutput = copyPRFOutput(input.prfResult, operation);
285
+ return wrapKey(
286
+ profile,
287
+ input.credentialId,
288
+ prfOutput,
289
+ keyMaterial,
290
+ operation,
291
+ );
292
+ }
293
+
294
+ async function unwrapKeyWithPRFResult(
295
+ input: UnwrapKeyWithPRFResultInput,
296
+ operation = "unwrapKeyWithPRFResult",
297
+ ): Promise<Uint8Array> {
298
+ if (!input || typeof input !== "object") throw invalidInput("input is required", operation);
299
+ const wrappedKey = {
300
+ ...input.wrappedKey,
301
+ profile: copyAndValidateProfile(input.wrappedKey?.profile),
302
+ };
303
+ validateWrappedKey(wrappedKey, profile);
304
+ const prfOutput = copyPRFOutput(input.prfResult, operation);
305
+ return unwrapKey(profile, prfOutput, wrappedKey, operation);
306
+ }
205
307
 
206
- const deriveKek = (prfOutput: ArrayBuffer | Uint8Array) =>
207
- deriveKekFromPrf(prfOutput, hkdfInfo);
308
+ async function createAndWrapKey(input: CreateAndWrapKeyInput) {
309
+ if (!input || typeof input !== "object") throw invalidInput("input is required");
310
+ validateKey(input.key, "createAndWrapKey");
311
+ const key = input.key.slice();
312
+ const user = copyUser(input.user);
313
+ const result = await runCeremony("createAndWrapKey", input.signal, (signal) =>
314
+ createPrfCredential(context(profile, relyingPartyName, timeoutMs), user, signal),
315
+ );
316
+ recordSuccessfulCredential(result);
317
+ const wrappedKey = await wrapKeyWithPRFResult(
318
+ {
319
+ keyMaterial: key,
320
+ credentialId: result.credentialId,
321
+ prfResult: { output: result.prfOutput },
322
+ },
323
+ "createAndWrapKey",
324
+ );
325
+ return { credentialId: result.credentialId, wrappedKey };
326
+ }
208
327
 
209
- async function wrapWithPrfResult(
210
- prfResult: PrfPasskeyResult,
211
- cek: Uint8Array,
212
- ): Promise<WrappedCek> {
213
- const kek = await deriveKek(prfResult.prfOutput);
214
- return wrapCek({ credentialId: prfResult.credentialId, kek, cek });
328
+ function prepareRecovery(input: RecoverKeyInput) {
329
+ if (!input || typeof input !== "object") throw invalidInput("input is required");
330
+ const wrappedKeys = copyWrappedKeys(input.wrappedKeys);
331
+ for (const wrapped of wrappedKeys) validateWrappedKey(wrapped, profile);
332
+ return wrappedKeys;
333
+ }
334
+
335
+ async function evaluateCredential(
336
+ input: EvaluateCredentialInput,
337
+ operation = "evaluateCredential",
338
+ ) {
339
+ if (!input || typeof input !== "object") throw invalidInput("input is required");
340
+ const interaction = input.interaction ?? "interactive";
341
+ if (interaction !== "interactive" && interaction !== "immediatelyAvailable") {
342
+ throw invalidInput("interaction must be interactive or immediatelyAvailable", operation);
343
+ }
344
+ if (interaction === "immediatelyAvailable") {
345
+ throw new PasskeyKeyError(
346
+ "unsupported",
347
+ "immediatelyAvailable credential evaluation is not supported in browsers",
348
+ { operation },
349
+ );
350
+ }
351
+ const credentialIds = orderedCredentialIds(
352
+ input.credentialIds,
353
+ input.preferredCredentialId,
354
+ );
355
+ const result = await runCeremony(operation, input.signal, (signal) =>
356
+ evaluatePrfCredential(
357
+ context(profile, relyingPartyName, timeoutMs),
358
+ credentialIds,
359
+ signal,
360
+ ),
361
+ );
362
+ recordSuccessfulCredential(result);
363
+ return {
364
+ credentialId: result.credentialId,
365
+ prfResult: { output: result.prfOutput.slice() },
366
+ };
215
367
  }
216
368
 
217
369
  return {
218
- async isPrfSupported(): Promise<boolean> {
219
- if (prfSupportCache !== null) return prfSupportCache;
220
- prfSupportCache = await detectPrfSupport();
221
- return prfSupportCache;
370
+ async capability(input) {
371
+ if (
372
+ !input ||
373
+ typeof input !== "object" ||
374
+ (input.operation !== "enroll" && input.operation !== "recover")
375
+ ) {
376
+ throw invalidInput("operation must be enroll or recover", "capability");
377
+ }
378
+ return detectCapability(input.operation);
222
379
  },
223
380
 
224
- resetPrfSupportCache(): void {
225
- prfSupportCache = null;
226
- },
381
+ createAndWrapKey,
227
382
 
228
- createPasskey(user: PasskeyUser): Promise<PrfPasskeyResult | null> {
229
- return createPrfPasskey(ceremonyContext, user);
383
+ evaluateCredential(input) {
384
+ return evaluateCredential(input);
230
385
  },
231
386
 
232
- async authenticate(
233
- credentialIds: string[],
234
- options: { throwOnCancel?: boolean } = {},
235
- ): Promise<PrfPasskeyResult | null> {
236
- // An empty allowCredentials list would start a discoverable-passkey
237
- // ceremony against ANY credential, breaking the "only the supplied
238
- // ids" contract.
239
- if (credentialIds.length === 0) return null;
240
- return authenticatePrfPasskey(ceremonyContext, credentialIds, options);
387
+ wrapKeyWithPRFResult(input) {
388
+ return wrapKeyWithPRFResult(input);
241
389
  },
242
390
 
243
- deriveKek,
244
-
245
- async enroll(opts: {
246
- user: PasskeyUser;
247
- cek: Uint8Array;
248
- }): Promise<EnrollResult | null> {
249
- const prfResult = await createPrfPasskey(ceremonyContext, opts.user);
250
- if (!prfResult) return null;
251
- const wrappedCek = await wrapWithPrfResult(prfResult, opts.cek);
252
- return { credentialId: prfResult.credentialId, wrappedCek, prfResult };
391
+ unwrapKeyWithPRFResult(input) {
392
+ return unwrapKeyWithPRFResult(input);
253
393
  },
254
394
 
255
- async unlock(wrappedCeks: WrappedCek[]): Promise<UnlockResult | null> {
256
- if (wrappedCeks.length === 0) return null;
257
- const prfResult = await authenticatePrfPasskey(
258
- ceremonyContext,
259
- wrappedCeks.map((w) => w.credentialId),
395
+ async recoverKey(input) {
396
+ const wrappedKeys = prepareRecovery(input);
397
+ const result = await evaluateCredential(
398
+ {
399
+ credentialIds: wrappedKeys.map((wrapped) => wrapped.credentialId),
400
+ preferredCredentialId: input.preferredCredentialId,
401
+ signal: input.signal,
402
+ interaction: input.interaction,
403
+ },
404
+ "recoverKey",
260
405
  );
261
- if (!prfResult) return null;
262
- const match = wrappedCeks.find(
263
- (w) => w.credentialId === prfResult.credentialId,
406
+ const wrapped = wrappedKeys.find(
407
+ (candidate) => candidate.credentialId === result.credentialId,
264
408
  );
265
- if (!match) {
266
- logger.error?.(
267
- "assertion matched a credential with no wrapped CEK",
268
- undefined,
269
- { action: "unlock", credentialId: prfResult.credentialId },
270
- );
271
- return null;
272
- }
273
- const kek = await deriveKek(prfResult.prfOutput);
274
- const cek = await unwrapCek(kek, match);
275
- return { credentialId: prfResult.credentialId, cek };
409
+ if (!wrapped) throw invalidInput("credential has no matching wrapped key", "recoverKey");
410
+ return {
411
+ credentialId: result.credentialId,
412
+ key: await unwrapKeyWithPRFResult(
413
+ { wrappedKey: wrapped, prfResult: result.prfResult },
414
+ "recoverKey",
415
+ ),
416
+ };
276
417
  },
277
418
 
278
- async unlockWithCachedPrf(
279
- wrappedCeks: WrappedCek[],
280
- ): Promise<UnlockResult | null> {
281
- const cached = getCachedPrfResult();
419
+ async recoverKeyFromCache(input) {
420
+ const wrappedKeys = prepareRecovery(input);
421
+ const cached = loadCachedResult();
282
422
  if (!cached) return null;
283
- const match = wrappedCeks.find(
284
- (w) => w.credentialId === cached.credentialId,
423
+ const wrapped = wrappedKeys.find(
424
+ (candidate) => candidate.credentialId === cached.credentialId,
285
425
  );
286
- if (!match) return null;
426
+ if (!wrapped) return null;
287
427
  try {
288
- const kek = await deriveKek(cached.prfOutput);
289
- const cek = await unwrapCek(kek, match);
290
- return { credentialId: cached.credentialId, cek };
291
- } catch (error) {
292
- logger.error?.("failed to unwrap CEK with cached PRF output", error, {
293
- action: "unlockWithCachedPrf",
428
+ return {
294
429
  credentialId: cached.credentialId,
295
- });
430
+ key: await unwrapKeyWithPRFResult(
431
+ {
432
+ wrappedKey: wrapped,
433
+ prfResult: { output: cached.prfOutput },
434
+ },
435
+ "recoverKeyFromCache",
436
+ ),
437
+ };
438
+ } catch {
296
439
  return null;
297
440
  }
298
441
  },
299
442
 
300
- async rewrapWithCachedPrf(cek: Uint8Array): Promise<WrappedCek | null> {
301
- const cached = getCachedPrfResult();
443
+ async rewrapKeyFromCache(input) {
444
+ if (!input || typeof input !== "object") throw invalidInput("input is required");
445
+ validateKey(input.key, "rewrapKeyFromCache");
446
+ const cached = loadCachedResult();
302
447
  if (!cached) return null;
303
- return wrapWithPrfResult(cached, cek);
304
- },
305
-
306
- wrapWithPrfResult,
307
-
308
- async unwrapWithPrfResult(
309
- prfResult: PrfPasskeyResult,
310
- wrapped: Pick<WrappedCek, "kekIvHex" | "wrappedKeyHex">,
311
- ): Promise<Uint8Array> {
312
- const kek = await deriveKek(prfResult.prfOutput);
313
- return unwrapCek(kek, wrapped);
314
- },
315
-
316
- getCachedPrfResult,
317
-
318
- clearCachedPrfResult(): void {
319
- storage?.removeItem(storageKeys.prfResult);
448
+ return wrapKeyWithPRFResult(
449
+ {
450
+ keyMaterial: input.key,
451
+ credentialId: cached.credentialId,
452
+ prfResult: { output: cached.prfOutput },
453
+ },
454
+ "rewrapKeyFromCache",
455
+ );
320
456
  },
321
457
 
322
- getLocalCredentialId(): string | null {
323
- return storage?.getItem(storageKeys.localCredentialId) ?? null;
458
+ clearLocalState() {
459
+ try {
460
+ config.storage?.clear();
461
+ } catch {
462
+ // Storage is best-effort.
463
+ }
324
464
  },
325
465
 
326
- setLocalCredentialId,
327
-
328
- clearLocalState(): void {
329
- storage?.removeItem(storageKeys.prfResult);
330
- storage?.removeItem(storageKeys.localCredentialId);
466
+ cancelActiveCeremony() {
467
+ const operation = activeCeremony?.operation;
468
+ activeCeremony?.cancel(
469
+ new PasskeyKeyError("cancelled", "the passkey operation was cancelled", {
470
+ operation,
471
+ }),
472
+ );
331
473
  },
332
474
  };
333
475
  }