web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit f4b51b23b0b6f8065b93e08a57c177b35ebe7cf4
parent 878eff9856c31a382e8b39681ea2320f9f17f50e
Author: triesap <triesap@radroots.dev>
Date:   Thu, 20 Nov 2025 13:47:25 +0000

client: add shared web aes-gcm cipher for encrypted persistence, integrating keystore, sql, datastore and radroots clients, and exposing nostr keystore support via updated exports

Diffstat:
Mclient/package.json | 52++++++++++++++++++++++++++++++++++++++++++++--------
Aclient/src/cipher/web.ts | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/datastore/types.ts | 3++-
Mclient/src/datastore/web.ts | 23++++++++++++++---------
Dclient/src/index.ts | 8--------
Dclient/src/keystore/aes-gcm-cipher.ts | 98-------------------------------------------------------------------------------
Mclient/src/keystore/index.ts | 3++-
Mclient/src/keystore/types.ts | 11++++++++++-
Aclient/src/keystore/web-nostr.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/keystore/web.ts | 61++++++++++++++++++++++++++++++++++++++++++-------------------
Mclient/src/radroots/types.ts | 26+++++++++++++-------------
Mclient/src/radroots/web.ts | 10+++++-----
Mclient/src/sql/web.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Dclient/src/utils/idb.ts | 5-----
14 files changed, 466 insertions(+), 188 deletions(-)

diff --git a/client/package.json b/client/package.json @@ -4,14 +4,51 @@ "private": true, "license": "GPLv3", "type": "module", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/types/index.d.ts", "exports": { - ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" + "./cipher": { + "types": "./dist/types/cipher/index.d.ts", + "import": "./dist/esm/cipher/index.js", + "require": "./dist/cjs/cipher/index.js" + }, + "./datastore": { + "types": "./dist/types/datastore/index.d.ts", + "import": "./dist/esm/datastore/index.js", + "require": "./dist/cjs/datastore/index.js" + }, + "./fs": { + "types": "./dist/types/fs/index.d.ts", + "import": "./dist/esm/fs/index.js", + "require": "./dist/cjs/fs/index.js" + }, + "./geolocation": { + "types": "./dist/types/geolocation/index.d.ts", + "import": "./dist/esm/geolocation/index.js", + "require": "./dist/cjs/geolocation/index.js" + }, + "./http": { + "types": "./dist/types/http/index.d.ts", + "import": "./dist/esm/http/index.js", + "require": "./dist/cjs/http/index.js" + }, + "./keystore": { + "types": "./dist/types/keystore/index.d.ts", + "import": "./dist/esm/keystore/index.js", + "require": "./dist/cjs/keystore/index.js" + }, + "./notifications": { + "types": "./dist/types/notifications/index.d.ts", + "import": "./dist/esm/notifications/index.js", + "require": "./dist/cjs/notifications/index.js" + }, + "./radroots": { + "types": "./dist/types/radroots/index.d.ts", + "import": "./dist/esm/radroots/index.js", + "require": "./dist/cjs/radroots/index.js" + }, + "./sql": { + "types": "./dist/types/sql/index.d.ts", + "import": "./dist/esm/sql/index.js", + "require": "./dist/cjs/sql/index.js" } }, "scripts": { @@ -24,7 +61,6 @@ "watch": "tsc -w" }, "dependencies": { - "@radroots/models": "*", "@radroots/utils": "*", "@radroots/utils-nostr": "*", "idb": "^8.0.3", diff --git a/client/src/cipher/web.ts b/client/src/cipher/web.ts @@ -0,0 +1,185 @@ +import { IdbClientConfig } from "@radroots/utils"; +import { createStore, del as idb_del, get as idb_get, set as idb_set, type UseStore } from "idb-keyval"; + +function as_array_buffer(u8: Uint8Array): ArrayBuffer { + if (u8.byteOffset === 0 && u8.buffer instanceof ArrayBuffer && u8.byteLength === u8.buffer.byteLength) { + return u8.buffer; + } + return u8.slice().buffer; +} + +const DEFAULT_DB_NAME = "radroots-aes-gcm-keystore"; +const DEFAULT_STORE_NAME = "default"; +const DEFAULT_KEY_NAME = "radroots.aes-gcm.key"; +const DEFAULT_ALGORITHM_NAME = "AES-GCM"; +const DEFAULT_KEY_LENGTH = 256; +const DEFAULT_IV_LENGTH = 12; +const DEFAULT_KEY_USAGES: KeyUsage[] = ["encrypt", "decrypt"]; + +export type WebAesGcmCipherConfig = { + idb_config?: Partial<IdbClientConfig>; + key_name?: string; + key_length?: number; + iv_length?: number; + algorithm?: string; +}; + +export class WebAesGcmCipher { + private readonly db_name: string; + private readonly store_name: string; + private readonly key_name: string; + private readonly algorithm_name: string; + private readonly key_usages: readonly KeyUsage[]; + private readonly iv_length: number; + private readonly key_length: number; + private readonly store: UseStore; + private cached_key: CryptoKey | null; + private key_promise: Promise<CryptoKey> | null; + + constructor(config?: WebAesGcmCipherConfig) { + const idb_config = config?.idb_config ?? {}; + this.db_name = idb_config.database ?? DEFAULT_DB_NAME; + this.store_name = idb_config.store ?? DEFAULT_STORE_NAME; + this.key_name = config?.key_name ?? DEFAULT_KEY_NAME; + this.algorithm_name = config?.algorithm ?? DEFAULT_ALGORITHM_NAME; + this.key_usages = DEFAULT_KEY_USAGES; + this.iv_length = config?.iv_length ?? DEFAULT_IV_LENGTH; + this.key_length = config?.key_length ?? DEFAULT_KEY_LENGTH; + + if (typeof indexedDB === "undefined") { + throw new Error("error.client.keystore.idb_undefined"); + } + if (!globalThis.crypto || !globalThis.crypto.subtle) { + throw new Error("error.client.keystore.crypto_undefined"); + } + + this.store = createStore(this.db_name, this.store_name); + this.cached_key = null; + this.key_promise = null; + } + + public get_config(): IdbClientConfig { + return { + database: this.db_name, + store: this.store_name + }; + } + + private async import_key(raw_key: Uint8Array): Promise<CryptoKey> { + const key_usages: KeyUsage[] = [...this.key_usages]; + const key = await crypto.subtle.importKey( + "raw", + as_array_buffer(raw_key), + this.algorithm_name, + false, + key_usages + ); + return key; + } + + private async persist_key(key: CryptoKey): Promise<void> { + try { + await idb_set(this.key_name, key, this.store); + } catch { + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + try { + await idb_set(this.key_name, raw, this.store); + } finally { + raw.fill(0); + } + } + } + + private async generate_and_persist_key(): Promise<CryptoKey> { + const key = await crypto.subtle.generateKey( + { name: this.algorithm_name, length: this.key_length }, + false, + this.key_usages + ); + await this.persist_key(key); + return key; + } + + private async resolve_persisted_key(): Promise<CryptoKey | null> { + const stored = await idb_get(this.key_name, this.store); + if (!stored) { + return null; + } + if (stored instanceof CryptoKey) { + return stored; + } + if (stored instanceof Uint8Array) { + return this.import_key(stored); + } + if (ArrayBuffer.isView(stored) && stored.buffer instanceof ArrayBuffer) { + const view = new Uint8Array(stored.buffer); + return this.import_key(view); + } + return null; + } + + private async load_key(): Promise<CryptoKey> { + if (this.cached_key) { + return this.cached_key; + } + if (this.key_promise) { + return this.key_promise; + } + this.key_promise = this.inner_load_key(); + const key = await this.key_promise; + this.cached_key = key; + this.key_promise = null; + return key; + } + + private async inner_load_key(): Promise<CryptoKey> { + const existing = await this.resolve_persisted_key(); + if (existing) { + return existing; + } + return this.generate_and_persist_key(); + } + + public async reset(): Promise<void> { + this.cached_key = null; + this.key_promise = null; + await idb_del(this.key_name, this.store); + } + + public async encrypt(data: Uint8Array): Promise<Uint8Array> { + if (data.byteLength === 0) { + return data; + } + const key = await this.load_key(); + const iv = crypto.getRandomValues(new Uint8Array(this.iv_length)); + const ciphertext_buffer = await crypto.subtle.encrypt( + { name: this.algorithm_name, iv: as_array_buffer(iv) }, + key, + as_array_buffer(data) + ); + const ciphertext = new Uint8Array(ciphertext_buffer); + const out = new Uint8Array(this.iv_length + ciphertext.byteLength); + out.set(iv, 0); + out.set(ciphertext, this.iv_length); + return out; + } + + public async decrypt(blob: Uint8Array): Promise<Uint8Array> { + if (blob.byteLength <= this.iv_length) { + throw new Error("error.client.keystore.invalid_ciphertext"); + } + const key = await this.load_key(); + const iv = blob.slice(0, this.iv_length); + const ciphertext = blob.slice(this.iv_length); + try { + const plaintext = await crypto.subtle.decrypt( + { name: this.algorithm_name, iv: as_array_buffer(iv) }, + key, + as_array_buffer(ciphertext) + ); + return new Uint8Array(plaintext); + } catch { + throw new Error("error.client.keystore.decrypt_failure"); + } + } +} diff --git a/client/src/datastore/types.ts b/client/src/datastore/types.ts @@ -1,4 +1,4 @@ -import type { ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils"; +import type { IdbClientConfig, ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils"; export type IClientDatastoreValue = string | null; @@ -20,6 +20,7 @@ export type IClientDatastore< TKeyObjMap extends IClientDatastoreKeyMap, > = { init(): Promise<ResolveError<void>>; + get_config(): IdbClientConfig set(key: keyof TKeyMap, value: string): Promise<ResolveError<ResultPass>>; get(key: keyof TKeyMap): Promise<ResolveError<ResultObj<string>>>; set_obj(key: keyof TKeyObjMap, value: TKeyObjMap): Promise<ResolveError<ResultPass>>; diff --git a/client/src/datastore/web.ts b/client/src/datastore/web.ts @@ -1,4 +1,4 @@ -import { err_msg, handle_err, ResolveError, ResultObj } from "@radroots/utils"; +import { err_msg, handle_err, IdbClientConfig, ResolveError, ResultObj } from "@radroots/utils"; import { createStore, clear as idb_clear, @@ -8,7 +8,6 @@ import { set as idb_set, type UseStore } from "idb-keyval"; -import { type IClientIdbConfig } from "../utils/idb.js"; import type { IClientDatastore, IClientDatastoreDelPrefResolve, @@ -34,7 +33,7 @@ export class WebDatastore< private _key_param_map: Tp; private _key_obj_map: TkO; - constructor(key_map: Tk, key_param_map: Tp, key_obj_map: TkO, config?: IClientIdbConfig) { + constructor(key_map: Tk, key_param_map: Tp, key_obj_map: TkO, config?: Partial<IdbClientConfig>) { this.db_name = config?.database || "radroots-web-datastore"; this.store_name = config?.store || "default"; this.store = null; @@ -51,6 +50,13 @@ export class WebDatastore< return this.store; } + public get_config(): IdbClientConfig { + return { + database: this.db_name, + store: this.store_name, + }; + } + public async init() { try { this.get_store(); @@ -80,7 +86,7 @@ export class WebDatastore< public async del(key: keyof Tk): Promise<IClientDatastoreDelResolve> { try { - await idb_del(this._key_map[key]); + await idb_del(this._key_map[key], this.get_store()); return { result: key.toString() }; } catch (e) { return handle_err(e); @@ -124,7 +130,7 @@ export class WebDatastore< public async del_obj(key: keyof TkO): Promise<IClientDatastoreDelResolve> { try { - await idb_del(this._key_obj_map[key]); + await idb_del(this._key_obj_map[key], this.get_store()); return { result: key.toString() }; } catch (e) { return handle_err(e); @@ -149,7 +155,7 @@ export class WebDatastore< key_param: Parameters<Tp[K]>[0] ): Promise<IClientDatastoreGetPResolve> { try { - const value = await idb_get(this._key_param_map[key](key_param)); + const value = await idb_get(this._key_param_map[key](key_param), this.get_store()); if (!value) return err_msg("error.client.datastore.no_result") return { result: value }; } catch (e) { @@ -164,7 +170,7 @@ export class WebDatastore< const filtered_keys = all_keys.filter((k): k is string => (typeof k === "string" && k.startsWith(key_prefix))); console.log(JSON.stringify(filtered_keys, null, 4), `filtered_keys`) for (const key of filtered_keys) { - await idb_del(key); + await idb_del(key, this.get_store()); } return { results: filtered_keys }; } catch (e) { @@ -189,4 +195,4 @@ export class WebDatastore< return handle_err(e); } } -} -\ No newline at end of file +} diff --git a/client/src/index.ts b/client/src/index.ts @@ -1,8 +0,0 @@ -export * as datastore from "./datastore/index.js" -export * as fs from "./fs/index.js" -export * as geolocation from "./geolocation/index.js" -export * as http from "./http/index.js" -export * as keystore from "./keystore/index.js" -export * as notifications from "./notifications/index.js" -export * as radroots from "./radroots/index.js" -export * as sql from "./sql/index.js" diff --git a/client/src/keystore/aes-gcm-cipher.ts b/client/src/keystore/aes-gcm-cipher.ts @@ -1,98 +0,0 @@ -import { createStore, del as idb_del, get as idb_get, set as idb_set } from "idb-keyval"; - -function asArrayBuffer(u8: Uint8Array): ArrayBuffer { - if (u8.byteOffset === 0 && u8.buffer instanceof ArrayBuffer && u8.byteLength === u8.buffer.byteLength) { - return u8.buffer; - } - return u8.slice().buffer; -} - -export class AesGcmKeystoreCipher { - private static readonly dbName = "radroots-aes-gcm-keystore"; - private static readonly storeName = "default"; - private static readonly keystoreKey = "radroots.aes-gcm.key"; - private static readonly algorithmName = "AES-GCM"; - private static readonly keyUsages: KeyUsage[] = ["encrypt", "decrypt"]; - private static readonly ivLength = 12; - private static readonly store = createStore(AesGcmKeystoreCipher.dbName, AesGcmKeystoreCipher.storeName); - private static cachedKey: CryptoKey | null = null; - - private static async importKey(rawKey: Uint8Array): Promise<CryptoKey> { - return crypto.subtle.importKey( - "raw", - asArrayBuffer(rawKey), - AesGcmKeystoreCipher.algorithmName, - false, - AesGcmKeystoreCipher.keyUsages - ); - } - - private static async generateAndPersistKey(): Promise<CryptoKey> { - const key = await crypto.subtle.generateKey( - { name: AesGcmKeystoreCipher.algorithmName, length: 256 }, - true, - AesGcmKeystoreCipher.keyUsages - ); - const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key)); - try { - await idb_set(AesGcmKeystoreCipher.keystoreKey, raw, AesGcmKeystoreCipher.store); - const importedKey = await AesGcmKeystoreCipher.importKey(raw); - AesGcmKeystoreCipher.cachedKey = importedKey; - return importedKey; - } finally { - raw.fill(0); - } - } - - static async load_key(): Promise<CryptoKey> { - if (AesGcmKeystoreCipher.cachedKey) { - return AesGcmKeystoreCipher.cachedKey; - } - const existing = await idb_get(AesGcmKeystoreCipher.keystoreKey, AesGcmKeystoreCipher.store); - if (existing instanceof Uint8Array) { - const key = await AesGcmKeystoreCipher.importKey(existing); - AesGcmKeystoreCipher.cachedKey = key; - return key; - } - return AesGcmKeystoreCipher.generateAndPersistKey(); - } - - static async reset(): Promise<void> { - AesGcmKeystoreCipher.cachedKey = null; - await idb_del(AesGcmKeystoreCipher.keystoreKey, AesGcmKeystoreCipher.store); - } - - static async encrypt(data: Uint8Array): Promise<Uint8Array> { - const key = await AesGcmKeystoreCipher.load_key(); - const iv = crypto.getRandomValues(new Uint8Array(AesGcmKeystoreCipher.ivLength)); - const ciphertextBuffer = await crypto.subtle.encrypt( - { name: AesGcmKeystoreCipher.algorithmName, iv: asArrayBuffer(iv) }, - key, - asArrayBuffer(data) - ); - const ciphertext = new Uint8Array(ciphertextBuffer); - const out = new Uint8Array(AesGcmKeystoreCipher.ivLength + ciphertext.byteLength); - out.set(iv, 0); - out.set(ciphertext, AesGcmKeystoreCipher.ivLength); - return out; - } - - static async decrypt(blob: Uint8Array): Promise<Uint8Array> { - if (blob.byteLength < AesGcmKeystoreCipher.ivLength + 1) { - return blob; - } - const key = await AesGcmKeystoreCipher.load_key(); - const iv = blob.slice(0, AesGcmKeystoreCipher.ivLength); - const ciphertext = blob.slice(AesGcmKeystoreCipher.ivLength); - try { - const plaintext = await crypto.subtle.decrypt( - { name: AesGcmKeystoreCipher.algorithmName, iv: asArrayBuffer(iv) }, - key, - asArrayBuffer(ciphertext) - ); - return new Uint8Array(plaintext); - } catch { - return blob; - } - } -} diff --git a/client/src/keystore/index.ts b/client/src/keystore/index.ts @@ -1,4 +1,5 @@ -export * from "./aes-gcm-cipher.js"; +export * from "../cipher/web.js"; export * from "./types.js"; +export * from "./web-nostr.js"; export * from "./web.js"; diff --git a/client/src/keystore/types.ts b/client/src/keystore/types.ts @@ -1,4 +1,4 @@ -import type { ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils"; +import type { ResolveError, ResultObj, ResultPass, ResultPublicKey, ResultSecretKey, ResultsList } from "@radroots/utils"; export type IClientKeystoreValue = string | null; @@ -8,4 +8,13 @@ export type IClientKeystore = { read(key?: string | null): Promise<ResolveError<ResultObj<IClientKeystoreValue>>>; keys(key: string): Promise<ResolveError<ResultsList<string>>>; reset(): Promise<ResolveError<ResultPass>>; +}; + +export type IClientKeystoreNostr = { + generate(): Promise<ResolveError<ResultPublicKey>>; + add(secret_key: string): Promise<ResolveError<ResultPublicKey>>; + read(public_key: string): Promise<ResolveError<ResultSecretKey>>; + keys(): Promise<ResolveError<ResultsList<string>>>; + remove(public_key: string): Promise<ResolveError<ResultObj<string>>>; + reset(): Promise<ResolveError<ResultPass>>; }; \ No newline at end of file diff --git a/client/src/keystore/web-nostr.ts b/client/src/keystore/web-nostr.ts @@ -0,0 +1,87 @@ +import { err_msg, handle_err, IdbClientConfig } from '@radroots/utils'; +import { lib_nostr_key_generate, lib_nostr_public_key, lib_nostr_secret_key_validate } from '@radroots/utils-nostr'; +import type { IClientKeystoreNostr } from './types.js'; +import { WebKeystore } from './web.js'; + + +export class WebKeystoreNostr implements IClientKeystoreNostr { + private keystore_config: IdbClientConfig; + private _keystore: WebKeystore; + + constructor(config?: Partial<IdbClientConfig>) { + this.keystore_config = { database: config?.database || "radroots-web-keystore-nostr", store: config?.store || "default" }; + this._keystore = new WebKeystore(this.keystore_config); + } + + private async add_secret_key(secret_key_raw: string) { + const secret_key = lib_nostr_secret_key_validate(secret_key_raw); + if (!secret_key) throw new Error("error.nostr.invalid_secret_key"); + const public_key = lib_nostr_public_key(secret_key); + return await this._keystore.add(public_key, secret_key); + } + + public get_config(): IdbClientConfig { + return this._keystore.get_config(); + } + + public async generate() { + try { + const secret_key = lib_nostr_key_generate(); + const resolve = await this.add_secret_key(secret_key); + if ("err" in resolve) return resolve; + return { public_key: resolve.result }; + } catch (e) { + return handle_err(e); + } + }; + + public async add(secret_key_raw: string) { + try { + const resolve = await this.add_secret_key(secret_key_raw); + if ("err" in resolve) return resolve; + return { public_key: resolve.result }; + } catch (e) { + return handle_err(e); + } + }; + + public async read(public_key?: string) { + try { + const resolve = await this._keystore.read(public_key); + if ("err" in resolve) return resolve; + return { secret_key: resolve.result }; + } catch (e) { + return handle_err(e); + } + }; + + public async keys() { + try { + const resolve = await this._keystore.keys(); + if ("err" in resolve) return resolve; + if (resolve.results.length) return resolve; + return err_msg("error.client.keystore-nostr.no_results"); + } catch (e) { + return handle_err(e); + } + }; + + public async remove(public_key: string) { + try { + const resolve = await this._keystore.remove(public_key); + if ("err" in resolve) return resolve; + return { result: public_key }; + } catch (e) { + return handle_err(e); + } + }; + + public async reset() { + try { + const resolve = await this._keystore.reset(); + return resolve; + } catch (e) { + return handle_err(e); + } + }; +} diff --git a/client/src/keystore/web.ts b/client/src/keystore/web.ts @@ -1,35 +1,53 @@ -import { err_msg, handle_err, text_dec, text_enc } from "@radroots/utils"; +import { err_msg, handle_err, IdbClientConfig, text_dec, text_enc } from "@radroots/utils"; import { createStore, clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; -import { type IClientIdbConfig } from "../utils/idb.js"; -import { AesGcmKeystoreCipher } from "./aes-gcm-cipher.js"; +import { WebAesGcmCipher, type WebAesGcmCipherConfig } from "../cipher/web.js"; import type { IClientKeystore } from "./types.js"; - export class WebKeystore implements IClientKeystore { - private db_name: string; - private store_name: string; - private store: UseStore | null = null; + private config: IdbClientConfig; + private store: UseStore | null; + private cipher: WebAesGcmCipher; - constructor(config?: IClientIdbConfig) { - this.db_name = config?.database || "radroots-web-keystore"; - this.store_name = config?.store || "default"; + constructor(config?: IdbClientConfig) { + this.config = { + database: config?.database || "radroots-web-keystore", + store: config?.store || "default" + }; this.store = null; - AesGcmKeystoreCipher.load_key(); + + const cipher_config: WebAesGcmCipherConfig = { + idb_config: { + database: `${this.config.database}-cipher`, + store: this.config.store + }, + key_name: `radroots.keystore.${this.config.store}.aes-gcm.key` + }; + + this.cipher = new WebAesGcmCipher(cipher_config); } private get_store(): UseStore { if (!this.store) { - if (typeof indexedDB === "undefined") throw new Error("error.client.keystore.idb_undefined"); - this.store = createStore(this.db_name, this.store_name); + if (typeof indexedDB === "undefined") { + throw new Error("error.client.keystore.idb_undefined"); + } + this.store = createStore(this.config.database, this.config.store); } return this.store; } + public get_config(): IdbClientConfig { + return { + database: this.config.database, + store: this.config.store + }; + } + public async add(key: string, value: string) { try { const bytes = text_enc(value); - const cipher = await AesGcmKeystoreCipher.encrypt(bytes); - await idb_set(key, cipher, this.get_store()); + const cipher_bytes = await this.cipher.encrypt(bytes); + await idb_set(key, cipher_bytes, this.get_store()); return { result: key }; } catch (e) { return handle_err(e); @@ -47,10 +65,14 @@ export class WebKeystore implements IClientKeystore { public async read(key?: string | null) { try { - if (!key) return err_msg("error.client.keystore.missing_key"); - const cipher = await idb_get<Uint8Array | null>(key, this.get_store()); - if (!(cipher instanceof Uint8Array)) return err_msg("error.client.keystore.corrupt_data"); - const bytes = await AesGcmKeystoreCipher.decrypt(cipher); + if (!key) { + return err_msg("error.client.keystore.missing_key"); + } + const cipher_bytes = await idb_get<Uint8Array | null>(key, this.get_store()); + if (!(cipher_bytes instanceof Uint8Array)) { + return err_msg("error.client.keystore.corrupt_data"); + } + const bytes = await this.cipher.decrypt(cipher_bytes); const plain = text_dec(bytes); return { result: plain }; } catch (e) { @@ -70,6 +92,7 @@ export class WebKeystore implements IClientKeystore { public async reset() { try { await idb_clear(this.get_store()); + await this.cipher.reset(); return { pass: true } as const; } catch (e) { return handle_err(e); diff --git a/client/src/radroots/types.ts b/client/src/radroots/types.ts @@ -1,23 +1,23 @@ import type { IError } from "@radroots/types-bindings"; import { type FilePath, type ResultObj, type ResultPass } from '@radroots/utils'; -export type IClientRadrootsFetchProfileRequestMessage = +export type IClientRadrootsProfileRequestMessage = | string | `error.client.request_failure` | `*-registered`; -export type IClientRadrootsFetchProfileRequest = { profile_name: string; secret_key: string; }; -export type IClientRadrootsFetchProfileRequestResolve = ResultObj<string> | IError<IClientRadrootsFetchProfileRequestMessage>; -export type IClientRadrootsFetchProfileCreate = { tok: string; secret_key: string; }; -export type IClientRadrootsFetchProfileCreateResolve = ResultObj<string> | IError<IClientRadrootsFetchProfileRequestMessage>; -export type IClientRadrootsFetchProfileActivate = { id: string; secret_key: string; }; -export type IClientRadrootsFetchProfileActivateResolve = ResultPass | IError<IClientRadrootsFetchProfileRequestMessage>; -export type IClientRadrootsFetchMediaImageUpload = { file_path: FilePath; file_data: Uint8Array; secret_key: string; }; -export type IClientRadrootsFetchMediaImageUploadResolve = any; +export type IClientRadrootsProfileRequest = { profile_name: string; secret_key: string; }; +export type IClientRadrootsProfileRequestResolve = ResultObj<string> | IError<IClientRadrootsProfileRequestMessage>; +export type IClientRadrootsProfileCreate = { tok: string; secret_key: string; }; +export type IClientRadrootsProfileCreateResolve = ResultObj<string> | IError<IClientRadrootsProfileRequestMessage>; +export type IClientRadrootsProfileActivate = { id: string; secret_key: string; }; +export type IClientRadrootsProfileActivateResolve = ResultPass | IError<IClientRadrootsProfileRequestMessage>; +export type IClientRadrootsMediaImageUpload = { file_path: FilePath; file_data: Uint8Array; secret_key: string; }; +export type IClientRadrootsMediaImageUploadResolve = any; export type IClientRadroots = { - fetch_profile_request: (opts: IClientRadrootsFetchProfileRequest) => Promise<IClientRadrootsFetchProfileRequestResolve>; - fetch_profile_create: (opts: IClientRadrootsFetchProfileCreate) => Promise<IClientRadrootsFetchProfileCreateResolve>; - fetch_profile_activate: (opts: IClientRadrootsFetchProfileActivate) => Promise<IClientRadrootsFetchProfileActivateResolve>; - fetch_media_image_upload: (opts: IClientRadrootsFetchMediaImageUpload) => Promise<IClientRadrootsFetchMediaImageUploadResolve>; + profile_request: (opts: IClientRadrootsProfileRequest) => Promise<IClientRadrootsProfileRequestResolve>; + profile_create: (opts: IClientRadrootsProfileCreate) => Promise<IClientRadrootsProfileCreateResolve>; + profile_activate: (opts: IClientRadrootsProfileActivate) => Promise<IClientRadrootsProfileActivateResolve>; + media_image_upload: (opts: IClientRadrootsMediaImageUpload) => Promise<IClientRadrootsMediaImageUploadResolve>; }; diff --git a/client/src/radroots/web.ts b/client/src/radroots/web.ts @@ -1,7 +1,7 @@ import { err_msg, type IHttpResponse, is_err_response, is_error_response } from '@radroots/utils'; import { lib_nostr_event_sign_attest } from '@radroots/utils-nostr'; import { WebHttp } from '../http/web.js'; -import type { IClientRadroots, IClientRadrootsFetchMediaImageUpload, IClientRadrootsFetchMediaImageUploadResolve, IClientRadrootsFetchProfileActivate, IClientRadrootsFetchProfileActivateResolve, IClientRadrootsFetchProfileCreate, IClientRadrootsFetchProfileCreateResolve, IClientRadrootsFetchProfileRequest, IClientRadrootsFetchProfileRequestResolve } from "./types.js"; +import type { IClientRadroots, IClientRadrootsMediaImageUpload, IClientRadrootsMediaImageUploadResolve, IClientRadrootsProfileActivate, IClientRadrootsProfileActivateResolve, IClientRadrootsProfileCreate, IClientRadrootsProfileCreateResolve, IClientRadrootsProfileRequest, IClientRadrootsProfileRequestResolve } from "./types.js"; export class WebClientRadroots implements IClientRadroots { private _base_url: string @@ -20,7 +20,7 @@ export class WebClientRadroots implements IClientRadroots { if (typeof field === `string` && field) return field } - public fetch_profile_request = async (opts: IClientRadrootsFetchProfileRequest): Promise<IClientRadrootsFetchProfileRequestResolve> => { + public profile_request = async (opts: IClientRadrootsProfileRequest): Promise<IClientRadrootsProfileRequestResolve> => { const { profile_name, secret_key } = opts const res = await this._http_client.fetch({ url: `${this._base_url}/public/profile/request`, @@ -39,7 +39,7 @@ export class WebClientRadroots implements IClientRadroots { return err_msg(`error.radroots.profile_registered`) } - public fetch_profile_create = async (opts: IClientRadrootsFetchProfileCreate): Promise<IClientRadrootsFetchProfileCreateResolve> => { + public profile_create = async (opts: IClientRadrootsProfileCreate): Promise<IClientRadrootsProfileCreateResolve> => { const { tok, secret_key } = opts const res = await this._http_client.fetch({ url: `${this._base_url}/public/profile/create`, @@ -58,7 +58,7 @@ export class WebClientRadroots implements IClientRadroots { return err_msg(`error.client.request_failure`) } - public fetch_profile_activate = async (opts: IClientRadrootsFetchProfileActivate): Promise<IClientRadrootsFetchProfileActivateResolve> => { + public profile_activate = async (opts: IClientRadrootsProfileActivate): Promise<IClientRadrootsProfileActivateResolve> => { const { id, secret_key } = opts const res = await this._http_client.fetch({ url: `${this._base_url}/public/profile/activate`, @@ -74,7 +74,7 @@ export class WebClientRadroots implements IClientRadroots { return err_msg(`error.client.request_failure`) } - public fetch_media_image_upload = async (opts: IClientRadrootsFetchMediaImageUpload): Promise<IClientRadrootsFetchMediaImageUploadResolve> => { + public media_image_upload = async (opts: IClientRadrootsMediaImageUpload): Promise<IClientRadrootsMediaImageUploadResolve> => { const { file_path, file_data, secret_key } = opts const res = await this._http_client.fetch({ url: `${this._base_url}/public/media/image/upload`, diff --git a/client/src/sql/web.ts b/client/src/sql/web.ts @@ -1,28 +1,48 @@ +import { IdbClientConfig } from "@radroots/utils"; import { del as idb_del } from "idb-keyval"; import type { BindParams, Database, SqlJsStatic, SqlValue, Statement } from "sql.js"; import init_sql_js from "sql.js/dist/sql-wasm.js"; -import { AesGcmKeystoreCipher } from "../keystore/aes-gcm-cipher.js"; +import { WebAesGcmCipher } from "../cipher/web.js"; import type { IClientSqlEncryptedStore, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow } from "./types.js"; class WebSqlEngineEncryptedStore implements IClientSqlEncryptedStore { - constructor(private readonly key: string) { } + private readonly db_key: string; + private readonly cipher: WebAesGcmCipher; + + constructor(key: string, cipher?: WebAesGcmCipher) { + this.db_key = key; + this.cipher = cipher ?? new WebAesGcmCipher({ + idb_config: { + database: "radroots-web-sql-cipher", + store: "default" + }, + key_name: `radroots.sql.${key}.aes-gcm.key` + }); + } async load() { - const get = (globalThis as any).indexedDB ? (await import("idb-keyval")).get : null; - if (!get) return null; - const data = await get(this.key); - if (data instanceof Uint8Array) return AesGcmKeystoreCipher.decrypt(data); + if (typeof indexedDB === "undefined") { + return null; + } + const { get } = await import("idb-keyval"); + const data = await get(this.db_key); + if (data instanceof Uint8Array) { + return this.cipher.decrypt(data); + } return null; } async save(bytes: Uint8Array) { - const enc = await AesGcmKeystoreCipher.encrypt(bytes); - const set = (globalThis as any).indexedDB ? (await import("idb-keyval")).set : null; - if (set) await set(this.key, enc); + if (typeof indexedDB === "undefined") { + return; + } + const { set } = await import("idb-keyval"); + const enc = await this.cipher.encrypt(bytes); + await set(this.db_key, enc); } async remove() { - await idb_del(this.key); + await idb_del(this.db_key); } } @@ -35,11 +55,22 @@ export class WebSqlEngine { private readonly store: WebSqlEngineEncryptedStore ) { } - static async create(store_key: string): Promise<WebSqlEngine> { + static async create(store_key: string, cipher_config: IdbClientConfig | null): Promise<WebSqlEngine> { const sql = await init_sql_js({ locateFile: f => `/assets/${f}` }); - const kv = new WebSqlEngineEncryptedStore(store_key); + + const cipher = new WebAesGcmCipher({ + idb_config: cipher_config || { + database: "radroots-web-sql-cipher", + store: "default" + }, + key_name: `radroots.sql.${store_key}.aes-gcm.key` + }); + + const kv = new WebSqlEngineEncryptedStore(store_key, cipher); + const existing = await kv.load(); const db = existing ? new sql.Database(existing) : new sql.Database(); + return new WebSqlEngine(sql, db, kv); } @@ -52,7 +83,9 @@ export class WebSqlEngine { } private schedule_persist(): void { - if (this.save_timer) return; + if (this.save_timer) { + return; + } this.save_timer = self.setTimeout(async () => { const bytes = this.db.export(); await this.store.save(bytes); @@ -77,26 +110,31 @@ export class WebSqlEngine { return rows; } + public export_bytes(): Uint8Array { + return this.db.export(); + } + private prepare(sql: string): Statement { return this.db.prepare(sql); } private bind(st: Statement, params: SqlJsParams): void { - let bindParams: BindParams; + let bind_params: BindParams; if (Array.isArray(params)) { - bindParams = [...params]; + bind_params = [...params]; } else { - bindParams = { ...(params as Readonly<Record<string, SqlValue>>) }; + bind_params = { ...(params as Readonly<Record<string, SqlValue>>) }; } - st.bind(bindParams); + st.bind(bind_params); } private consume_exec(st: Statement): SqlJsExecOutcome { const changes_before = this.db.getRowsModified(); let last_id = 0; + while (st.step()) { - const colNames = st.getColumnNames(); - const idx = colNames.indexOf("last_insert_rowid()"); + const col_names = st.getColumnNames(); + const idx = col_names.indexOf("last_insert_rowid()"); if (idx >= 0) { const v = st.get()[idx]; if (typeof v === "number") { @@ -104,7 +142,9 @@ export class WebSqlEngine { } } } + const changes = this.db.getRowsModified() - changes_before; + if (!last_id) { const res = this.db.exec("select last_insert_rowid() as id"); if (res[0]?.values?.[0]?.[0]) { @@ -114,12 +154,14 @@ export class WebSqlEngine { } } } + return { changes, last_insert_id: last_id }; } private collect_rows(st: Statement): SqlJsResultRow[] { const out: SqlJsResultRow[] = []; const names = st.getColumnNames(); + while (st.step()) { const row = st.get(); const obj: SqlJsResultRow = {}; @@ -128,7 +170,7 @@ export class WebSqlEngine { } out.push(obj); } + return out; } } - diff --git a/client/src/utils/idb.ts b/client/src/utils/idb.ts @@ -1,4 +0,0 @@ -export type IClientIdbConfig = { - database?: string; - store?: string; -}; -\ No newline at end of file