local-state-sync v1.0.0-beta.1
Installation
$ yarn add local-state-sync
# or
$ npm i local-state-sync
Usage
- Generate an encryption key (32 bytes, base64url encoded):
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
- Create a LocalStateSync object
import { LocalStateSync } from 'local-state-sync'
const localStateSync = new LocalStateSync({
// Required parameters
encryptionKey: '...',
onStateUpdated: state => console.dir(state)
})
await localStateSync.setState({
name: 'Alice',
age: 30
})
The onStateUpdated
callback will be called when another tab or
window has called setState
, or on load when reading an existing
persisted state.
Parsing & Serializing
By default, JSON.stringify
is used to convert your state to a string
before encryption, and JSON.parse
to hydrate it after decryption.
For complex states, it's recommended to use a custom parser, like zod.
import { z } from 'zod'
const stateParser = z.object({
name: z.string(),
age: z.number()
})
new LocalStateSync({
// ...
parseState: serializedState => stateParser.parse(JSON.parse(serializedState))
})
You can also provide a custom serializer:
new LocalStateSync<number>({
// ...
parseState: parseInt,
serializeState: state => state.toFixed()
})
TypeScript
The type of state is inferred from the first argument of the function you pass
to onStateUpdated
.
You can also specify the state type explicitly:
type MyState = {
name: string
age: number
}
new LocalStateSync<MyState>({
// ...
onStateUpdated: console.dir
})
Namespacing
If you want to use multiple states in parallel, you can provide an optional
namespace
argument:
new LocalStateSync({
// ...
namespace: `${userID}:ui-preferences`
})
new LocalStateSync({
// ...
namespace: `${userID}:secret-store`
})
You could also provide different encryption keys to get the same effect, but namespaces allow you to add some variance with fewer constraints on the key size and format.
Examples
React
import { LocalStateSync } from 'local-state-sync'
import React from 'react'
export const MySyncedComponent = () => {
const [state, setState] = React.useState('')
const [localStateSync] = React.useState(
() =>
new LocalStateSync<string>({
encryptionKey: '...',
onStateUpdated: state => setState(state)
})
)
return (
<input
value={state}
onChange={e => {
setState(e.target.event)
localStateSync.setState(e.target.event)
}}
/>
)
}
🙏 Contributions welcome for other frameworks
Threat modelling
This should be secure against other scripts running on the same origin, as long as you don't store the encryption key itself in accessible storage.
It will not be secure against an attacker that inspects the source code of the page (eg: browser extensions) to find the key and can run arbitrary scripts on your origin to decrypt the stored state.
Cryptography
State is encrypted using AES-GCM with a 256 bit key.
The IV and ciphertext are base64url encoded, and joined together using a dot .
character:
5otu-QPdwu3_fL9Y.tYtssqv_YASLeW65aLqrd66l4RECKJtr-R20n5odkA
[ iv (12 bytes)].[ ciphertext ]
The final encryption key is derived from the provided base encryption key and
the namespace (set to 'default'
if unspecified).
The storage key is then derived from the encryption key:
encryption key = SHA-512(base key || namespace)[0:31]
storage key = base64Encode(SHA-512(encryption key)[0:31])
Legend:
x || y -> concatenation
[x:y] -> slice
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago