Skip to content

Commit

Permalink
Merge pull request #1 from kamranasad7/v1.0.4
Browse files Browse the repository at this point in the history
V1.0.4
  • Loading branch information
kamranasad7 authored Apr 2, 2024
2 parents e4c2bd0 + 6294ed4 commit 2036851
Show file tree
Hide file tree
Showing 8 changed files with 630 additions and 328 deletions.
Binary file added CHANGELOG.md
Binary file not shown.
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,66 @@
## Reactive LocalStorage for React

Reactive LocalStorage for React that uses [secure-ls](https://github.com/softvar/secure-ls) underneath for encryption.
<div align="center">
<h3>Reactive LocalStorage for React</h3>
<br />
</div>

Reactive LocalStorage for React that uses [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) underneath for encryption using AES-GCM.


### Table of Contents
- [Browser Support](#browser-support)
- [Installation](#installation)
- [API](#api)
- [Usage](#usage)
- [Encryption](#encryption)
- [Disabling Encryption](#disabling-encryption)


### Browser Support
![Chrome](https://raw.githubusercontent.com/alrra/browser-logos/main/src/chrome/chrome_48x48.png) | ![Firefox](https://raw.githubusercontent.com/alrra/browser-logos/main/src/firefox/firefox_48x48.png) | ![Safari](https://raw.githubusercontent.com/alrra/browser-logos/main/src/safari/safari_48x48.png) | ![Opera](https://raw.githubusercontent.com/alrra/browser-logos/main/src/opera/opera_48x48.png) | ![Edge](https://raw.githubusercontent.com/alrra/browser-logos/main/src/edge/edge_48x48.png) |
--- | --- | --- | --- | --- |
Latest ✔ | Latest ✔ | Latest ✔ | Latest ✔ | Latest ✔ |


### Installation
```
```bash
$ pnpm i react-reactive-storage
```

### API
It provides an easy-to-use `useLsState` hook which is very similar to `useState` in react. An additional `key` parameter is used which defines key of object on local storage.

```
```ts
const [state, setState] = useLsState<T>(key: string, defaultValue?: T);
```

### Usage
The usage is similar to `useState` from React with an
```
The usage is similar to `useState` from React.
```ts
import { useLsState } from "react-reactive-storage";

const [user, setUser] = useLsState<{name: string, age: number}>('user', {name: 'asad', age: 2});
```
The item in localstorage is fully reactive and is updated and reflected everywhere even when changed through multiple tabs, either by 'setState' method or any other means for example through `window.localstorage` or devtools.

The item in localstorage is fully reactive and is updated and reflected everywhere even when changed through multiple tabs, either by 'setState' method or any other means for example through `window.localstorage` or devtools.
Note that if encryption is enabled (see below) and the encrypted data is changed by any other means than this library, it will corrupt the data and value will be read null. See below for more details.


### Encryption
**Important**: Sensitive secrets should never be stored on client-side even in encrypted format.

The encryption is done through Web Crypto API which is native in Javascript that provides cryptographic functions under CryptoSubtle interface.

The library uses `AES_GCM` algorithm. First it generates a symmetric key, encrypts the key further using a wrapping key and stores it in local storage with name `secure_reactive_storage` to persist the key across reloads.
If `secure_reactive_storage` already exists in local storage then it decrypts the key and use it for encryption/decryption.
Whenever data is read/written, the data is encrypted/decrypted using this symmetric key.
If `secure_reactive_storage` gets deleted or modified, the key will be failed to load on page load and a new key will be generated hence discarding all the data encrypted with that key.

##### Disabling Encryption
By default encryption is **enabled**. The encryption can be enabled/disabled through `ReactiveStorage` class using `disableEncryption` method. Call the function once in the entry point of your app (e.g. in `main.tsx` or `App.tsx` whatever you have).

```ts
import { ReactiveStorage } from 'react-reactive-storage'

ReactiveStorage.disableEncryption(true);
```
81 changes: 81 additions & 0 deletions lib/ReactiveStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export abstract class ReactiveStorage {
static #encrypted = true;

static disableEncryption(value: boolean) {
this.#encrypted = !value;
}

static isEncrypted() { return this.#encrypted; }
}

class Crypto {

#key: CryptoKey | null;
enc = new TextEncoder();
dec = new TextDecoder();

static #iv = new Uint8Array([-26, 100, -118, -90, -29, 56, 24, -66, -18, -107, -24, 109, -100, -34, -102, -72, -32]);
static #keyType = { name: 'AES-GCM', length: 256 };
static #algorithm = { name: 'AES-GCM', iv: this.#iv };
static #wrappingKeyBuffer = new Uint8Array([81, -21, 31, -34, 109, 47, -19, 122, -31, -45, 127, -83, 10, -15, 126, -68, 99, -6, -87, 54, -24, 107, -44, 111, 124, -36, -102, 109, -108, 120, 63, 57]);
static #wrappingKey: CryptoKey | null = null;

private constructor(key: CryptoKey | null) {
this.#key = key;
}

encrypt = async (str: string): Promise<string> => {
if (crypto && this.#key) {
const cr = await crypto.subtle.encrypt(Crypto.#algorithm, this.#key, this.enc.encode(str));
return JSON.stringify(Array.from(new Int8Array(cr)));
}
else return str;
}

decrypt = async (str: string): Promise<string> => {
if (crypto && this.#key) {
try {
const parsedBuffer = new Int8Array(JSON.parse(str));
const dc = await crypto.subtle.decrypt(Crypto.#algorithm, this.#key, parsedBuffer.buffer);
return this.dec.decode(dc);
}
catch (e) {
return 'null';
}
}
else return str;
}

static async generateKey(wrappingKey: CryptoKey) {
const cryptoKey = await crypto.subtle.generateKey(this.#keyType, true, ['encrypt', 'decrypt']);
const wrappedKey = await crypto.subtle.wrapKey('raw', cryptoKey, wrappingKey, this.#algorithm);
localStorage.setItem('secure_reactive_storage', JSON.stringify(Array.from(new Int8Array(wrappedKey))));
return cryptoKey;
}

static async createInstance() {
if (crypto) {
let cryptoKey: CryptoKey | null = null;
this.#wrappingKey = await crypto.subtle.importKey('raw', this.#wrappingKeyBuffer, this.#algorithm.name, false, ['wrapKey', 'unwrapKey'])

const currentKey = localStorage.getItem('secure_reactive_storage');
if (currentKey) {
try {
const keyBuffer = new Int8Array(JSON.parse(currentKey))
cryptoKey = await crypto.subtle.unwrapKey('raw', keyBuffer, this.#wrappingKey, this.#algorithm, this.#algorithm.name, true, ['encrypt', 'decrypt']);
} catch {
cryptoKey = await this.generateKey(this.#wrappingKey);
}
}
else {
cryptoKey = await this.generateKey(this.#wrappingKey);
}

return new Crypto(cryptoKey as CryptoKey);
}
else return new Crypto(null);
}
}

const encryption = await Crypto.createInstance();
export { encryption };
40 changes: 31 additions & 9 deletions lib/hooks/useLsState.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
import { useEffect, useState } from "react";
import SecureLS from "secure-ls";
import { ReactiveStorage, encryption } from "../ReactiveStorage";

const useLsState = <T>(key: string, defaultValue?: T): [T, (value: T) => void] => {
const get = async <T>(key: string): Promise<T | null> => {
const value = localStorage.getItem(key);
if (value === null) { return value; }

const ls = new SecureLS();
const [state, _setState] = useState<T>(ls.get(key) || null);
if (ReactiveStorage.isEncrypted()) {
return JSON.parse(await encryption.decrypt(value)) as T;
}
else return JSON.parse(value) as T;
}

const set = async <T>(key: string, value: T | null): Promise<void> => {
if (ReactiveStorage.isEncrypted()) {
localStorage.setItem(key, await encryption.encrypt(JSON.stringify(value)))
}
else localStorage.setItem(key, JSON.stringify(value));
}

const useLsState = <T>(key: string, defaultValue?: T): [T | null, (value: T) => void] => {

const setState = (value: T): void => {
ls.set(key, value);
const [state, _setState] = useState<T | null>(defaultValue ?? null);

const setState = (value: T | null): void => {
set(key, value);
_setState(value);
}

const onStorageChange = (e: StorageEvent) => {
if(e.key == key) { _setState(ls.get(key)); }
const onStorageChange = async (e: StorageEvent) => {
if(e.key == key) { _setState(await get(key)); }
}

useEffect(() => {
if(defaultValue && !ls.getAllKeys().includes(key)) { setState(defaultValue); }
const getInitialValue = async () => {
const initialValue = await get<T>(key);
setState(initialValue ?? defaultValue ?? null);
};

getInitialValue();

window.addEventListener('storage', onStorageChange);
return () => window.removeEventListener('storage', onStorageChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return [state, setState];
Expand Down
1 change: 1 addition & 0 deletions lib/main.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useLsState } from "./hooks/useLsState";
export { ReactiveStorage } from "./ReactiveStorage";
41 changes: 25 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
{
"name": "react-reactive-storage",
"author": "kamranasad7",
"version": "1.0.3",
"description": "An encrypted and reactive localstorage system for react using hooks like useState",
"version": "1.0.4",
"private": false,
"keywords": [
"localstorage",
"react",
"reactive",
"storage",
"react-hooks"
],
"repository": {
"type": "git",
"url": "https://github.com/kamranasad7/reactive-localstorage"
},
"type": "module",
"keywords": ["localstorage", "react", "reactive", "storage", "react-hooks"],
"main": "./dist/react-reactive-storage.umd.cjs",
"module": "./dist/react-reactive-storage.js",
"types": "./dist/react-reactive-storage.d.ts",
Expand All @@ -24,25 +35,23 @@
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"secure-ls": "^1.2.6"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react-swc": "^3.4.1",
"eslint": "^8.53.0",
"@types/node": "^20.11.30",
"@types/react": "^18.2.69",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-dts": "^3.6.3"
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.4.3",
"vite": "^5.2.6",
"vite-plugin-dts": "^3.7.3",
"vite-plugin-top-level-await": "^1.4.1"
}
}
Loading

0 comments on commit 2036851

Please sign in to comment.