@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/kit.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
import {
|
|
10
|
+
deriveKeyEncryptionKey as deriveKekFromPrf,
|
|
11
|
+
unwrapCek,
|
|
12
|
+
wrapCek,
|
|
13
|
+
} 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";
|
|
17
|
+
import type {
|
|
18
|
+
EnrollResult,
|
|
19
|
+
PasskeyKitConfig,
|
|
20
|
+
PasskeyKitStorageKeys,
|
|
21
|
+
PasskeyUser,
|
|
22
|
+
PrfPasskeyResult,
|
|
23
|
+
UnlockResult,
|
|
24
|
+
WrappedCek,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
import {
|
|
27
|
+
authenticatePrfPasskey,
|
|
28
|
+
createPrfPasskey,
|
|
29
|
+
type CeremonyContext,
|
|
30
|
+
} from "./webauthn.js";
|
|
31
|
+
|
|
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;
|
|
45
|
+
|
|
46
|
+
interface PrfCacheEntry {
|
|
47
|
+
credentialId: string;
|
|
48
|
+
prfOutput: string; // base64-encoded
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PasskeyKit {
|
|
52
|
+
/** Optimistic device/browser PRF support check (cached per kit). */
|
|
53
|
+
isPrfSupported(): Promise<boolean>;
|
|
54
|
+
resetPrfSupportCache(): void;
|
|
55
|
+
|
|
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>;
|
|
71
|
+
|
|
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>;
|
|
101
|
+
|
|
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;
|
|
114
|
+
}
|
|
115
|
+
|
|
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();
|
|
138
|
+
|
|
139
|
+
let prfSupportCache: boolean | null = null;
|
|
140
|
+
|
|
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)),
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
storage.setItem(storageKeys.prfResult, JSON.stringify(entry));
|
|
149
|
+
} catch {
|
|
150
|
+
// best-effort
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCachedPrfResult(): PrfPasskeyResult | null {
|
|
155
|
+
if (!storage) return null;
|
|
156
|
+
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
|
+
};
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setLocalCredentialId(credentialId: string): void {
|
|
170
|
+
storage?.setItem(storageKeys.localCredentialId, credentialId);
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
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
|
+
};
|
|
205
|
+
|
|
206
|
+
const deriveKek = (prfOutput: ArrayBuffer | Uint8Array) =>
|
|
207
|
+
deriveKekFromPrf(prfOutput, hkdfInfo);
|
|
208
|
+
|
|
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 });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
async isPrfSupported(): Promise<boolean> {
|
|
219
|
+
if (prfSupportCache !== null) return prfSupportCache;
|
|
220
|
+
prfSupportCache = await detectPrfSupport();
|
|
221
|
+
return prfSupportCache;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
resetPrfSupportCache(): void {
|
|
225
|
+
prfSupportCache = null;
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
createPasskey(user: PasskeyUser): Promise<PrfPasskeyResult | null> {
|
|
229
|
+
return createPrfPasskey(ceremonyContext, user);
|
|
230
|
+
},
|
|
231
|
+
|
|
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);
|
|
241
|
+
},
|
|
242
|
+
|
|
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 };
|
|
253
|
+
},
|
|
254
|
+
|
|
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),
|
|
260
|
+
);
|
|
261
|
+
if (!prfResult) return null;
|
|
262
|
+
const match = wrappedCeks.find(
|
|
263
|
+
(w) => w.credentialId === prfResult.credentialId,
|
|
264
|
+
);
|
|
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 };
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async unlockWithCachedPrf(
|
|
279
|
+
wrappedCeks: WrappedCek[],
|
|
280
|
+
): Promise<UnlockResult | null> {
|
|
281
|
+
const cached = getCachedPrfResult();
|
|
282
|
+
if (!cached) return null;
|
|
283
|
+
const match = wrappedCeks.find(
|
|
284
|
+
(w) => w.credentialId === cached.credentialId,
|
|
285
|
+
);
|
|
286
|
+
if (!match) return null;
|
|
287
|
+
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",
|
|
294
|
+
credentialId: cached.credentialId,
|
|
295
|
+
});
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async rewrapWithCachedPrf(cek: Uint8Array): Promise<WrappedCek | null> {
|
|
301
|
+
const cached = getCachedPrfResult();
|
|
302
|
+
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);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
getLocalCredentialId(): string | null {
|
|
323
|
+
return storage?.getItem(storageKeys.localCredentialId) ?? null;
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
setLocalCredentialId,
|
|
327
|
+
|
|
328
|
+
clearLocalState(): void {
|
|
329
|
+
storage?.removeItem(storageKeys.prfResult);
|
|
330
|
+
storage?.removeItem(storageKeys.localCredentialId);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tinfoil v1 protocol constants. Every client wrapping the same CEK must
|
|
3
|
+
* use identical values: the PRF salt input and HKDF info string both feed
|
|
4
|
+
* the KEK derivation, so changing either changes every derived KEK.
|
|
5
|
+
* Override both to establish a new protocol domain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Input to the WebAuthn PRF `eval.first` salt. */
|
|
9
|
+
export const TINFOIL_PRF_SALT_INPUT_V1 = "tinfoil-chat-key-encryption";
|
|
10
|
+
|
|
11
|
+
/** HKDF info string for domain separation when deriving the KEK. */
|
|
12
|
+
export const TINFOIL_HKDF_INFO_V1 = "tinfoil-chat-kek-v1";
|
|
13
|
+
|
|
14
|
+
/** HKDF info string for deriving a stable public CEK identifier. */
|
|
15
|
+
export const TINFOIL_KEY_ID_INFO_V1 = "tinfoil-key-id-v1";
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable local persistence for the SDK's device-side state: the cached
|
|
3
|
+
* PRF output and the credential id this device owns. Adapters are
|
|
4
|
+
* synchronous by design (mirroring the Web Storage API) so callers can
|
|
5
|
+
* read cached state without awaiting.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: the PRF output cached through an adapter is raw key material —
|
|
8
|
+
* anyone who can read it can re-derive the KEK and unwrap the CEK. The
|
|
9
|
+
* default `localStorage` adapter stores it in plaintext, which is only as
|
|
10
|
+
* strong as the origin's script-injection defenses (an XSS attacker could
|
|
11
|
+
* equally just run the ceremony or exfiltrate decrypted data). Hosts with
|
|
12
|
+
* stricter requirements should supply their own adapter with at-rest
|
|
13
|
+
* protection, or pass `storage: null` to disable caching and re-prompt
|
|
14
|
+
* biometrics instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface StorageAdapter {
|
|
18
|
+
getItem(key: string): string | null
|
|
19
|
+
setItem(key: string, value: string): void
|
|
20
|
+
removeItem(key: string): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default adapter backed by `window.localStorage`. Every operation is
|
|
25
|
+
* best-effort: quota errors, privacy-mode failures, blocked-storage
|
|
26
|
+
* contexts (e.g. sandboxed frames, where even touching `localStorage`
|
|
27
|
+
* throws), and SSR (no window) all degrade to no-ops so storage problems
|
|
28
|
+
* never interrupt a passkey ceremony.
|
|
29
|
+
*/
|
|
30
|
+
export const browserLocalStorageAdapter: StorageAdapter = {
|
|
31
|
+
getItem(key: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
if (typeof localStorage === 'undefined') return null
|
|
34
|
+
return localStorage.getItem(key)
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
setItem(key: string, value: string): void {
|
|
40
|
+
try {
|
|
41
|
+
if (typeof localStorage === 'undefined') return
|
|
42
|
+
localStorage.setItem(key, value)
|
|
43
|
+
} catch {
|
|
44
|
+
// best-effort
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
removeItem(key: string): void {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof localStorage === 'undefined') return
|
|
50
|
+
localStorage.removeItem(key)
|
|
51
|
+
} catch {
|
|
52
|
+
// best-effort
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** In-memory adapter for tests and non-browser environments. */
|
|
58
|
+
export function createMemoryStorageAdapter(): StorageAdapter {
|
|
59
|
+
const store = new Map<string, string>()
|
|
60
|
+
return {
|
|
61
|
+
getItem: (key) => store.get(key) ?? null,
|
|
62
|
+
setItem: (key, value) => {
|
|
63
|
+
store.set(key, value)
|
|
64
|
+
},
|
|
65
|
+
removeItem: (key) => {
|
|
66
|
+
store.delete(key)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/support.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRF support detection.
|
|
3
|
+
*
|
|
4
|
+
* Checks whether the current browser/platform supports the WebAuthn PRF
|
|
5
|
+
* extension. This is an optimistic check — actual PRF support is only
|
|
6
|
+
* confirmed when a credential is created with prf.enabled: true in the
|
|
7
|
+
* response. If creation fails, callers should fall back to a manual flow.
|
|
8
|
+
*
|
|
9
|
+
* Detection strategy:
|
|
10
|
+
* 1. Check window.PublicKeyCredential exists (basic WebAuthn support)
|
|
11
|
+
* 2. Check isUserVerifyingPlatformAuthenticatorAvailable() (biometric/PIN authenticator present)
|
|
12
|
+
* 3. Optionally check getClientCapabilities() for explicit PRF support signal (new API, not universal)
|
|
13
|
+
*/
|
|
14
|
+
export async function detectPrfSupport(): Promise<boolean> {
|
|
15
|
+
if (typeof window === 'undefined') {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!window.PublicKeyCredential) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Check that a platform authenticator is available (Face ID, Touch ID, Windows Hello, etc.)
|
|
24
|
+
try {
|
|
25
|
+
const available =
|
|
26
|
+
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
|
27
|
+
if (!available) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// If getClientCapabilities is available, use it for a more precise check.
|
|
35
|
+
// This API is newer and may not be present in all browsers.
|
|
36
|
+
try {
|
|
37
|
+
if (typeof PublicKeyCredential.getClientCapabilities === 'function') {
|
|
38
|
+
const caps = await PublicKeyCredential.getClientCapabilities()
|
|
39
|
+
if (caps && typeof caps === 'object') {
|
|
40
|
+
// The shape of this API varies across browser versions.
|
|
41
|
+
// Chrome returns a map-like object; check for extension-prf or prf key.
|
|
42
|
+
const hasPrf =
|
|
43
|
+
(caps as Record<string, boolean>)['extension-prf'] === true ||
|
|
44
|
+
(caps as Record<string, boolean>)['prf'] === true
|
|
45
|
+
if (hasPrf) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
// If getClientCapabilities is available but doesn't report PRF,
|
|
49
|
+
// that's a strong negative signal on platforms that implement it.
|
|
50
|
+
// However, since this API is still evolving, we don't treat absence
|
|
51
|
+
// as definitive — fall through to the optimistic path.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// getClientCapabilities not available or threw — fall through
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Optimistic: platform authenticator is available, WebAuthn is supported.
|
|
59
|
+
// Actual PRF support will be confirmed during credential creation.
|
|
60
|
+
return true
|
|
61
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { StorageAdapter } from "./storage.js";
|
|
2
|
+
|
|
3
|
+
/** Structured logging hooks; the SDK never writes to the console itself. */
|
|
4
|
+
export interface PasskeyKitLogger {
|
|
5
|
+
info?(message: string, metadata?: Record<string, unknown>): void;
|
|
6
|
+
error?(
|
|
7
|
+
message: string,
|
|
8
|
+
error?: unknown,
|
|
9
|
+
metadata?: Record<string, unknown>,
|
|
10
|
+
): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PasskeyKitStorageKeys {
|
|
14
|
+
/** Key under which the cached PRF result is persisted. */
|
|
15
|
+
prfResult: string;
|
|
16
|
+
/** Key under which this device's own credential id is persisted. */
|
|
17
|
+
localCredentialId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Overrides for the messages on errors the SDK throws. Useful for branding
|
|
22
|
+
* or localization; the error classes themselves stay the same, so
|
|
23
|
+
* `instanceof` checks are unaffected.
|
|
24
|
+
*/
|
|
25
|
+
export interface PasskeyKitErrorMessages {
|
|
26
|
+
/** Message used for `PrfNotSupportedError`. */
|
|
27
|
+
prfNotSupported?: string;
|
|
28
|
+
/** Message used for `PasskeyTimeoutError`. */
|
|
29
|
+
timeout?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PasskeyKitConfig {
|
|
33
|
+
/** WebAuthn relying party id (e.g. `example.com`, or `localhost` in dev). */
|
|
34
|
+
rpId: string;
|
|
35
|
+
/** Human-readable relying party name shown in passkey prompts. */
|
|
36
|
+
rpName: string;
|
|
37
|
+
/**
|
|
38
|
+
* Input to the PRF `eval.first` salt. Must stay stable across all clients
|
|
39
|
+
* of the same protocol: changing it changes every derived KEK.
|
|
40
|
+
* Defaults to the Tinfoil v1 protocol constant.
|
|
41
|
+
*/
|
|
42
|
+
prfSaltInput?: string | Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* HKDF info string used for domain separation when deriving the KEK from
|
|
45
|
+
* the PRF output. Defaults to the Tinfoil v1 protocol constant.
|
|
46
|
+
*/
|
|
47
|
+
hkdfInfo?: string | Uint8Array;
|
|
48
|
+
/**
|
|
49
|
+
* Local persistence for the PRF cache and this device's credential id.
|
|
50
|
+
* Defaults to a best-effort `localStorage` adapter; pass `null` to
|
|
51
|
+
* disable local persistence entirely.
|
|
52
|
+
*/
|
|
53
|
+
storage?: StorageAdapter | null;
|
|
54
|
+
storageKeys?: Partial<PasskeyKitStorageKeys>;
|
|
55
|
+
/** Timeout passed to the WebAuthn API (some browsers ignore this). */
|
|
56
|
+
webauthnTimeoutMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Hard client-side timeout guarding against providers that never resolve
|
|
59
|
+
* the WebAuthn promise. When exceeded, `PasskeyTimeoutError` is thrown.
|
|
60
|
+
*/
|
|
61
|
+
stuckTimeoutMs?: number;
|
|
62
|
+
/** Custom messages for the errors the SDK throws. */
|
|
63
|
+
errorMessages?: PasskeyKitErrorMessages;
|
|
64
|
+
logger?: PasskeyKitLogger;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Identity attached to a newly created passkey. */
|
|
68
|
+
export interface PasskeyUser {
|
|
69
|
+
/** Stable opaque user id (becomes the WebAuthn user handle). */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Account identifier shown in passkey pickers (usually an email). */
|
|
72
|
+
name: string;
|
|
73
|
+
/** Friendly display name; falls back to `name` when omitted. */
|
|
74
|
+
displayName?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Result of a successful PRF ceremony (create or authenticate). */
|
|
78
|
+
export interface PrfPasskeyResult {
|
|
79
|
+
/** base64url-encoded credential id. */
|
|
80
|
+
credentialId: string;
|
|
81
|
+
/** Raw 32-byte PRF output; treat as secret key material. */
|
|
82
|
+
prfOutput: ArrayBuffer;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A CEK wrapped under a passkey-derived KEK with AES-256-GCM. */
|
|
86
|
+
export interface WrappedCek {
|
|
87
|
+
/** base64url-encoded credential id whose PRF output wraps this CEK. */
|
|
88
|
+
credentialId: string;
|
|
89
|
+
/** 12-byte AES-GCM IV, hex-encoded. */
|
|
90
|
+
kekIvHex: string;
|
|
91
|
+
/** Wrapped CEK ciphertext (including GCM tag), hex-encoded. */
|
|
92
|
+
wrappedKeyHex: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Result of the high-level enroll flow: create passkey + wrap CEK. */
|
|
96
|
+
export interface EnrollResult {
|
|
97
|
+
credentialId: string;
|
|
98
|
+
/** Ciphertext safe to persist server-side. */
|
|
99
|
+
wrappedCek: WrappedCek;
|
|
100
|
+
/**
|
|
101
|
+
* Device-local secret state (PRF output). Already persisted through the
|
|
102
|
+
* storage adapter when one is configured; returned so hosts with custom
|
|
103
|
+
* persistence can store it themselves.
|
|
104
|
+
*/
|
|
105
|
+
prfResult: PrfPasskeyResult;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Result of the high-level unlock flow: authenticate + unwrap CEK. */
|
|
109
|
+
export interface UnlockResult {
|
|
110
|
+
credentialId: string;
|
|
111
|
+
/** The recovered raw 32-byte CEK. */
|
|
112
|
+
cek: Uint8Array;
|
|
113
|
+
}
|