@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.
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 +331 -155
  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 +431 -260
  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/crypto.ts CHANGED
@@ -1,161 +1,195 @@
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
- */
1
+ import {
2
+ base64UrlToBytes,
3
+ bytesToBase64Url,
4
+ bytesToHex,
5
+ hexToBytes,
6
+ } from "./codec.js";
7
+ import { invalidInput, operationFailed, PasskeyKeyError } from "./errors.js";
8
+ import type { PasskeyKeyProfile, WrappedKey } from "./types.js";
9
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;
10
+ const KEY_BYTES = 32;
11
+ const PRF_OUTPUT_BYTES = 32;
16
12
  const AES_GCM_IV_BYTES = 12;
17
- const DEFAULT_KEY_ID_BYTES = 16;
13
+ const AES_GCM_TAG_BYTES = 16;
14
+ export const PROFILE_KEYS = [
15
+ "version",
16
+ "relyingPartyId",
17
+ "prfSalt",
18
+ "hkdfInfo",
19
+ ] as const;
18
20
 
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));
21
+ function assertBytes(value: unknown, name: string, allowEmpty = false): asserts value is Uint8Array {
22
+ if (!(value instanceof Uint8Array) || (!allowEmpty && value.length === 0)) {
23
+ throw invalidInput(`${name} must be ${allowEmpty ? "a" : "a non-empty"} Uint8Array`);
24
+ }
22
25
  }
23
26
 
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;
27
+ export function copyAndValidateProfile(profile: PasskeyKeyProfile): PasskeyKeyProfile {
28
+ if (!profile || typeof profile !== "object") throw invalidInput("profile is required");
29
+ const keys = Object.keys(profile).sort();
30
+ const expected = [...PROFILE_KEYS].sort();
31
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
32
+ throw invalidInput(`profile must contain exactly ${PROFILE_KEYS.join(", ")}`);
33
+ }
34
+ if (profile.version !== 1) {
35
+ throw invalidInput("profile.version must be 1");
36
+ }
37
+ if (typeof profile.relyingPartyId !== "string" || profile.relyingPartyId.length === 0) {
38
+ throw invalidInput("profile.relyingPartyId must be a non-empty string");
39
+ }
40
+ assertBytes(profile.prfSalt, "profile.prfSalt");
41
+ assertBytes(profile.hkdfInfo, "profile.hkdfInfo");
42
+ return {
43
+ version: profile.version,
44
+ relyingPartyId: profile.relyingPartyId,
45
+ prfSalt: profile.prfSalt.slice(),
46
+ hkdfInfo: profile.hkdfInfo.slice(),
47
+ };
31
48
  }
32
49
 
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"],
50
+ export function profilesEqual(
51
+ left: PasskeyKeyProfile,
52
+ right: PasskeyKeyProfile,
53
+ ): boolean {
54
+ return (
55
+ left.version === right.version &&
56
+ left.relyingPartyId === right.relyingPartyId &&
57
+ left.prfSalt.length === right.prfSalt.length &&
58
+ left.prfSalt.every((byte, index) => byte === right.prfSalt[index]) &&
59
+ left.hkdfInfo.length === right.hkdfInfo.length &&
60
+ left.hkdfInfo.every((byte, index) => byte === right.hkdfInfo[index])
54
61
  );
62
+ }
55
63
 
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
- );
64
+ export function decodeCanonicalBase64Url(value: unknown, field: string): Uint8Array {
65
+ if (
66
+ typeof value !== "string" ||
67
+ value.length === 0 ||
68
+ !/^[A-Za-z0-9_-]+$/.test(value) ||
69
+ value.length % 4 === 1
70
+ ) {
71
+ throw invalidInput(`${field} must be unpadded base64url`);
72
+ }
73
+ try {
74
+ const bytes = base64UrlToBytes(value);
75
+ if (bytes.length === 0 || bytesToBase64Url(bytes) !== value) {
76
+ throw invalidInput(`${field} must use canonical unpadded base64url`);
77
+ }
78
+ return bytes;
79
+ } catch (cause) {
80
+ if (cause instanceof PasskeyKeyError) throw cause;
81
+ throw invalidInput(`${field} must be unpadded base64url`);
82
+ }
68
83
  }
69
84
 
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
- );
85
+ export function validateCredentialId(credentialId: string): void {
86
+ decodeCanonicalBase64Url(credentialId, "credentialId");
87
+ }
88
+
89
+ export function validateKey(key: Uint8Array, operation?: string): void {
90
+ if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) {
91
+ throw invalidInput(`key must be exactly ${KEY_BYTES} bytes`, operation);
84
92
  }
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
93
  }
