Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/pid 2627 - Add session keystore #283

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"idb-keyval": "6.2.0",
"js-sha3": "0.9.3",
"jsonld": "8.3.1",
"mitt": "^2.1.0",
"pubsub-js": "1.9.4",
"uuid": "9.0.1"
},
Expand Down
1 change: 1 addition & 0 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './local-storage';
export * from './indexed-db';
export * from './shared';
export * from './fs';
export * from './session';
2 changes: 2 additions & 0 deletions src/storage/session/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './keystore';
export * from './utility';
235 changes: 235 additions & 0 deletions src/storage/session/keystore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @cspell/spellchecker */
import mitt, { Emitter } from 'mitt'
import { split, saveToWindowName, loadFromWindowName, join } from './utility'

interface ExpirableKeyV0 {
readonly key: string
readonly expiresAt?: number // timestamp
}

interface ExpirableKeyV1 {
v: 1
readonly value: string
readonly expiresAt?: number // timestamp
}

const isExpirableKeyV0 = (entry: any): entry is ExpirableKeyV0 => {
return entry.v === undefined && !!entry.key
}
const isExpirableKeyV1 = (entry: any): entry is ExpirableKeyV1 => {
return entry.v === 1 && !!entry.value
}

const convertV0toV1 = (v0Entry: ExpirableKeyV0): ExpirableKeyV1 => ({
v: 1,
value: v0Entry.key,
expiresAt: v0Entry.expiresAt
})

// --

export interface KeyEvent<Keys> {
name: Keys
}

export interface EventMap<Keys> {
created: KeyEvent<Keys>
read: KeyEvent<Keys>
updated: KeyEvent<Keys>
deleted: KeyEvent<Keys>
expired: KeyEvent<Keys>
persisting: KeyEvent<Keys>
}

export type EventTypes<Keys> = keyof EventMap<Keys>
export type EventPayload<Keys, T extends EventTypes<Keys>> = EventMap<Keys>[T]
export type Callback<Keys, T extends EventTypes<Keys>> = (
value: EventPayload<Keys, T>
) => void

export interface ConstructorOptions {
name?: string,
onChanged: (keyName: string) => void,
onExpired: () => void
}

// --

