@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// --- Encryption-specific errors ---
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when an encryption or decryption operation fails.
|
|
7
|
+
* Includes context about what went wrong to aid debugging.
|
|
8
|
+
*/
|
|
9
|
+
export class EncryptionError extends KoraError {
|
|
10
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
11
|
+
super(message, 'ENCRYPTION_ERROR', context)
|
|
12
|
+
this.name = 'EncryptionError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when the Web Crypto API is not available in the current environment.
|
|
18
|
+
* The encryption module requires `crypto.subtle` for AES-256-GCM operations.
|
|
19
|
+
*/
|
|
20
|
+
export class CryptoUnavailableError extends KoraError {
|
|
21
|
+
constructor() {
|
|
22
|
+
super(
|
|
23
|
+
'Web Crypto API (crypto.subtle) is not available in this environment. ' +
|
|
24
|
+
'Database encryption requires crypto.subtle, which is available in modern browsers and Node.js 20+. ' +
|
|
25
|
+
'If running in SSR, ensure your runtime provides the Web Crypto API.',
|
|
26
|
+
'CRYPTO_UNAVAILABLE',
|
|
27
|
+
)
|
|
28
|
+
this.name = 'CryptoUnavailableError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Internal helpers ---
|
|
33
|
+
|
|
34
|
+
/** AES-GCM algorithm name, used throughout the module. */
|
|
35
|
+
const AES_GCM = 'AES-GCM' as const
|
|
36
|
+
|
|
37
|
+
/** AES-256 key length in bits. */
|
|
38
|
+
const AES_KEY_LENGTH = 256
|
|
39
|
+
|
|
40
|
+
/** GCM initialization vector length in bytes (96 bits / 12 bytes is the recommended size). */
|
|
41
|
+
const IV_LENGTH = 12
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Asserts that `crypto.subtle` is available, throwing a clear error if not.
|
|
45
|
+
*/
|
|
46
|
+
function assertCryptoAvailable(): void {
|
|
47
|
+
if (typeof globalThis.crypto === 'undefined' || typeof globalThis.crypto.subtle === 'undefined') {
|
|
48
|
+
throw new CryptoUnavailableError()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Public API ---
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generates a random 256-bit AES-GCM encryption key.
|
|
56
|
+
*
|
|
57
|
+
* The key is extractable so it can be exported for persistence (e.g., encrypted
|
|
58
|
+
* with a passphrase-derived key and stored locally). Use {@link exportKey} to
|
|
59
|
+
* get the raw bytes.
|
|
60
|
+
*
|
|
61
|
+
* @returns A CryptoKey for AES-256-GCM encryption and decryption
|
|
62
|
+
* @throws {CryptoUnavailableError} If `crypto.subtle` is not available
|
|
63
|
+
* @throws {EncryptionError} If key generation fails
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const key = await generateEncryptionKey()
|
|
68
|
+
* // key can be used with encryptData() and decryptData()
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export async function generateEncryptionKey(): Promise<CryptoKey> {
|
|
72
|
+
assertCryptoAvailable()
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const key = await globalThis.crypto.subtle.generateKey(
|
|
76
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
77
|
+
// extractable: true so the key can be exported and persisted
|
|
78
|
+
true,
|
|
79
|
+
['encrypt', 'decrypt'],
|
|
80
|
+
)
|
|
81
|
+
return key
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
throw new EncryptionError(
|
|
84
|
+
'Failed to generate AES-256-GCM encryption key. ' +
|
|
85
|
+
'Ensure the runtime supports the AES-GCM algorithm.',
|
|
86
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Encrypts data using AES-256-GCM with a randomly generated IV.
|
|
93
|
+
*
|
|
94
|
+
* Each call generates a fresh 12-byte IV, ensuring that encrypting the same
|
|
95
|
+
* plaintext twice produces different ciphertext. The IV must be stored alongside
|
|
96
|
+
* the ciphertext for decryption.
|
|
97
|
+
*
|
|
98
|
+
* AES-GCM provides both confidentiality and integrity: the ciphertext includes
|
|
99
|
+
* an authentication tag that detects tampering.
|
|
100
|
+
*
|
|
101
|
+
* @param key - An AES-256-GCM CryptoKey (from {@link generateEncryptionKey} or {@link importKey})
|
|
102
|
+
* @param plaintext - The data to encrypt
|
|
103
|
+
* @returns An object containing the ciphertext and the IV used for encryption
|
|
104
|
+
* @throws {CryptoUnavailableError} If `crypto.subtle` is not available
|
|
105
|
+
* @throws {EncryptionError} If encryption fails
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const key = await generateEncryptionKey()
|
|
110
|
+
* const data = new TextEncoder().encode('sensitive data')
|
|
111
|
+
* const { ciphertext, iv } = await encryptData(key, data)
|
|
112
|
+
* // Store ciphertext and iv together; both are needed for decryption
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export async function encryptData(
|
|
116
|
+
key: CryptoKey,
|
|
117
|
+
plaintext: Uint8Array,
|
|
118
|
+
): Promise<{ ciphertext: Uint8Array; iv: Uint8Array }> {
|
|
119
|
+
assertCryptoAvailable()
|
|
120
|
+
|
|
121
|
+
// Generate a fresh random IV for each encryption operation.
|
|
122
|
+
// AES-GCM with a 96-bit IV is the recommended configuration per NIST SP 800-38D.
|
|
123
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH))
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
127
|
+
{ name: AES_GCM, iv: iv as unknown as ArrayBuffer },
|
|
128
|
+
key,
|
|
129
|
+
plaintext as unknown as ArrayBuffer,
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
ciphertext: new Uint8Array(ciphertextBuffer),
|
|
133
|
+
iv,
|
|
134
|
+
}
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
throw new EncryptionError(
|
|
137
|
+
'Failed to encrypt data with AES-256-GCM. ' +
|
|
138
|
+
'Ensure the key is a valid AES-GCM CryptoKey with "encrypt" usage.',
|
|
139
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decrypts AES-256-GCM encrypted data.
|
|
146
|
+
*
|
|
147
|
+
* The IV must be the same one that was used during encryption. AES-GCM
|
|
148
|
+
* authenticates the ciphertext, so any tampering will cause decryption to fail.
|
|
149
|
+
*
|
|
150
|
+
* @param key - The AES-256-GCM CryptoKey used for encryption
|
|
151
|
+
* @param ciphertext - The encrypted data (from {@link encryptData})
|
|
152
|
+
* @param iv - The initialization vector used during encryption (from {@link encryptData})
|
|
153
|
+
* @returns The decrypted plaintext
|
|
154
|
+
* @throws {CryptoUnavailableError} If `crypto.subtle` is not available
|
|
155
|
+
* @throws {EncryptionError} If decryption fails (wrong key, tampered ciphertext, or wrong IV)
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* const decrypted = await decryptData(key, ciphertext, iv)
|
|
160
|
+
* const text = new TextDecoder().decode(decrypted)
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export async function decryptData(
|
|
164
|
+
key: CryptoKey,
|
|
165
|
+
ciphertext: Uint8Array,
|
|
166
|
+
iv: Uint8Array,
|
|
167
|
+
): Promise<Uint8Array> {
|
|
168
|
+
assertCryptoAvailable()
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
172
|
+
{ name: AES_GCM, iv: iv as unknown as ArrayBuffer },
|
|
173
|
+
key,
|
|
174
|
+
ciphertext as unknown as ArrayBuffer,
|
|
175
|
+
)
|
|
176
|
+
return new Uint8Array(plaintextBuffer)
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
throw new EncryptionError(
|
|
179
|
+
'Failed to decrypt data with AES-256-GCM. ' +
|
|
180
|
+
'This may indicate a wrong key, tampered ciphertext, or incorrect IV.',
|
|
181
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Exports an AES-256-GCM CryptoKey to its raw byte representation.
|
|
188
|
+
*
|
|
189
|
+
* The raw key is 32 bytes (256 bits). This is useful for persisting the key
|
|
190
|
+
* (e.g., encrypting it with a passphrase-derived key before storing to disk).
|
|
191
|
+
*
|
|
192
|
+
* **Security warning:** Raw key bytes are sensitive material. Never log them,
|
|
193
|
+
* store them in plaintext, or transmit them over the network without encryption.
|
|
194
|
+
*
|
|
195
|
+
* @param key - An extractable AES-256-GCM CryptoKey
|
|
196
|
+
* @returns The raw key bytes (32 bytes for AES-256)
|
|
197
|
+
* @throws {CryptoUnavailableError} If `crypto.subtle` is not available
|
|
198
|
+
* @throws {EncryptionError} If the key export fails (e.g., key is not extractable)
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```typescript
|
|
202
|
+
* const key = await generateEncryptionKey()
|
|
203
|
+
* const rawBytes = await exportKey(key)
|
|
204
|
+
* // rawBytes.length === 32
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export async function exportKey(key: CryptoKey): Promise<Uint8Array> {
|
|
208
|
+
assertCryptoAvailable()
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const rawBuffer = await globalThis.crypto.subtle.exportKey('raw', key)
|
|
212
|
+
return new Uint8Array(rawBuffer)
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
throw new EncryptionError(
|
|
215
|
+
'Failed to export AES-256-GCM key. ' +
|
|
216
|
+
'The key may not be extractable. Only keys generated with extractable=true can be exported.',
|
|
217
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Imports raw key bytes into an AES-256-GCM CryptoKey.
|
|
224
|
+
*
|
|
225
|
+
* The input must be exactly 32 bytes (256 bits). The imported key is extractable
|
|
226
|
+
* and supports both encrypt and decrypt operations.
|
|
227
|
+
*
|
|
228
|
+
* @param rawKey - Raw key bytes (must be exactly 32 bytes for AES-256)
|
|
229
|
+
* @returns A CryptoKey for AES-256-GCM operations
|
|
230
|
+
* @throws {CryptoUnavailableError} If `crypto.subtle` is not available
|
|
231
|
+
* @throws {EncryptionError} If the raw key is invalid or import fails
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```typescript
|
|
235
|
+
* const rawBytes = new Uint8Array(32) // previously exported key bytes
|
|
236
|
+
* const key = await importKey(rawBytes)
|
|
237
|
+
* // key can now be used with encryptData() and decryptData()
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export async function importKey(rawKey: Uint8Array): Promise<CryptoKey> {
|
|
241
|
+
assertCryptoAvailable()
|
|
242
|
+
|
|
243
|
+
if (rawKey.length !== 32) {
|
|
244
|
+
throw new EncryptionError(
|
|
245
|
+
`Invalid key length: expected 32 bytes (256 bits) for AES-256, but received ${rawKey.length} bytes.`,
|
|
246
|
+
{ actualLength: rawKey.length, expectedLength: 32 },
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
252
|
+
'raw',
|
|
253
|
+
rawKey as unknown as ArrayBuffer,
|
|
254
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
255
|
+
true,
|
|
256
|
+
['encrypt', 'decrypt'],
|
|
257
|
+
)
|
|
258
|
+
return key
|
|
259
|
+
} catch (cause) {
|
|
260
|
+
throw new EncryptionError(
|
|
261
|
+
'Failed to import raw key bytes as AES-256-GCM key. ' + 'Ensure the key material is valid.',
|
|
262
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// --- Key derivation errors ---
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a key derivation operation fails.
|
|
7
|
+
*/
|
|
8
|
+
export class KeyDerivationError extends KoraError {
|
|
9
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
10
|
+
super(message, 'KEY_DERIVATION_ERROR', context)
|
|
11
|
+
this.name = 'KeyDerivationError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Internal helpers ---
|
|
16
|
+
|
|
17
|
+
/** Salt length in bytes. 32 bytes (256 bits) provides sufficient randomness. */
|
|
18
|
+
const SALT_LENGTH = 32
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PBKDF2 iteration count. 600,000 iterations is the OWASP-recommended minimum
|
|
22
|
+
* for SHA-256 as of 2024, providing strong resistance against brute-force attacks.
|
|
23
|
+
*/
|
|
24
|
+
const PBKDF2_ITERATIONS = 600_000
|
|
25
|
+
|
|
26
|
+
/** Derived key length in bits (AES-256). */
|
|
27
|
+
const DERIVED_KEY_LENGTH = 256
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Asserts that `crypto.subtle` is available, throwing a clear error if not.
|
|
31
|
+
*/
|
|
32
|
+
function assertCryptoAvailable(): void {
|
|
33
|
+
if (typeof globalThis.crypto === 'undefined' || typeof globalThis.crypto.subtle === 'undefined') {
|
|
34
|
+
throw new KeyDerivationError(
|
|
35
|
+
'Web Crypto API (crypto.subtle) is not available in this environment. ' +
|
|
36
|
+
'Key derivation requires crypto.subtle, which is available in modern browsers and Node.js 20+.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Public API ---
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates a cryptographically random 32-byte salt for key derivation.
|
|
45
|
+
*
|
|
46
|
+
* Each call returns a unique salt. The salt should be stored alongside the
|
|
47
|
+
* encrypted data so that the same passphrase can reproduce the same key later.
|
|
48
|
+
*
|
|
49
|
+
* @returns A random 32-byte Uint8Array
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* const salt = generateSalt()
|
|
54
|
+
* // salt.length === 32
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function generateSalt(): Uint8Array {
|
|
58
|
+
if (typeof globalThis.crypto === 'undefined') {
|
|
59
|
+
throw new KeyDerivationError(
|
|
60
|
+
'Web Crypto API (crypto) is not available. Cannot generate random salt.',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derives an AES-256-GCM encryption key from a passphrase using PBKDF2.
|
|
69
|
+
*
|
|
70
|
+
* Uses PBKDF2 with SHA-256 and 600,000 iterations (OWASP-recommended minimum)
|
|
71
|
+
* to derive a 256-bit key from the passphrase. The derived key can be used with
|
|
72
|
+
* the database encryption functions ({@link encryptData}, {@link decryptData}).
|
|
73
|
+
*
|
|
74
|
+
* If no salt is provided, a random 32-byte salt is generated. The salt must be
|
|
75
|
+
* persisted alongside the encrypted data so the key can be re-derived later.
|
|
76
|
+
*
|
|
77
|
+
* **Deterministic:** The same passphrase and salt always produce the same key.
|
|
78
|
+
* This is essential for decryption: the user enters their passphrase, the stored
|
|
79
|
+
* salt is used, and the identical key is re-derived to decrypt the data.
|
|
80
|
+
*
|
|
81
|
+
* @param passphrase - The user's passphrase (any non-empty string)
|
|
82
|
+
* @param salt - Optional salt bytes. If omitted, a random 32-byte salt is generated.
|
|
83
|
+
* @returns An object containing the derived CryptoKey and the salt used
|
|
84
|
+
* @throws {KeyDerivationError} If the passphrase is empty, crypto is unavailable, or derivation fails
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* // First time: derive key with a new salt
|
|
89
|
+
* const { key, salt } = await deriveEncryptionKey('my-secure-passphrase')
|
|
90
|
+
* // Store the salt alongside the encrypted data
|
|
91
|
+
*
|
|
92
|
+
* // Later: re-derive the same key using the stored salt
|
|
93
|
+
* const { key: sameKey } = await deriveEncryptionKey('my-secure-passphrase', salt)
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export async function deriveEncryptionKey(
|
|
97
|
+
passphrase: string,
|
|
98
|
+
salt?: Uint8Array,
|
|
99
|
+
): Promise<{ key: CryptoKey; salt: Uint8Array }> {
|
|
100
|
+
assertCryptoAvailable()
|
|
101
|
+
|
|
102
|
+
if (passphrase.length === 0) {
|
|
103
|
+
throw new KeyDerivationError(
|
|
104
|
+
'Passphrase must not be empty. Provide a non-empty string for key derivation.',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const usedSalt = salt ?? generateSalt()
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Step 1: Import the passphrase as raw key material for PBKDF2
|
|
112
|
+
const passphraseBytes = new TextEncoder().encode(passphrase)
|
|
113
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
114
|
+
'raw',
|
|
115
|
+
passphraseBytes,
|
|
116
|
+
'PBKDF2',
|
|
117
|
+
false,
|
|
118
|
+
['deriveBits', 'deriveKey'],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// Step 2: Derive an AES-256-GCM key using PBKDF2 with SHA-256
|
|
122
|
+
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
123
|
+
{
|
|
124
|
+
name: 'PBKDF2',
|
|
125
|
+
salt: usedSalt as unknown as ArrayBuffer,
|
|
126
|
+
iterations: PBKDF2_ITERATIONS,
|
|
127
|
+
hash: 'SHA-256',
|
|
128
|
+
},
|
|
129
|
+
baseKey,
|
|
130
|
+
{ name: 'AES-GCM', length: DERIVED_KEY_LENGTH },
|
|
131
|
+
// extractable: true so the derived key can be exported if needed
|
|
132
|
+
true,
|
|
133
|
+
['encrypt', 'decrypt'],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return { key: derivedKey, salt: usedSalt }
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
// Re-throw KeyDerivationError as-is (e.g., from assertCryptoAvailable)
|
|
139
|
+
if (cause instanceof KeyDerivationError) {
|
|
140
|
+
throw cause
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new KeyDerivationError(
|
|
144
|
+
'Failed to derive encryption key from passphrase using PBKDF2. ' +
|
|
145
|
+
'Ensure the runtime supports PBKDF2 with SHA-256.',
|
|
146
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|