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:
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