@jeanharo98/typed-storage 0.1.6 → 0.1.7
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 +66 -13
- package/dist/create-storage.js +0 -18
- package/dist/storage-signal.js +23 -3
- package/dist/types.d.ts +1 -0
- package/dist/xor.d.ts +3 -0
- package/dist/xor.js +14 -0
- package/index.html +10 -31
- package/package.json +1 -1
- package/src/create-storage.ts +0 -19
- package/src/storage-signal.test.ts +40 -0
- package/src/storage-signal.ts +27 -3
- package/src/types.ts +1 -0
- package/src/xor.ts +18 -0
package/README.md
CHANGED
|
@@ -224,7 +224,8 @@ const appStorage = createStorage(schema, options);
|
|
|
224
224
|
| `version` | `number` | — | Current schema version — required for migrations |
|
|
225
225
|
| `migrations` | `Record<number, (data) => data>` | — | Migration functions per version |
|
|
226
226
|
| `compress` | `boolean` | `false` | Compresses data with LZ-string before storing |
|
|
227
|
-
| `encrypt` | `boolean` | `false` |
|
|
227
|
+
| `encrypt` | `boolean` | `false` | Obfuscates data with XOR + Base64 — see security note below |
|
|
228
|
+
| `secret` | `string` | — | Required when `encrypt: true` — the obfuscation key |
|
|
228
229
|
|
|
229
230
|
---
|
|
230
231
|
|
|
@@ -279,27 +280,79 @@ appStorage.theme.remove(); // → 'theme changed to: dark' (initialValue)
|
|
|
279
280
|
|
|
280
281
|
---
|
|
281
282
|
|
|
282
|
-
## 🔒
|
|
283
|
+
## 🔒 Encryption (XOR obfuscation)
|
|
283
284
|
|
|
284
|
-
|
|
285
|
+
`typed-storage` can obfuscate values using XOR + Base64 before storing them. This is **not real cryptography** — read this section carefully before using it.
|
|
285
286
|
|
|
286
|
-
|
|
287
|
+
```typescript
|
|
288
|
+
const secureStorage = createStorage({
|
|
289
|
+
token: ''
|
|
290
|
+
}, {
|
|
291
|
+
encrypt: true,
|
|
292
|
+
secret: 'your-secret-key', // required when encrypt is true
|
|
293
|
+
ttl: 3600000 // recommended — expire alongside your real token
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
secureStorage.token.set('eyJhbGciOiJIUzI1NiJ9.xxx.yyy');
|
|
297
|
+
// Stored in localStorage as obfuscated text, not the readable JWT
|
|
298
|
+
|
|
299
|
+
const token = secureStorage.token();
|
|
300
|
+
// Automatically decrypted — returns the real JWT
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### ⚠️ What this actually protects against
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
✅ Hides the value from casual inspection in DevTools/Application/Storage
|
|
307
|
+
✅ Discourages non-technical users from reading or copying the value
|
|
308
|
+
✅ Combined with ttl, expires alongside your backend token
|
|
309
|
+
|
|
310
|
+
❌ Does NOT protect against a technical attacker
|
|
311
|
+
❌ Does NOT protect against debugger breakpoints — the secret and
|
|
312
|
+
decrypted value are visible in memory while the app runs
|
|
313
|
+
❌ Is NOT equivalent to real cryptography (AES, etc.)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Why this limitation exists — and why no frontend library can fix it
|
|
317
|
+
|
|
318
|
+
The `secret` you pass lives in your JavaScript code, which runs in the user's browser. No matter the algorithm used (XOR, AES, anything), **the key must be present in the frontend to decrypt the value**, which means it's always inspectable:
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
1. The secret travels safely over HTTPS — that's not the problem
|
|
322
|
+
2. Once it reaches the browser, it must be used by your JS to decrypt
|
|
323
|
+
3. Anyone with DevTools open can set a breakpoint where decryption
|
|
324
|
+
happens and read the secret and the decrypted value directly
|
|
325
|
+
4. This is true even with industry-standard encryption (Web Crypto AES) —
|
|
326
|
+
the algorithm's strength doesn't matter if the key is exposed
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
This is a fundamental limitation of any frontend-only encryption — not a flaw specific to typed-storage's XOR implementation.
|
|
330
|
+
|
|
331
|
+
### For real security with auth tokens
|
|
287
332
|
|
|
288
333
|
```typescript
|
|
289
334
|
// In your backend (Express / NestJS):
|
|
290
335
|
res.cookie('authToken', token, {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
336
|
+
httpOnly: true, // JavaScript cannot read this — ever
|
|
337
|
+
secure: true, // HTTPS only
|
|
338
|
+
sameSite: 'strict'
|
|
294
339
|
});
|
|
295
340
|
```
|
|
296
341
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
342
|
+
httpOnly cookies are the only approach where the token never becomes accessible to JavaScript running in the browser — because the browser itself enforces the restriction, not your code.
|
|
343
|
+
|
|
344
|
+
### When `encrypt` is still worth using
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
✅ You understand it's obfuscation, not security
|
|
348
|
+
✅ You want to deter casual inspection, not block determined attackers
|
|
349
|
+
✅ You're combining it with ttl so values expire predictably
|
|
350
|
+
✅ The data isn't critical enough to justify httpOnly cookie infrastructure
|
|
351
|
+
(e.g. you're prototyping, or it's a low-stakes internal tool)
|
|
352
|
+
|
|
353
|
+
❌ Don't rely on this alone for banking, healthcare, or any data where
|
|
354
|
+
a breach has real consequences — use httpOnly cookies on the backend
|
|
355
|
+
```
|
|
303
356
|
|
|
304
357
|
---
|
|
305
358
|
|
package/dist/create-storage.js
CHANGED
|
@@ -17,24 +17,6 @@ export function createStorage(schema, options) {
|
|
|
17
17
|
}
|
|
18
18
|
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
19
19
|
registerPrefix(options?.prefix ?? '', sto);
|
|
20
|
-
if (options?.encrypt) {
|
|
21
|
-
console.warn(`
|
|
22
|
-
⚠️ typed-storage: la opción encrypt está activada.
|
|
23
|
-
|
|
24
|
-
Encriptar valores en localStorage no es seguro —
|
|
25
|
-
la clave vive en el frontend y cualquier dev puede accederla.
|
|
26
|
-
|
|
27
|
-
Para datos sensibles usa:
|
|
28
|
-
✅ httpOnly cookies (tokens, sesiones)
|
|
29
|
-
✅ Variables de entorno en el servidor
|
|
30
|
-
|
|
31
|
-
typed-storage es ideal para:
|
|
32
|
-
✅ Preferencias de UI (theme, language)
|
|
33
|
-
✅ Estado de navegación
|
|
34
|
-
❌ Tokens de autenticación
|
|
35
|
-
❌ Datos financieros o personales sensibles
|
|
36
|
-
`);
|
|
37
|
-
}
|
|
38
20
|
const result = [];
|
|
39
21
|
let keys = Object.keys(schema);
|
|
40
22
|
for (let key of keys) {
|
package/dist/storage-signal.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import LZString from 'lz-string';
|
|
2
2
|
import { MemoryStorage } from "./memory-storage.js";
|
|
3
|
+
import { xorEncrypt, xorDecrypt } from './xor.js';
|
|
3
4
|
function safeParseJSON(value, fallback) {
|
|
4
5
|
if (!value)
|
|
5
6
|
return { value: fallback };
|
|
@@ -33,9 +34,17 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
33
34
|
key = `${options.prefix}:${key}`;
|
|
34
35
|
}
|
|
35
36
|
const rawData = sto.getItem(key);
|
|
36
|
-
|
|
37
|
+
let savedData = options?.compress && rawData
|
|
37
38
|
? LZString.decompress(rawData)
|
|
38
39
|
: rawData;
|
|
40
|
+
if (options?.encrypt && options?.secret && savedData) {
|
|
41
|
+
try {
|
|
42
|
+
savedData = xorDecrypt(savedData, options.secret);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
savedData = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
39
48
|
let currentValue;
|
|
40
49
|
const listeners = [];
|
|
41
50
|
function notify(value) {
|
|
@@ -64,9 +73,17 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
64
73
|
notify(initialValue);
|
|
65
74
|
return currentValue = initialValue;
|
|
66
75
|
}
|
|
67
|
-
|
|
76
|
+
let rawNewValue = options?.compress
|
|
68
77
|
? LZString.decompress(event.newValue)
|
|
69
78
|
: event.newValue;
|
|
79
|
+
if (options?.encrypt && options?.secret) {
|
|
80
|
+
try {
|
|
81
|
+
rawNewValue = xorDecrypt(rawNewValue, options.secret);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
rawNewValue = '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
70
87
|
const item = safeParseJSON(rawNewValue, initialValue);
|
|
71
88
|
notify(item.value);
|
|
72
89
|
return currentValue = item.value;
|
|
@@ -80,9 +97,12 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
80
97
|
value: newValue,
|
|
81
98
|
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
82
99
|
});
|
|
83
|
-
|
|
100
|
+
let finalData = options?.compress
|
|
84
101
|
? LZString.compress(dataToStore)
|
|
85
102
|
: dataToStore;
|
|
103
|
+
if (options?.encrypt && options?.secret) {
|
|
104
|
+
finalData = xorEncrypt(finalData, options.secret);
|
|
105
|
+
}
|
|
86
106
|
sto.setItem(key, finalData);
|
|
87
107
|
};
|
|
88
108
|
signalBase.reset = function () {
|
package/dist/types.d.ts
CHANGED
package/dist/xor.d.ts
ADDED
package/dist/xor.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function xorTransform(text, secret) {
|
|
2
|
+
return text.split('').map((char, i) => {
|
|
3
|
+
const keyChar = secret[i % secret.length];
|
|
4
|
+
return String.fromCharCode(char.charCodeAt(0) ^ keyChar.charCodeAt(0));
|
|
5
|
+
}).join('');
|
|
6
|
+
}
|
|
7
|
+
export function xorEncrypt(text, secret) {
|
|
8
|
+
const xored = xorTransform(text, secret);
|
|
9
|
+
return btoa(xored);
|
|
10
|
+
}
|
|
11
|
+
export function xorDecrypt(text, secret) {
|
|
12
|
+
const xored = atob(text);
|
|
13
|
+
return xorTransform(xored, secret);
|
|
14
|
+
}
|
package/index.html
CHANGED
|
@@ -5,41 +5,20 @@
|
|
|
5
5
|
</head>
|
|
6
6
|
<body>
|
|
7
7
|
<script type="module">
|
|
8
|
-
import {
|
|
8
|
+
import { createStorage } from './dist/index.js';
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
userPhotos: []
|
|
10
|
+
const secureStorage = createStorage({
|
|
11
|
+
token: ''
|
|
13
12
|
}, {
|
|
14
|
-
|
|
13
|
+
prefix: 'secure',
|
|
14
|
+
encrypt: true,
|
|
15
|
+
secret: 'mi-clave-secreta',
|
|
16
|
+
ttl: 3600000
|
|
15
17
|
});
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{ id: 1, name: 'Documento 1' },
|
|
21
|
-
{ id: 2, name: 'Documento 2' }
|
|
22
|
-
]);
|
|
23
|
-
console.log('✅ Documentos guardados');
|
|
24
|
-
|
|
25
|
-
// Get
|
|
26
|
-
const docs = await heavyStorage.documents.get();
|
|
27
|
-
console.log('📄 Documentos leídos:', docs);
|
|
28
|
-
|
|
29
|
-
// onChange
|
|
30
|
-
heavyStorage.documents.onChange((newValue) => {
|
|
31
|
-
console.log('🔔 documents cambió a:', newValue);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
await heavyStorage.documents.set([{ id: 3, name: 'Documento nuevo' }]);
|
|
35
|
-
|
|
36
|
-
// Remove
|
|
37
|
-
await heavyStorage.userPhotos.remove();
|
|
38
|
-
const photos = await heavyStorage.userPhotos.get();
|
|
39
|
-
console.log('🗑️ userPhotos después de remove:', photos);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
test();
|
|
19
|
+
secureStorage.token.set('eyJhbGciOiJIUzI1NiJ9.test.jwt');
|
|
20
|
+
console.log('Valor leído:', secureStorage.token());
|
|
21
|
+
console.log('Valor en localStorage (debe estar ofuscado):', localStorage.getItem('secure:token'));
|
|
43
22
|
</script>
|
|
44
23
|
</body>
|
|
45
24
|
</html>
|
package/package.json
CHANGED
package/src/create-storage.ts
CHANGED
|
@@ -42,25 +42,6 @@ export function createStorage<T extends StorageSchema>(
|
|
|
42
42
|
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
43
43
|
registerPrefix(options?.prefix ?? '', sto);
|
|
44
44
|
|
|
45
|
-
if ( options?.encrypt ) {
|
|
46
|
-
console.warn(`
|
|
47
|
-
⚠️ typed-storage: la opción encrypt está activada.
|
|
48
|
-
|
|
49
|
-
Encriptar valores en localStorage no es seguro —
|
|
50
|
-
la clave vive en el frontend y cualquier dev puede accederla.
|
|
51
|
-
|
|
52
|
-
Para datos sensibles usa:
|
|
53
|
-
✅ httpOnly cookies (tokens, sesiones)
|
|
54
|
-
✅ Variables de entorno en el servidor
|
|
55
|
-
|
|
56
|
-
typed-storage es ideal para:
|
|
57
|
-
✅ Preferencias de UI (theme, language)
|
|
58
|
-
✅ Estado de navegación
|
|
59
|
-
❌ Tokens de autenticación
|
|
60
|
-
❌ Datos financieros o personales sensibles
|
|
61
|
-
`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
45
|
const result: any = [];
|
|
65
46
|
|
|
66
47
|
let keys = Object.keys(schema);
|
|
@@ -118,4 +118,44 @@ describe('compress option', () => {
|
|
|
118
118
|
|
|
119
119
|
expect(compressedSize).toBeLessThan(uncompressedSize);
|
|
120
120
|
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('encrypt option', () => {
|
|
124
|
+
beforeEach(() => localStorage.clear());
|
|
125
|
+
|
|
126
|
+
it('debe ofuscar el valor guardado en localStorage', () => {
|
|
127
|
+
const signal = createStorageSignal('token', '', {
|
|
128
|
+
encrypt: true,
|
|
129
|
+
secret: 'mi-clave'
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
signal.set('eyJhbGciOiJIUzI1NiJ9.test.jwt');
|
|
133
|
+
|
|
134
|
+
const rawStored = localStorage.getItem('token');
|
|
135
|
+
// El valor crudo NO debe contener el JWT en texto plano
|
|
136
|
+
expect(rawStored).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('debe desencriptar correctamente al leer', () => {
|
|
140
|
+
const signal = createStorageSignal('token', '', {
|
|
141
|
+
encrypt: true,
|
|
142
|
+
secret: 'mi-clave'
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const originalValue = 'eyJhbGciOiJIUzI1NiJ9.test.jwt';
|
|
146
|
+
signal.set(originalValue);
|
|
147
|
+
|
|
148
|
+
expect(signal()).toBe(originalValue);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('debe funcionar junto con TTL', () => {
|
|
152
|
+
const signal = createStorageSignal('token', '', {
|
|
153
|
+
encrypt: true,
|
|
154
|
+
secret: 'mi-clave',
|
|
155
|
+
ttl: 1000
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
signal.set('mi-token');
|
|
159
|
+
expect(signal()).toBe('mi-token');
|
|
160
|
+
});
|
|
121
161
|
});
|
package/src/storage-signal.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { StorageSignal, StorageSignalOptions } from "./types.js";
|
|
|
6
6
|
// Memory
|
|
7
7
|
import { MemoryStorage } from "./memory-storage.js";
|
|
8
8
|
|
|
9
|
+
// Xor
|
|
10
|
+
import { xorEncrypt, xorDecrypt } from './xor.js';
|
|
11
|
+
|
|
9
12
|
// Interface
|
|
10
13
|
interface StoredValue<T> {
|
|
11
14
|
value: T;
|
|
@@ -66,9 +69,18 @@ export function createStorageSignal<T>(
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const rawData = sto.getItem(key);
|
|
69
|
-
|
|
72
|
+
let savedData = options?.compress && rawData
|
|
70
73
|
? LZString.decompress(rawData)
|
|
71
74
|
: rawData;
|
|
75
|
+
|
|
76
|
+
// Desencripta si encrypt está activo
|
|
77
|
+
if ( options?.encrypt && options?.secret && savedData ) {
|
|
78
|
+
try {
|
|
79
|
+
savedData = xorDecrypt(savedData, options.secret);
|
|
80
|
+
} catch {
|
|
81
|
+
savedData = null; // si falla desencriptar, trata como vacío
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
let currentValue: T;
|
|
73
85
|
|
|
74
86
|
const listeners: Array<(value: T) => void> = [];
|
|
@@ -106,9 +118,17 @@ export function createStorageSignal<T>(
|
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// Parseamos el nuevo valor
|
|
109
|
-
|
|
121
|
+
let rawNewValue = options?.compress
|
|
110
122
|
? LZString.decompress(event.newValue)
|
|
111
123
|
: event.newValue;
|
|
124
|
+
|
|
125
|
+
if ( options?.encrypt && options?.secret ) {
|
|
126
|
+
try {
|
|
127
|
+
rawNewValue = xorDecrypt(rawNewValue, options.secret);
|
|
128
|
+
} catch {
|
|
129
|
+
rawNewValue = '';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
112
132
|
const item = safeParseJSON(rawNewValue, initialValue);
|
|
113
133
|
|
|
114
134
|
notify(item.value as T);
|
|
@@ -126,10 +146,14 @@ export function createStorageSignal<T>(
|
|
|
126
146
|
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
127
147
|
});
|
|
128
148
|
|
|
129
|
-
|
|
149
|
+
let finalData = options?.compress
|
|
130
150
|
? LZString.compress(dataToStore)
|
|
131
151
|
: dataToStore;
|
|
132
152
|
|
|
153
|
+
if ( options?.encrypt && options?.secret ) {
|
|
154
|
+
finalData = xorEncrypt(finalData, options.secret);
|
|
155
|
+
}
|
|
156
|
+
|
|
133
157
|
sto.setItem( key, finalData );
|
|
134
158
|
}
|
|
135
159
|
|
package/src/types.ts
CHANGED
package/src/xor.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function xorTransform ( text: string, secret: string ): string {
|
|
2
|
+
return text.split('').map((char, i) => {
|
|
3
|
+
const keyChar = secret[i % secret.length];
|
|
4
|
+
return String.fromCharCode(
|
|
5
|
+
char.charCodeAt(0) ^ keyChar.charCodeAt(0)
|
|
6
|
+
);
|
|
7
|
+
}).join('');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function xorEncrypt ( text: string, secret: string ): string {
|
|
11
|
+
const xored = xorTransform(text, secret);
|
|
12
|
+
return btoa(xored);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function xorDecrypt ( text: string, secret: string ): string {
|
|
16
|
+
const xored = atob(text);
|
|
17
|
+
return xorTransform(xored, secret);
|
|
18
|
+
}
|