@tinfoilsh/passkey-kit 0.1.0 → 0.2.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 (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 +305 -159
  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 +405 -263
  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/README.md CHANGED
@@ -1,17 +1,13 @@
1
1
  # Tinfoil Passkey Kit
2
2
 
3
- Cross-platform SDKs for protecting content-encryption keys with passkeys and
4
- the WebAuthn PRF extension. The JavaScript and Swift implementations share the
5
- same protocol constants and wire formats, so either client can recover a CEK
6
- wrapped by the other.
3
+ JavaScript and Swift libraries for wrapping 32-byte keys with passkeys and the
4
+ WebAuthn PRF extension. Both implementations use the same profile-driven wire
5
+ format and can recover records produced by the other.
7
6
 
8
- - Passkey creation and authentication with PRF
9
- - HKDF-SHA-256 key-encryption-key derivation
10
- - AES-256-GCM CEK wrapping and unwrapping
11
- - Stable CEK key-ID derivation
12
- - Device-local PRF and credential persistence
7
+ This kit performs local key wrapping. It does not log users in, authenticate a
8
+ session to a server, or provide hosted storage.
13
9
 
14
- ## JavaScript
10
+ ## JavaScript quickstart
15
11
 
16
12
  Install the browser package:
17
13
 
@@ -19,109 +15,170 @@ Install the browser package:
19
15
  npm install @tinfoilsh/passkey-kit
20
16
  ```
21
17
 
22
- ```ts
23
- import { createPasskeyKit, generateCek } from "@tinfoilsh/passkey-kit";
18
+ Create one explicit version 1 profile and keep every field stable for existing
19
+ records. The display name belongs to manager configuration, not the profile.
24
20
 
25
- const kit = createPasskeyKit({
26
- rpId: "example.com",
27
- rpName: "Example App",
21
+ ```ts
22
+ import {
23
+ createPasskeyKeyManager,
24
+ decodeWrappedKeyRecord,
25
+ encodeWrappedKeyRecord,
26
+ } from "@tinfoilsh/passkey-kit";
27
+
28
+ const encoder = new TextEncoder();
29
+ const profile = {
30
+ version: 1,
31
+ relyingPartyId: "example.com",
32
+ prfSalt: encoder.encode("example-key-wrapping"),
33
+ hkdfInfo: encoder.encode("example-wrapping-key-v1"),
34
+ };
35
+ const manager = createPasskeyKeyManager({
36
+ profile,
37
+ relyingPartyName: "Example App",
28
38
  });
29
-
30
- const cek = generateCek();
31
- const enrolled = await kit.enroll({
32
- user: { id: userId, name: email, displayName },
33
- cek,
39
+ const key = crypto.getRandomValues(new Uint8Array(32));
40
+
41
+ const created = await manager.createAndWrapKey({
42
+ user: {
43
+ id: crypto.getRandomValues(new Uint8Array(32)),
44
+ name: "person@example.com",
45
+ displayName: "Example Person",
46
+ },
47
+ key,
34
48
  });
49
+ await wrappedKeyRepository.save(encodeWrappedKeyRecord(created.wrappedKey));
35
50
 
36
- if (enrolled) {
37
- await api.saveBundle(enrolled.wrappedCek);
38
- }
51
+ const records: string[] = await wrappedKeyRepository.list();
52
+ const wrappedKeys = records.map(decodeWrappedKeyRecord);
53
+ const recovered = await manager.recoverKey({ wrappedKeys });
54
+ useKey(recovered.key);
55
+ ```
39
56
 
40
- const unlocked = await kit.unlock(bundlesFromServer);
41
- if (unlocked) {
42
- useCek(unlocked.cek);
43
- }
57
+ `evaluateCredential` is available for advanced migrations that need direct PRF
58
+ evaluation:
59
+
60
+ ```ts
61
+ const evaluated = await manager.evaluateCredential({
62
+ credentialIds: wrappedKeys.map(({ credentialId }) => credentialId),
63
+ });
64
+ usePRFOutput(evaluated.prfResult.output);
65
+ evaluated.prfResult.output.fill(0);
44
66
  ```
45
67
 
46
- High-level ceremony methods return `null` when the user cancels. They throw
47
- `PrfNotSupportedError` when the authenticator lacks PRF support and
48
- `PasskeyTimeoutError` when the provider hangs. Classify these errors with
49
- `instanceof`, not message strings.
68
+ Treat PRF output as secret key material and prefer `recoverKey` for normal
69
+ recovery. `wrappedKeyRepository` is application-owned. See the minimal
70
+ [repository interface](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/wrapped-key-repository.md)
71
+ and runnable [web example](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/Examples/Web/README.md).
50
72
 
51
- The kit also provides cached unlock and rewrap flows. Lower-level exports
52
- include `detectPrfSupport`, `deriveKeyEncryptionKey`, `generateCek`,
53
- `isValidCek`, `wrapCek`, `unwrapCek`, and `deriveKeyId`.
73
+ Ceremony failures throw `PasskeyKeyError`. Branch on its stable `category`, not
74
+ its message. Local PRF caching is disabled unless the application supplies a
75
+ `PasskeyKeyStorage`; cached PRF output is secret key material.
54
76
 
55
- The default storage adapter uses `localStorage` on a best-effort basis. Pass
56
- `storage: null` to disable persistence or provide a custom `StorageAdapter`.
57
- Cached PRF output is raw secret key material and must be protected accordingly.
77
+ Persistence is disabled by default. Cached recovery and rewrap require an
78
+ explicit synchronous `PasskeyKeyStorage`. Cached PRF output is raw secret key
79
+ material and requires host-appropriate protection.
58
80
 
59
- ## Swift
81
+ The memory adapter is suitable for tests. The explicitly named
82
+ `createInsecureBrowserLocalStoragePasskeyKeyStorage(namespace)` adapter stores
83
+ raw PRF output unencrypted, isolates records by its required namespace, and is
84
+ insecure because same-origin scripts can read the cached secret material.
85
+
86
+ `evaluateCredential` exposes raw PRF output for advanced migrations. Treat
87
+ `prfResult.output` as secret key material and prefer `recoverKey` for normal
88
+ recovery. `wrapKeyWithPRFResult` and `unwrapKeyWithPRFResult` perform explicit
89
+ crypto-only operations without starting a ceremony or accessing storage.
90
+
91
+ ## Swift quickstart
60
92
 
61
93
  Add this repository as a Swift Package Manager dependency and link the
62
94
  `TinfoilPasskeyKit` product. The package requires iOS 18 or macOS 15.
63
95
 
64
96
  ```swift
97
+ import Foundation
65
98
  import TinfoilPasskeyKit
66
99
 
67
100
  @MainActor
68
- func configurePasskeyKit() async throws {
69
- let store = KeychainPasskeyStateStore(
70
- service: "example.com",
71
- account: "com.example.passkey-prf",
72
- localCredentialIdKey: "com.example.local-passkey-id"
101
+ func protectKey(
102
+ _ key: Data,
103
+ presentationAnchorProvider: any PasskeyPresentationAnchorProviding
104
+ ) async throws {
105
+ let profile = try PasskeyKeyProfile(
106
+ version: 1,
107
+ relyingPartyId: "example.com",
108
+ prfSalt: Data("example-key-wrapping".utf8),
109
+ hkdfInfo: Data("example-wrapping-key-v1".utf8)
73
110
  )
74
- let kit = PasskeyKit(
75
- configuration: PasskeyKitConfiguration(
76
- rpId: "example.com",
77
- rpName: "Example App",
78
- stateStore: store
79
- )
111
+ let manager = try PasskeyKeyManager(
112
+ profile: profile,
113
+ relyingPartyName: "Example App",
114
+ presentationAnchorProvider: presentationAnchorProvider
80
115
  )
81
116
 
82
- let cek = try PasskeyCrypto.generateCEK()
83
- let enrollment = try await kit.enroll(
84
- user: PasskeyUser(id: userId, name: email, displayName: displayName),
85
- cek: cek
117
+ let created = try await manager.createAndWrapKey(
118
+ user: PasskeyUser(
119
+ id: opaqueUserHandle,
120
+ name: "person@example.com",
121
+ displayName: "Example Person"
122
+ ),
123
+ key: key
86
124
  )
87
- await saveToServer(enrollment.wrappedCEK)
125
+ await wrappedKeyRepository.save(try encodeWrappedKeyRecord(created.wrappedKey))
88
126
 
89
- let unlocked = try await kit.unlock(wrappedCEKsFromServer)
90
- useCEK(unlocked.cek)
127
+ let records: [Data] = await wrappedKeyRepository.list()
128
+ let wrappedKeys = try records.map(decodeWrappedKeyRecord)
129
+ let recovered = try await manager.recoverKey(wrappedKeys: wrappedKeys)
130
+ useKey(recovered.key)
91
131
  }
92
132
  ```
93
133
 
94
- `KeychainPasskeyStateStore` stores cached PRF output with
95
- `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Pass `stateStore: nil` to
96
- disable local persistence. The host app must provide the `webcredentials`
97
- associated-domain entitlement for its relying-party domain.
98
-
99
- ## Protocol
100
-
101
- Both implementations default to the Tinfoil v1 PRF salt and HKDF info. These
102
- values must remain identical across clients that wrap the same CEK. Override
103
- both values together to establish a separate protocol domain.
104
-
105
- The server-persisted wrapped bundle contains only:
106
-
107
- - The unpadded base64url credential ID
108
- - The 12-byte AES-GCM IV as lowercase hexadecimal
109
- - The wrapped CEK ciphertext and 16-byte authentication tag as lowercase
110
- hexadecimal
111
-
112
- User identity, server persistence, associated-domain configuration, and
113
- recovery UI remain the host application's responsibility.
114
-
115
- ## License
116
-
117
- Apache License 2.0. See [LICENSE](LICENSE).
134
+ Advanced flows can call `evaluateCredential(credentialIds:interaction:)`.
135
+ Its `prfResult.output` is raw secret key material. Do not log, transmit, or
136
+ retain it longer than necessary.
137
+
138
+ Apple hosts must pass a `PasskeyPresentationAnchorProviding` implementation to
139
+ the manager as `presentationAnchorProvider`. The provider returns the iOS or
140
+ macOS window AuthenticationServices uses for interactive presentation. Making
141
+ it required prevents constructing a manager that cannot present a ceremony.
142
+ AuthenticationServices does not accept the configured `relyingPartyName`; Apple
143
+ derives relying-party presentation from system and associated-domain metadata.
144
+
145
+ Persistence is disabled by default. `KeychainPasskeyKeyStorage` is an optional
146
+ generic Apple adapter that stores cached PRF output with
147
+ `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Its records are device-bound and
148
+ unavailable while the device is locked, but any process context that can read
149
+ the item can recover keys without another passkey prompt. Choose storage based
150
+ on the host app's threat model. The host app must also provide the
151
+ `webcredentials` associated-domain entitlement for its relying-party domain.
152
+ Keychain operations are synchronous and can block the manager's main actor.
153
+
154
+ On iOS 18 and macOS 15, recovery supports platform and synced passkeys,
155
+ including Apple's cross-device passkey flow. Explicit security-key PRF is not
156
+ currently enabled in the Apple target because the baseline toolchain does not
157
+ provide that API. Browsers may support security-key or hybrid recovery.
158
+ Capability remains `unknown` when Apple cannot preflight PRF support; callers
159
+ should allow an attempt.
160
+
161
+ See the compilable [Apple example](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/Examples/Apple/README.md).
162
+
163
+ ## Documentation
164
+
165
+ - [API contract](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/api-contract.md)
166
+ - [Support matrix](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/support-matrix.md)
167
+ - [Security boundary](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/security-boundary.md)
168
+ - [Scope](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/docs/scope.md)
169
+ - [Contributing](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/CONTRIBUTING.md)
170
+ - [Security policy](https://github.com/tinfoilsh/tinfoil-passkey-kit/blob/main/SECURITY.md)
118
171
 
119
172
  ## Development
120
173
 
121
174
  ```sh
122
- npm install
175
+ npm ci
123
176
  npm test
124
177
  npm run typecheck
125
178
  npm run build
126
179
  swift test
127
180
  ```
181
+
182
+ ## License
183
+
184
+ Apache License 2.0. See [LICENSE](LICENSE).
package/dist/crypto.d.ts CHANGED
@@ -1,55 +1,12 @@
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
- import type { WrappedCek } from "./types.js";
10
- export declare const CEK_BYTES = 32;
11
- /** Generate a fresh random 32-byte CEK suitable for {@link wrapCek}. */
12
- export declare function generateCek(): Uint8Array;
13
- /**
14
- * Type guard for a well-formed CEK: a Uint8Array of exactly
15
- * {@link CEK_BYTES} bytes. Useful for validating deserialized input
16
- * before wrapping.
17
- */
18
- export declare function isValidCek(cek: unknown): cek is Uint8Array;
19
- /**
20
- * Derive an AES-256-GCM Key Encryption Key (KEK) from PRF output using HKDF.
21
- *
22
- * Raw PRF output is treated as Input Keying Material (IKM), not used
23
- * directly as a key. HKDF with a purpose-binding info string produces the
24
- * final non-extractable CryptoKey. An empty HKDF salt is used, which is
25
- * fine for high-entropy IKM (RFC 5869 §3.1).
26
- *
27
- * `hkdfInfo` defaults to the Tinfoil v1 protocol constant so standalone
28
- * callers derive the same interoperable KEK as a default-configured kit.
29
- */
30
- export declare function deriveKeyEncryptionKey(prfOutput: ArrayBuffer | Uint8Array, hkdfInfo?: string | Uint8Array): Promise<CryptoKey>;
31
- /**
32
- * Wrap a raw 32-byte CEK under a passkey-derived KEK using AES-256-GCM
33
- * with a fresh random IV. The returned hex fields are safe to persist
34
- * server-side; only the matching passkey can recover the CEK.
35
- */
36
- export declare function wrapCek(opts: {
37
- credentialId: string;
38
- kek: CryptoKey;
39
- cek: Uint8Array;
40
- }): Promise<WrappedCek>;
41
- /**
42
- * Inverse of {@link wrapCek}: recover the raw CEK bytes given the same KEK.
43
- * Throws on tamper (GCM auth failure) or any shape mismatch.
44
- */
45
- export declare function unwrapCek(kek: CryptoKey, wrapped: Pick<WrappedCek, "kekIvHex" | "wrappedKeyHex">): Promise<Uint8Array>;
46
- /**
47
- * Derive a stable public identifier for a CEK via HKDF-SHA-256 with an
48
- * empty salt and a purpose-binding info string. The result identifies the key
49
- * without revealing it (one-way derivation).
50
- */
51
- export declare function deriveKeyId(cek: Uint8Array, opts?: {
52
- info?: string | Uint8Array;
53
- lengthBytes?: number;
54
- }): Promise<Uint8Array>;
1
+ import type { PasskeyKeyProfile, WrappedKey } from "./types.js";
2
+ export declare const PROFILE_KEYS: readonly ["version", "relyingPartyId", "prfSalt", "hkdfInfo"];
3
+ export declare function copyAndValidateProfile(profile: PasskeyKeyProfile): PasskeyKeyProfile;
4
+ export declare function profilesEqual(left: PasskeyKeyProfile, right: PasskeyKeyProfile): boolean;
5
+ export declare function decodeCanonicalBase64Url(value: unknown, field: string): Uint8Array;
6
+ export declare function validateCredentialId(credentialId: string): void;
7
+ export declare function validateKey(key: Uint8Array, operation?: string): void;
8
+ export declare function validateWrappedKey(wrapped: WrappedKey, profile: PasskeyKeyProfile): void;
9
+ export declare function deriveWrappingKey(prfOutput: Uint8Array, profile: PasskeyKeyProfile): Promise<CryptoKey>;
10
+ export declare function wrapKey(profile: PasskeyKeyProfile, credentialId: string, prfOutput: Uint8Array, key: Uint8Array, operation?: string): Promise<WrappedKey>;
11
+ export declare function unwrapKey(profile: PasskeyKeyProfile, prfOutput: Uint8Array, wrapped: WrappedKey, operation?: string): Promise<Uint8Array>;
55
12
  //# sourceMappingURL=crypto.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,SAAS,KAAK,CAAC;AAI5B,wEAAwE;AACxE,wBAAgB,WAAW,IAAI,UAAU,CAExC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,UAAU,CAE1D;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,EAAE,WAAW,GAAG,UAAU,EACnC,QAAQ,GAAE,MAAM,GAAG,UAAiC,GACnD,OAAO,CAAC,SAAS,CAAC,CAqBpB;AAED;;;;GAIG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,UAAU,CAAC;CACjB,GAAG,OAAO,CAAC,UAAU,CAAC,CAiBtB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,GAAG,eAAe,CAAC,GACtD,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,UAAU,EACf,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9D,OAAO,CAAC,UAAU,CAAC,CAyBrB"}
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAMhE,eAAO,MAAM,YAAY,+DAKf,CAAC;AAQX,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAqBpF;AAED,wBAAgB,aAAa,CAC3B,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAST;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,CAmBlF;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAE/D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAIrE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAmBxF;AAED,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,UAAU,EACrB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,SAAS,CAAC,CAwBpB;AAED,wBAAsB,OAAO,CAC3B,OAAO,EAAE,iBAAiB,EAC1B,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,UAAU,EACrB,GAAG,EAAE,UAAU,EACf,SAAS,SAAqB,GAC7B,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED,wBAAsB,SAAS,CAC7B,OAAO,EAAE,iBAAiB,EAC1B,SAAS,EAAE,UAAU,EACrB,OAAO,EAAE,UAAU,EACnB,SAAS,SAAe,GACvB,OAAO,CAAC,UAAU,CAAC,CAgBrB"}
package/dist/crypto.js CHANGED
@@ -1,105 +1,154 @@
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
- import { bytesToHex, hexToBytes, toBytes } from "./codec.js";
10
- import { PasskeyKitError } from "./errors.js";
11
- import { TINFOIL_HKDF_INFO_V1, TINFOIL_KEY_ID_INFO_V1 } from "./protocol.js";
12
- export const CEK_BYTES = 32;
1
+ import { base64UrlToBytes, bytesToBase64Url, bytesToHex, hexToBytes, } from "./codec.js";
2
+ import { invalidInput, operationFailed, PasskeyKeyError } from "./errors.js";
3
+ const KEY_BYTES = 32;
4
+ const PRF_OUTPUT_BYTES = 32;
13
5
  const AES_GCM_IV_BYTES = 12;
14
- const DEFAULT_KEY_ID_BYTES = 16;
15
- /** Generate a fresh random 32-byte CEK suitable for {@link wrapCek}. */
16
- export function generateCek() {
17
- return crypto.getRandomValues(new Uint8Array(CEK_BYTES));
18
- }
19
- /**
20
- * Type guard for a well-formed CEK: a Uint8Array of exactly
21
- * {@link CEK_BYTES} bytes. Useful for validating deserialized input
22
- * before wrapping.
23
- */
24
- export function isValidCek(cek) {
25
- return cek instanceof Uint8Array && cek.length === CEK_BYTES;
26
- }
27
- /**
28
- * Derive an AES-256-GCM Key Encryption Key (KEK) from PRF output using HKDF.
29
- *
30
- * Raw PRF output is treated as Input Keying Material (IKM), not used
31
- * directly as a key. HKDF with a purpose-binding info string produces the
32
- * final non-extractable CryptoKey. An empty HKDF salt is used, which is
33
- * fine for high-entropy IKM (RFC 5869 §3.1).
34
- *
35
- * `hkdfInfo` defaults to the Tinfoil v1 protocol constant so standalone
36
- * callers derive the same interoperable KEK as a default-configured kit.
37
- */
38
- export async function deriveKeyEncryptionKey(prfOutput, hkdfInfo = TINFOIL_HKDF_INFO_V1) {
39
- const masterKey = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, // non-extractable
40
- ["deriveKey"]);
41
- return crypto.subtle.deriveKey({
42
- name: "HKDF",
43
- hash: "SHA-256",
44
- salt: new Uint8Array(),
45
- info: toBytes(hkdfInfo),
46
- }, masterKey, { name: "AES-GCM", length: 256 }, false, // non-extractable
47
- ["encrypt", "decrypt"]);
6
+ const AES_GCM_TAG_BYTES = 16;
7
+ export const PROFILE_KEYS = [
8
+ "version",
9
+ "relyingPartyId",
10
+ "prfSalt",
11
+ "hkdfInfo",
12
+ ];
13
+ function assertBytes(value, name, allowEmpty = false) {
14
+ if (!(value instanceof Uint8Array) || (!allowEmpty && value.length === 0)) {
15
+ throw invalidInput(`${name} must be ${allowEmpty ? "a" : "a non-empty"} Uint8Array`);
16
+ }
48
17
  }
49
- /**
50
- * Wrap a raw 32-byte CEK under a passkey-derived KEK using AES-256-GCM
51
- * with a fresh random IV. The returned hex fields are safe to persist
52
- * server-side; only the matching passkey can recover the CEK.
53
- */
54
- export async function wrapCek(opts) {
55
- if (opts.cek.length !== CEK_BYTES) {
56
- throw new PasskeyKitError(`passkey-kit: CEK must be ${CEK_BYTES} bytes, got ${opts.cek.length}`);
57
- }
58
- const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
59
- const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, opts.kek, opts.cek);
18
+ export function copyAndValidateProfile(profile) {
19
+ if (!profile || typeof profile !== "object")
20
+ throw invalidInput("profile is required");
21
+ const keys = Object.keys(profile).sort();
22
+ const expected = [...PROFILE_KEYS].sort();
23
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
24
+ throw invalidInput(`profile must contain exactly ${PROFILE_KEYS.join(", ")}`);
25
+ }
26
+ if (profile.version !== 1) {
27
+ throw invalidInput("profile.version must be 1");
28
+ }
29
+ if (typeof profile.relyingPartyId !== "string" || profile.relyingPartyId.length === 0) {
30
+ throw invalidInput("profile.relyingPartyId must be a non-empty string");
31
+ }
32
+ assertBytes(profile.prfSalt, "profile.prfSalt");
33
+ assertBytes(profile.hkdfInfo, "profile.hkdfInfo");
60
34
  return {
61
- credentialId: opts.credentialId,
62
- kekIvHex: bytesToHex(iv),
63
- wrappedKeyHex: bytesToHex(new Uint8Array(ciphertext)),
35
+ version: profile.version,
36
+ relyingPartyId: profile.relyingPartyId,
37
+ prfSalt: profile.prfSalt.slice(),
38
+ hkdfInfo: profile.hkdfInfo.slice(),
64
39
  };
65
40
  }
66
- /**
67
- * Inverse of {@link wrapCek}: recover the raw CEK bytes given the same KEK.
68
- * Throws on tamper (GCM auth failure) or any shape mismatch.
69
- */
70
- export async function unwrapCek(kek, wrapped) {
71
- if (!wrapped.kekIvHex || !wrapped.wrappedKeyHex) {
72
- throw new PasskeyKitError("passkey-kit: missing iv or wrapped key");
73
- }
74
- const iv = hexToBytes(wrapped.kekIvHex);
75
- if (iv.length !== AES_GCM_IV_BYTES) {
76
- throw new PasskeyKitError("passkey-kit: iv length mismatch");
77
- }
78
- const ciphertext = hexToBytes(wrapped.wrappedKeyHex);
79
- const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, kek, ciphertext);
80
- const cek = new Uint8Array(plaintext);
81
- if (cek.length !== CEK_BYTES) {
82
- throw new PasskeyKitError(`passkey-kit: unwrapped CEK has wrong length ${cek.length}`);
83
- }
84
- return cek;
41
+ export function profilesEqual(left, right) {
42
+ return (left.version === right.version &&
43
+ left.relyingPartyId === right.relyingPartyId &&
44
+ left.prfSalt.length === right.prfSalt.length &&
45
+ left.prfSalt.every((byte, index) => byte === right.prfSalt[index]) &&
46
+ left.hkdfInfo.length === right.hkdfInfo.length &&
47
+ left.hkdfInfo.every((byte, index) => byte === right.hkdfInfo[index]));
48
+ }
49
+ export function decodeCanonicalBase64Url(value, field) {
50
+ if (typeof value !== "string" ||
51
+ value.length === 0 ||
52
+ !/^[A-Za-z0-9_-]+$/.test(value) ||
53
+ value.length % 4 === 1) {
54
+ throw invalidInput(`${field} must be unpadded base64url`);
55
+ }
56
+ try {
57
+ const bytes = base64UrlToBytes(value);
58
+ if (bytes.length === 0 || bytesToBase64Url(bytes) !== value) {
59
+ throw invalidInput(`${field} must use canonical unpadded base64url`);
60
+ }
61
+ return bytes;
62
+ }
63
+ catch (cause) {
64
+ if (cause instanceof PasskeyKeyError)
65
+ throw cause;
66
+ throw invalidInput(`${field} must be unpadded base64url`);
67
+ }
68
+ }
69
+ export function validateCredentialId(credentialId) {
70
+ decodeCanonicalBase64Url(credentialId, "credentialId");
85
71
  }
86
- /**
87
- * Derive a stable public identifier for a CEK via HKDF-SHA-256 with an
88
- * empty salt and a purpose-binding info string. The result identifies the key
89
- * without revealing it (one-way derivation).
90
- */
91
- export async function deriveKeyId(cek, opts = {}) {
92
- if (cek.length !== CEK_BYTES) {
93
- throw new PasskeyKitError(`passkey-kit: CEK must be ${CEK_BYTES} bytes, got ${cek.length}`);
94
- }
95
- const lengthBytes = opts.lengthBytes ?? DEFAULT_KEY_ID_BYTES;
96
- const ikm = await crypto.subtle.importKey("raw", cek, "HKDF", false, ["deriveBits"]);
97
- const bits = await crypto.subtle.deriveBits({
98
- name: "HKDF",
99
- hash: "SHA-256",
100
- salt: new Uint8Array(0),
101
- info: toBytes(opts.info ?? TINFOIL_KEY_ID_INFO_V1),
102
- }, ikm, lengthBytes * 8);
103
- return new Uint8Array(bits);
72
+ export function validateKey(key, operation) {
73
+ if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) {
74
+ throw invalidInput(`key must be exactly ${KEY_BYTES} bytes`, operation);
75
+ }
76
+ }
77
+ export function validateWrappedKey(wrapped, profile) {
78
+ if (!wrapped || typeof wrapped !== "object")
79
+ throw invalidInput("wrapped key is required");
80
+ const keys = Object.keys(wrapped).sort();
81
+ const expected = ["profile", "credentialId", "kekIvHex", "wrappedKeyHex"].sort();
82
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
83
+ throw invalidInput("wrapped key has unexpected fields");
84
+ }
85
+ const wrappedProfile = copyAndValidateProfile(wrapped.profile);
86
+ if (!profilesEqual(wrappedProfile, profile)) {
87
+ throw invalidInput("wrapped key profile mismatch");
88
+ }
89
+ validateCredentialId(wrapped.credentialId);
90
+ if (!/^[0-9a-f]{24}$/.test(wrapped.kekIvHex)) {
91
+ throw invalidInput("kekIvHex must be a lowercase 12-byte hex value");
92
+ }
93
+ const ciphertextHexLength = (KEY_BYTES + AES_GCM_TAG_BYTES) * 2;
94
+ if (!new RegExp(`^[0-9a-f]{${ciphertextHexLength}}$`).test(wrapped.wrappedKeyHex)) {
95
+ throw invalidInput("wrappedKeyHex has an invalid format or length");
96
+ }
97
+ }
98
+ export async function deriveWrappingKey(prfOutput, profile) {
99
+ if (!(prfOutput instanceof Uint8Array) || prfOutput.length !== PRF_OUTPUT_BYTES) {
100
+ throw invalidInput(`PRF output must be exactly ${PRF_OUTPUT_BYTES} bytes`);
101
+ }
102
+ try {
103
+ const ikm = await crypto.subtle.importKey("raw", prfOutput.slice(), "HKDF", false, [
104
+ "deriveKey",
105
+ ]);
106
+ return await crypto.subtle.deriveKey({
107
+ name: "HKDF",
108
+ hash: "SHA-256",
109
+ salt: new Uint8Array(),
110
+ info: profile.hkdfInfo,
111
+ }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
112
+ }
113
+ catch (cause) {
114
+ if (cause instanceof PasskeyKeyError)
115
+ throw cause;
116
+ throw operationFailed("failed to derive wrapping key", cause);
117
+ }
118
+ }
119
+ export async function wrapKey(profile, credentialId, prfOutput, key, operation = "createAndWrapKey") {
120
+ validateCredentialId(credentialId);
121
+ validateKey(key, operation);
122
+ try {
123
+ const wrappingKey = await deriveWrappingKey(prfOutput, profile);
124
+ const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
125
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, wrappingKey, key);
126
+ return {
127
+ profile: copyAndValidateProfile(profile),
128
+ credentialId,
129
+ kekIvHex: bytesToHex(iv),
130
+ wrappedKeyHex: bytesToHex(new Uint8Array(ciphertext)),
131
+ };
132
+ }
133
+ catch (cause) {
134
+ if (cause instanceof PasskeyKeyError)
135
+ throw cause;
136
+ throw operationFailed("failed to wrap key", cause, operation);
137
+ }
138
+ }
139
+ export async function unwrapKey(profile, prfOutput, wrapped, operation = "recoverKey") {
140
+ validateWrappedKey(wrapped, profile);
141
+ try {
142
+ const wrappingKey = await deriveWrappingKey(prfOutput, profile);
143
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: hexToBytes(wrapped.kekIvHex) }, wrappingKey, hexToBytes(wrapped.wrappedKeyHex));
144
+ const key = new Uint8Array(plaintext);
145
+ validateKey(key, operation);
146
+ return key;
147
+ }
148
+ catch (cause) {
149
+ if (cause instanceof PasskeyKeyError)
150
+ throw cause;
151
+ throw operationFailed("failed to recover key", cause, operation);
152
+ }
104
153
  }
105
154
  //# sourceMappingURL=crypto.js.map