@tinfoilsh/passkey-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +127 -0
  3. package/dist/codec.d.ts +12 -0
  4. package/dist/codec.d.ts.map +1 -0
  5. package/dist/codec.js +61 -0
  6. package/dist/codec.js.map +1 -0
  7. package/dist/crypto.d.ts +55 -0
  8. package/dist/crypto.d.ts.map +1 -0
  9. package/dist/crypto.js +105 -0
  10. package/dist/crypto.js.map +1 -0
  11. package/dist/errors.d.ts +23 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +33 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/kit.d.ts +63 -0
  20. package/dist/kit.d.ts.map +1 -0
  21. package/dist/kit.js +199 -0
  22. package/dist/kit.js.map +1 -0
  23. package/dist/protocol.d.ts +13 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +13 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/storage.d.ts +31 -0
  28. package/dist/storage.d.ts.map +1 -0
  29. package/dist/storage.js +68 -0
  30. package/dist/storage.js.map +1 -0
  31. package/dist/support.d.ts +15 -0
  32. package/dist/support.d.ts.map +1 -0
  33. package/dist/support.js +58 -0
  34. package/dist/support.js.map +1 -0
  35. package/dist/types.d.ts +101 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/webauthn.d.ts +38 -0
  40. package/dist/webauthn.d.ts.map +1 -0
  41. package/dist/webauthn.js +189 -0
  42. package/dist/webauthn.js.map +1 -0
  43. package/package.json +55 -0
  44. package/src/codec.ts +71 -0
  45. package/src/crypto.ts +161 -0
  46. package/src/errors.ts +41 -0
  47. package/src/index.ts +47 -0
  48. package/src/kit.ts +333 -0
  49. package/src/protocol.ts +15 -0
  50. package/src/storage.ts +69 -0
  51. package/src/support.ts +61 -0
  52. package/src/types.ts +113 -0
  53. package/src/webauthn.ts +258 -0
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,38 @@
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
+ import type { PasskeyKitErrorMessages, PasskeyKitLogger, PasskeyUser, PrfPasskeyResult } from "./types.js";
7
+ export interface CeremonyContext {
8
+ rpId: string;
9
+ rpName: string;
10
+ /** Salt passed to PRF eval.first — the client internally computes
11
+ * SHA-256("WebAuthn PRF" || 0x00 || salt). */
12
+ prfSalt: Uint8Array;
13
+ webauthnTimeoutMs: number;
14
+ stuckTimeoutMs: number;
15
+ errorMessages?: PasskeyKitErrorMessages;
16
+ logger: PasskeyKitLogger;
17
+ /** Invoked after every successful PRF ceremony so the kit can cache state. */
18
+ onPrfResult(result: PrfPasskeyResult, credential: PublicKeyCredential): void;
19
+ }
20
+ /**
21
+ * Create a new PRF-capable passkey for the given user.
22
+ *
23
+ * Returns the credential ID and PRF output, or null if the user cancels.
24
+ * Throws {@link PrfNotSupportedError} when the authenticator cannot supply
25
+ * PRF output and {@link PasskeyTimeoutError} when the provider hangs.
26
+ */
27
+ export declare function createPrfPasskey(ctx: CeremonyContext, user: PasskeyUser): Promise<PrfPasskeyResult | null>;
28
+ /**
29
+ * Authenticate with an existing PRF passkey to derive the PRF output.
30
+ *
31
+ * @param credentialIds - base64url-encoded credential IDs to allow. Pass all
32
+ * known PRF credential IDs so the browser can select the right one.
33
+ * @returns The matched credential ID and PRF output, or null on failure/cancel.
34
+ */
35
+ export declare function authenticatePrfPasskey(ctx: CeremonyContext, credentialIds: string[], options?: {
36
+ throwOnCancel?: boolean;
37
+ }): Promise<PrfPasskeyResult | null>;
38
+ //# sourceMappingURL=webauthn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webauthn.d.ts","sourceRoot":"","sources":["../src/webauthn.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAYH,OAAO,KAAK,EACV,uBAAuB,EACvB,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf;mDAC+C;IAC/C,OAAO,EAAE,UAAU,CAAC;IACpB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,MAAM,EAAE,gBAAgB,CAAC;IACzB,8EAA8E;IAC9E,WAAW,CAAC,MAAM,EAAE,gBAAgB,EAAE,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC9E;AAwBD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,WAAW,GAChB,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAqGlC;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,eAAe,EACpB,aAAa,EAAE,MAAM,EAAE,EACvB,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CA0ElC"}
@@ -0,0 +1,189 @@
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
+ import { base64UrlToBytes, bufferSourceToArrayBuffer, bytesToBase64Url, } from "./codec.js";
7
+ import { PasskeyKitError, PasskeyTimeoutError, PrfNotSupportedError, } from "./errors.js";
8
+ const MAX_USER_HANDLE_BYTE_COUNT = 64;
9
+ async function withStuckTimeout(promise, ctx) {
10
+ let timer;
11
+ try {
12
+ return await Promise.race([
13
+ promise,
14
+ new Promise((_, reject) => {
15
+ timer = setTimeout(() => reject(new PasskeyTimeoutError(ctx.errorMessages?.timeout)), ctx.stuckTimeoutMs);
16
+ }),
17
+ ]);
18
+ }
19
+ finally {
20
+ if (timer !== undefined)
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+ /**
25
+ * Create a new PRF-capable passkey for the given user.
26
+ *
27
+ * Returns the credential ID and PRF output, or null if the user cancels.
28
+ * Throws {@link PrfNotSupportedError} when the authenticator cannot supply
29
+ * PRF output and {@link PasskeyTimeoutError} when the provider hangs.
30
+ */
31
+ export async function createPrfPasskey(ctx, user) {
32
+ const userIdBytes = new TextEncoder().encode(user.id);
33
+ if (userIdBytes.byteLength > MAX_USER_HANDLE_BYTE_COUNT) {
34
+ throw new PasskeyKitError(`passkey-kit: user id must be at most ${MAX_USER_HANDLE_BYTE_COUNT} UTF-8 bytes`);
35
+ }
36
+ try {
37
+ const credential = (await withStuckTimeout(navigator.credentials.create({
38
+ publicKey: {
39
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
40
+ rp: { id: ctx.rpId, name: ctx.rpName },
41
+ user: {
42
+ id: userIdBytes,
43
+ name: user.name,
44
+ displayName: user.displayName || user.name,
45
+ },
46
+ pubKeyCredParams: [
47
+ { type: "public-key", alg: -7 }, // ES256
48
+ { type: "public-key", alg: -257 }, // RS256 (broader compat)
49
+ ],
50
+ authenticatorSelection: {
51
+ residentKey: "preferred",
52
+ userVerification: "required",
53
+ },
54
+ timeout: ctx.webauthnTimeoutMs,
55
+ extensions: {
56
+ prf: { eval: { first: ctx.prfSalt } },
57
+ },
58
+ },
59
+ }), ctx));
60
+ if (!credential) {
61
+ return null;
62
+ }
63
+ const extensionResults = credential.getClientExtensionResults();
64
+ const prfResults = extensionResults.prf;
65
+ if (!prfResults?.enabled) {
66
+ ctx.logger.info?.("Authenticator does not support PRF", {
67
+ action: "createPrfPasskey",
68
+ });
69
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
70
+ }
71
+ const credentialId = bytesToBase64Url(new Uint8Array(credential.rawId));
72
+ // Some authenticators return PRF results during creation, others don't.
73
+ // "Not all authenticators support evaluating the PRFs during credential
74
+ // creation so outputs may, or may not, be provided."
75
+ // — https://w3c.github.io/webauthn/#prf-extension (eval description)
76
+ if (prfResults.results?.first) {
77
+ const result = {
78
+ credentialId,
79
+ prfOutput: bufferSourceToArrayBuffer(prfResults.results.first),
80
+ };
81
+ ctx.onPrfResult(result, credential);
82
+ return result;
83
+ }
84
+ // PRF enabled but no results during create — do an immediate get()
85
+ ctx.logger.info?.("PRF enabled but no results during creation, doing immediate auth", { action: "createPrfPasskey" });
86
+ // Pass throwOnCancel so a user-cancelled assertion surfaces as a
87
+ // DOMException we can handle below — otherwise a `null` return would
88
+ // be indistinguishable from "provider returned no PRF output" and we'd
89
+ // show the misleading "PRF not supported" error for a plain cancel.
90
+ const postCreateAuth = await authenticatePrfPasskey(ctx, [credentialId], {
91
+ throwOnCancel: true,
92
+ });
93
+ if (!postCreateAuth) {
94
+ // The provider claimed PRF support during creation but didn't deliver
95
+ // a PRF output on the immediately-following assertion. Treat this as
96
+ // a lack of real PRF support rather than a silent failure.
97
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
98
+ }
99
+ return postCreateAuth;
100
+ }
101
+ catch (error) {
102
+ if (error instanceof PrfNotSupportedError)
103
+ throw error;
104
+ if (error instanceof PasskeyTimeoutError)
105
+ throw error;
106
+ // DOMException with name "NotAllowedError" means the user cancelled
107
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
108
+ ctx.logger.info?.("User cancelled passkey creation", {
109
+ action: "createPrfPasskey",
110
+ });
111
+ return null;
112
+ }
113
+ ctx.logger.error?.("Failed to create PRF passkey", error, {
114
+ action: "createPrfPasskey",
115
+ });
116
+ throw error;
117
+ }
118
+ }
119
+ /**
120
+ * Authenticate with an existing PRF passkey to derive the PRF output.
121
+ *
122
+ * @param credentialIds - base64url-encoded credential IDs to allow. Pass all
123
+ * known PRF credential IDs so the browser can select the right one.
124
+ * @returns The matched credential ID and PRF output, or null on failure/cancel.
125
+ */
126
+ export async function authenticatePrfPasskey(ctx, credentialIds, options = {}) {
127
+ const { throwOnCancel = false } = options;
128
+ const allowCredentials = credentialIds.map((id) => ({
129
+ id: base64UrlToBytes(id),
130
+ type: "public-key",
131
+ }));
132
+ try {
133
+ const assertion = (await withStuckTimeout(navigator.credentials.get({
134
+ publicKey: {
135
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
136
+ rpId: ctx.rpId,
137
+ allowCredentials,
138
+ userVerification: "required",
139
+ timeout: ctx.webauthnTimeoutMs,
140
+ extensions: {
141
+ prf: { eval: { first: ctx.prfSalt } },
142
+ },
143
+ },
144
+ }), ctx));
145
+ if (!assertion) {
146
+ ctx.logger.info?.("passkey assertion returned no credential", {
147
+ action: "authenticatePrfPasskey",
148
+ allowedCredentials: credentialIds.length,
149
+ });
150
+ return null;
151
+ }
152
+ const extensionResults = assertion.getClientExtensionResults();
153
+ const prfOutput = extensionResults.prf?.results?.first;
154
+ if (!prfOutput) {
155
+ ctx.logger.error?.("PRF output missing from assertion", undefined, {
156
+ action: "authenticatePrfPasskey",
157
+ });
158
+ throw new PrfNotSupportedError(ctx.errorMessages?.prfNotSupported);
159
+ }
160
+ const result = {
161
+ credentialId: bytesToBase64Url(new Uint8Array(assertion.rawId)),
162
+ prfOutput: bufferSourceToArrayBuffer(prfOutput),
163
+ };
164
+ ctx.onPrfResult(result, assertion);
165
+ return result;
166
+ }
167
+ catch (error) {
168
+ if (error instanceof PasskeyTimeoutError)
169
+ throw error;
170
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
171
+ // NotAllowedError covers both a user cancel and the case where the
172
+ // provider has no usable credential for any of the allowed ids
173
+ // (e.g. the passkey was created in a different browser/profile and
174
+ // never persisted on this device).
175
+ ctx.logger.info?.("passkey authentication not allowed (cancelled or no usable credential)", {
176
+ action: "authenticatePrfPasskey",
177
+ allowedCredentials: credentialIds.length,
178
+ });
179
+ if (throwOnCancel)
180
+ throw error;
181
+ return null;
182
+ }
183
+ ctx.logger.error?.("Failed to authenticate with PRF passkey", error, {
184
+ action: "authenticatePrfPasskey",
185
+ });
186
+ throw error;
187
+ }
188
+ }
189
+ //# sourceMappingURL=webauthn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webauthn.js","sourceRoot":"","sources":["../src/webauthn.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@tinfoilsh/passkey-kit",
3
+ "version": "0.1.0",
4
+ "description": "WebAuthn PRF passkey SDK for protecting encryption keys with passkey-derived keys.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
23
+ "build": "npm run clean && tsc -p tsconfig.build.json",
24
+ "test": "vitest run",
25
+ "typecheck": "tsc -p tsconfig.test.json",
26
+ "prepare": "npm run build",
27
+ "prepublishOnly": "npm run test && npm run typecheck"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/tinfoilsh/tinfoil-passkey-kit.git"
32
+ },
33
+ "homepage": "https://github.com/tinfoilsh/tinfoil-passkey-kit#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/tinfoilsh/tinfoil-passkey-kit/issues"
36
+ },
37
+ "keywords": [
38
+ "passkey",
39
+ "webauthn",
40
+ "prf",
41
+ "webcrypto",
42
+ "encryption"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "devDependencies": {
51
+ "happy-dom": "^20.0.11",
52
+ "typescript": "^5",
53
+ "vitest": "^4.0.16"
54
+ }
55
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,71 @@
1
+ /** Dependency-free binary codecs shared across the SDK. */
2
+
3
+ export function bytesToHex(bytes: Uint8Array): string {
4
+ let out = ''
5
+ for (let i = 0; i < bytes.length; i++) {
6
+ out += bytes[i].toString(16).padStart(2, '0')
7
+ }
8
+ return out
9
+ }
10
+
11
+ export function hexToBytes(hex: string): Uint8Array {
12
+ if (hex.length % 2 !== 0) {
13
+ throw new Error('passkey-kit: odd-length hex input')
14
+ }
15
+ if (!/^[0-9a-fA-F]*$/.test(hex)) {
16
+ throw new Error('passkey-kit: invalid hex character')
17
+ }
18
+ const out = new Uint8Array(hex.length / 2)
19
+ for (let i = 0; i < out.length; i++) {
20
+ out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16)
21
+ }
22
+ return out
23
+ }
24
+
25
+ export function bytesToBase64(bytes: Uint8Array): string {
26
+ const CHUNK_SIZE = 0x8000
27
+ const chunks: string[] = []
28
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
29
+ const chunk = bytes.subarray(i, i + CHUNK_SIZE)
30
+ chunks.push(String.fromCharCode.apply(null, Array.from(chunk)))
31
+ }
32
+ return btoa(chunks.join(''))
33
+ }
34
+
35
+ export function base64ToBytes(base64: string): Uint8Array {
36
+ const binary = atob(base64)
37
+ const bytes = new Uint8Array(binary.length)
38
+ for (let i = 0; i < binary.length; i++) {
39
+ bytes[i] = binary.charCodeAt(i)
40
+ }
41
+ return bytes
42
+ }
43
+
44
+ export function bytesToBase64Url(bytes: Uint8Array): string {
45
+ return bytesToBase64(bytes)
46
+ .replace(/\+/g, '-')
47
+ .replace(/\//g, '_')
48
+ .replace(/=+$/, '')
49
+ }
50
+
51
+ export function base64UrlToBytes(base64url: string): Uint8Array {
52
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
53
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
54
+ return base64ToBytes(padded)
55
+ }
56
+
57
+ /** Convert a BufferSource (ArrayBuffer or view) to a standalone ArrayBuffer copy. */
58
+ export function bufferSourceToArrayBuffer(source: BufferSource): ArrayBuffer {
59
+ if (source instanceof ArrayBuffer) {
60
+ return source.slice(0)
61
+ }
62
+ return source.buffer.slice(
63
+ source.byteOffset,
64
+ source.byteOffset + source.byteLength,
65
+ ) as ArrayBuffer
66
+ }
67
+
68
+ /** Normalize a string (UTF-8 encoded) or byte input to bytes. */
69
+ export function toBytes(input: string | Uint8Array): Uint8Array {
70
+ return typeof input === 'string' ? new TextEncoder().encode(input) : input
71
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Pure WebCrypto primitives: PRF output → KEK derivation (HKDF-SHA-256)
3
+ * and CEK wrap/unwrap under that KEK (AES-256-GCM).
4
+ *
5
+ * References:
6
+ * - W3C WebAuthn Level 3, §10.1.4 (PRF extension): https://w3c.github.io/webauthn/#prf-extension
7
+ * - RFC 5869 (HKDF): https://tools.ietf.org/html/rfc5869
8
+ */
9
+
10
+ import { bytesToHex, hexToBytes, toBytes } from "./codec.js";
11
+ import { PasskeyKitError } from "./errors.js";
12
+ import { TINFOIL_HKDF_INFO_V1, TINFOIL_KEY_ID_INFO_V1 } from "./protocol.js";
13
+ import type { WrappedCek } from "./types.js";
14
+
15
+ export const CEK_BYTES = 32;
16
+ const AES_GCM_IV_BYTES = 12;
17
+ const DEFAULT_KEY_ID_BYTES = 16;
18
+
19
+ /** Generate a fresh random 32-byte CEK suitable for {@link wrapCek}. */
20
+ export function generateCek(): Uint8Array {
21
+ return crypto.getRandomValues(new Uint8Array(CEK_BYTES));
22
+ }
23
+
24
+ /**
25
+ * Type guard for a well-formed CEK: a Uint8Array of exactly
26
+ * {@link CEK_BYTES} bytes. Useful for validating deserialized input
27
+ * before wrapping.
28
+ */
29
+ export function isValidCek(cek: unknown): cek is Uint8Array {
30
+ return cek instanceof Uint8Array && cek.length === CEK_BYTES;
31
+ }
32
+
33
+ /**
34
+ * Derive an AES-256-GCM Key Encryption Key (KEK) from PRF output using HKDF.
35
+ *
36
+ * Raw PRF output is treated as Input Keying Material (IKM), not used
37
+ * directly as a key. HKDF with a purpose-binding info string produces the
38
+ * final non-extractable CryptoKey. An empty HKDF salt is used, which is
39
+ * fine for high-entropy IKM (RFC 5869 §3.1).
40
+ *
41
+ * `hkdfInfo` defaults to the Tinfoil v1 protocol constant so standalone
42
+ * callers derive the same interoperable KEK as a default-configured kit.
43
+ */
44
+ export async function deriveKeyEncryptionKey(
45
+ prfOutput: ArrayBuffer | Uint8Array,
46
+ hkdfInfo: string | Uint8Array = TINFOIL_HKDF_INFO_V1,
47
+ ): Promise<CryptoKey> {
48
+ const masterKey = await crypto.subtle.importKey(
49
+ "raw",
50
+ prfOutput as BufferSource,
51
+ "HKDF",
52
+ false, // non-extractable
53
+ ["deriveKey"],
54
+ );
55
+
56
+ return crypto.subtle.deriveKey(
57
+ {
58
+ name: "HKDF",
59
+ hash: "SHA-256",
60
+ salt: new Uint8Array(),
61
+ info: toBytes(hkdfInfo) as BufferSource,
62
+ },
63
+ masterKey,
64
+ { name: "AES-GCM", length: 256 },
65
+ false, // non-extractable
66
+ ["encrypt", "decrypt"],
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Wrap a raw 32-byte CEK under a passkey-derived KEK using AES-256-GCM
72
+ * with a fresh random IV. The returned hex fields are safe to persist
73
+ * server-side; only the matching passkey can recover the CEK.
74
+ */
75
+ export async function wrapCek(opts: {
76
+ credentialId: string;
77
+ kek: CryptoKey;
78
+ cek: Uint8Array;
79
+ }): Promise<WrappedCek> {
80
+ if (opts.cek.length !== CEK_BYTES) {
81
+ throw new PasskeyKitError(
82
+ `passkey-kit: CEK must be ${CEK_BYTES} bytes, got ${opts.cek.length}`,
83
+ );
84
+ }
85
+ const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
86
+ const ciphertext = await crypto.subtle.encrypt(
87
+ { name: "AES-GCM", iv: iv as BufferSource },
88
+ opts.kek,
89
+ opts.cek as BufferSource,
90
+ );
91
+ return {
92
+ credentialId: opts.credentialId,
93
+ kekIvHex: bytesToHex(iv),
94
+ wrappedKeyHex: bytesToHex(new Uint8Array(ciphertext)),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Inverse of {@link wrapCek}: recover the raw CEK bytes given the same KEK.
100
+ * Throws on tamper (GCM auth failure) or any shape mismatch.
101
+ */
102
+ export async function unwrapCek(
103
+ kek: CryptoKey,
104
+ wrapped: Pick<WrappedCek, "kekIvHex" | "wrappedKeyHex">,
105
+ ): Promise<Uint8Array> {
106
+ if (!wrapped.kekIvHex || !wrapped.wrappedKeyHex) {
107
+ throw new PasskeyKitError("passkey-kit: missing iv or wrapped key");
108
+ }
109
+ const iv = hexToBytes(wrapped.kekIvHex);
110
+ if (iv.length !== AES_GCM_IV_BYTES) {
111
+ throw new PasskeyKitError("passkey-kit: iv length mismatch");
112
+ }
113
+ const ciphertext = hexToBytes(wrapped.wrappedKeyHex);
114
+ const plaintext = await crypto.subtle.decrypt(
115
+ { name: "AES-GCM", iv: iv as BufferSource },
116
+ kek,
117
+ ciphertext as BufferSource,
118
+ );
119
+ const cek = new Uint8Array(plaintext);
120
+ if (cek.length !== CEK_BYTES) {
121
+ throw new PasskeyKitError(
122
+ `passkey-kit: unwrapped CEK has wrong length ${cek.length}`,
123
+ );
124
+ }
125
+ return cek;
126
+ }
127
+
128
+ /**
129
+ * Derive a stable public identifier for a CEK via HKDF-SHA-256 with an
130
+ * empty salt and a purpose-binding info string. The result identifies the key
131
+ * without revealing it (one-way derivation).
132
+ */
133
+ export async function deriveKeyId(
134
+ cek: Uint8Array,
135
+ opts: { info?: string | Uint8Array; lengthBytes?: number } = {},
136
+ ): Promise<Uint8Array> {
137
+ if (cek.length !== CEK_BYTES) {
138
+ throw new PasskeyKitError(
139
+ `passkey-kit: CEK must be ${CEK_BYTES} bytes, got ${cek.length}`,
140
+ );
141
+ }
142
+ const lengthBytes = opts.lengthBytes ?? DEFAULT_KEY_ID_BYTES;
143
+ const ikm = await crypto.subtle.importKey(
144
+ "raw",
145
+ cek as BufferSource,
146
+ "HKDF",
147
+ false,
148
+ ["deriveBits"],
149
+ );
150
+ const bits = await crypto.subtle.deriveBits(
151
+ {
152
+ name: "HKDF",
153
+ hash: "SHA-256",
154
+ salt: new Uint8Array(0) as BufferSource,
155
+ info: toBytes(opts.info ?? TINFOIL_KEY_ID_INFO_V1) as BufferSource,
156
+ },
157
+ ikm,
158
+ lengthBytes * 8,
159
+ );
160
+ return new Uint8Array(bits);
161
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Typed errors thrown by the SDK. Callers should branch on `instanceof`
3
+ * (never on message strings) to drive recovery flows.
4
+ */
5
+
6
+ /** Base class for every error the SDK throws on its own behalf. */
7
+ export class PasskeyKitError extends Error {
8
+ constructor(message: string) {
9
+ super(message)
10
+ this.name = 'PasskeyKitError'
11
+ }
12
+ }
13
+
14
+ const PROVIDER_SUGGESTION =
15
+ "Try using iCloud Keychain, Chrome's built-in passkey manager, or the Passwords app in your device settings."
16
+
17
+ /**
18
+ * The authenticator created a credential but does not support the WebAuthn
19
+ * PRF extension, so no key material can be derived from it.
20
+ */
21
+ export class PrfNotSupportedError extends PasskeyKitError {
22
+ constructor(
23
+ message = `Your passkey provider doesn't support the security features required by this app. ${PROVIDER_SUGGESTION}`,
24
+ ) {
25
+ super(message)
26
+ this.name = 'PrfNotSupportedError'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The passkey provider never resolved the WebAuthn promise within the
32
+ * SDK's hard timeout (some password-manager browser extensions hang).
33
+ */
34
+ export class PasskeyTimeoutError extends PasskeyKitError {
35
+ constructor(
36
+ message = `Your passkey provider took too long to respond. This can happen with some browser extension password managers. ${PROVIDER_SUGGESTION}`,
37
+ ) {
38
+ super(message)
39
+ this.name = 'PasskeyTimeoutError'
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ export {
2
+ base64ToBytes,
3
+ base64UrlToBytes,
4
+ bufferSourceToArrayBuffer,
5
+ bytesToBase64,
6
+ bytesToBase64Url,
7
+ bytesToHex,
8
+ hexToBytes,
9
+ } from "./codec.js";
10
+ export {
11
+ CEK_BYTES,
12
+ deriveKeyEncryptionKey,
13
+ deriveKeyId,
14
+ generateCek,
15
+ isValidCek,
16
+ unwrapCek,
17
+ wrapCek,
18
+ } from "./crypto.js";
19
+ export {
20
+ PasskeyKitError,
21
+ PasskeyTimeoutError,
22
+ PrfNotSupportedError,
23
+ } from "./errors.js";
24
+ export { createPasskeyKit } from "./kit.js";
25
+ export type { PasskeyKit } from "./kit.js";
26
+ export {
27
+ TINFOIL_HKDF_INFO_V1,
28
+ TINFOIL_KEY_ID_INFO_V1,
29
+ TINFOIL_PRF_SALT_INPUT_V1,
30
+ } from "./protocol.js";
31
+ export {
32
+ browserLocalStorageAdapter,
33
+ createMemoryStorageAdapter,
34
+ } from "./storage.js";
35
+ export type { StorageAdapter } from "./storage.js";
36
+ export { detectPrfSupport } from "./support.js";
37
+ export type {
38
+ EnrollResult,
39
+ PasskeyKitConfig,
40
+ PasskeyKitErrorMessages,
41
+ PasskeyKitLogger,
42
+ PasskeyKitStorageKeys,
43
+ PasskeyUser,
44
+ PrfPasskeyResult,
45
+ UnlockResult,
46
+ WrappedCek,
47
+ } from "./types.js";