├── demo
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
├── demo.tsx
└── hooks.ts
├── .gitignore
├── img
├── header@2x.png
├── key-derivation.png
└── key-derivation@2x.png
├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ ├── cd.yml
│ └── ci.yml
├── .npmignore
├── src
├── utility.test.ts
├── utility.ts
├── index.test.ts
└── index.ts
├── test
└── browser.env.js
├── tsconfig.json
├── LICENSE
├── package.json
└── README.md
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | .cache/
2 | dist/
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .env
4 | yarn-error.log
5 | coverage/
6 |
--------------------------------------------------------------------------------
/img/header@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/47ng/session-keystore/HEAD/img/header@2x.png
--------------------------------------------------------------------------------
/img/key-derivation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/47ng/session-keystore/HEAD/img/key-derivation.png
--------------------------------------------------------------------------------
/img/key-derivation@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/47ng/session-keystore/HEAD/img/key-derivation@2x.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [franky47]
2 | liberapay: francoisbest
3 | custom: ['https://paypal.me/francoisbest?locale.x=fr_FR']
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | **/*.test.ts
2 | tsconfig.json
3 | .env
4 | .volumes/
5 | yarn-error.log
6 | .github/**
7 | src/**
8 | coverage/
9 | .dependabot/
10 |
--------------------------------------------------------------------------------
/src/utility.test.ts:
--------------------------------------------------------------------------------
1 | import { split, join } from './utility'
2 |
3 | test('Split / Join isomorphism', () => {
4 | const expected = 'Hello, World !'
5 | const [a, b] = split(expected)
6 | const received = join(a, b)
7 | expect(received).toEqual(expected)
8 | })
9 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | session-keystore demo
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | time: "09:00"
8 | timezone: Europe/Paris
9 | assignees:
10 | - franky47
11 | - package-ecosystem: github-actions
12 | directory: /
13 | schedule:
14 | interval: monthly
15 | assignees:
16 | - franky47
17 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "session-keystore-demo",
3 | "version": "0.0.0",
4 | "private": true,
5 | "main": "index.html",
6 | "license": "MIT",
7 | "scripts": {
8 | "build": "parcel build ./index.html",
9 | "start": "serve ./dist"
10 | },
11 | "dependencies": {
12 | "parcel-bundler": "^1.12.4",
13 | "react": "^17.0.1",
14 | "react-dom": "^17.0.1",
15 | "serve": "^11.3.2"
16 | },
17 | "devDependencies": {
18 | "@types/react": "^17.0.0",
19 | "@types/react-dom": "^17.0.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/test/browser.env.js:
--------------------------------------------------------------------------------
1 | const Environment = require('jest-environment-jsdom')
2 | const crypto = require('crypto')
3 |
4 | /**
5 | * JSDOM does not include TextEncoder / TextDecoder by default
6 | */
7 | module.exports = class CustomTestEnvironment extends Environment {
8 | async setup() {
9 | await super.setup()
10 | if (typeof this.global.TextEncoder === 'undefined') {
11 | const { TextEncoder } = require('util')
12 | this.global.TextEncoder = TextEncoder
13 | }
14 | if (typeof this.global.TextDecoder === 'undefined') {
15 | const { TextDecoder } = require('util')
16 | this.global.TextDecoder = TextDecoder
17 | }
18 |
19 | this.global.window.crypto = {
20 | getRandomValues: buffer => {
21 | return crypto.randomBytes(buffer.byteLength)
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "baseUrl": ".",
5 | "target": "es2018",
6 | "module": "commonjs",
7 | "lib": ["DOM", "DOM.Iterable"],
8 | "declaration": true,
9 | "outDir": "./dist",
10 | "paths": { "*": ["types/*"] },
11 |
12 | // Strict Type-Checking Options
13 | "strict": true,
14 | "noImplicitAny": true,
15 | "strictNullChecks": true,
16 | "strictFunctionTypes": true,
17 | "strictPropertyInitialization": true,
18 | "noImplicitThis": true,
19 | "alwaysStrict": true,
20 |
21 | // Additional Checks
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noImplicitReturns": true,
25 | "noFallthroughCasesInSwitch": true,
26 |
27 | "esModuleInterop": true,
28 |
29 | // Experimental Options
30 | "experimentalDecorators": true,
31 | "emitDecoratorMetadata": true
32 | },
33 | "exclude": ["./src/**/*.test.ts", "./dist", "./node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "es2018",
5 | "module": "commonjs",
6 | "lib": ["DOM", "DOM.Iterable"],
7 | "declaration": true,
8 | "outDir": "./dist",
9 | "rootDir": "./src",
10 | "paths": { "*": ["types/*"] },
11 |
12 | // Strict Type-Checking Options
13 | "strict": true,
14 | "noImplicitAny": true,
15 | "strictNullChecks": true,
16 | "strictFunctionTypes": true,
17 | "strictPropertyInitialization": true,
18 | "noImplicitThis": true,
19 | "alwaysStrict": true,
20 |
21 | // Additional Checks
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noImplicitReturns": true,
25 | "noFallthroughCasesInSwitch": true,
26 |
27 | "esModuleInterop": true,
28 |
29 | // Experimental Options
30 | "experimentalDecorators": true,
31 | "emitDecoratorMetadata": true
32 | },
33 | "exclude": ["./src/**/*.test.ts", "./dist", "./node_modules", "./demo"]
34 | }
35 |
--------------------------------------------------------------------------------
/demo/demo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import { useSessionKey } from './hooks'
4 |
5 | const Demo = () => {
6 | const [foo, { set: setFoo, del: deleteFoo }] = useSessionKey('foo')
7 | const [bar, { set: setBar, del: deleteBar }] = useSessionKey('bar')
8 |
9 | return (
10 | <>
11 |
14 |
19 |
27 |
28 |
29 |
30 | Key foo: {JSON.stringify(foo)}
31 |
32 | Key bar: {JSON.stringify(bar)}
33 |
34 | >
35 | )
36 | }
37 |
38 | render(, document.getElementById('react-root'))
39 |
--------------------------------------------------------------------------------
/demo/hooks.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import SessionKeystore, { ConstructorOptions } from '../src/index'
3 |
4 | export function useSessionKeystore(
5 | options: ConstructorOptions = {}
6 | ) {
7 | const store = React.useMemo(() => new SessionKeystore(options), [
8 | options.name
9 | ])
10 |
11 | return {
12 | set: store.set.bind(store),
13 | get: store.get.bind(store),
14 | clear: store.clear.bind(store),
15 | del: store.delete.bind(store)
16 | }
17 | }
18 |
19 | export function useSessionKey(name: string) {
20 | const { set, get, del } = useSessionKeystore({
21 | name,
22 | onChanged: keyName => {
23 | const value = get(keyName)
24 | setInternalKeyState(value)
25 | },
26 | onExpired: () => {
27 | setInternalKeyState(null)
28 | }
29 | })
30 | const [key, setInternalKeyState] = React.useState(get(name))
31 |
32 | return [
33 | key,
34 | {
35 | set: (value: string, expiresAt?: Date | number) => {
36 | set(name, value, expiresAt)
37 | },
38 | del: () => del(name)
39 | }
40 | ] as const
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 François Best
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Delivery
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | cd:
10 | name: Continuous Delivery
11 | runs-on: ubuntu-latest
12 | steps:
13 | - id: yarn-cache
14 | name: Get Yarn cache path
15 | run: echo "::set-output name=dir::$(yarn cache dir)"
16 | - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
17 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d
18 | with:
19 | node-version: 14.x
20 | - uses: actions/cache@26968a09c0ea4f3e233fdddbafd1166051a095f6
21 | name: Load Yarn cache
22 | with:
23 | path: ${{ steps.yarn-cache.outputs.dir }}
24 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
25 | restore-keys: |
26 | ${{ runner.os }}-yarn-
27 | - run: yarn install --ignore-scripts
28 | name: Install dependencies
29 | - run: yarn build
30 | name: Build package
31 |
32 | # Continuous Delivery Pipeline --
33 |
34 | - uses: docker://ghcr.io/codfish/semantic-release-action@sha256:16ab6c16b1bff6bebdbcc6cfc07dfafff49d23c6818490500b8edb3babfff29e
35 | name: Semantic Release
36 | id: semantic
37 | env:
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
40 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | push:
5 | branches:
6 | - next
7 | - feature/*
8 | - dependabot/*
9 | pull_request:
10 | types: [opened, edited, reopened, synchronize]
11 |
12 | jobs:
13 | ci:
14 | name: Continuous Integration
15 | runs-on: ubuntu-latest
16 | steps:
17 | - id: yarn-cache
18 | name: Get Yarn cache path
19 | run: echo "::set-output name=dir::$(yarn cache dir)"
20 | - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
21 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d
22 | with:
23 | node-version: 14.x
24 | - uses: actions/cache@26968a09c0ea4f3e233fdddbafd1166051a095f6
25 | name: Load Yarn cache
26 | with:
27 | path: ${{ steps.yarn-cache.outputs.dir }}
28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
29 | restore-keys: |
30 | ${{ runner.os }}-yarn-
31 | - run: yarn install --ignore-scripts
32 | name: Install dependencies
33 | - run: yarn ci
34 | name: Run integration tests
35 | - uses: coverallsapp/github-action@8cbef1dea373ebce56de0a14c68d6267baa10b44
36 | name: Report code coverage
37 | with:
38 | github-token: ${{ secrets.GITHUB_TOKEN }}
39 | - uses: 47ng/actions-slack-notify@main
40 | name: Notify on Slack
41 | if: always()
42 | with:
43 | status: ${{ job.status }}
44 | env:
45 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
46 |
--------------------------------------------------------------------------------
/src/utility.ts:
--------------------------------------------------------------------------------
1 | import { b64, utf8 } from '@47ng/codec'
2 |
3 | const randomBytes = (length: number): Uint8Array => {
4 | if (typeof window === 'undefined') {
5 | const crypto = require('crypto') // Node.js crypto module
6 | return crypto.randomBytes(length)
7 | } else {
8 | return window.crypto.getRandomValues(new Uint8Array(length))
9 | }
10 | }
11 |
12 | export const split = (secret: string): string[] => {
13 | const buff = utf8.encode(secret)
14 | const rand1 = randomBytes(buff.length)
15 | const rand2 = new Uint8Array(rand1) // Make a copy
16 | for (const i in buff) {
17 | rand2[i] = rand2[i] ^ buff[i]
18 | }
19 | return [b64.encode(rand1), b64.encode(rand2)]
20 | }
21 |
22 | export const join = (a: string, b: string) => {
23 | if (a.length !== b.length) {
24 | return null
25 | }
26 | const aBuff = b64.decode(a)
27 | const bBuff = b64.decode(b)
28 | const output = new Uint8Array(aBuff.length)
29 | for (const i in output) {
30 | output[i] = aBuff[i] ^ bBuff[i]
31 | }
32 | return utf8.decode(output)
33 | }
34 |
35 | // --
36 |
37 | const loadObjectFromWindowName = (): { [key: string]: string } => {
38 | if (!window.top.name || window.top.name === '') {
39 | return {}
40 | }
41 | try {
42 | return JSON.parse(window.top.name)
43 | } catch {}
44 | return {}
45 | }
46 |
47 | export const saveToWindowName = (name: string, data: string) => {
48 | const obj = loadObjectFromWindowName()
49 | obj[name] = data
50 | window.top.name = JSON.stringify(obj)
51 | }
52 |
53 | export const loadFromWindowName = (name: string) => {
54 | const saved = loadObjectFromWindowName()
55 | if (!(name in saved)) {
56 | return null
57 | }
58 | const { [name]: out, ...safe } = saved
59 | const json = JSON.stringify(safe)
60 | window.top.name = json === '{}' ? '' : json
61 | return out || null
62 | }
63 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "session-keystore",
3 | "version": "0.0.0-semantically-released",
4 | "description": "Secure cryptographic key storage in the browser",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "license": "MIT",
8 | "author": {
9 | "name": "François Best",
10 | "email": "contact@francoisbest.com",
11 | "url": "https://francoisbest.com"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/47ng/session-keystore"
16 | },
17 | "keywords": [
18 | "key-storage",
19 | "session-storage"
20 | ],
21 | "publishConfig": {
22 | "access": "public"
23 | },
24 | "scripts": {
25 | "test": "jest --coverage",
26 | "test:watch": "jest --watch",
27 | "build:clean": "rm -rf ./dist",
28 | "build:ts": "tsc",
29 | "build": "run-s build:clean build:ts",
30 | "ci": "run-s build test"
31 | },
32 | "dependencies": {
33 | "@47ng/codec": "^1.0.0",
34 | "mitt": "^2.1.0"
35 | },
36 | "devDependencies": {
37 | "@commitlint/config-conventional": "^11.0.0",
38 | "@types/jest": "^26.0.15",
39 | "@types/node": "^14.14.5",
40 | "commitlint": "^11.0.0",
41 | "husky": "4.x",
42 | "jest": "^26.6.1",
43 | "npm-run-all": "^4.1.5",
44 | "ts-jest": "^26.4.3",
45 | "ts-node": "^9.0.0",
46 | "typescript": "^4.0.5"
47 | },
48 | "browser": {
49 | "crypto": false
50 | },
51 | "jest": {
52 | "verbose": true,
53 | "preset": "ts-jest/presets/js-with-ts",
54 | "testEnvironment": "./test/browser.env.js"
55 | },
56 | "prettier": {
57 | "arrowParens": "avoid",
58 | "semi": false,
59 | "singleQuote": true,
60 | "tabWidth": 2,
61 | "trailingComma": "none",
62 | "useTabs": false
63 | },
64 | "husky": {
65 | "hooks": {
66 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
67 | }
68 | },
69 | "commitlint": {
70 | "extends": [
71 | "@commitlint/config-conventional"
72 | ],
73 | "rules": {
74 | "type-enum": [
75 | 2,
76 | "always",
77 | [
78 | "build",
79 | "chore",
80 | "ci",
81 | "clean",
82 | "doc",
83 | "feat",
84 | "fix",
85 | "perf",
86 | "ref",
87 | "revert",
88 | "style",
89 | "test"
90 | ]
91 | ],
92 | "subject-case": [
93 | 0,
94 | "always",
95 | "sentence-case"
96 | ],
97 | "body-leading-blank": [
98 | 2,
99 | "always",
100 | true
101 | ]
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import SK from './index'
2 | import { split, saveToWindowName } from './utility'
3 |
4 | test('Store with default name', () => {
5 | const store = new SK()
6 | expect(store.name).toEqual('default')
7 | })
8 |
9 | test('Store with a name', () => {
10 | const store = new SK({ name: 'foo' })
11 | expect(store.name).toEqual('foo')
12 | })
13 |
14 | describe('Event handlers', () => {
15 | test('created/updated', () => {
16 | const store = new SK()
17 | const created = jest.fn()
18 | const updated = jest.fn()
19 | store.on('created', created)
20 | store.on('updated', updated)
21 | store.set('foo', 'bar')
22 | expect(created).toHaveBeenCalledTimes(1)
23 | expect(created.mock.calls[0][0]).toEqual({ name: 'foo' })
24 | expect(updated).not.toHaveBeenCalled()
25 | created.mockReset()
26 | updated.mockReset()
27 | store.set('foo', 'egg')
28 | expect(created).not.toHaveBeenCalled()
29 | expect(updated).toHaveBeenCalledTimes(1)
30 | expect(updated.mock.calls[0][0]).toEqual({ name: 'foo' })
31 | created.mockReset()
32 | updated.mockReset()
33 | store.set('foo', 'egg') // Same value
34 | expect(created).not.toHaveBeenCalled()
35 | expect(updated).not.toHaveBeenCalled()
36 | })
37 |
38 | test('read', () => {
39 | const store = new SK()
40 | const read = jest.fn()
41 | store.on('read', read)
42 | store.set('foo', 'bar')
43 | expect(read).not.toHaveBeenCalled()
44 | store.get('foo')
45 | expect(read).toHaveBeenCalledTimes(1)
46 | expect(read.mock.calls[0][0]).toEqual({ name: 'foo' })
47 | })
48 |
49 | test('deleted', () => {
50 | const store = new SK()
51 | const deleted = jest.fn()
52 | store.on('deleted', deleted)
53 | store.set('foo', 'bar')
54 | store.get('foo')
55 | expect(deleted).not.toHaveBeenCalled()
56 | store.delete('foo')
57 | expect(deleted).toHaveBeenCalledTimes(1)
58 | expect(deleted.mock.calls[0][0]).toEqual({ name: 'foo' })
59 | })
60 |
61 | test('expired', () => {
62 | const store = new SK()
63 | const expired = jest.fn()
64 | const deleted = jest.fn()
65 | store.on('expired', expired)
66 | store.on('deleted', deleted)
67 | const expirationDate = Date.now() + 1000
68 | store.set('foo', 'bar', expirationDate)
69 | store.get('foo', expirationDate - 100)
70 | expect(expired).not.toHaveBeenCalled()
71 | store.get('foo', expirationDate + 100)
72 | expect(expired).toHaveBeenCalledTimes(1)
73 | expect(expired.mock.calls[0][0]).toEqual({ name: 'foo' })
74 | expect(deleted).not.toHaveBeenCalled()
75 | })
76 |
77 | test('Unsubscribe via returned off callback', () => {
78 | const store = new SK()
79 | const read = jest.fn()
80 | const off = store.on('read', read)
81 | store.set('foo', 'bar')
82 | store.get('foo')
83 | off()
84 | store.get('foo')
85 | expect(read).toHaveBeenCalledTimes(1)
86 | })
87 |
88 | test('Unsubscribe via off method', () => {
89 | const store = new SK()
90 | const read = jest.fn()
91 | store.on('read', read)
92 | store.set('foo', 'bar')
93 | store.get('foo')
94 | store.off('read', read)
95 | store.get('foo')
96 | expect(read).toHaveBeenCalledTimes(1)
97 | })
98 |
99 | test('By default, this is not bound to the store', () => {
100 | const store = new SK()
101 | const nobind = jest.fn().mockReturnThis()
102 | const bind = jest.fn().mockReturnThis()
103 | store.on('created', nobind)
104 | store.on('created', bind.bind(store))
105 | store.set('foo', 'bar')
106 | expect(bind.mock.results[0].value).toEqual(store)
107 | expect(nobind.mock.results[0].value).toBeUndefined()
108 | })
109 | })
110 |
111 | test('Clear', () => {
112 | const store = new SK()
113 | const deleted = jest.fn()
114 | store.on('deleted', deleted)
115 | store.set('foo', 'bar')
116 | store.set('egg', 'spam')
117 | expect(store.get('foo')).toEqual('bar')
118 | expect(store.get('egg')).toEqual('spam')
119 | store.clear()
120 | expect(deleted).toHaveBeenCalledTimes(2)
121 | expect(deleted.mock.calls[0][0]).toEqual({ name: 'foo' })
122 | expect(deleted.mock.calls[1][0]).toEqual({ name: 'egg' })
123 | expect(store.get('foo')).toBeNull()
124 | expect(store.get('egg')).toBeNull()
125 | })
126 |
127 | test('Key expiration', () => {
128 | jest.useFakeTimers()
129 | const store = new SK()
130 | const expired = jest.fn()
131 | const deleted = jest.fn()
132 | store.on('expired', expired)
133 | store.on('deleted', deleted)
134 | store.set('foo', 'bar', Date.now() + 100)
135 | store.set('egg', 'qux', Date.now() + 200)
136 | jest.advanceTimersByTime(150)
137 | expect(expired).toHaveBeenCalledTimes(1)
138 | expect(expired.mock.calls[0][0]).toEqual({ name: 'foo' })
139 | expect(store.get('foo')).toBeNull()
140 | expect(store.get('egg')).toEqual('qux')
141 | expect(deleted).not.toHaveBeenCalled()
142 | jest.useRealTimers()
143 | })
144 |
145 | test('Set a key that already expired', () => {
146 | const store = new SK()
147 | const created = jest.fn()
148 | const expired = jest.fn()
149 | store.on('created', created)
150 | store.on('expired', expired)
151 | store.set('foo', 'bar', 0)
152 | expect(created).not.toHaveBeenCalled()
153 | expect(expired).toHaveBeenCalledTimes(1)
154 | })
155 |
156 | test('Persistence', () => {
157 | const storeA = new SK()
158 | storeA.set('foo', 'bar')
159 | storeA.persist()
160 | const storeB = new SK()
161 | expect(storeB.get('foo')).toEqual('bar')
162 | })
163 |
164 | test('v0 to v1 conversion', () => {
165 | const future = Date.now() + 1000
166 | const json = JSON.stringify([['foo', { key: 'bar', expiresAt: future }]])
167 | const [a, b] = split(json)
168 | const storageKey = 'session-keystore:default'
169 | saveToWindowName(storageKey, a)
170 | window.sessionStorage.setItem(storageKey, b)
171 | const store = new SK()
172 | expect(store.get('foo')).toEqual('bar')
173 | expect(store.get('foo', future)).toBeNull()
174 | })
175 |
176 | test('Expiration date', () => {
177 | const future = new Date(Date.now() + 1000)
178 | const store = new SK()
179 | store.set('foo', 'bar', future)
180 | expect(store.get('foo')).toEqual('bar')
181 | expect(store.get('foo', future.valueOf())).toBeNull()
182 | })
183 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import mitt, { Emitter } from 'mitt'
2 | import { split, saveToWindowName, loadFromWindowName, join } from './utility'
3 |
4 | interface ExpirableKeyV0 {
5 | readonly key: string
6 | readonly expiresAt?: number // timestamp
7 | }
8 |
9 | interface ExpirableKeyV1 {
10 | v: 1
11 | readonly value: string
12 | readonly expiresAt?: number // timestamp
13 | }
14 |
15 | const isExpirableKeyV0 = (entry: any): entry is ExpirableKeyV0 => {
16 | return entry.v === undefined && !!entry.key
17 | }
18 | const isExpirableKeyV1 = (entry: any): entry is ExpirableKeyV1 => {
19 | return entry.v === 1 && !!entry.value
20 | }
21 |
22 | const convertV0toV1 = (v0Entry: ExpirableKeyV0): ExpirableKeyV1 => ({
23 | v: 1,
24 | value: v0Entry.key,
25 | expiresAt: v0Entry.expiresAt
26 | })
27 |
28 | // --
29 |
30 | export interface KeyEvent {
31 | name: Keys
32 | }
33 |
34 | export interface EventMap {
35 | created: KeyEvent
36 | read: KeyEvent
37 | updated: KeyEvent
38 | deleted: KeyEvent
39 | expired: KeyEvent
40 | }
41 |
42 | export type EventTypes = keyof EventMap
43 | export type EventPayload> = EventMap[T]
44 | export type Callback> = (
45 | value: EventPayload
46 | ) => void
47 |
48 | export interface ConstructorOptions {
49 | name?: string
50 | }
51 |
52 | // --
53 |
54 | export default class SessionKeystore {
55 | // Members
56 | readonly name: string
57 | readonly #storageKey: string
58 | #emitter: Emitter
59 | #store: Map
60 | #timeouts: Map
61 |
62 | // --
63 |
64 | constructor(opts: ConstructorOptions = {}) {
65 | this.name = opts.name || 'default'
66 | this.#storageKey = `session-keystore:${this.name}`
67 | this.#emitter = mitt()
68 | this.#store = new Map()
69 | this.#timeouts = new Map()
70 | /* istanbul ignore else */
71 | if (typeof window !== 'undefined') {
72 | try {
73 | this._load()
74 | } catch {}
75 | window.addEventListener('unload', this.persist.bind(this))
76 | }
77 | }
78 |
79 | // Event Emitter --
80 |
81 | // Returns an unsubscribe callback
82 | on>(event: T, callback: Callback) {
83 | this.#emitter.on(event, callback as any)
84 | return () => this.#emitter.off(event, callback as any)
85 | }
86 |
87 | off>(event: T, callback: Callback) {
88 | this.#emitter.off(event, callback as any)
89 | }
90 |
91 | // API --
92 |
93 | set(key: Keys, value: string, expiresAt?: Date | number) {
94 | let d: number | undefined
95 | if (expiresAt !== undefined) {
96 | d = typeof expiresAt === 'number' ? expiresAt : expiresAt.valueOf()
97 | }
98 | const newItem: ExpirableKeyV1 = {
99 | v: 1,
100 | value,
101 | expiresAt: d
102 | }
103 | const oldItem = this.#store.get(key)
104 | this.#store.set(key, newItem)
105 | if (this._setTimeout(key) === 'expired') {
106 | return // Don't call created or updated
107 | }
108 | if (!oldItem) {
109 | this.#emitter.emit('created', { name: key })
110 | } else if (oldItem.value !== newItem.value) {
111 | this.#emitter.emit('updated', { name: key })
112 | }
113 | }
114 |
115 | get(key: Keys, now = Date.now()) {
116 | const item = this.#store.get(key)
117 | if (!item) {
118 | return null
119 | }
120 | if (item.expiresAt !== undefined && item.expiresAt <= now) {
121 | this._expired(key)
122 | return null
123 | }
124 | this.#emitter.emit('read', { name: key })
125 | return item.value
126 | }
127 |
128 | delete(key: Keys) {
129 | this._clearTimeout(key)
130 | this.#store.delete(key)
131 | this.#emitter.emit('deleted', { name: key })
132 | }
133 |
134 | clear() {
135 | this.#store.forEach((_, key) => this.delete(key))
136 | }
137 |
138 | // --
139 |
140 | persist() {
141 | /* istanbul ignore next */
142 | if (typeof window === 'undefined') {
143 | throw new Error(
144 | 'SessionKeystore.persist is only available in the browser.'
145 | )
146 | }
147 | const json = JSON.stringify(Array.from(this.#store.entries()))
148 | const [a, b] = split(json)
149 | saveToWindowName(this.#storageKey, a)
150 | window.sessionStorage.setItem(this.#storageKey, b)
151 | }
152 |
153 | private _load() {
154 | const a = loadFromWindowName(this.#storageKey)
155 | const b = window.sessionStorage.getItem(this.#storageKey)
156 | window.sessionStorage.removeItem(this.#storageKey)
157 | if (!a || !b) {
158 | return
159 | }
160 | const json = join(a, b)
161 | /* istanbul ignore next */
162 | if (!json) {
163 | return
164 | }
165 | const entries: [Keys, ExpirableKeyV1][] = JSON.parse(json)
166 |
167 | this.#store = new Map(
168 | entries.map(([key, item]) => {
169 | if (isExpirableKeyV0(item)) {
170 | return [key, convertV0toV1(item)]
171 | }
172 | if (isExpirableKeyV1(item)) {
173 | return [key, item]
174 | }
175 | /* istanbul ignore next */
176 | return [key, item]
177 | })
178 | )
179 | // Re-establish timeouts
180 | this.#store.forEach((_, key) => {
181 | this._setTimeout(key)
182 | })
183 | }
184 |
185 | private _setTimeout(key: Keys): 'expired' | undefined {
186 | this._clearTimeout(key)
187 | const keyEntry = this.#store.get(key)
188 | if (keyEntry?.expiresAt === undefined) {
189 | return
190 | }
191 | const now = Date.now()
192 | const timeout = keyEntry.expiresAt - now
193 | if (timeout <= 0) {
194 | this._expired(key)
195 | return 'expired'
196 | }
197 | const t = setTimeout(() => {
198 | this._expired(key)
199 | }, timeout)
200 | this.#timeouts.set(key, t)
201 | return undefined
202 | }
203 |
204 | private _clearTimeout(key: Keys) {
205 | const timeoutId = this.#timeouts.get(key)
206 | clearTimeout(timeoutId)
207 | this.#timeouts.delete(key)
208 | }
209 |
210 | private _expired(key: Keys) {
211 | this._clearTimeout(key)
212 | this.#store.delete(key)
213 | this.#emitter.emit('expired', { name: key })
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | [](https://www.npmjs.com/package/session-keystore)
8 | [](https://github.com/47ng/session-keystore/blob/master/LICENSE)
9 | [](https://github.com/47ng/session-keystore/actions)
10 | [](https://coveralls.io/github/47ng/session-keystore?branch=next)
11 | [](https://dependabot.com)
12 |
13 | Secure cryptographic key storage in the browser and Node.js
14 |
15 | Ideal to store keys derived from user credentials (username/password) in
16 | E2EE applications.
17 |
18 | ## Features
19 |
20 | - In-memory storage: no clear-text persistance to disk
21 | - Session-bound: cleared when closing tab/window (browser-only)
22 | - Survives hard-reloads of the page (browser-only)
23 | - Optional expiration dates
24 | - Event emitter API for key CRUD operations
25 |
26 | ## Installation
27 |
28 | ```shell
29 | $ yarn add session-keystore
30 | # or
31 | $ npm i session-keystore
32 | ```
33 |
34 | ## Usage
35 |
36 | ```ts
37 | import SessionKeystore from 'session-keystore'
38 |
39 | // Create a store
40 | const store = new SessionKeystore()
41 |
42 | // You can create multiple stores, but give them a unique name:
43 | // (default name is 'default')
44 | const otherStore = new SessionKeystore({ name: 'other' })
45 |
46 | // Save a session-bound key
47 | store.set('foo', 'supersecret')
48 |
49 | // Set an expiration date (Date or timestamp in ms)
50 | store.set('bar', 'supersecret', Date.now() + 1000 * 60 * 5) // 5 minutes
51 |
52 | // Retrieve the key
53 | const key = store.get('bar')
54 | // key will be null if it has expired
55 |
56 | // Revoke a single key
57 | store.delete('foo')
58 |
59 | // Clear all keys in storage
60 | store.clear()
61 | ```
62 |
63 | ## CRUD Event Emitter
64 |
65 | Event types:
66 |
67 | - `created`
68 | - `read`
69 | - `updated`
70 | - `deleted`
71 | - `expired`
72 |
73 | Listen to events on a keystore with the `on` method:
74 |
75 | ```ts
76 | import SessionKeystore from 'session-keystore'
77 |
78 | const store = new SessionKeystore()
79 | store.on('created', ({ name }) => console.log('Key created: ', name))
80 | store.on('updated', ({ name }) => console.log('Key updated: ', name))
81 | store.on('deleted', ({ name }) => console.log('Key deleted: ', name))
82 | store.on('expired', ({ name }) => console.log('Key expired: ', name))
83 | store.on('read', ({ name }) => console.log('Key accessed: ', name))
84 | ```
85 |
86 | Note: `deleted` will be called when the key has been manually deleted,
87 | and `expired` when its expiration date has arrived.
88 |
89 | When setting a key that is already expired, `created` or `updated` will
90 | NOT be called, and `expired` will be called instead.
91 |
92 | ## TypeScript
93 |
94 | `session-keystore` is written in TypeScript. You can tell a store about the keys it is supposed to hold:
95 |
96 | ```ts
97 | import SessionKeystore from 'session-keystore'
98 |
99 | const store = new SessionKeystore<'foo' | 'bar'>()
100 |
101 | store.get('foo') // ok
102 | store.get('bar') // ok
103 | store.get('egg') // Error: Argument of type '"egg"' is not assignable to parameter of type '"foo" | "bar"'
104 | ```
105 |
106 | This can be handy if you have multiple stores, to avoid accidental key leakage.
107 |
108 | ## How it works
109 |
110 | Heavily inspired from the [Secure Session Storage](https://github.com/ProtonMail/proton-shared/blob/master/lib/helpers/secureSessionStorage.js#L7) implementation by [ProtonMail](https://protonmail.com),
111 | itself inspired from Thomas Frank's [SessionVars](https://www.thomasfrank.se/sessionvars.html).
112 |
113 | Read the [writeup article](https://francoisbest.com/posts/2019/how-to-store-e2ee-keys-in-the-browser) on [my blog](https://francoisbest.com/posts).
114 |
115 | From the ProtonMail documentation:
116 |
117 | > However, we aim to deliberately be non-persistent. This is useful for
118 | > data that wants to be preserved across refreshes, but is too sensitive
119 | > to be safely written to disk. Unfortunately, although sessionStorage is
120 | > deleted when a session ends, major browsers automatically write it
121 | > to disk to enable a session recovery feature, so using sessionStorage
122 | > alone is inappropriate.
123 | >
124 | > To achieve this, we do two tricks. The first trick is to delay writing
125 | > any possibly persistent data until the user is actually leaving the
126 | > page (onunload). This already prevents any persistence in the face of
127 | > crashes, and severely limits the lifetime of any data in possibly
128 | > persistent form on refresh.
129 | >
130 | > The second, more important trick is to split sensitive data between
131 | > `window.name` and sessionStorage. `window.name` is a property that, like
132 | > sessionStorage, is preserved across refresh and navigation within the
133 | > same tab - however, it seems to never be stored persistently. This
134 | > provides exactly the lifetime we want. Unfortunately, `window.name` is
135 | > readable and transferable between domains, so any sensitive data stored
136 | > in it would leak to random other websites.
137 | >
138 | > To avoid this leakage, we split sensitive data into two shares which
139 | > xor to the sensitive information but which individually are completely
140 | > random and give away nothing. One share is stored in `window.name`, while
141 | > the other share is stored in sessionStorage. This construction provides
142 | > security that is the best of both worlds - random websites can't read
143 | > the data since they can't access sessionStorage, while disk inspections
144 | > can't read the data since they can't access `window.name`. The lifetime
145 | > of the data is therefore the smaller lifetime, that of `window.name`.
146 |
147 | ## License
148 |
149 | [MIT](https://github.com/47ng/session-keystore/blob/master/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com)
150 |
151 | Using this package at work ? [Sponsor me](https://github.com/sponsors/franky47) to help with support and maintenance.
152 |
--------------------------------------------------------------------------------