@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/storage.ts CHANGED
@@ -1,69 +1,135 @@
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
- */
1
+ import { base64ToBytes, bytesToBase64 } from "./codec.js";
2
+ import type { PasskeyKeyProfile } from "./types.js";
16
3
 
17
- export interface StorageAdapter {
18
- getItem(key: string): string | null
19
- setItem(key: string, value: string): void
20
- removeItem(key: string): void
4
+ export interface CachedPRFResult {
5
+ profile: PasskeyKeyProfile;
6
+ credentialId: string;
7
+ prfOutput: Uint8Array;
21
8
  }
22
9
 
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
- },
10
+ export interface PasskeyKeyStorage {
11
+ loadCachedPRFResult(): CachedPRFResult | null;
12
+ saveCachedPRFResult(result: CachedPRFResult): void;
13
+ loadLocalCredentialId(): string | null;
14
+ saveLocalCredentialId(credentialId: string): void;
15
+ clear(): void;
55
16
  }
56
17
 
57
- /** In-memory adapter for tests and non-browser environments. */
58
- export function createMemoryStorageAdapter(): StorageAdapter {
59
- const store = new Map<string, string>()
18
+ function copyProfile(profile: PasskeyKeyProfile): PasskeyKeyProfile {
60
19
  return {
61
- getItem: (key) => store.get(key) ?? null,
62
- setItem: (key, value) => {
63
- store.set(key, value)
20
+ ...profile,
21
+ prfSalt: profile.prfSalt.slice(),
22
+ hkdfInfo: profile.hkdfInfo.slice(),
23
+ };
24
+ }
25
+
26
+ function copyCachedResult(result: CachedPRFResult): CachedPRFResult {
27
+ return {
28
+ profile: copyProfile(result.profile),
29
+ credentialId: result.credentialId,
30
+ prfOutput: result.prfOutput.slice(),
31
+ };
32
+ }
33
+
34
+ export function createMemoryPasskeyKeyStorage(): PasskeyKeyStorage {
35
+ let cached: CachedPRFResult | null = null;
36
+ let localCredentialId: string | null = null;
37
+ return {
38
+ loadCachedPRFResult() {
39
+ return cached ? copyCachedResult(cached) : null;
40
+ },
41
+ saveCachedPRFResult(result) {
42
+ cached = copyCachedResult(result);
43
+ },
44
+ loadLocalCredentialId() {
45
+ return localCredentialId;
46
+ },
47
+ saveLocalCredentialId(credentialId) {
48
+ localCredentialId = credentialId;
49
+ },
50
+ clear() {
51
+ cached = null;
52
+ localCredentialId = null;
53
+ },
54
+ };
55
+ }
56
+
57
+ interface SerializedCachedPRFResult {
58
+ profile: Omit<PasskeyKeyProfile, "prfSalt" | "hkdfInfo"> & {
59
+ prfSaltBase64: string;
60
+ hkdfInfoBase64: string;
61
+ };
62
+ credentialId: string;
63
+ prfOutputBase64: string;
64
+ }
65
+
66
+ /** Stores raw PRF output unencrypted in localStorage under an explicit namespace. */
67
+ export function createInsecureBrowserLocalStoragePasskeyKeyStorage(
68
+ namespace: string,
69
+ ): PasskeyKeyStorage {
70
+ const prefix = `passkey-key/${encodeURIComponent(namespace)}`;
71
+ const cachedKey = `${prefix}/cached-prf`;
72
+ const localCredentialKey = `${prefix}/local-credential`;
73
+ return {
74
+ loadCachedPRFResult() {
75
+ try {
76
+ if (typeof localStorage === "undefined") return null;
77
+ const value = localStorage.getItem(cachedKey);
78
+ if (!value) return null;
79
+ const stored = JSON.parse(value) as SerializedCachedPRFResult;
80
+ return {
81
+ profile: {
82
+ version: stored.profile.version,
83
+ relyingPartyId: stored.profile.relyingPartyId,
84
+ prfSalt: base64ToBytes(stored.profile.prfSaltBase64),
85
+ hkdfInfo: base64ToBytes(stored.profile.hkdfInfoBase64),
86
+ },
87
+ credentialId: stored.credentialId,
88
+ prfOutput: base64ToBytes(stored.prfOutputBase64),
89
+ };
90
+ } catch {
91
+ return null;
92
+ }
93
+ },
94
+ saveCachedPRFResult(result) {
95
+ try {
96
+ if (typeof localStorage === "undefined") return;
97
+ const serialized: SerializedCachedPRFResult = {
98
+ profile: {
99
+ version: result.profile.version,
100
+ relyingPartyId: result.profile.relyingPartyId,
101
+ prfSaltBase64: bytesToBase64(result.profile.prfSalt),
102
+ hkdfInfoBase64: bytesToBase64(result.profile.hkdfInfo),
103
+ },
104
+ credentialId: result.credentialId,
105
+ prfOutputBase64: bytesToBase64(result.prfOutput),
106
+ };
107
+ localStorage.setItem(cachedKey, JSON.stringify(serialized));
108
+ } catch {}
109
+ },
110
+ loadLocalCredentialId() {
111
+ try {
112
+ if (typeof localStorage === "undefined") return null;
113
+ return localStorage.getItem(localCredentialKey);
114
+ } catch {
115
+ return null;
116
+ }
117
+ },
118
+ saveLocalCredentialId(credentialId) {
119
+ try {
120
+ if (typeof localStorage === "undefined") return;
121
+ localStorage.setItem(localCredentialKey, credentialId);
122
+ } catch {}
64
123
  },
65
- removeItem: (key) => {
66
- store.delete(key)
124
+ clear() {
125
+ try {
126
+ if (typeof localStorage === "undefined") return;
127
+ localStorage.removeItem(cachedKey);
128
+ } catch {}
129
+ try {
130
+ if (typeof localStorage === "undefined") return;
131
+ localStorage.removeItem(localCredentialKey);
132
+ } catch {}
67
133
  },
68
- }
134
+ };
69
135
  }
package/src/support.ts CHANGED
@@ -1,61 +1,47 @@
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
- }
1
+ import type { PasskeyCapability } from "./types.js";
18
2
 
19
- if (!window.PublicKeyCredential) {
20
- return false
21
- }
3
+ function hasWebAuthn(operation: "enroll" | "recover"): boolean {
4
+ return (
5
+ typeof navigator !== "undefined" &&
6
+ !!navigator.credentials &&
7
+ typeof navigator.credentials[operation === "enroll" ? "create" : "get"] ===
8
+ "function" &&
9
+ typeof PublicKeyCredential !== "undefined"
10
+ );
11
+ }
22
12
 
23
- // Check that a platform authenticator is available (Face ID, Touch ID, Windows Hello, etc.)
13
+ async function prfCapability(): Promise<PasskeyCapability> {
14
+ const credentialClass = PublicKeyCredential as typeof PublicKeyCredential & {
15
+ getClientCapabilities?: () => Promise<Record<string, boolean>>;
16
+ };
17
+ if (typeof credentialClass.getClientCapabilities !== "function") return "unknown";
24
18
  try {
25
- const available =
26
- await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
27
- if (!available) {
28
- return false
29
- }
19
+ const capabilities = await credentialClass.getClientCapabilities();
20
+ const reported = capabilities["extension:prf"];
21
+ if (reported === true) return "supported";
22
+ if (reported === false) return "unsupported";
30
23
  } catch {
31
- return false
24
+ return "unknown";
32
25
  }
26
+ return "unknown";
27
+ }
33
28
 
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.
29
+ export async function capability(
30
+ operation: "enroll" | "recover",
31
+ ): Promise<PasskeyCapability> {
32
+ if (!hasWebAuthn(operation)) return "unsupported";
33
+ if (operation === "recover") return prfCapability();
34
+ if (
35
+ typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== "function"
36
+ ) {
37
+ return "unknown";
38
+ }
36
39
  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
- }
40
+ if (!(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable())) {
41
+ return "unsupported";
53
42
  }
54
43
  } catch {
55
- // getClientCapabilities not available or threw — fall through
44
+ return "unknown";
56
45
  }