export class SessionKeystore<Keys = string> {
// Members
readonly name: string
readonly #storageKey: string
#emitter: Emitter
#store: Map<Keys, ExpirableKeyV1>
#timeouts: Map<Keys, any>
readonly id: number

// --

constructor(opts: ConstructorOptions = {
onChanged: function (keyName: string): void {
throw new Error(`Function not implemented. keyName: ${keyName}`)
},
onExpired: function (): void {
throw new Error('Function not implemented.')
}
}) {
this.name = opts.name || 'default'
this.id = Math.floor(Math.random() * 1000000)
this.#storageKey = `session-keystore:${this.name}`
this.#emitter = mitt()
this.#store = new Map()
this.#timeouts = new Map()
/* istanbul ignore else */
if (typeof window !== 'undefined') {
try {
this._load()
} catch { /* empty */ }
window.addEventListener('unload', this.persist.bind(this))
}
}

// Event Emitter --

// Returns an unsubscribe callback
on<T extends EventTypes<Keys>>(event: T, callback: Callback<Keys, T>) {
this.#emitter.on(event, callback as any)
return () => this.#emitter.off(event, callback as any)
}

off<T extends EventTypes<Keys>>(event: T, callback: Callback<Keys, T>) {
this.#emitter.off(event, callback as any)
}

// API --

set(key: Keys, value: string, expiresAt?: Date | number) {
console.log('key', key)
let d: number | undefined
if (expiresAt !== undefined) {
d = typeof expiresAt === 'number' ? expiresAt : expiresAt.valueOf()
}
const newItem: ExpirableKeyV1 = {
v: 1,
value,
expiresAt: d
}
console.log('newItem', newItem)
const oldItem = this.#store.get(key)
console.log('oldItem', oldItem)
this.#store.set(key, newItem)
if (this._setTimeout(key) === 'expired') {
return // Don't call created or updated
}
if (!oldItem) {
this.#emitter.emit('created', { name: key, value: newItem.value })
} else if (oldItem.value !== newItem.value) {
this.#emitter.emit('updated', { name: key })
}
}

get(key: Keys, now = Date.now()) {
console.log('get', key)
const item = this.#store.get(key)
console.log('item', item)
if (!item) {
return null
}
if (item.expiresAt !== undefined && item.expiresAt <= now) {
this._expired(key)
return null
}
this.#emitter.emit('read', { name: key })
return item.value
}

delete(key: Keys) {
this._clearTimeout(key)
this.#store.delete(key)
this.#emitter.emit('deleted', { name: key })
}

clear() {
this.#store.forEach((_, key) => this.delete(key))
}

// --

persist() {
this.#emitter.emit('persisting', { name: this.name });
/* istanbul ignore next */
if (typeof window === 'undefined') {
throw new Error(
'SessionKeystore.persist is only available in the browser.'
)
}
const json = JSON.stringify(Array.from(this.#store.entries()))
const [a, b] = split(json)
saveToWindowName(this.#storageKey, a)
window.sessionStorage.setItem(this.#storageKey, b)
}

private _load() {
const a = loadFromWindowName(this.#storageKey)
const b = window.sessionStorage.getItem(this.#storageKey)
window.sessionStorage.removeItem(this.#storageKey)
if (!a || !b) {
return
}
const json = join(a, b)
/* istanbul ignore next */
if (!json) {
return
}
const entries: [Keys, ExpirableKeyV1][] = JSON.parse(json)

this.#store = new Map(
entries.map(([key, item]) => {
if (isExpirableKeyV0(item)) {
return [key, convertV0toV1(item)]
}
if (isExpirableKeyV1(item)) {
return [key, item]
}
/* istanbul ignore next */
return [key, item]
})
)
// Re-establish timeouts
this.#store.forEach((_, key) => {
this._setTimeout(key)
})
}

private _setTimeout(key: Keys): 'expired' | undefined {
this._clearTimeout(key)
const keyEntry = this.#store.get(key)
if (keyEntry?.expiresAt === undefined) {
return
}
const now = Date.now()
const timeout = keyEntry.expiresAt - now
if (timeout <= 0) {
this._expired(key)
return 'expired'
}
const t = setTimeout(() => {
this._expired(key)
}, timeout)
this.#timeouts.set(key, t)
return undefined
}

private _clearTimeout(key: Keys) {
const timeoutId = this.#timeouts.get(key)
clearTimeout(timeoutId)
this.#timeouts.delete(key)
}

private _expired(key: Keys) {
this._clearTimeout(key)
this.#store.delete(key)
this.#emitter.emit('expired', { name: key })
}
}
67 changes: 67 additions & 0 deletions src/storage/session/utility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import crypto from 'crypto' // Node.js crypto module
import { decodeBase64url, encodeBase64url, utf8Decoder, utf8Encoder } from '../../utils'
import { base64UrlToBytes, bytesToBase64url } from '../../utils/encoding';

const randomBytes = (length: number): Uint8Array => {
if (typeof window === 'undefined') {
return crypto.randomBytes(length)
} else {
return window.crypto.getRandomValues(new Uint8Array(length))
}
}

export const split = (secret: string): string[] => {
const buff = utf8Encoder(secret)
const rand1 = randomBytes(buff.length)
const rand2 = new Uint8Array(rand1) // Make a copy
for (let i = 0; i < buff.length; i++) {
rand2[i] = rand2[i] ^ buff[i]
}
return [encodeBase64url(bytesToBase64url(rand1)), encodeBase64url(bytesToBase64url(rand2))]
}

export const join = (a: string, b: string) => {
if (a.length !== b.length) {
return null
}
const aBuff = base64UrlToBytes(decodeBase64url(a))
const bBuff = base64UrlToBytes(decodeBase64url(b))
const output = new Uint8Array(aBuff.length)
for (const i in output) {
output[i] = aBuff[i] ^ bBuff[i]
}
return utf8Decoder(output)
}

// --

const loadObjectFromWindowName = (): { [key: string]: string } => {
if (!window.top || !window.top.name || window.top.name === '') {
return {}
}
try {
return JSON.parse(window.top.name)
} catch { /* empty */ }
return {}
}

export const saveToWindowName = (name: string, data: string) => {
const obj = loadObjectFromWindowName()
obj[name] = data
if (window.top) {
window.top.name = JSON.stringify(obj)
}
}

export const loadFromWindowName = (name: string) => {
const saved = loadObjectFromWindowName()
if (!(name in saved)) {
return null
}
const { [name]: out, ...safe } = saved
const json = JSON.stringify(safe)
if (window.top) {
window.top.name = json === '{}' ? '' : json
}
return out || null
}
9 changes: 9 additions & 0 deletions src/utils/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,12 @@ export function decodeBase64url(s: string, opts = { loose: true }): string {
export function bytesToHex(b: Uint8Array): string {
return Hex.encodeString(b);
}

export function utf8Decoder(b: Uint8Array): string {
return byteDecoder.decode(b);
}

export function utf8Encoder(s: string): Uint8Array {
const buf = byteEncoder.encode(s);
return new Uint8Array(buf, 0, buf.length);
}
Loading