@tinfoilsh/passkey-kit 0.1.0 → 0.2.1
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/README.md +138 -81
- package/dist/crypto.d.ts +11 -54
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +146 -97
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +11 -21
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -27
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/kit.d.ts +2 -62
- package/dist/kit.d.ts.map +1 -1
- package/dist/kit.js +331 -155
- package/dist/kit.js.map +1 -1
- package/dist/storage.d.ts +15 -29
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +113 -62
- package/dist/storage.js.map +1 -1
- package/dist/support.d.ts +2 -14
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +32 -47
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +85 -89
- package/dist/types.d.ts.map +1 -1
- package/dist/webauthn.d.ts +10 -33
- package/dist/webauthn.d.ts.map +1 -1
- package/dist/webauthn.js +89 -177
- package/dist/webauthn.js.map +1 -1
- package/dist/wrapped-key-record-codec.d.ts +4 -0
- package/dist/wrapped-key-record-codec.d.ts.map +1 -0
- package/dist/wrapped-key-record-codec.js +89 -0
- package/dist/wrapped-key-record-codec.js.map +1 -0
- package/package.json +4 -2
- package/src/crypto.ts +171 -137
- package/src/errors.ts +29 -33
- package/src/index.ts +26 -41
- package/src/kit.ts +431 -260
- package/src/storage.ts +126 -60
- package/src/support.ts +36 -50
- package/src/types.ts +99 -99
- package/src/webauthn.ts +121 -237
- package/src/wrapped-key-record-codec.ts +93 -0
- package/dist/protocol.d.ts +0 -13
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -13
- package/dist/protocol.js.map +0 -1
- package/src/protocol.ts +0 -15
package/src/kit.ts
CHANGED
|
@@ -1,333 +1,504 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
copyAndValidateProfile,
|
|
3
|
+
profilesEqual,
|
|
4
|
+
unwrapKey,
|
|
5
|
+
validateCredentialId,
|
|
6
|
+
validateKey,
|
|
7
|
+
validateWrappedKey,
|
|
8
|
+
wrapKey,
|
|
13
9
|
} from "./crypto.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
CreateAndWrapKeyInput,
|
|
15
|
+
EvaluateCredentialInput,
|
|
16
|
+
PasskeyKeyManager,
|
|
17
|
+
PasskeyKeyManagerConfig,
|
|
21
18
|
PasskeyUser,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
RecoverKeyInput,
|
|
20
|
+
UnwrapKeyWithPRFResultInput,
|
|
21
|
+
WrapKeyWithPRFResultInput,
|
|
22
|
+
WrappedKey,
|
|
25
23
|
} from "./types.js";
|
|
26
24
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
createPrfCredential,
|
|
26
|
+
evaluatePrfCredential,
|
|
29
27
|
type CeremonyContext,
|
|
28
|
+
type InternalPrfResult,
|
|
30
29
|
} from "./webauthn.js";
|
|
31
30
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
interface ActiveCeremony {
|
|
36
|
+
token: symbol;
|
|
37
|
+
operation: string;
|
|
38
|
+
cancel(error: PasskeyKeyError): void;
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
|
|
180
|
+
try {
|
|
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 {
|
|
147
192
|
try {
|
|
148
|
-
storage
|
|
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
|
|
155
|
-
|
|
210
|
+
function loadCachedResult(): CachedPRFResult | null {
|
|
211
|
+
let result: CachedPRFResult | null;
|
|
156
212
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
164
222
|
} catch {
|
|
165
223
|
return null;
|
|
166
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
|
+
};
|
|
167
235
|
}
|
|
168
236
|
|
|
169
|
-
function
|
|
170
|
-
|
|
237
|
+
function loadPreferredCredentialId(): string | null {
|
|
238
|
+
try {
|
|
239
|
+
return config.storage?.loadLocalCredentialId() ?? null;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
171
243
|
}
|
|
172
244
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (credential.authenticatorAttachment === "platform") {
|
|
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
|
+
// Skip candidates that are not canonical base64url instead of
|
|
253
|
+
// failing the ceremony: one malformed ID (e.g. a legacy record
|
|
254
|
+
// written by another client) must not block assertion against
|
|
255
|
+
// the valid ones.
|
|
256
|
+
const unique = [...new Set(credentialIds)].filter((credentialId) => {
|
|
186
257
|
try {
|
|
187
|
-
|
|
258
|
+
validateCredentialId(credentialId);
|
|
259
|
+
return true;
|
|
188
260
|
} catch {
|
|
189
|
-
|
|
190
|
-
// ceremony result
|
|
261
|
+
return false;
|
|
191
262
|
}
|
|
263
|
+
});
|
|
264
|
+
if (unique.length === 0) {
|
|
265
|
+
throw invalidInput("no credentialId is valid unpadded base64url");
|
|
192
266
|
}
|
|
267
|
+
const preferred = preferredCredentialId ?? loadPreferredCredentialId();
|
|
268
|
+
if (!preferred || !unique.includes(preferred)) return unique;
|
|
269
|
+
return [preferred, ...unique.filter((credentialId) => credentialId !== preferred)];
|
|
193
270
|
}
|
|
194
271
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
272
|
+
function copyPRFOutput(
|
|
273
|
+
prfResult: { output: Uint8Array },
|
|
274
|
+
operation: string,
|
|
275
|
+
): Uint8Array {
|
|
276
|
+
if (
|
|
277
|
+
!prfResult ||
|
|
278
|
+
typeof prfResult !== "object" ||
|
|
279
|
+
!(prfResult.output instanceof Uint8Array) ||
|
|
280
|
+
prfResult.output.length !== 32
|
|
281
|
+
) {
|
|
282
|
+
throw invalidInput("prfResult.output must be exactly 32 bytes", operation);
|
|
283
|
+
}
|
|
284
|
+
return prfResult.output.slice();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function wrapKeyWithPRFResult(
|
|
288
|
+
input: WrapKeyWithPRFResultInput,
|
|
289
|
+
operation = "wrapKeyWithPRFResult",
|
|
290
|
+
): Promise<WrappedKey> {
|
|
291
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required", operation);
|
|
292
|
+
validateKey(input.keyMaterial, operation);
|
|
293
|
+
validateCredentialId(input.credentialId);
|
|
294
|
+
const keyMaterial = input.keyMaterial.slice();
|
|
295
|
+
const prfOutput = copyPRFOutput(input.prfResult, operation);
|
|
296
|
+
return wrapKey(
|
|
297
|
+
profile,
|
|
298
|
+
input.credentialId,
|
|
299
|
+
prfOutput,
|
|
300
|
+
keyMaterial,
|
|
301
|
+
operation,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
205
304
|
|
|
206
|
-
|
|
207
|
-
|
|
305
|
+
async function unwrapKeyWithPRFResult(
|
|
306
|
+
input: UnwrapKeyWithPRFResultInput,
|
|
307
|
+
operation = "unwrapKeyWithPRFResult",
|
|
308
|
+
): Promise<Uint8Array> {
|
|
309
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required", operation);
|
|
310
|
+
const wrappedKey = {
|
|
311
|
+
...input.wrappedKey,
|
|
312
|
+
profile: copyAndValidateProfile(input.wrappedKey?.profile),
|
|
313
|
+
};
|
|
314
|
+
validateWrappedKey(wrappedKey, profile);
|
|
315
|
+
const prfOutput = copyPRFOutput(input.prfResult, operation);
|
|
316
|
+
return unwrapKey(profile, prfOutput, wrappedKey, operation);
|
|
317
|
+
}
|
|
208
318
|
|
|
209
|
-
async function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
319
|
+
async function createAndWrapKey(input: CreateAndWrapKeyInput) {
|
|
320
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required");
|
|
321
|
+
validateKey(input.key, "createAndWrapKey");
|
|
322
|
+
const key = input.key.slice();
|
|
323
|
+
const user = copyUser(input.user);
|
|
324
|
+
const result = await runCeremony("createAndWrapKey", input.signal, (signal) =>
|
|
325
|
+
createPrfCredential(context(profile, relyingPartyName, timeoutMs), user, signal),
|
|
326
|
+
);
|
|
327
|
+
recordSuccessfulCredential(result);
|
|
328
|
+
const wrappedKey = await wrapKeyWithPRFResult(
|
|
329
|
+
{
|
|
330
|
+
keyMaterial: key,
|
|
331
|
+
credentialId: result.credentialId,
|
|
332
|
+
prfResult: { output: result.prfOutput },
|
|
333
|
+
},
|
|
334
|
+
"createAndWrapKey",
|
|
335
|
+
);
|
|
336
|
+
return { credentialId: result.credentialId, wrappedKey };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function prepareRecovery(input: RecoverKeyInput) {
|
|
340
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required");
|
|
341
|
+
// Skip malformed candidates (wrong profile, non-canonical
|
|
342
|
+
// credential ID, bad lengths) rather than failing the whole set,
|
|
343
|
+
// so one corrupt or foreign bundle cannot block recovery from the
|
|
344
|
+
// healthy ones. Throw only when no usable candidate remains.
|
|
345
|
+
if (!Array.isArray(input.wrappedKeys) || input.wrappedKeys.length === 0) {
|
|
346
|
+
throw invalidInput("at least one wrapped key is required");
|
|
347
|
+
}
|
|
348
|
+
const wrappedKeys: WrappedKey[] = [];
|
|
349
|
+
for (const candidate of input.wrappedKeys) {
|
|
350
|
+
try {
|
|
351
|
+
const [wrapped] = copyWrappedKeys([candidate]);
|
|
352
|
+
validateWrappedKey(wrapped, profile);
|
|
353
|
+
wrappedKeys.push(wrapped);
|
|
354
|
+
} catch {
|
|
355
|
+
// Skipped: malformed candidate.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (wrappedKeys.length === 0) {
|
|
359
|
+
throw invalidInput("no wrapped key matches this profile and format");
|
|
360
|
+
}
|
|
361
|
+
return wrappedKeys;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function evaluateCredential(
|
|
365
|
+
input: EvaluateCredentialInput,
|
|
366
|
+
operation = "evaluateCredential",
|
|
367
|
+
) {
|
|
368
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required");
|
|
369
|
+
const interaction = input.interaction ?? "interactive";
|
|
370
|
+
if (interaction !== "interactive" && interaction !== "immediatelyAvailable") {
|
|
371
|
+
throw invalidInput("interaction must be interactive or immediatelyAvailable", operation);
|
|
372
|
+
}
|
|
373
|
+
if (interaction === "immediatelyAvailable") {
|
|
374
|
+
throw new PasskeyKeyError(
|
|
375
|
+
"unsupported",
|
|
376
|
+
"immediatelyAvailable credential evaluation is not supported in browsers",
|
|
377
|
+
{ operation },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const credentialIds = orderedCredentialIds(
|
|
381
|
+
input.credentialIds,
|
|
382
|
+
input.preferredCredentialId,
|
|
383
|
+
);
|
|
384
|
+
const result = await runCeremony(operation, input.signal, (signal) =>
|
|
385
|
+
evaluatePrfCredential(
|
|
386
|
+
context(profile, relyingPartyName, timeoutMs),
|
|
387
|
+
credentialIds,
|
|
388
|
+
signal,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
recordSuccessfulCredential(result);
|
|
392
|
+
return {
|
|
393
|
+
credentialId: result.credentialId,
|
|
394
|
+
prfResult: { output: result.prfOutput.slice() },
|
|
395
|
+
};
|
|
215
396
|
}
|
|
216
397
|
|
|
217
398
|
return {
|
|
218
|
-
async
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
399
|
+
async capability(input) {
|
|
400
|
+
if (
|
|
401
|
+
!input ||
|
|
402
|
+
typeof input !== "object" ||
|
|
403
|
+
(input.operation !== "enroll" && input.operation !== "recover")
|
|
404
|
+
) {
|
|
405
|
+
throw invalidInput("operation must be enroll or recover", "capability");
|
|
406
|
+
}
|
|
407
|
+
return detectCapability(input.operation);
|
|
222
408
|
},
|
|
223
409
|
|
|
224
|
-
|
|
225
|
-
prfSupportCache = null;
|
|
226
|
-
},
|
|
410
|
+
createAndWrapKey,
|
|
227
411
|
|
|
228
|
-
|
|
229
|
-
return
|
|
412
|
+
evaluateCredential(input) {
|
|
413
|
+
return evaluateCredential(input);
|
|
230
414
|
},
|
|
231
415
|
|
|
232
|
-
|
|
233
|
-
|
|
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);
|
|
416
|
+
wrapKeyWithPRFResult(input) {
|
|
417
|
+
return wrapKeyWithPRFResult(input);
|
|
241
418
|
},
|
|
242
419
|
|
|
243
|
-
|
|
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 };
|
|
420
|
+
unwrapKeyWithPRFResult(input) {
|
|
421
|
+
return unwrapKeyWithPRFResult(input);
|
|
253
422
|
},
|
|
254
423
|
|
|
255
|
-
async
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
424
|
+
async recoverKey(input) {
|
|
425
|
+
const wrappedKeys = prepareRecovery(input);
|
|
426
|
+
const result = await evaluateCredential(
|
|
427
|
+
{
|
|
428
|
+
credentialIds: wrappedKeys.map((wrapped) => wrapped.credentialId),
|
|
429
|
+
preferredCredentialId: input.preferredCredentialId,
|
|
430
|
+
signal: input.signal,
|
|
431
|
+
interaction: input.interaction,
|
|
432
|
+
},
|
|
433
|
+
"recoverKey",
|
|
260
434
|
);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
(w) => w.credentialId === prfResult.credentialId,
|
|
435
|
+
const wrapped = wrappedKeys.find(
|
|
436
|
+
(candidate) => candidate.credentialId === result.credentialId,
|
|
264
437
|
);
|
|
265
|
-
if (!
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
const kek = await deriveKek(prfResult.prfOutput);
|
|
274
|
-
const cek = await unwrapCek(kek, match);
|
|
275
|
-
return { credentialId: prfResult.credentialId, cek };
|
|
438
|
+
if (!wrapped) throw invalidInput("credential has no matching wrapped key", "recoverKey");
|
|
439
|
+
return {
|
|
440
|
+
credentialId: result.credentialId,
|
|
441
|
+
key: await unwrapKeyWithPRFResult(
|
|
442
|
+
{ wrappedKey: wrapped, prfResult: result.prfResult },
|
|
443
|
+
"recoverKey",
|
|
444
|
+
),
|
|
445
|
+
};
|
|
276
446
|
},
|
|
277
447
|
|
|
278
|
-
async
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const cached = getCachedPrfResult();
|
|
448
|
+
async recoverKeyFromCache(input) {
|
|
449
|
+
const wrappedKeys = prepareRecovery(input);
|
|
450
|
+
const cached = loadCachedResult();
|
|
282
451
|
if (!cached) return null;
|
|
283
|
-
const
|
|
284
|
-
(
|
|
452
|
+
const wrapped = wrappedKeys.find(
|
|
453
|
+
(candidate) => candidate.credentialId === cached.credentialId,
|
|
285
454
|
);
|
|
286
|
-
if (!
|
|
455
|
+
if (!wrapped) return null;
|
|
287
456
|
try {
|
|
288
|
-
|
|
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",
|
|
457
|
+
return {
|
|
294
458
|
credentialId: cached.credentialId,
|
|
295
|
-
|
|
459
|
+
key: await unwrapKeyWithPRFResult(
|
|
460
|
+
{
|
|
461
|
+
wrappedKey: wrapped,
|
|
462
|
+
prfResult: { output: cached.prfOutput },
|
|
463
|
+
},
|
|
464
|
+
"recoverKeyFromCache",
|
|
465
|
+
),
|
|
466
|
+
};
|
|
467
|
+
} catch {
|
|
296
468
|
return null;
|
|
297
469
|
}
|
|
298
470
|
},
|
|
299
471
|
|
|
300
|
-
async
|
|
301
|
-
|
|
472
|
+
async rewrapKeyFromCache(input) {
|
|
473
|
+
if (!input || typeof input !== "object") throw invalidInput("input is required");
|
|
474
|
+
validateKey(input.key, "rewrapKeyFromCache");
|
|
475
|
+
const cached = loadCachedResult();
|
|
302
476
|
if (!cached) return null;
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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);
|
|
477
|
+
return wrapKeyWithPRFResult(
|
|
478
|
+
{
|
|
479
|
+
keyMaterial: input.key,
|
|
480
|
+
credentialId: cached.credentialId,
|
|
481
|
+
prfResult: { output: cached.prfOutput },
|
|
482
|
+
},
|
|
483
|
+
"rewrapKeyFromCache",
|
|
484
|
+
);
|
|
320
485
|
},
|
|
321
486
|
|
|
322
|
-
|
|
323
|
-
|
|
487
|
+
clearLocalState() {
|
|
488
|
+
try {
|
|
489
|
+
config.storage?.clear();
|
|
490
|
+
} catch {
|
|
491
|
+
// Storage is best-effort.
|
|
492
|
+
}
|
|
324
493
|
},
|
|
325
494
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
495
|
+
cancelActiveCeremony() {
|
|
496
|
+
const operation = activeCeremony?.operation;
|
|
497
|
+
activeCeremony?.cancel(
|
|
498
|
+
new PasskeyKeyError("cancelled", "the passkey operation was cancelled", {
|
|
499
|
+
operation,
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
331
502
|
},
|
|
332
503
|
};
|
|
333
504
|
}
|