1.0.0-beta.1 • Published 1 year ago

local-state-sync v1.0.0-beta.1

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

NPM MIT License CI/CD Coverage Status

Installation

$ yarn add local-state-sync
# or
$ npm i local-state-sync

Usage

  1. Generate an encryption key (32 bytes, base64url encoded):
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
  1. 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