├── 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 | session-keystore 6 | 7 | [![NPM](https://img.shields.io/npm/v/session-keystore?color=red)](https://www.npmjs.com/package/session-keystore) 8 | [![MIT License](https://img.shields.io/github/license/47ng/session-keystore.svg?color=blue)](https://github.com/47ng/session-keystore/blob/master/LICENSE) 9 | [![Continuous Integration](https://github.com/47ng/session-keystore/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/47ng/session-keystore/actions) 10 | [![Coverage Status](https://coveralls.io/repos/github/47ng/session-keystore/badge.svg?branch=next)](https://coveralls.io/github/47ng/session-keystore?branch=next) 11 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=47ng/session-keystore)](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 | --------------------------------------------------------------------------------