57
-
58
- // Optimistic: platform authenticator is available, WebAuthn is supported.
59
- // Actual PRF support will be confirmed during credential creation.
60
- return true
46
+ return prfCapability();
61
47
  }
package/src/types.ts CHANGED
@@ -1,113 +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. */
1
+ import type { PasskeyKeyStorage } from "./storage.js";
2
+
3
+ export interface PasskeyKeyProfile {
4
+ version: 1;
5
+ relyingPartyId: string;
6
+ prfSalt: Uint8Array;
7
+ hkdfInfo: Uint8Array;
8
+ }
9
+
10
+ export interface WrappedKey {
11
+ profile: PasskeyKeyProfile;
12
+ credentialId: string;
13
+ kekIvHex: string;
14
+ wrappedKeyHex: string;
15
+ }
16
+
68
17
  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). */
18
+ id: Uint8Array;
72
19
  name: string;
73
- /** Friendly display name; falls back to `name` when omitted. */
74
20
  displayName?: string;
75
21
  }
76
22
 
77
- /** Result of a successful PRF ceremony (create or authenticate). */
78
- export interface PrfPasskeyResult {
79
- /** base64url-encoded credential id. */
23
+ export type PasskeyCapability = "supported" | "unsupported" | "unknown";
24
+ export type PasskeyInteraction = "interactive" | "immediatelyAvailable";
25
+
26
+ export interface CreateAndWrapKeyInput {
27
+ user: PasskeyUser;
28
+ key: Uint8Array;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ export interface CreatedWrappedKey {
33
+ credentialId: string;
34
+ wrappedKey: WrappedKey;
35
+ }
36
+
37
+ export interface RecoverKeyInput {
38
+ wrappedKeys: WrappedKey[];
39
+ preferredCredentialId?: string;
40
+ signal?: AbortSignal;
41
+ interaction?: PasskeyInteraction;
42
+ }
43
+
44
+ export interface EvaluateCredentialInput {
45
+ credentialIds: string[];
46
+ preferredCredentialId?: string;
47
+ signal?: AbortSignal;
48
+ interaction?: PasskeyInteraction;
49
+ }
50
+
51
+ export interface PRFResult {
52
+ output: Uint8Array;
53
+ }
54
+
55
+ export interface EvaluatedCredential {
80
56
  credentialId: string;
81
- /** Raw 32-byte PRF output; treat as secret key material. */
82
- prfOutput: ArrayBuffer;
57
+ prfResult: PRFResult;
83
58
  }
84
59
 
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. */
60
+ export interface WrapKeyWithPRFResultInput {
61
+ keyMaterial: Uint8Array;
88
62
  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;
63
+ prfResult: PRFResult;
93
64
  }
94
65
 
95
- /** Result of the high-level enroll flow: create passkey + wrap CEK. */
96
- export interface EnrollResult {
66
+ export interface UnwrapKeyWithPRFResultInput {
67
+ wrappedKey: WrappedKey;
68
+ prfResult: PRFResult;
69
+ }
70
+
71
+ export interface RecoveredKey {
97
72
  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 {
73
+ key: Uint8Array;
74
+ }
75
+
76
+ export interface RewrapKeyInput {
77
+ key: Uint8Array;
78
+ }
79
+
80
+ export interface PasskeyKeyManagerConfig {
81
+ profile: PasskeyKeyProfile;
82
+ relyingPartyName: string;
83
+ timeoutMs?: number;
84
+ storage?: PasskeyKeyStorage;
85
+ }
86
+
87
+ export interface PasskeyKeyManager {
88
+ capability(input: {
89
+ operation: "enroll" | "recover";
90
+ }): Promise<PasskeyCapability>;
91
+ createAndWrapKey(input: CreateAndWrapKeyInput): Promise<CreatedWrappedKey>;
92
+ recoverKey(input: RecoverKeyInput): Promise<RecoveredKey>;
93
+ evaluateCredential(input: EvaluateCredentialInput): Promise<EvaluatedCredential>;
94
+ wrapKeyWithPRFResult(input: WrapKeyWithPRFResultInput): Promise<WrappedKey>;
95
+ unwrapKeyWithPRFResult(input: UnwrapKeyWithPRFResultInput): Promise<Uint8Array>;
96
+ recoverKeyFromCache(input: RecoverKeyInput): Promise<RecoveredKey | null>;
97
+ rewrapKeyFromCache(input: RewrapKeyInput): Promise<WrappedKey | null>;
98
+ clearLocalState(): void;
99
+ cancelActiveCeremony(): void;
100
+ }
101
+
102
+ export interface WrappedKeyRecord {
103
+ version: 1;
104
+ profile: {
105
+ version: 1;
106
+ relyingPartyId: string;
107
+ prfSalt: string;
108
+ hkdfInfo: string;
109
+ };
110
110
  credentialId: string;
111
- /** The recovered raw 32-byte CEK. */
112
- cek: Uint8Array;
111
+ kekIvHex: string;
112
+ wrappedKeyHex: string;
113
113
  }