97
94
 
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");
95
+ export function validateWrappedKey(wrapped: WrappedKey, profile: PasskeyKeyProfile): void {
96
+ if (!wrapped || typeof wrapped !== "object") throw invalidInput("wrapped key is required");
97
+ const keys = Object.keys(wrapped).sort();
98
+ const expected = ["profile", "credentialId", "kekIvHex", "wrappedKeyHex"].sort();
99
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
100
+ throw invalidInput("wrapped key has unexpected fields");
108
101
  }
109
- const iv = hexToBytes(wrapped.kekIvHex);
110
- if (iv.length !== AES_GCM_IV_BYTES) {
111
- throw new PasskeyKitError("passkey-kit: iv length mismatch");
102
+ const wrappedProfile = copyAndValidateProfile(wrapped.profile);
103
+ if (!profilesEqual(wrappedProfile, profile)) {
104
+ throw invalidInput("wrapped key profile mismatch");
112
105
  }
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}`,
106
+ validateCredentialId(wrapped.credentialId);
107
+ if (!/^[0-9a-f]{24}$/.test(wrapped.kekIvHex)) {
108
+ throw invalidInput("kekIvHex must be a lowercase 12-byte hex value");
109
+ }
110
+ const ciphertextHexLength = (KEY_BYTES + AES_GCM_TAG_BYTES) * 2;
111
+ if (!new RegExp(`^[0-9a-f]{${ciphertextHexLength}}$`).test(wrapped.wrappedKeyHex)) {
112
+ throw invalidInput("wrappedKeyHex has an invalid format or length");
113
+ }
114
+ }
115
+
116
+ export async function deriveWrappingKey(
117
+ prfOutput: Uint8Array,
118
+ profile: PasskeyKeyProfile,
119
+ ): Promise<CryptoKey> {
120
+ if (!(prfOutput instanceof Uint8Array) || prfOutput.length !== PRF_OUTPUT_BYTES) {
121
+ throw invalidInput(`PRF output must be exactly ${PRF_OUTPUT_BYTES} bytes`);
122
+ }
123
+ try {
124
+ const ikm = await crypto.subtle.importKey("raw", prfOutput.slice(), "HKDF", false, [
125
+ "deriveKey",
126
+ ]);
127
+ return await crypto.subtle.deriveKey(
128
+ {
129
+ name: "HKDF",
130
+ hash: "SHA-256",
131
+ salt: new Uint8Array() as BufferSource,
132
+ info: profile.hkdfInfo as BufferSource,
133
+ },
134
+ ikm,
135
+ { name: "AES-GCM", length: 256 },
136
+ false,
137
+ ["encrypt", "decrypt"],
138
+ );
139
+ } catch (cause) {
140
+ if (cause instanceof PasskeyKeyError) throw cause;
141
+ throw operationFailed("failed to derive wrapping key", cause);
142
+ }
143
+ }
144
+
145
+ export async function wrapKey(
146
+ profile: PasskeyKeyProfile,
147
+ credentialId: string,
148
+ prfOutput: Uint8Array,
149
+ key: Uint8Array,
150
+ operation = "createAndWrapKey",
151
+ ): Promise<WrappedKey> {
152
+ validateCredentialId(credentialId);
153
+ validateKey(key, operation);
154
+ try {
155
+ const wrappingKey = await deriveWrappingKey(prfOutput, profile);
156
+ const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
157
+ const ciphertext = await crypto.subtle.encrypt(
158
+ { name: "AES-GCM", iv: iv as BufferSource },
159
+ wrappingKey,
160
+ key as BufferSource,
123
161
  );
162
+ return {
163
+ profile: copyAndValidateProfile(profile),
164
+ credentialId,
165
+ kekIvHex: bytesToHex(iv),
166
+ wrappedKeyHex: bytesToHex(new Uint8Array(ciphertext)),
167
+ };
168
+ } catch (cause) {
169
+ if (cause instanceof PasskeyKeyError) throw cause;
170
+ throw operationFailed("failed to wrap key", cause, operation);
124
171
  }
125
- return cek;
126
172
  }
127
173
 
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 } = {},
174
+ export async function unwrapKey(
175
+ profile: PasskeyKeyProfile,
176
+ prfOutput: Uint8Array,
177
+ wrapped: WrappedKey,
178
+ operation = "recoverKey",
136
179
  ): Promise<Uint8Array> {
137
- if (cek.length !== CEK_BYTES) {
138
- throw new PasskeyKitError(
139
- `passkey-kit: CEK must be ${CEK_BYTES} bytes, got ${cek.length}`,
180
+ validateWrappedKey(wrapped, profile);
181
+ try {
182
+ const wrappingKey = await deriveWrappingKey(prfOutput, profile);
183
+ const plaintext = await crypto.subtle.decrypt(
184
+ { name: "AES-GCM", iv: hexToBytes(wrapped.kekIvHex) as BufferSource },
185
+ wrappingKey,
186
+ hexToBytes(wrapped.wrappedKeyHex) as BufferSource,
140
187
  );
188
+ const key = new Uint8Array(plaintext);
189
+ validateKey(key, operation);
190
+ return key;
191
+ } catch (cause) {
192
+ if (cause instanceof PasskeyKeyError) throw cause;
193
+ throw operationFailed("failed to recover key", cause, operation);
141
194
  }
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
195
  }
package/src/errors.ts CHANGED
@@ -1,41 +1,37 @@
1
- /**
2
- * Typed errors thrown by the SDK. Callers should branch on `instanceof`
3
- * (never on message strings) to drive recovery flows.
4
- */
1
+ export type PasskeyKeyErrorCategory =
2
+ | "unsupported"
3
+ | "cancelled"
4
+ | "timeout"
5
+ | "operation_in_progress"
6
+ | "invalid_input"
7
+ | "operation_failed";
5
8
 
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
- }
9
+ export class PasskeyKeyError extends Error {
10
+ readonly category: PasskeyKeyErrorCategory;
11
+ readonly operation?: string;
12
+ readonly cause?: unknown;
13
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
14
  constructor(
23
- message = `Your passkey provider doesn't support the security features required by this app. ${PROVIDER_SUGGESTION}`,
15
+ category: PasskeyKeyErrorCategory,
16
+ message: string,
17
+ options: { cause?: unknown; operation?: string } = {},
24
18
  ) {
25
- super(message)
26
- this.name = 'PrfNotSupportedError'
19
+ super(message);
20
+ this.name = "PasskeyKeyError";
21
+ this.category = category;
22
+ this.operation = options.operation;
23
+ this.cause = options.cause;
27
24
  }
