@alikhalilll/nuxt-crypto 1.0.3 → 1.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.
- package/README.md +144 -40
- package/dist/{chunk-HNTEUBQ7.mjs → chunk-FKHKOTFR.mjs} +20 -59
- package/dist/chunk-FKHKOTFR.mjs.map +1 -0
- package/dist/chunk-FKXZ7VYW.mjs +50 -0
- package/dist/chunk-FKXZ7VYW.mjs.map +1 -0
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.mjs +4 -2
- package/dist/module.d.ts +1 -1
- package/dist/{payload-Cht5Ar0K.d.ts → payload-BOueSWcI.d.ts} +28 -5
- package/dist/runtime/plugin.mjs +2 -1
- package/dist/runtime/plugin.mjs.map +1 -1
- package/dist/server/index.d.ts +76 -0
- package/dist/server/index.mjs +42 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/types/index.d.ts +1 -1
- package/package.json +17 -4
- package/dist/chunk-HNTEUBQ7.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Symmetric encryption for Nuxt 3 / 4, built on the native **Web Crypto API**. Def
|
|
|
7
7
|
- **Pluggable algorithms** — swap the default AES-GCM implementation for your own `CryptoAlgorithm` without touching the payload envelope.
|
|
8
8
|
- **Versioned payload format** — `v1.{salt}.{iv}.{cipher}` with clean forward compatibility.
|
|
9
9
|
- **Server-only mode** — opt into registering the plugin only on the server so the passphrase never ships to the browser bundle.
|
|
10
|
+
- **Device fingerprint** _(new)_ — bind a payload to the browser that created it via an HttpOnly cookie. Survives IP changes (Wi-Fi ↔ 4G, VPN rotation) while still blocking copy-paste to another browser or device.
|
|
10
11
|
- **Strong types** — `CryptoService`, `CryptoServiceConfig`, and `CryptoAlgorithm` all exported.
|
|
11
12
|
|
|
12
13
|
---
|
|
@@ -16,21 +17,22 @@ Symmetric encryption for Nuxt 3 / 4, built on the native **Web Crypto API**. Def
|
|
|
16
17
|
1. [Install](#install)
|
|
17
18
|
2. [Register the module](#register-the-module)
|
|
18
19
|
3. [Usage](#usage)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
- [Basic round-trip](#basic-round-trip)
|
|
21
|
+
- [Encrypt / decrypt a JSON object](#encrypt--decrypt-a-json-object)
|
|
22
|
+
- [Encrypt / decrypt an arbitrary value](#encrypt--decrypt-an-arbitrary-value)
|
|
23
|
+
- [Persist a ciphertext in a cookie](#persist-a-ciphertext-in-a-cookie)
|
|
24
|
+
- [Bulk encrypt / decrypt (key cache benefits)](#bulk-encrypt--decrypt-key-cache-benefits)
|
|
25
|
+
- [Clear the key cache](#clear-the-key-cache)
|
|
26
|
+
- [Error handling](#error-handling)
|
|
26
27
|
4. [Server-only mode](#server-only-mode)
|
|
27
28
|
5. [Nitro / API routes](#nitro--api-routes)
|
|
28
|
-
6. [
|
|
29
|
-
7. [
|
|
30
|
-
8. [
|
|
31
|
-
9. [
|
|
32
|
-
10. [
|
|
33
|
-
11. [
|
|
29
|
+
6. [Device fingerprint](#device-fingerprint)
|
|
30
|
+
7. [Framework-agnostic core](#framework-agnostic-core)
|
|
31
|
+
8. [Custom algorithm](#custom-algorithm)
|
|
32
|
+
9. [Rotating the passphrase (re-encryption)](#rotating-the-passphrase-re-encryption)
|
|
33
|
+
10. [Payload format](#payload-format)
|
|
34
|
+
11. [Module options reference](#module-options-reference)
|
|
35
|
+
12. [Exported types](#exported-types)
|
|
34
36
|
|
|
35
37
|
---
|
|
36
38
|
|
|
@@ -49,7 +51,7 @@ export default defineNuxtConfig({
|
|
|
49
51
|
crypto: {
|
|
50
52
|
// Always wire this from an env var. Never commit the passphrase.
|
|
51
53
|
passphrase: process.env.NUXT_ENCRYPTION_PASSPHRASE ?? '',
|
|
52
|
-
provideName: '$crypto',
|
|
54
|
+
provideName: '$crypto', // leading "$" optional
|
|
53
55
|
iterations: 100_000,
|
|
54
56
|
keyCacheSize: 64,
|
|
55
57
|
serverOnly: false,
|
|
@@ -148,6 +150,7 @@ $crypto.clearKeyCache();
|
|
|
148
150
|
```
|
|
149
151
|
|
|
150
152
|
Useful when:
|
|
153
|
+
|
|
151
154
|
- You rotate the passphrase (the cache would still hold old keys).
|
|
152
155
|
- You want to force re-derivation in a security-audit test.
|
|
153
156
|
|
|
@@ -164,13 +167,13 @@ try {
|
|
|
164
167
|
|
|
165
168
|
Errors you can see:
|
|
166
169
|
|
|
167
|
-
| Scenario
|
|
168
|
-
|
|
169
|
-
| Payload isn't `a.b.c.d`
|
|
170
|
-
| A segment is empty
|
|
171
|
-
| Algorithm version mismatch
|
|
172
|
-
| Wrong passphrase / tampered ciphertext
|
|
173
|
-
| Passphrase not set in module config
|
|
170
|
+
| Scenario | Message |
|
|
171
|
+
| -------------------------------------- | ------------------------------------------------------------- |
|
|
172
|
+
| Payload isn't `a.b.c.d` | `Invalid payload format — expected 4 dot-separated segments.` |
|
|
173
|
+
| A segment is empty | `Invalid payload format — one or more segments were empty.` |
|
|
174
|
+
| Algorithm version mismatch | `Unsupported payload version: v2 (algorithm expects v1).` |
|
|
175
|
+
| Wrong passphrase / tampered ciphertext | Native `OperationError` from Web Crypto. |
|
|
176
|
+
| Passphrase not set in module config | `[nuxt-crypto] passphrase is required.` |
|
|
174
177
|
|
|
175
178
|
## Server-only mode
|
|
176
179
|
|
|
@@ -184,30 +187,120 @@ crypto: {
|
|
|
184
187
|
```
|
|
185
188
|
|
|
186
189
|
With this enabled, `$crypto` is `undefined` on the client. Use it only in:
|
|
190
|
+
|
|
187
191
|
- Nitro server routes (`server/api/*.ts`)
|
|
188
192
|
- Server-only plugins
|
|
189
193
|
- `<script setup>` blocks guarded by `import.meta.server`
|
|
190
194
|
|
|
191
195
|
## Nitro / API routes
|
|
192
196
|
|
|
197
|
+
Nitro event handlers run outside the Nuxt app context, so `useNuxtApp()` — and therefore `$crypto` — isn't available there. Use the framework-agnostic core directly. A tiny server utility keeps the service cached across requests:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// server/utils/crypto.ts
|
|
201
|
+
import { createCryptoService } from '@alikhalilll/nuxt-crypto/core';
|
|
202
|
+
|
|
203
|
+
let servicePromise: ReturnType<typeof createCryptoService> | null = null;
|
|
204
|
+
|
|
205
|
+
export function useServerCrypto() {
|
|
206
|
+
if (!servicePromise) {
|
|
207
|
+
servicePromise = createCryptoService({
|
|
208
|
+
passphrase: process.env.NUXT_ENCRYPTION_PASSPHRASE!,
|
|
209
|
+
iterations: 100_000,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return servicePromise;
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
193
216
|
Encrypt data before sending it to the browser, decrypt it when it comes back:
|
|
194
217
|
|
|
195
218
|
```ts
|
|
196
219
|
// server/api/session/encode.post.ts
|
|
197
220
|
export default defineEventHandler(async (event) => {
|
|
198
221
|
const body = await readBody(event);
|
|
199
|
-
const
|
|
200
|
-
return { token: await
|
|
222
|
+
const crypto = await useServerCrypto();
|
|
223
|
+
return { token: await crypto.encrypt(JSON.stringify(body)) };
|
|
201
224
|
});
|
|
202
225
|
|
|
203
226
|
// server/api/session/decode.post.ts
|
|
204
227
|
export default defineEventHandler(async (event) => {
|
|
205
228
|
const { token } = await readBody(event);
|
|
206
|
-
const
|
|
207
|
-
return JSON.parse(await
|
|
229
|
+
const crypto = await useServerCrypto();
|
|
230
|
+
return JSON.parse(await crypto.decrypt(token));
|
|
208
231
|
});
|
|
209
232
|
```
|
|
210
233
|
|
|
234
|
+
## Device fingerprint
|
|
235
|
+
|
|
236
|
+
Bind a payload to the browser that created it, so a copy of the ciphertext in another browser or on another device won't decrypt. Useful for short-lived CSRF tokens, magic links, and anti-replay nonces.
|
|
237
|
+
|
|
238
|
+
The fingerprint is built from an **HttpOnly device-ID cookie** — not the client IP — so it survives Wi-Fi → 4G switches, cell handoffs, VPN rotations, and residential IP rotation while still blocking copy-paste to another origin.
|
|
239
|
+
|
|
240
|
+
### Setup
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
// nuxt.config.ts
|
|
244
|
+
export default defineNuxtConfig({
|
|
245
|
+
runtimeConfig: {
|
|
246
|
+
// Server-side pepper. Keep this secret; never expose to the client.
|
|
247
|
+
cryptoFingerprintSalt: process.env.NUXT_CRYPTO_FINGERPRINT_SALT ?? '',
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
# .env
|
|
254
|
+
NUXT_CRYPTO_FINGERPRINT_SALT=<64 random hex chars>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Encrypt / decrypt with a fingerprint
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
// server/api/session.post.ts
|
|
261
|
+
import { getClientFingerprint } from '@alikhalilll/nuxt-crypto/server';
|
|
262
|
+
|
|
263
|
+
export default defineEventHandler(async (event) => {
|
|
264
|
+
const body = await readBody(event);
|
|
265
|
+
const crypto = await useServerCrypto(); // see "Nitro / API routes" above
|
|
266
|
+
|
|
267
|
+
const fingerprint = await getClientFingerprint(event, {
|
|
268
|
+
salt: useRuntimeConfig().cryptoFingerprintSalt,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
token: await crypto.encrypt(JSON.stringify(body), { fingerprint }),
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The first call sets an HttpOnly cookie (`__nuxt_crypto_device`) with a random 32-byte ID; subsequent calls reuse it. Pass the same `{ fingerprint }` to `crypto.decrypt` on the server to round-trip.
|
|
278
|
+
|
|
279
|
+
### What this protects against
|
|
280
|
+
|
|
281
|
+
| Scenario | Decrypts? |
|
|
282
|
+
| ----------------------------------------- | ------------------------------------ |
|
|
283
|
+
| Wi-Fi → 4G on the same device | ✅ yes — cookie travels with browser |
|
|
284
|
+
| VPN exit node or ISP IP rotation | ✅ yes |
|
|
285
|
+
| Copy token to another browser on same box | ❌ no — no cookie in that browser |
|
|
286
|
+
| XSS-exfiltrated token to an attacker | ❌ no — HttpOnly cookie unreachable |
|
|
287
|
+
| User clears cookies | ❌ no — permanently unrecoverable |
|
|
288
|
+
|
|
289
|
+
### `deriveFingerprint` — bring your own device ID
|
|
290
|
+
|
|
291
|
+
If you already manage per-device identity (session table, signed JWT, etc.), skip the cookie helper:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { deriveFingerprint } from '@alikhalilll/nuxt-crypto/server';
|
|
295
|
+
|
|
296
|
+
const fingerprint = await deriveFingerprint({
|
|
297
|
+
deviceId: session.id,
|
|
298
|
+
salt: useRuntimeConfig().cryptoFingerprintSalt,
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
> **Warning** — a fingerprinted payload becomes undecryptable if the device cookie is cleared or the session rotates. Use only for tokens the user can afford to lose (short sessions, magic links, one-shot nonces), never for long-lived data.
|
|
303
|
+
|
|
211
304
|
## Framework-agnostic core
|
|
212
305
|
|
|
213
306
|
Everything the Nuxt plugin wraps is available as a plain factory:
|
|
@@ -238,9 +331,15 @@ import type { CryptoAlgorithm } from '@alikhalilll/nuxt-crypto/types';
|
|
|
238
331
|
|
|
239
332
|
const myAlgo: CryptoAlgorithm = {
|
|
240
333
|
version: 'v2',
|
|
241
|
-
async deriveKey({ subtle, passphrase, salt, iterations }) {
|
|
242
|
-
|
|
243
|
-
|
|
334
|
+
async deriveKey({ subtle, passphrase, salt, iterations }) {
|
|
335
|
+
/* ... */
|
|
336
|
+
},
|
|
337
|
+
async encrypt({ subtle, key, plainText }) {
|
|
338
|
+
/* ... */
|
|
339
|
+
},
|
|
340
|
+
async decrypt({ subtle, key, cipher, iv }) {
|
|
341
|
+
/* ... */
|
|
342
|
+
},
|
|
244
343
|
};
|
|
245
344
|
|
|
246
345
|
const service = await createCryptoService({
|
|
@@ -271,22 +370,22 @@ Run `rotate` over your stored ciphertexts during a background migration. Clear t
|
|
|
271
370
|
|
|
272
371
|
`v1.{saltB64}.{ivB64}.{cipherB64}` — four dot-separated segments, each standard base64:
|
|
273
372
|
|
|
274
|
-
| Segment | Bytes | Notes
|
|
275
|
-
|
|
276
|
-
| `v1` | — | Algorithm / version tag.
|
|
277
|
-
| salt | 16 | Per-encryption PBKDF2 salt.
|
|
278
|
-
| iv | 12 | AES-GCM initialization vector.
|
|
373
|
+
| Segment | Bytes | Notes |
|
|
374
|
+
| ------- | ----- | ---------------------------------- |
|
|
375
|
+
| `v1` | — | Algorithm / version tag. |
|
|
376
|
+
| salt | 16 | Per-encryption PBKDF2 salt. |
|
|
377
|
+
| iv | 12 | AES-GCM initialization vector. |
|
|
279
378
|
| cipher | N | Ciphertext + 16-byte GCM auth tag. |
|
|
280
379
|
|
|
281
380
|
## Module options reference
|
|
282
381
|
|
|
283
|
-
| Option | Type
|
|
284
|
-
|
|
285
|
-
| `passphrase` | `string`
|
|
286
|
-
| `provideName` | `string`
|
|
287
|
-
| `iterations` | `number`
|
|
288
|
-
| `keyCacheSize` | `number`
|
|
289
|
-
| `serverOnly` | `boolean
|
|
382
|
+
| Option | Type | Default | Purpose |
|
|
383
|
+
| -------------- | --------- | ----------- | -------------------------------------------------------------- |
|
|
384
|
+
| `passphrase` | `string` | `''` | Passphrase to derive the AES key from. Throws at use if empty. |
|
|
385
|
+
| `provideName` | `string` | `'$crypto'` | Injected under `$<name>`. Leading `$` is stripped. |
|
|
386
|
+
| `iterations` | `number` | `100_000` | PBKDF2 iteration count. |
|
|
387
|
+
| `keyCacheSize` | `number` | `64` | Max derived keys kept in memory. Set to 0 to disable caching. |
|
|
388
|
+
| `serverOnly` | `boolean` | `false` | When true, plugin runs only on the server. |
|
|
290
389
|
|
|
291
390
|
## Exported types
|
|
292
391
|
|
|
@@ -294,11 +393,16 @@ Run `rotate` over your stored ciphertexts during a background migration. Clear t
|
|
|
294
393
|
import type {
|
|
295
394
|
CryptoService,
|
|
296
395
|
CryptoServiceConfig,
|
|
396
|
+
CryptoOperationOptions,
|
|
297
397
|
CryptoAlgorithm,
|
|
298
398
|
CryptoModuleOptions,
|
|
299
399
|
Bytes,
|
|
300
400
|
ParsedPayload,
|
|
301
401
|
} from '@alikhalilll/nuxt-crypto/types';
|
|
402
|
+
|
|
403
|
+
// Server subpath (Nitro / H3 only)
|
|
404
|
+
import type { ClientFingerprintOptions, FingerprintParts } from '@alikhalilll/nuxt-crypto/server';
|
|
405
|
+
import { getClientFingerprint, deriveFingerprint } from '@alikhalilll/nuxt-crypto/server';
|
|
302
406
|
```
|
|
303
407
|
|
|
304
408
|
## License
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromBase64,
|
|
3
|
+
getRandomBytes,
|
|
4
|
+
getSubtle,
|
|
5
|
+
toBase64
|
|
6
|
+
} from "./chunk-FKXZ7VYW.mjs";
|
|
7
|
+
|
|
1
8
|
// src/core/algorithms/aes-gcm.ts
|
|
2
9
|
var aesGcmPbkdf2 = {
|
|
3
10
|
version: "v1",
|
|
4
|
-
async deriveKey({ subtle, passphrase, salt, iterations }) {
|
|
11
|
+
async deriveKey({ subtle, passphrase, fingerprint, salt, iterations }) {
|
|
12
|
+
const material = fingerprint ? `${passphrase}\0${fingerprint}` : passphrase;
|
|
5
13
|
const keyMaterial = await subtle.importKey(
|
|
6
14
|
"raw",
|
|
7
|
-
new TextEncoder().encode(
|
|
15
|
+
new TextEncoder().encode(material),
|
|
8
16
|
{ name: "PBKDF2" },
|
|
9
17
|
false,
|
|
10
18
|
["deriveKey"]
|
|
@@ -33,31 +41,6 @@ var aesGcmPbkdf2 = {
|
|
|
33
41
|
}
|
|
34
42
|
};
|
|
35
43
|
|
|
36
|
-
// src/core/base64.ts
|
|
37
|
-
function toBase64(data) {
|
|
38
|
-
if (typeof btoa === "function") {
|
|
39
|
-
let binary = "";
|
|
40
|
-
const CHUNK = 32768;
|
|
41
|
-
for (let i = 0; i < data.length; i += CHUNK) {
|
|
42
|
-
binary += String.fromCharCode(...data.subarray(i, i + CHUNK));
|
|
43
|
-
}
|
|
44
|
-
return btoa(binary);
|
|
45
|
-
}
|
|
46
|
-
return Buffer.from(data).toString("base64");
|
|
47
|
-
}
|
|
48
|
-
function fromBase64(input) {
|
|
49
|
-
if (typeof atob === "function") {
|
|
50
|
-
const binary = atob(input);
|
|
51
|
-
const out2 = new Uint8Array(binary.length);
|
|
52
|
-
for (let i = 0; i < binary.length; i++) out2[i] = binary.charCodeAt(i);
|
|
53
|
-
return out2;
|
|
54
|
-
}
|
|
55
|
-
const buf = Buffer.from(input, "base64");
|
|
56
|
-
const out = new Uint8Array(buf.length);
|
|
57
|
-
out.set(buf);
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
44
|
// src/core/key-cache.ts
|
|
62
45
|
var KeyCache = class {
|
|
63
46
|
constructor(capacity) {
|
|
@@ -65,8 +48,8 @@ var KeyCache = class {
|
|
|
65
48
|
}
|
|
66
49
|
capacity;
|
|
67
50
|
map = /* @__PURE__ */ new Map();
|
|
68
|
-
static key(salt, iterations) {
|
|
69
|
-
return `${iterations}.${toBase64(salt)}`;
|
|
51
|
+
static key(salt, iterations, fingerprint) {
|
|
52
|
+
return `${iterations}.${fingerprint ?? ""}.${toBase64(salt)}`;
|
|
70
53
|
}
|
|
71
54
|
get(key) {
|
|
72
55
|
const value = this.map.get(key);
|
|
@@ -112,24 +95,6 @@ function parsePayload(payload) {
|
|
|
112
95
|
};
|
|
113
96
|
}
|
|
114
97
|
|
|
115
|
-
// src/core/subtle.ts
|
|
116
|
-
async function getSubtle() {
|
|
117
|
-
if (globalThis.crypto?.subtle) return globalThis.crypto.subtle;
|
|
118
|
-
const { webcrypto } = await import("crypto");
|
|
119
|
-
return webcrypto.subtle;
|
|
120
|
-
}
|
|
121
|
-
function getRandomBytes(length) {
|
|
122
|
-
const out = new Uint8Array(length);
|
|
123
|
-
const c = globalThis.crypto;
|
|
124
|
-
if (c?.getRandomValues) {
|
|
125
|
-
c.getRandomValues(out);
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
throw new Error(
|
|
129
|
-
"[nuxt-crypto] globalThis.crypto.getRandomValues is not available. Upgrade your runtime."
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
98
|
// src/core/create-service.ts
|
|
134
99
|
var DEFAULT_ITERATIONS = 1e5;
|
|
135
100
|
var DEFAULT_CACHE = 64;
|
|
@@ -145,32 +110,32 @@ async function createCryptoService(config) {
|
|
|
145
110
|
const passphrase = String(config.passphrase).trim();
|
|
146
111
|
const encoder = new TextEncoder();
|
|
147
112
|
const decoder = new TextDecoder();
|
|
148
|
-
async function getDerivedKey(salt) {
|
|
149
|
-
const key = KeyCache.key(salt, iterations);
|
|
113
|
+
async function getDerivedKey(salt, fingerprint) {
|
|
114
|
+
const key = KeyCache.key(salt, iterations, fingerprint);
|
|
150
115
|
const cached = cache.get(key);
|
|
151
116
|
if (cached) return cached;
|
|
152
|
-
const pending = algorithm.deriveKey({ subtle, passphrase, salt, iterations });
|
|
117
|
+
const pending = algorithm.deriveKey({ subtle, passphrase, fingerprint, salt, iterations });
|
|
153
118
|
cache.set(key, pending);
|
|
154
119
|
return pending;
|
|
155
120
|
}
|
|
156
121
|
return {
|
|
157
|
-
async encrypt(plainText) {
|
|
122
|
+
async encrypt(plainText, options) {
|
|
158
123
|
const salt = getRandomBytes(SALT_BYTES);
|
|
159
|
-
const key = await getDerivedKey(salt);
|
|
124
|
+
const key = await getDerivedKey(salt, options?.fingerprint);
|
|
160
125
|
const encoded = encoder.encode(plainText);
|
|
161
126
|
const plainBytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
|
162
127
|
plainBytes.set(encoded);
|
|
163
128
|
const { cipher, iv } = await algorithm.encrypt({ subtle, key, plainText: plainBytes });
|
|
164
129
|
return encodePayload({ version: algorithm.version, salt, iv, cipher });
|
|
165
130
|
},
|
|
166
|
-
async decrypt(payload) {
|
|
131
|
+
async decrypt(payload, options) {
|
|
167
132
|
const parsed = parsePayload(payload);
|
|
168
133
|
if (parsed.version !== algorithm.version) {
|
|
169
134
|
throw new Error(
|
|
170
135
|
`[nuxt-crypto] Unsupported payload version: ${parsed.version} (algorithm expects ${algorithm.version}).`
|
|
171
136
|
);
|
|
172
137
|
}
|
|
173
|
-
const key = await getDerivedKey(parsed.salt);
|
|
138
|
+
const key = await getDerivedKey(parsed.salt, options?.fingerprint);
|
|
174
139
|
const plainBytes = await algorithm.decrypt({
|
|
175
140
|
subtle,
|
|
176
141
|
key,
|
|
@@ -187,13 +152,9 @@ async function createCryptoService(config) {
|
|
|
187
152
|
|
|
188
153
|
export {
|
|
189
154
|
aesGcmPbkdf2,
|
|
190
|
-
toBase64,
|
|
191
|
-
fromBase64,
|
|
192
155
|
KeyCache,
|
|
193
156
|
encodePayload,
|
|
194
157
|
parsePayload,
|
|
195
|
-
getSubtle,
|
|
196
|
-
getRandomBytes,
|
|
197
158
|
createCryptoService
|
|
198
159
|
};
|
|
199
|
-
//# sourceMappingURL=chunk-
|
|
160
|
+
//# sourceMappingURL=chunk-FKHKOTFR.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/algorithms/aes-gcm.ts","../src/core/key-cache.ts","../src/core/payload.ts","../src/core/create-service.ts"],"sourcesContent":["import type { CryptoAlgorithm } from '../types';\n\n/**\n * AES-256-GCM with PBKDF2-SHA256 key derivation. This is the default and\n * currently the only built-in algorithm; pluggable via `CryptoServiceConfig.algorithm`.\n */\nexport const aesGcmPbkdf2: CryptoAlgorithm = {\n version: 'v1',\n\n async deriveKey({ subtle, passphrase, fingerprint, salt, iterations }) {\n // NUL separator prevents `\"ab\" + \"cd\"` and `\"abc\" + \"d\"` from colliding.\n const material = fingerprint ? `${passphrase}\\x00${fingerprint}` : passphrase;\n const keyMaterial = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(material),\n { name: 'PBKDF2' },\n false,\n ['deriveKey']\n );\n\n return subtle.deriveKey(\n { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt']\n );\n },\n\n async encrypt({ subtle, key, plainText }) {\n const iv = new Uint8Array(new ArrayBuffer(12));\n globalThis.crypto.getRandomValues(iv);\n const buf = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainText);\n const cipher = new Uint8Array(new ArrayBuffer(buf.byteLength));\n cipher.set(new Uint8Array(buf));\n return { cipher, iv };\n },\n\n async decrypt({ subtle, key, cipher, iv }) {\n const buf = await subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);\n const out = new Uint8Array(new ArrayBuffer(buf.byteLength));\n out.set(new Uint8Array(buf));\n return out;\n },\n};\n","import type { Bytes } from './types';\nimport { toBase64 } from './base64';\n\n/**\n * A tiny LRU-ish cache for derived `CryptoKey` values, keyed on the salt.\n * When capacity is exceeded the oldest entry is evicted. Derived key\n * material is expensive (PBKDF2 is designed to be slow), so caching has\n * a big impact when decrypting many payloads in a row.\n */\nexport class KeyCache {\n private readonly map = new Map<string, Promise<CryptoKey>>();\n constructor(private readonly capacity: number) {}\n\n static key(salt: Bytes, iterations: number, fingerprint?: string): string {\n // `.` separates the fields; iterations and base64 can't contain one,\n // so the joined key is unambiguous even when fingerprint is empty.\n return `${iterations}.${fingerprint ?? ''}.${toBase64(salt)}`;\n }\n\n get(key: string): Promise<CryptoKey> | undefined {\n const value = this.map.get(key);\n if (!value) return undefined;\n // Refresh recency by re-inserting.\n this.map.delete(key);\n this.map.set(key, value);\n return value;\n }\n\n set(key: string, value: Promise<CryptoKey>): void {\n if (this.capacity <= 0) return;\n this.map.set(key, value);\n while (this.map.size > this.capacity) {\n const oldest = this.map.keys().next().value;\n if (oldest === undefined) break;\n this.map.delete(oldest);\n }\n }\n\n clear(): void {\n this.map.clear();\n }\n}\n","import { fromBase64, toBase64 } from './base64';\nimport type { Bytes } from './types';\n\n/**\n * Payload serialisation format (v1): `${version}.${saltB64}.${ivB64}.${cipherB64}`.\n *\n * Each part is URL-safe base64, all joined with `.`. The version tag is\n * always the first segment so consumers can route to the correct algorithm\n * during decrypt.\n */\nexport interface ParsedPayload {\n version: string;\n salt: Bytes;\n iv: Bytes;\n cipher: Bytes;\n}\n\nexport function encodePayload(parts: ParsedPayload): string {\n return [parts.version, toBase64(parts.salt), toBase64(parts.iv), toBase64(parts.cipher)].join(\n '.'\n );\n}\n\nexport function parsePayload(payload: string): ParsedPayload {\n const segments = payload.split('.');\n if (segments.length !== 4) {\n throw new Error('[nuxt-crypto] Invalid payload format — expected 4 dot-separated segments.');\n }\n const [version, saltB64, ivB64, cipherB64] = segments;\n if (!version || !saltB64 || !ivB64 || !cipherB64) {\n throw new Error('[nuxt-crypto] Invalid payload format — one or more segments were empty.');\n }\n return {\n version,\n salt: fromBase64(saltB64),\n iv: fromBase64(ivB64),\n cipher: fromBase64(cipherB64),\n };\n}\n","import { aesGcmPbkdf2 } from './algorithms/aes-gcm';\nimport { KeyCache } from './key-cache';\nimport { encodePayload, parsePayload } from './payload';\nimport { getRandomBytes, getSubtle } from './subtle';\nimport type { CryptoAlgorithm, CryptoService, CryptoServiceConfig } from './types';\n\nconst DEFAULT_ITERATIONS = 100_000;\nconst DEFAULT_CACHE = 64;\nconst SALT_BYTES = 16;\n\n/**\n * Create a framework-agnostic `CryptoService` from a passphrase.\n *\n * Uses AES-256-GCM with PBKDF2-SHA256 (100k iterations by default). Pass a\n * different `algorithm` to plug in another cipher while preserving the same\n * versioned payload envelope.\n */\nexport async function createCryptoService(config: CryptoServiceConfig): Promise<CryptoService> {\n if (!config.passphrase) {\n throw new Error('[nuxt-crypto] passphrase is required.');\n }\n\n const iterations = config.iterations ?? DEFAULT_ITERATIONS;\n const algorithm: CryptoAlgorithm = config.algorithm ?? aesGcmPbkdf2;\n const subtle = config.subtle ?? (await getSubtle());\n const cache = new KeyCache(config.keyCacheSize ?? DEFAULT_CACHE);\n const passphrase = String(config.passphrase).trim();\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n\n async function getDerivedKey(\n salt: Uint8Array<ArrayBuffer>,\n fingerprint?: string\n ): Promise<CryptoKey> {\n const key = KeyCache.key(salt, iterations, fingerprint);\n const cached = cache.get(key);\n if (cached) return cached;\n const pending = algorithm.deriveKey({ subtle, passphrase, fingerprint, salt, iterations });\n cache.set(key, pending);\n return pending;\n }\n\n return {\n async encrypt(plainText, options) {\n const salt = getRandomBytes(SALT_BYTES);\n const key = await getDerivedKey(salt, options?.fingerprint);\n const encoded = encoder.encode(plainText);\n const plainBytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));\n plainBytes.set(encoded);\n const { cipher, iv } = await algorithm.encrypt({ subtle, key, plainText: plainBytes });\n return encodePayload({ version: algorithm.version, salt, iv, cipher });\n },\n\n async decrypt(payload, options) {\n const parsed = parsePayload(payload);\n if (parsed.version !== algorithm.version) {\n throw new Error(\n `[nuxt-crypto] Unsupported payload version: ${parsed.version} (algorithm expects ${algorithm.version}).`\n );\n }\n const key = await getDerivedKey(parsed.salt, options?.fingerprint);\n const plainBytes = await algorithm.decrypt({\n subtle,\n key,\n cipher: parsed.cipher,\n iv: parsed.iv,\n });\n return decoder.decode(plainBytes);\n },\n\n clearKeyCache(): void {\n cache.clear();\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/core/base64.ts
|
|
2
|
+
function toBase64(data) {
|
|
3
|
+
if (typeof btoa === "function") {
|
|
4
|
+
let binary = "";
|
|
5
|
+
const CHUNK = 32768;
|
|
6
|
+
for (let i = 0; i < data.length; i += CHUNK) {
|
|
7
|
+
binary += String.fromCharCode(...data.subarray(i, i + CHUNK));
|
|
8
|
+
}
|
|
9
|
+
return btoa(binary);
|
|
10
|
+
}
|
|
11
|
+
return Buffer.from(data).toString("base64");
|
|
12
|
+
}
|
|
13
|
+
function fromBase64(input) {
|
|
14
|
+
if (typeof atob === "function") {
|
|
15
|
+
const binary = atob(input);
|
|
16
|
+
const out2 = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++) out2[i] = binary.charCodeAt(i);
|
|
18
|
+
return out2;
|
|
19
|
+
}
|
|
20
|
+
const buf = Buffer.from(input, "base64");
|
|
21
|
+
const out = new Uint8Array(buf.length);
|
|
22
|
+
out.set(buf);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/core/subtle.ts
|
|
27
|
+
async function getSubtle() {
|
|
28
|
+
if (globalThis.crypto?.subtle) return globalThis.crypto.subtle;
|
|
29
|
+
const { webcrypto } = await import("crypto");
|
|
30
|
+
return webcrypto.subtle;
|
|
31
|
+
}
|
|
32
|
+
function getRandomBytes(length) {
|
|
33
|
+
const out = new Uint8Array(length);
|
|
34
|
+
const c = globalThis.crypto;
|
|
35
|
+
if (c?.getRandomValues) {
|
|
36
|
+
c.getRandomValues(out);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
"[nuxt-crypto] globalThis.crypto.getRandomValues is not available. Upgrade your runtime."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
toBase64,
|
|
46
|
+
fromBase64,
|
|
47
|
+
getSubtle,
|
|
48
|
+
getRandomBytes
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=chunk-FKXZ7VYW.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/base64.ts","../src/core/subtle.ts"],"sourcesContent":["import type { Bytes } from './types';\n\n/** Encode bytes to standard base64. Works in browser and Node. */\nexport function toBase64(data: Bytes): string {\n if (typeof btoa === 'function') {\n let binary = '';\n const CHUNK = 0x8000;\n for (let i = 0; i < data.length; i += CHUNK) {\n binary += String.fromCharCode(...data.subarray(i, i + CHUNK));\n }\n return btoa(binary);\n }\n // Node fallback — Buffer is globally available in Node.\n return Buffer.from(data).toString('base64');\n}\n\n/** Decode a base64 string back to bytes. Works in browser and Node. */\nexport function fromBase64(input: string): Bytes {\n if (typeof atob === 'function') {\n const binary = atob(input);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n }\n const buf = Buffer.from(input, 'base64');\n const out = new Uint8Array(buf.length);\n out.set(buf);\n return out;\n}\n","/**\n * Resolve a SubtleCrypto implementation across environments:\n *\n * - Browser / Deno / Bun / Node 20+: `globalThis.crypto.subtle` is available.\n * - Older Node: fall back to `node:crypto`'s `webcrypto`.\n */\nexport async function getSubtle(): Promise<SubtleCrypto> {\n if (globalThis.crypto?.subtle) return globalThis.crypto.subtle;\n const { webcrypto } = await import('node:crypto');\n return webcrypto.subtle as unknown as SubtleCrypto;\n}\n\n/** Fill the given buffer with cryptographically strong random bytes. */\nexport function getRandomBytes(length: number): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(length);\n const c = globalThis.crypto;\n if (c?.getRandomValues) {\n c.getRandomValues(out);\n return out;\n }\n throw new Error(\n '[nuxt-crypto] globalThis.crypto.getRandomValues is not available. Upgrade your runtime.'\n );\n}\n"],"mappings":";AAGO,SAAS,SAAS,MAAqB;AAC5C,MAAI,OAAO,SAAS,YAAY;AAC9B,QAAI,SAAS;AACb,UAAM,QAAQ;AACd,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,OAAO;AAC3C,gBAAU,OAAO,aAAa,GAAG,KAAK,SAAS,GAAG,IAAI,KAAK,CAAC;AAAA,IAC9D;AACA,WAAO,KAAK,MAAM;AAAA,EACpB;AAEA,SAAO,OAAO,KAAK,IAAI,EAAE,SAAS,QAAQ;AAC5C;AAGO,SAAS,WAAW,OAAsB;AAC/C,MAAI,OAAO,SAAS,YAAY;AAC9B,UAAM,SAAS,KAAK,KAAK;AACzB,UAAMA,OAAM,IAAI,WAAW,OAAO,MAAM;AACxC,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,IAAK,CAAAA,KAAI,CAAC,IAAI,OAAO,WAAW,CAAC;AACpE,WAAOA;AAAA,EACT;AACA,QAAM,MAAM,OAAO,KAAK,OAAO,QAAQ;AACvC,QAAM,MAAM,IAAI,WAAW,IAAI,MAAM;AACrC,MAAI,IAAI,GAAG;AACX,SAAO;AACT;;;ACtBA,eAAsB,YAAmC;AACvD,MAAI,WAAW,QAAQ,OAAQ,QAAO,WAAW,OAAO;AACxD,QAAM,EAAE,UAAU,IAAI,MAAM,OAAO,QAAa;AAChD,SAAO,UAAU;AACnB;AAGO,SAAS,eAAe,QAAyC;AACtE,QAAM,MAAM,IAAI,WAAW,MAAM;AACjC,QAAM,IAAI,WAAW;AACrB,MAAI,GAAG,iBAAiB;AACtB,MAAE,gBAAgB,GAAG;AACrB,WAAO;AAAA,EACT;AACA,QAAM,IAAI;AAAA,IACR;AAAA,EACF;AACF;","names":["out"]}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { P as ParsedPayload, e as encodePayload, p as parsePayload } from '../payload-
|
|
1
|
+
import { c as CryptoServiceConfig, b as CryptoService, C as CryptoAlgorithm, B as Bytes } from '../payload-BOueSWcI.js';
|
|
2
|
+
export { a as CryptoOperationOptions, P as ParsedPayload, e as encodePayload, p as parsePayload } from '../payload-BOueSWcI.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Create a framework-agnostic `CryptoService` from a passphrase.
|
|
@@ -41,7 +41,7 @@ declare class KeyCache {
|
|
|
41
41
|
private readonly capacity;
|
|
42
42
|
private readonly map;
|
|
43
43
|
constructor(capacity: number);
|
|
44
|
-
static key(salt: Bytes, iterations: number): string;
|
|
44
|
+
static key(salt: Bytes, iterations: number, fingerprint?: string): string;
|
|
45
45
|
get(key: string): Promise<CryptoKey> | undefined;
|
|
46
46
|
set(key: string, value: Promise<CryptoKey>): void;
|
|
47
47
|
clear(): void;
|
package/dist/core/index.mjs
CHANGED
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
aesGcmPbkdf2,
|
|
4
4
|
createCryptoService,
|
|
5
5
|
encodePayload,
|
|
6
|
+
parsePayload
|
|
7
|
+
} from "../chunk-FKHKOTFR.mjs";
|
|
8
|
+
import {
|
|
6
9
|
fromBase64,
|
|
7
10
|
getRandomBytes,
|
|
8
11
|
getSubtle,
|
|
9
|
-
parsePayload,
|
|
10
12
|
toBase64
|
|
11
|
-
} from "../chunk-
|
|
13
|
+
} from "../chunk-FKXZ7VYW.mjs";
|
|
12
14
|
export {
|
|
13
15
|
KeyCache,
|
|
14
16
|
aesGcmPbkdf2,
|
package/dist/module.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NuxtModule } from '@nuxt/schema';
|
|
2
2
|
import { CryptoModuleOptions } from './types/index.js';
|
|
3
|
-
export { B as Bytes, C as CryptoAlgorithm, a as
|
|
3
|
+
export { B as Bytes, C as CryptoAlgorithm, a as CryptoOperationOptions, b as CryptoService, c as CryptoServiceConfig, P as ParsedPayload } from './payload-BOueSWcI.js';
|
|
4
4
|
|
|
5
5
|
declare const cryptoModule: NuxtModule<CryptoModuleOptions>;
|
|
6
6
|
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
/** Shorthand for the `Uint8Array` we use everywhere in the crypto package. */
|
|
2
2
|
type Bytes = Uint8Array<ArrayBuffer>;
|
|
3
|
+
/**
|
|
4
|
+
* Options accepted by `encrypt` / `decrypt`. Both calls must receive the
|
|
5
|
+
* same `fingerprint` value to round-trip; passing different fingerprints
|
|
6
|
+
* produces undecryptable ciphertext.
|
|
7
|
+
*/
|
|
8
|
+
interface CryptoOperationOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Optional extra key material mixed into the PBKDF2 input. Typically a
|
|
11
|
+
* device fingerprint produced server-side from an HttpOnly device-ID
|
|
12
|
+
* cookie via `getClientFingerprint()` from `@alikhalilll/nuxt-crypto/server`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Binding ciphertext to a fingerprint means the payload becomes
|
|
15
|
+
* undecryptable the moment the device cookie is cleared. Use only for
|
|
16
|
+
* ephemeral tokens (short-lived session cookies, one-time links), never
|
|
17
|
+
* for long-lived user data.
|
|
18
|
+
*/
|
|
19
|
+
fingerprint?: string;
|
|
20
|
+
}
|
|
3
21
|
/**
|
|
4
22
|
* A CryptoService encrypts plaintext to a serialisable string payload and
|
|
5
23
|
* decrypts it back. Every operation is async because it hits the Web Crypto
|
|
@@ -7,9 +25,9 @@ type Bytes = Uint8Array<ArrayBuffer>;
|
|
|
7
25
|
*/
|
|
8
26
|
interface CryptoService {
|
|
9
27
|
/** Encrypt a UTF-8 string to a versioned, base64-encoded payload. */
|
|
10
|
-
encrypt(plainText: string): Promise<string>;
|
|
11
|
-
/** Decrypt a payload produced by `encrypt`. */
|
|
12
|
-
decrypt(payload: string): Promise<string>;
|
|
28
|
+
encrypt(plainText: string, options?: CryptoOperationOptions): Promise<string>;
|
|
29
|
+
/** Decrypt a payload produced by `encrypt`. Pass the same `fingerprint` used at encrypt time, if any. */
|
|
30
|
+
decrypt(payload: string, options?: CryptoOperationOptions): Promise<string>;
|
|
13
31
|
/** Clear any cached derived keys. Safe to call at any time. */
|
|
14
32
|
clearKeyCache(): void;
|
|
15
33
|
}
|
|
@@ -41,10 +59,15 @@ interface CryptoServiceConfig {
|
|
|
41
59
|
interface CryptoAlgorithm {
|
|
42
60
|
/** Short identifier stored as the first segment of every payload (e.g. `'v1'`). */
|
|
43
61
|
readonly version: string;
|
|
44
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Derive a CryptoKey from a passphrase + salt pair. Expected to be idempotent.
|
|
64
|
+
* When `fingerprint` is provided it is mixed into the key-derivation input
|
|
65
|
+
* (alongside the passphrase) so ciphertext becomes bound to that fingerprint.
|
|
66
|
+
*/
|
|
45
67
|
deriveKey(args: {
|
|
46
68
|
subtle: SubtleCrypto;
|
|
47
69
|
passphrase: string;
|
|
70
|
+
fingerprint?: string;
|
|
48
71
|
salt: Bytes;
|
|
49
72
|
iterations: number;
|
|
50
73
|
}): Promise<CryptoKey>;
|
|
@@ -82,4 +105,4 @@ interface ParsedPayload {
|
|
|
82
105
|
declare function encodePayload(parts: ParsedPayload): string;
|
|
83
106
|
declare function parsePayload(payload: string): ParsedPayload;
|
|
84
107
|
|
|
85
|
-
export { type Bytes as B, type CryptoAlgorithm as C, type ParsedPayload as P, type
|
|
108
|
+
export { type Bytes as B, type CryptoAlgorithm as C, type ParsedPayload as P, type CryptoOperationOptions as a, type CryptoService as b, type CryptoServiceConfig as c, encodePayload as e, parsePayload as p };
|
package/dist/runtime/plugin.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/runtime/plugin.ts"],"sourcesContent":["import { defineNuxtPlugin } from '#app';\nimport config from '#build/crypto-config.mjs';\n\nimport { createCryptoService } from '../core/create-service';\n\nexport default defineNuxtPlugin(async () => {\n const { passphrase, iterations, keyCacheSize, provideName } = config;\n\n if (!passphrase && (import.meta as ImportMeta & { dev?: boolean }).dev) {\n // eslint-disable-next-line no-console\n console.warn(\n `[@alikhalilll/nuxt-crypto] Missing passphrase — $${provideName} will throw when used.`\n );\n }\n\n const service = await createCryptoService({\n passphrase: String(passphrase).trim(),\n iterations,\n keyCacheSize,\n });\n\n return {\n provide: {\n [provideName]: service,\n },\n };\n});\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../../src/runtime/plugin.ts"],"sourcesContent":["import { defineNuxtPlugin } from '#app';\nimport config from '#build/crypto-config.mjs';\n\nimport { createCryptoService } from '../core/create-service';\n\nexport default defineNuxtPlugin(async () => {\n const { passphrase, iterations, keyCacheSize, provideName } = config;\n\n if (!passphrase && (import.meta as ImportMeta & { dev?: boolean }).dev) {\n // eslint-disable-next-line no-console\n console.warn(\n `[@alikhalilll/nuxt-crypto] Missing passphrase — $${provideName} will throw when used.`\n );\n }\n\n const service = await createCryptoService({\n passphrase: String(passphrase).trim(),\n iterations,\n keyCacheSize,\n });\n\n return {\n provide: {\n [provideName]: service,\n },\n };\n});\n"],"mappings":";;;;;;AAAA,SAAS,wBAAwB;AACjC,OAAO,YAAY;AAInB,IAAO,iBAAQ,iBAAiB,YAAY;AAC1C,QAAM,EAAE,YAAY,YAAY,cAAc,YAAY,IAAI;AAE9D,MAAI,CAAC,cAAe,YAA+C,KAAK;AAEtE,YAAQ;AAAA,MACN,yDAAoD,WAAW;AAAA,IACjE;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,oBAAoB;AAAA,IACxC,YAAY,OAAO,UAAU,EAAE,KAAK;AAAA,IACpC;AAAA,IACA;AAAA,EACF,CAAC;AAED,SAAO;AAAA,IACL,SAAS;AAAA,MACP,CAAC,WAAW,GAAG;AAAA,IACjB;AAAA,EACF;AACF,CAAC;","names":[]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { setCookie, H3Event } from 'h3';
|
|
2
|
+
|
|
3
|
+
type CookieSerializeOptions = NonNullable<Parameters<typeof setCookie>[3]>;
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link getClientFingerprint}.
|
|
6
|
+
*/
|
|
7
|
+
interface ClientFingerprintOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Per-app pepper. Stored in a runtime-config secret (e.g.
|
|
10
|
+
* `NUXT_CRYPTO_FINGERPRINT_SALT`) — keeping it off the client means the
|
|
11
|
+
* fingerprint can't be reproduced from a leaked device cookie alone.
|
|
12
|
+
*/
|
|
13
|
+
salt: string;
|
|
14
|
+
/** Cookie name holding the device ID. Default `__nuxt_crypto_device`. */
|
|
15
|
+
cookieName?: string;
|
|
16
|
+
/** Cookie lifetime in seconds. Default 1 year. */
|
|
17
|
+
cookieMaxAge?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Override individual cookie flags. Sensible defaults apply (`httpOnly: true`,
|
|
20
|
+
* `sameSite: 'lax'`, `path: '/'`, and `secure` auto-detected from the request
|
|
21
|
+
* protocol).
|
|
22
|
+
*/
|
|
23
|
+
cookieOptions?: Partial<CookieSerializeOptions>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pure inputs accepted by {@link deriveFingerprint}. Use this if you already
|
|
27
|
+
* manage the device identity yourself (e.g. an existing session-cookie system
|
|
28
|
+
* or a signed JWT) and want to plug a fingerprint into `crypto.encrypt`.
|
|
29
|
+
*/
|
|
30
|
+
interface FingerprintParts {
|
|
31
|
+
/** Opaque, stable per-device identifier. 16+ random bytes recommended. */
|
|
32
|
+
deviceId: string;
|
|
33
|
+
/** Per-app pepper. Required. */
|
|
34
|
+
salt: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute a fingerprint string from explicit parts. SHA-256 → base64.
|
|
38
|
+
*
|
|
39
|
+
* The fingerprint is deterministic — call it twice with the same inputs and
|
|
40
|
+
* you get the same output. That's what lets `crypto.decrypt` succeed on a
|
|
41
|
+
* payload encrypted earlier with the same device ID.
|
|
42
|
+
*/
|
|
43
|
+
declare function deriveFingerprint(parts: FingerprintParts): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Ensure the client has a persistent HttpOnly device-ID cookie, then derive a
|
|
46
|
+
* fingerprint from it. Call this from Nitro route handlers or server
|
|
47
|
+
* middleware before encrypting / decrypting.
|
|
48
|
+
*
|
|
49
|
+
* Why an HttpOnly cookie (and not the client IP)?
|
|
50
|
+
* - **Survives network changes.** The cookie travels with the browser, so
|
|
51
|
+
* Wi-Fi ↔ 4G handoffs, cell-tower roaming, VPN rotations, and residential
|
|
52
|
+
* IP rotation don't invalidate the fingerprint.
|
|
53
|
+
* - **Anti-theft.** The cookie is HttpOnly, so XSS-exfiltrated ciphertext
|
|
54
|
+
* stored in localStorage can't be decrypted — the attacker can't read the
|
|
55
|
+
* cookie.
|
|
56
|
+
* - **Per-device / per-browser.** A token copied to another browser or
|
|
57
|
+
* device finds no device cookie there, so it won't decrypt.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* // server/api/session.post.ts
|
|
62
|
+
* import { getClientFingerprint } from '@alikhalilll/nuxt-crypto/server';
|
|
63
|
+
*
|
|
64
|
+
* export default defineEventHandler(async (event) => {
|
|
65
|
+
* const body = await readBody(event);
|
|
66
|
+
* const { $crypto } = useNuxtApp();
|
|
67
|
+
* const fingerprint = await getClientFingerprint(event, {
|
|
68
|
+
* salt: useRuntimeConfig().cryptoFingerprintSalt,
|
|
69
|
+
* });
|
|
70
|
+
* return { token: await $crypto.encrypt(JSON.stringify(body), { fingerprint }) };
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare function getClientFingerprint(event: H3Event, options: ClientFingerprintOptions): Promise<string>;
|
|
75
|
+
|
|
76
|
+
export { type ClientFingerprintOptions, type FingerprintParts, deriveFingerprint, getClientFingerprint };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRandomBytes,
|
|
3
|
+
getSubtle,
|
|
4
|
+
toBase64
|
|
5
|
+
} from "../chunk-FKXZ7VYW.mjs";
|
|
6
|
+
|
|
7
|
+
// src/server/index.ts
|
|
8
|
+
import { getCookie, getRequestProtocol, setCookie } from "h3";
|
|
9
|
+
var DEFAULT_COOKIE_NAME = "__nuxt_crypto_device";
|
|
10
|
+
var DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
11
|
+
var DEVICE_ID_BYTES = 32;
|
|
12
|
+
async function deriveFingerprint(parts) {
|
|
13
|
+
const material = `${parts.deviceId}\0${parts.salt}`;
|
|
14
|
+
const subtle = await getSubtle();
|
|
15
|
+
const hash = await subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
16
|
+
return toBase64(new Uint8Array(hash));
|
|
17
|
+
}
|
|
18
|
+
async function getClientFingerprint(event, options) {
|
|
19
|
+
if (!options.salt) {
|
|
20
|
+
throw new Error("[nuxt-crypto] getClientFingerprint: `salt` is required.");
|
|
21
|
+
}
|
|
22
|
+
const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
23
|
+
let deviceId = getCookie(event, cookieName);
|
|
24
|
+
if (!deviceId) {
|
|
25
|
+
deviceId = toBase64(getRandomBytes(DEVICE_ID_BYTES));
|
|
26
|
+
const isSecure = options.cookieOptions?.secure ?? getRequestProtocol(event, { xForwardedProto: true }) === "https";
|
|
27
|
+
setCookie(event, cookieName, deviceId, {
|
|
28
|
+
httpOnly: true,
|
|
29
|
+
sameSite: "lax",
|
|
30
|
+
path: "/",
|
|
31
|
+
maxAge: options.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE,
|
|
32
|
+
...options.cookieOptions,
|
|
33
|
+
secure: isSecure
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return deriveFingerprint({ deviceId, salt: options.salt });
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
deriveFingerprint,
|
|
40
|
+
getClientFingerprint
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["import type { H3Event } from 'h3';\nimport { getCookie, getRequestProtocol, setCookie } from 'h3';\n\nimport { toBase64 } from '../core/base64';\nimport { getRandomBytes, getSubtle } from '../core/subtle';\n\n// h3 doesn't re-export its cookie-options type, so derive it from setCookie.\ntype CookieSerializeOptions = NonNullable<Parameters<typeof setCookie>[3]>;\n\nconst DEFAULT_COOKIE_NAME = '__nuxt_crypto_device';\nconst DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // one year\nconst DEVICE_ID_BYTES = 32;\n\n/**\n * Options for {@link getClientFingerprint}.\n */\nexport interface ClientFingerprintOptions {\n /**\n * Per-app pepper. Stored in a runtime-config secret (e.g.\n * `NUXT_CRYPTO_FINGERPRINT_SALT`) — keeping it off the client means the\n * fingerprint can't be reproduced from a leaked device cookie alone.\n */\n salt: string;\n /** Cookie name holding the device ID. Default `__nuxt_crypto_device`. */\n cookieName?: string;\n /** Cookie lifetime in seconds. Default 1 year. */\n cookieMaxAge?: number;\n /**\n * Override individual cookie flags. Sensible defaults apply (`httpOnly: true`,\n * `sameSite: 'lax'`, `path: '/'`, and `secure` auto-detected from the request\n * protocol).\n */\n cookieOptions?: Partial<CookieSerializeOptions>;\n}\n\n/**\n * Pure inputs accepted by {@link deriveFingerprint}. Use this if you already\n * manage the device identity yourself (e.g. an existing session-cookie system\n * or a signed JWT) and want to plug a fingerprint into `crypto.encrypt`.\n */\nexport interface FingerprintParts {\n /** Opaque, stable per-device identifier. 16+ random bytes recommended. */\n deviceId: string;\n /** Per-app pepper. Required. */\n salt: string;\n}\n\n/**\n * Compute a fingerprint string from explicit parts. SHA-256 → base64.\n *\n * The fingerprint is deterministic — call it twice with the same inputs and\n * you get the same output. That's what lets `crypto.decrypt` succeed on a\n * payload encrypted earlier with the same device ID.\n */\nexport async function deriveFingerprint(parts: FingerprintParts): Promise<string> {\n // NUL separator prevents `\"abc\" + \"d\"` vs `\"ab\" + \"cd\"` collisions.\n const material = `${parts.deviceId}\\x00${parts.salt}`;\n const subtle = await getSubtle();\n const hash = await subtle.digest('SHA-256', new TextEncoder().encode(material));\n return toBase64(new Uint8Array(hash));\n}\n\n/**\n * Ensure the client has a persistent HttpOnly device-ID cookie, then derive a\n * fingerprint from it. Call this from Nitro route handlers or server\n * middleware before encrypting / decrypting.\n *\n * Why an HttpOnly cookie (and not the client IP)?\n * - **Survives network changes.** The cookie travels with the browser, so\n * Wi-Fi ↔ 4G handoffs, cell-tower roaming, VPN rotations, and residential\n * IP rotation don't invalidate the fingerprint.\n * - **Anti-theft.** The cookie is HttpOnly, so XSS-exfiltrated ciphertext\n * stored in localStorage can't be decrypted — the attacker can't read the\n * cookie.\n * - **Per-device / per-browser.** A token copied to another browser or\n * device finds no device cookie there, so it won't decrypt.\n *\n * @example\n * ```ts\n * // server/api/session.post.ts\n * import { getClientFingerprint } from '@alikhalilll/nuxt-crypto/server';\n *\n * export default defineEventHandler(async (event) => {\n * const body = await readBody(event);\n * const { $crypto } = useNuxtApp();\n * const fingerprint = await getClientFingerprint(event, {\n * salt: useRuntimeConfig().cryptoFingerprintSalt,\n * });\n * return { token: await $crypto.encrypt(JSON.stringify(body), { fingerprint }) };\n * });\n * ```\n */\nexport async function getClientFingerprint(\n event: H3Event,\n options: ClientFingerprintOptions\n): Promise<string> {\n if (!options.salt) {\n throw new Error('[nuxt-crypto] getClientFingerprint: `salt` is required.');\n }\n\n const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;\n let deviceId = getCookie(event, cookieName);\n\n if (!deviceId) {\n deviceId = toBase64(getRandomBytes(DEVICE_ID_BYTES));\n const isSecure =\n options.cookieOptions?.secure ??\n getRequestProtocol(event, { xForwardedProto: true }) === 'https';\n setCookie(event, cookieName, deviceId, {\n httpOnly: true,\n sameSite: 'lax',\n path: '/',\n maxAge: options.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE,\n ...options.cookieOptions,\n secure: isSecure,\n });\n }\n\n return deriveFingerprint({ deviceId, salt: options.salt });\n}\n"],"mappings":";;;;;;;AACA,SAAS,WAAW,oBAAoB,iBAAiB;AAQzD,IAAM,sBAAsB;AAC5B,IAAM,yBAAyB,KAAK,KAAK,KAAK;AAC9C,IAAM,kBAAkB;AA2CxB,eAAsB,kBAAkB,OAA0C;AAEhF,QAAM,WAAW,GAAG,MAAM,QAAQ,KAAO,MAAM,IAAI;AACnD,QAAM,SAAS,MAAM,UAAU;AAC/B,QAAM,OAAO,MAAM,OAAO,OAAO,WAAW,IAAI,YAAY,EAAE,OAAO,QAAQ,CAAC;AAC9E,SAAO,SAAS,IAAI,WAAW,IAAI,CAAC;AACtC;AAgCA,eAAsB,qBACpB,OACA,SACiB;AACjB,MAAI,CAAC,QAAQ,MAAM;AACjB,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AAEA,QAAM,aAAa,QAAQ,cAAc;AACzC,MAAI,WAAW,UAAU,OAAO,UAAU;AAE1C,MAAI,CAAC,UAAU;AACb,eAAW,SAAS,eAAe,eAAe,CAAC;AACnD,UAAM,WACJ,QAAQ,eAAe,UACvB,mBAAmB,OAAO,EAAE,iBAAiB,KAAK,CAAC,MAAM;AAC3D,cAAU,OAAO,YAAY,UAAU;AAAA,MACrC,UAAU;AAAA,MACV,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ,QAAQ,gBAAgB;AAAA,MAChC,GAAG,QAAQ;AAAA,MACX,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AAEA,SAAO,kBAAkB,EAAE,UAAU,MAAM,QAAQ,KAAK,CAAC;AAC3D;","names":[]}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { B as Bytes, C as CryptoAlgorithm, a as
|
|
1
|
+
export { B as Bytes, C as CryptoAlgorithm, a as CryptoOperationOptions, b as CryptoService, c as CryptoServiceConfig, P as ParsedPayload } from '../payload-BOueSWcI.js';
|
|
2
2
|
|
|
3
3
|
interface CryptoModuleOptions {
|
|
4
4
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alikhalilll/nuxt-crypto",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Symmetric Web Crypto service for Nuxt 3/4. AES-256-GCM + PBKDF2 with key caching and
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Symmetric Web Crypto service for Nuxt 3/4. AES-256-GCM + PBKDF2 with key caching, pluggable algorithms, and optional device-fingerprint binding.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "alikhalilll",
|
|
7
7
|
"repository": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"encryption",
|
|
26
26
|
"aes-gcm",
|
|
27
27
|
"web-crypto",
|
|
28
|
-
"pbkdf2"
|
|
28
|
+
"pbkdf2",
|
|
29
|
+
"fingerprint"
|
|
29
30
|
],
|
|
30
31
|
"exports": {
|
|
31
32
|
".": {
|
|
@@ -36,6 +37,10 @@
|
|
|
36
37
|
"types": "./dist/core/index.d.ts",
|
|
37
38
|
"import": "./dist/core/index.mjs"
|
|
38
39
|
},
|
|
40
|
+
"./server": {
|
|
41
|
+
"types": "./dist/server/index.d.ts",
|
|
42
|
+
"import": "./dist/server/index.mjs"
|
|
43
|
+
},
|
|
39
44
|
"./types": {
|
|
40
45
|
"types": "./dist/types/index.d.ts",
|
|
41
46
|
"import": "./dist/types/index.mjs"
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"core": [
|
|
52
57
|
"./dist/core/index.d.ts"
|
|
53
58
|
],
|
|
59
|
+
"server": [
|
|
60
|
+
"./dist/server/index.d.ts"
|
|
61
|
+
],
|
|
54
62
|
"types": [
|
|
55
63
|
"./dist/types/index.d.ts"
|
|
56
64
|
]
|
|
@@ -65,17 +73,22 @@
|
|
|
65
73
|
"node": ">=18"
|
|
66
74
|
},
|
|
67
75
|
"peerDependencies": {
|
|
68
|
-
"@nuxt/kit": "^3.0.0 || ^4.0.0"
|
|
76
|
+
"@nuxt/kit": "^3.0.0 || ^4.0.0",
|
|
77
|
+
"h3": "^1.0.0 || ^2.0.0"
|
|
69
78
|
},
|
|
70
79
|
"peerDependenciesMeta": {
|
|
71
80
|
"@nuxt/kit": {
|
|
72
81
|
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"h3": {
|
|
84
|
+
"optional": true
|
|
73
85
|
}
|
|
74
86
|
},
|
|
75
87
|
"devDependencies": {
|
|
76
88
|
"@nuxt/kit": "^4.0.0",
|
|
77
89
|
"@nuxt/schema": "^4.0.0",
|
|
78
90
|
"@types/node": "^22.13.0",
|
|
91
|
+
"h3": "^1.13.0",
|
|
79
92
|
"rimraf": "^6.0.1",
|
|
80
93
|
"tsup": "^8.5.0",
|
|
81
94
|
"typescript": "^5.9.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/algorithms/aes-gcm.ts","../src/core/base64.ts","../src/core/key-cache.ts","../src/core/payload.ts","../src/core/subtle.ts","../src/core/create-service.ts"],"sourcesContent":["import type { CryptoAlgorithm } from '../types';\n\n/**\n * AES-256-GCM with PBKDF2-SHA256 key derivation. This is the default and\n * currently the only built-in algorithm; pluggable via `CryptoServiceConfig.algorithm`.\n */\nexport const aesGcmPbkdf2: CryptoAlgorithm = {\n version: 'v1',\n\n async deriveKey({ subtle, passphrase, salt, iterations }) {\n const keyMaterial = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(passphrase),\n { name: 'PBKDF2' },\n false,\n ['deriveKey']\n );\n\n return subtle.deriveKey(\n { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt']\n );\n },\n\n async encrypt({ subtle, key, plainText }) {\n const iv = new Uint8Array(new ArrayBuffer(12));\n globalThis.crypto.getRandomValues(iv);\n const buf = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainText);\n const cipher = new Uint8Array(new ArrayBuffer(buf.byteLength));\n cipher.set(new Uint8Array(buf));\n return { cipher, iv };\n },\n\n async decrypt({ subtle, key, cipher, iv }) {\n const buf = await subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);\n const out = new Uint8Array(new ArrayBuffer(buf.byteLength));\n out.set(new Uint8Array(buf));\n return out;\n },\n};\n","import type { Bytes } from './types';\n\n/** Encode bytes to standard base64. Works in browser and Node. */\nexport function toBase64(data: Bytes): string {\n if (typeof btoa === 'function') {\n let binary = '';\n const CHUNK = 0x8000;\n for (let i = 0; i < data.length; i += CHUNK) {\n binary += String.fromCharCode(...data.subarray(i, i + CHUNK));\n }\n return btoa(binary);\n }\n // Node fallback — Buffer is globally available in Node.\n return Buffer.from(data).toString('base64');\n}\n\n/** Decode a base64 string back to bytes. Works in browser and Node. */\nexport function fromBase64(input: string): Bytes {\n if (typeof atob === 'function') {\n const binary = atob(input);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n }\n const buf = Buffer.from(input, 'base64');\n const out = new Uint8Array(buf.length);\n out.set(buf);\n return out;\n}\n","import type { Bytes } from './types';\nimport { toBase64 } from './base64';\n\n/**\n * A tiny LRU-ish cache for derived `CryptoKey` values, keyed on the salt.\n * When capacity is exceeded the oldest entry is evicted. Derived key\n * material is expensive (PBKDF2 is designed to be slow), so caching has\n * a big impact when decrypting many payloads in a row.\n */\nexport class KeyCache {\n private readonly map = new Map<string, Promise<CryptoKey>>();\n constructor(private readonly capacity: number) {}\n\n static key(salt: Bytes, iterations: number): string {\n return `${iterations}.${toBase64(salt)}`;\n }\n\n get(key: string): Promise<CryptoKey> | undefined {\n const value = this.map.get(key);\n if (!value) return undefined;\n // Refresh recency by re-inserting.\n this.map.delete(key);\n this.map.set(key, value);\n return value;\n }\n\n set(key: string, value: Promise<CryptoKey>): void {\n if (this.capacity <= 0) return;\n this.map.set(key, value);\n while (this.map.size > this.capacity) {\n const oldest = this.map.keys().next().value;\n if (oldest === undefined) break;\n this.map.delete(oldest);\n }\n }\n\n clear(): void {\n this.map.clear();\n }\n}\n","import { fromBase64, toBase64 } from './base64';\nimport type { Bytes } from './types';\n\n/**\n * Payload serialisation format (v1): `${version}.${saltB64}.${ivB64}.${cipherB64}`.\n *\n * Each part is URL-safe base64, all joined with `.`. The version tag is\n * always the first segment so consumers can route to the correct algorithm\n * during decrypt.\n */\nexport interface ParsedPayload {\n version: string;\n salt: Bytes;\n iv: Bytes;\n cipher: Bytes;\n}\n\nexport function encodePayload(parts: ParsedPayload): string {\n return [parts.version, toBase64(parts.salt), toBase64(parts.iv), toBase64(parts.cipher)].join(\n '.'\n );\n}\n\nexport function parsePayload(payload: string): ParsedPayload {\n const segments = payload.split('.');\n if (segments.length !== 4) {\n throw new Error('[nuxt-crypto] Invalid payload format — expected 4 dot-separated segments.');\n }\n const [version, saltB64, ivB64, cipherB64] = segments;\n if (!version || !saltB64 || !ivB64 || !cipherB64) {\n throw new Error('[nuxt-crypto] Invalid payload format — one or more segments were empty.');\n }\n return {\n version,\n salt: fromBase64(saltB64),\n iv: fromBase64(ivB64),\n cipher: fromBase64(cipherB64),\n };\n}\n","/**\n * Resolve a SubtleCrypto implementation across environments:\n *\n * - Browser / Deno / Bun / Node 20+: `globalThis.crypto.subtle` is available.\n * - Older Node: fall back to `node:crypto`'s `webcrypto`.\n */\nexport async function getSubtle(): Promise<SubtleCrypto> {\n if (globalThis.crypto?.subtle) return globalThis.crypto.subtle;\n const { webcrypto } = await import('node:crypto');\n return webcrypto.subtle as unknown as SubtleCrypto;\n}\n\n/** Fill the given buffer with cryptographically strong random bytes. */\nexport function getRandomBytes(length: number): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(length);\n const c = globalThis.crypto;\n if (c?.getRandomValues) {\n c.getRandomValues(out);\n return out;\n }\n throw new Error(\n '[nuxt-crypto] globalThis.crypto.getRandomValues is not available. Upgrade your runtime.'\n );\n}\n","import { aesGcmPbkdf2 } from './algorithms/aes-gcm';\nimport { KeyCache } from './key-cache';\nimport { encodePayload, parsePayload } from './payload';\nimport { getRandomBytes, getSubtle } from './subtle';\nimport type { CryptoAlgorithm, CryptoService, CryptoServiceConfig } from './types';\n\nconst DEFAULT_ITERATIONS = 100_000;\nconst DEFAULT_CACHE = 64;\nconst SALT_BYTES = 16;\n\n/**\n * Create a framework-agnostic `CryptoService` from a passphrase.\n *\n * Uses AES-256-GCM with PBKDF2-SHA256 (100k iterations by default). Pass a\n * different `algorithm` to plug in another cipher while preserving the same\n * versioned payload envelope.\n */\nexport async function createCryptoService(config: CryptoServiceConfig): Promise<CryptoService> {\n if (!config.passphrase) {\n throw new Error('[nuxt-crypto] passphrase is required.');\n }\n\n const iterations = config.iterations ?? DEFAULT_ITERATIONS;\n const algorithm: CryptoAlgorithm = config.algorithm ?? aesGcmPbkdf2;\n const subtle = config.subtle ?? (await getSubtle());\n const cache = new KeyCache(config.keyCacheSize ?? DEFAULT_CACHE);\n const passphrase = String(config.passphrase).trim();\n const encoder = new TextEncoder();\n const decoder = new TextDecoder();\n\n async function getDerivedKey(salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {\n const key = KeyCache.key(salt, iterations);\n const cached = cache.get(key);\n if (cached) return cached;\n const pending = algorithm.deriveKey({ subtle, passphrase, salt, iterations });\n cache.set(key, pending);\n return pending;\n }\n\n return {\n async encrypt(plainText: string): Promise<string> {\n const salt = getRandomBytes(SALT_BYTES);\n const key = await getDerivedKey(salt);\n const encoded = encoder.encode(plainText);\n const plainBytes = new Uint8Array(new ArrayBuffer(encoded.byteLength));\n plainBytes.set(encoded);\n const { cipher, iv } = await algorithm.encrypt({ subtle, key, plainText: plainBytes });\n return encodePayload({ version: algorithm.version, salt, iv, cipher });\n },\n\n async decrypt(payload: string): Promise<string> {\n const parsed = parsePayload(payload);\n if (parsed.version !== algorithm.version) {\n throw new Error(\n `[nuxt-crypto] Unsupported payload version: ${parsed.version} (algorithm expects ${algorithm.version}).`\n );\n }\n const key = await getDerivedKey(parsed.salt);\n const plainBytes = await algorithm.decrypt({\n subtle,\n key,\n cipher: parsed.cipher,\n iv: parsed.iv,\n });\n return decoder.decode(plainBytes);\n },\n\n clearKeyCache(): void {\n cache.clear();\n },\n };\n}\n"],"mappings":"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","names":["out"]}
|