28
25
  }
29
26
 
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
- }
27
+ export function invalidInput(message: string, operation?: string): PasskeyKeyError {
28
+ return new PasskeyKeyError("invalid_input", message, { operation });
29
+ }
30
+
31
+ export function operationFailed(
32
+ message: string,
33
+ cause: unknown,
34
+ operation?: string,
35
+ ): PasskeyKeyError {
36
+ return new PasskeyKeyError("operation_failed", message, { cause, operation });
41
37
  }
package/src/index.ts CHANGED
@@ -1,47 +1,32 @@
1
+ export { PasskeyKeyError } from "./errors.js";
2
+ export type { PasskeyKeyErrorCategory } from "./errors.js";
3
+ export { createPasskeyKeyManager } from "./kit.js";
1
4
  export {
2
- base64ToBytes,
3
- base64UrlToBytes,
4
- bufferSourceToArrayBuffer,
5
- bytesToBase64,
6
- bytesToBase64Url,
7
- bytesToHex,
8
- hexToBytes,
9
- } from "./codec.js";
5
+ decodeWrappedKeyRecord,
6
+ encodeWrappedKeyRecord,
7
+ } from "./wrapped-key-record-codec.js";
10
8
  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,
9
+ createInsecureBrowserLocalStoragePasskeyKeyStorage,
10
+ createMemoryPasskeyKeyStorage,
34
11
  } from "./storage.js";
35
- export type { StorageAdapter } from "./storage.js";
36
- export { detectPrfSupport } from "./support.js";
12
+ export type { CachedPRFResult, PasskeyKeyStorage } from "./storage.js";
37
13
  export type {
38
- EnrollResult,
39
- PasskeyKitConfig,
40
- PasskeyKitErrorMessages,
41
- PasskeyKitLogger,
42
- PasskeyKitStorageKeys,
14
+ CreateAndWrapKeyInput,
15
+ CreatedWrappedKey,
16
+ EvaluatedCredential,
17
+ EvaluateCredentialInput,
18
+ PasskeyCapability,
19
+ PasskeyInteraction,
20
+ PasskeyKeyManager,
21
+ PasskeyKeyManagerConfig,
22
+ PasskeyKeyProfile,
43
23
  PasskeyUser,
44
- PrfPasskeyResult,
45
- UnlockResult,
46
- WrappedCek,
24
+ PRFResult,
25
+ RecoveredKey,
26
+ RecoverKeyInput,
27
+ RewrapKeyInput,
28
+ UnwrapKeyWithPRFResultInput,
29
+ WrapKeyWithPRFResultInput,
30
+ WrappedKey,
31
+ WrappedKeyRecord,
47
32
  } from "./types.js";