commit db8a7e02867e1585220c8d9a2936362b2a7f2dbb
parent ff72e36fc17e79d4c6b89aa9b01d741cda314dbe
Author: triesap <triesap@radroots.dev>
Date: Sun, 21 Dec 2025 21:31:47 +0000
client: add envelope-based crypto service and encrypted backup import/export across web storage layers
Diffstat:
25 files changed, 1418 insertions(+), 183 deletions(-)
diff --git a/client/package.json b/client/package.json
@@ -15,6 +15,16 @@
"import": "./dist/esm/cipher/index.js",
"require": "./dist/cjs/cipher/index.js"
},
+ "./crypto": {
+ "types": "./dist/types/crypto/index.d.ts",
+ "import": "./dist/esm/crypto/index.js",
+ "require": "./dist/cjs/crypto/index.js"
+ },
+ "./backup": {
+ "types": "./dist/types/backup/index.d.ts",
+ "import": "./dist/esm/backup/index.js",
+ "require": "./dist/cjs/backup/index.js"
+ },
"./datastore": {
"types": "./dist/types/datastore/index.d.ts",
"import": "./dist/esm/datastore/index.js",
@@ -86,4 +96,4 @@
"publishConfig": {
"access": "public"
}
-}
-\ No newline at end of file
+}
diff --git a/client/src/backup/codec.ts b/client/src/backup/codec.ts
@@ -0,0 +1,110 @@
+import { as_array_buffer } from "@radroots/utils";
+import { cl_backup_error } from "./error.js";
+import { crypto_kdf_derive_kek, crypto_kdf_iterations_default, crypto_kdf_salt_create } from "../crypto/kdf.js";
+import type { BackupBundle, BackupBundleEnvelope } from "./types.js";
+import type { KeyMaterialProvider } from "../crypto/types.js";
+
+const ensure_crypto = (): void => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_backup_error.crypto_undefined);
+};
+
+export const backup_bytes_to_b64 = (bytes: Uint8Array): string => {
+ if (typeof btoa === "undefined") throw new Error(cl_backup_error.encode_failure);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary);
+};
+
+export const backup_b64_to_bytes = (value: string): Uint8Array => {
+ if (typeof atob === "undefined") throw new Error(cl_backup_error.decode_failure);
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+};
+
+const is_record = (value: unknown): value is Record<string, unknown> =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const is_backup_bundle_envelope = (value: unknown): value is BackupBundleEnvelope => {
+ if (!is_record(value)) return false;
+ return typeof value.version === "number"
+ && typeof value.created_at === "number"
+ && typeof value.kdf_salt_b64 === "string"
+ && typeof value.kdf_iterations === "number"
+ && typeof value.iv_b64 === "string"
+ && typeof value.ciphertext_b64 === "string";
+};
+
+const is_backup_bundle = (value: unknown): value is BackupBundle => {
+ if (!is_record(value)) return false;
+ if (!is_record(value.manifest)) return false;
+ if (!Array.isArray(value.payloads)) return false;
+ return typeof value.manifest.version === "number"
+ && typeof value.manifest.created_at === "number"
+ && Array.isArray(value.manifest.stores);
+};
+
+export const backup_bundle_encode = async (bundle: BackupBundle, provider: KeyMaterialProvider): Promise<Uint8Array> => {
+ ensure_crypto();
+ try {
+ const json = JSON.stringify(bundle);
+ const plaintext = new TextEncoder().encode(json);
+ const salt = crypto_kdf_salt_create();
+ const iterations = crypto_kdf_iterations_default();
+ const material = await provider.get_key_material();
+ const kek = await crypto_kdf_derive_kek(material, salt, iterations);
+ material.fill(0);
+ const iv = new Uint8Array(12);
+ crypto.getRandomValues(iv);
+ const cipher_buf = await crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(iv)
+ },
+ kek,
+ as_array_buffer(plaintext)
+ );
+ const envelope: BackupBundleEnvelope = {
+ version: 1,
+ created_at: Date.now(),
+ kdf_salt_b64: backup_bytes_to_b64(salt),
+ kdf_iterations: iterations,
+ iv_b64: backup_bytes_to_b64(iv),
+ ciphertext_b64: backup_bytes_to_b64(new Uint8Array(cipher_buf))
+ };
+ const encoded = JSON.stringify(envelope);
+ return new TextEncoder().encode(encoded);
+ } catch {
+ throw new Error(cl_backup_error.encode_failure);
+ }
+};
+
+export const backup_bundle_decode = async (blob: Uint8Array, provider: KeyMaterialProvider): Promise<BackupBundle> => {
+ ensure_crypto();
+ try {
+ const json = new TextDecoder().decode(blob);
+ const parsed = JSON.parse(json);
+ if (!is_backup_bundle_envelope(parsed)) throw new Error(cl_backup_error.invalid_bundle);
+ const salt = backup_b64_to_bytes(parsed.kdf_salt_b64);
+ const iv = backup_b64_to_bytes(parsed.iv_b64);
+ const ciphertext = backup_b64_to_bytes(parsed.ciphertext_b64);
+ const material = await provider.get_key_material();
+ const kek = await crypto_kdf_derive_kek(material, salt, parsed.kdf_iterations);
+ material.fill(0);
+ const plain_buf = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(iv)
+ },
+ kek,
+ as_array_buffer(ciphertext)
+ );
+ const plaintext = new TextDecoder().decode(new Uint8Array(plain_buf));
+ const bundle_parsed = JSON.parse(plaintext);
+ if (!is_backup_bundle(bundle_parsed)) throw new Error(cl_backup_error.invalid_bundle);
+ return bundle_parsed;
+ } catch {
+ throw new Error(cl_backup_error.decode_failure);
+ }
+};
diff --git a/client/src/backup/error.ts b/client/src/backup/error.ts
@@ -0,0 +1,10 @@
+export const cl_backup_error = {
+ crypto_undefined: "error.client.backup.crypto_undefined",
+ invalid_bundle: "error.client.backup.invalid_bundle",
+ decode_failure: "error.client.backup.decode_failure",
+ encode_failure: "error.client.backup.encode_failure",
+ provider_missing: "error.client.backup.provider_missing"
+} as const;
+
+export type ClientBackupError = keyof typeof cl_backup_error;
+export type ClientBackupErrorMessage = (typeof cl_backup_error)[ClientBackupError];
diff --git a/client/src/backup/index.ts b/client/src/backup/index.ts
@@ -0,0 +1,125 @@
+export * from "./error.js";
+export * from "./types.js";
+export * from "./codec.js";
+
+import { backup_bundle_decode, backup_bundle_encode } from "./codec.js";
+import type {
+ BackupBundle,
+ BackupBundlePayload,
+ BackupDatastoreStore,
+ BackupKeystoreStore,
+ BackupSqlStore
+} from "./types.js";
+import type { IWebCryptoService, KeyMaterialProvider } from "../crypto/types.js";
+import { DeviceKeyMaterialProvider } from "../crypto/provider.js";
+import type { ResolveError } from "@radroots/utils";
+import type { IError } from "@radroots/types-bindings";
+
+export type BackupBundleBuildOpts = {
+ sql_store?: BackupSqlStore;
+ keystore_store?: BackupKeystoreStore;
+ datastore_store?: BackupDatastoreStore;
+ app_version?: string;
+ crypto_service?: IWebCryptoService;
+ key_material_provider?: KeyMaterialProvider;
+};
+
+export type BackupBundleImportOpts = {
+ sql_store?: BackupSqlStore;
+ keystore_store?: BackupKeystoreStore;
+ datastore_store?: BackupDatastoreStore;
+ crypto_service?: IWebCryptoService;
+ key_material_provider?: KeyMaterialProvider;
+ import_registry?: boolean;
+};
+
+const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<BackupBundlePayload[]> => {
+ const payloads: BackupBundlePayload[] = [];
+ if (opts.sql_store) {
+ const data = unwrap_resolve(await opts.sql_store.export_backup());
+ payloads.push({
+ store_id: opts.sql_store.get_store_id(),
+ store_type: "sql",
+ data
+ });
+ }
+ if (opts.keystore_store) {
+ const data = unwrap_resolve(await opts.keystore_store.export_backup());
+ payloads.push({
+ store_id: opts.keystore_store.get_store_id(),
+ store_type: "keystore",
+ data
+ });
+ }
+ if (opts.datastore_store) {
+ const data = unwrap_resolve(await opts.datastore_store.export_backup());
+ payloads.push({
+ store_id: opts.datastore_store.get_store_id(),
+ store_type: "datastore",
+ data
+ });
+ }
+ return payloads;
+};
+
+const is_error = <T>(value: ResolveError<T>): value is IError<string> => {
+ return typeof value === "object" && value !== null && "err" in value;
+};
+
+const unwrap_resolve = <T>(value: ResolveError<T>): T => {
+ if (is_error(value)) throw new Error(value.err);
+ return value;
+};
+
+export const backup_bundle_build = async (opts: BackupBundleBuildOpts): Promise<BackupBundle> => {
+ const payloads = await collect_payloads(opts);
+ const stores = payloads.map((payload) => ({
+ store_id: payload.store_id,
+ store_type: payload.store_type
+ }));
+ const crypto_registry = opts.crypto_service
+ ? await opts.crypto_service.export_registry()
+ : { stores: [], keys: [] };
+ return {
+ manifest: {
+ version: 1,
+ created_at: Date.now(),
+ app_version: opts.app_version,
+ stores,
+ crypto_registry
+ },
+ payloads
+ };
+};
+
+export const backup_bundle_export = async (opts: BackupBundleBuildOpts): Promise<Uint8Array> => {
+ const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider();
+ const bundle = await backup_bundle_build(opts);
+ return await backup_bundle_encode(bundle, provider);
+};
+
+export const backup_bundle_import = async (blob: Uint8Array, opts: BackupBundleImportOpts): Promise<BackupBundle> => {
+ const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider();
+ const bundle = await backup_bundle_decode(blob, provider);
+ if (opts.import_registry && opts.crypto_service) {
+ await opts.crypto_service.import_registry(bundle.manifest.crypto_registry);
+ }
+ for (const payload of bundle.payloads) {
+ if (payload.store_type === "sql" && opts.sql_store) {
+ if (opts.sql_store.get_store_id() === payload.store_id) {
+ unwrap_resolve(await opts.sql_store.import_backup(payload.data));
+ }
+ }
+ if (payload.store_type === "keystore" && opts.keystore_store) {
+ if (opts.keystore_store.get_store_id() === payload.store_id) {
+ unwrap_resolve(await opts.keystore_store.import_backup(payload.data));
+ }
+ }
+ if (payload.store_type === "datastore" && opts.datastore_store) {
+ if (opts.datastore_store.get_store_id() === payload.store_id) {
+ unwrap_resolve(await opts.datastore_store.import_backup(payload.data));
+ }
+ }
+ }
+ return bundle;
+};
diff --git a/client/src/backup/types.ts b/client/src/backup/types.ts
@@ -0,0 +1,85 @@
+import type { ResolveError } from "@radroots/utils";
+import type { CryptoRegistryExport } from "../crypto/types.js";
+
+export type BackupBundleVersion = 1;
+
+export type BackupBundleStoreType = "sql" | "keystore" | "datastore";
+
+export type BackupSqlPayload = {
+ bytes_b64: string;
+};
+
+export type BackupKeystoreEntry = {
+ key: string;
+ value: string;
+};
+
+export type BackupKeystorePayload = {
+ entries: BackupKeystoreEntry[];
+};
+
+export type BackupDatastoreEntry = {
+ key: string;
+ value: string;
+};
+
+export type BackupDatastorePayload = {
+ entries: BackupDatastoreEntry[];
+};
+
+export type BackupBundlePayload =
+ | {
+ store_id: string;
+ store_type: "sql";
+ data: BackupSqlPayload;
+ }
+ | {
+ store_id: string;
+ store_type: "keystore";
+ data: BackupKeystorePayload;
+ }
+ | {
+ store_id: string;
+ store_type: "datastore";
+ data: BackupDatastorePayload;
+ };
+
+export type BackupBundleManifest = {
+ version: BackupBundleVersion;
+ created_at: number;
+ app_version?: string;
+ stores: { store_id: string; store_type: BackupBundleStoreType; }[];
+ crypto_registry: CryptoRegistryExport;
+};
+
+export type BackupBundle = {
+ manifest: BackupBundleManifest;
+ payloads: BackupBundlePayload[];
+};
+
+export type BackupBundleEnvelope = {
+ version: number;
+ created_at: number;
+ kdf_salt_b64: string;
+ kdf_iterations: number;
+ iv_b64: string;
+ ciphertext_b64: string;
+};
+
+export interface BackupSqlStore {
+ export_backup(): Promise<ResolveError<BackupSqlPayload>>;
+ import_backup(payload: BackupSqlPayload): Promise<ResolveError<void>>;
+ get_store_id(): string;
+}
+
+export interface BackupKeystoreStore {
+ export_backup(): Promise<ResolveError<BackupKeystorePayload>>;
+ import_backup(payload: BackupKeystorePayload): Promise<ResolveError<void>>;
+ get_store_id(): string;
+}
+
+export interface BackupDatastoreStore {
+ export_backup(): Promise<ResolveError<BackupDatastorePayload>>;
+ import_backup(payload: BackupDatastorePayload): Promise<ResolveError<void>>;
+ get_store_id(): string;
+}
diff --git a/client/src/cipher/web.ts b/client/src/cipher/web.ts
@@ -1,6 +1,9 @@
-import { as_array_buffer, type IdbClientConfig } from "@radroots/utils";
-import { createStore, del as idb_del, get as idb_get, set as idb_set, type UseStore } from "idb-keyval";
-import { type WebAesGcmCipherConfig } from "../keystore/web.js";
+import { type IdbClientConfig } from "@radroots/utils";
+import { createStore, del as idb_del, type UseStore } from "idb-keyval";
+import type { WebAesGcmCipherConfig } from "../keystore/web.js";
+import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js";
+import { WebCryptoService } from "../crypto/service.js";
+import type { LegacyKeyConfig } from "../crypto/types.js";
import { cl_cipher_error } from "./error.js";
import type { IClientCipher } from "./types.js";
@@ -16,8 +19,6 @@ const DEFAULT_WEB_AES_GCM_CONFIG = {
iv_length: 12
} as const;
-const DEFAULT_KEY_USAGES: KeyUsage[] = ["encrypt", "decrypt"];
-
export interface IWebAesGcmCipher extends IClientCipher { }
export class WebAesGcmCipher implements IWebAesGcmCipher {
@@ -25,12 +26,11 @@ export class WebAesGcmCipher implements IWebAesGcmCipher {
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;
+ private readonly legacy_store: UseStore;
+ private readonly store_id: string;
+ private readonly crypto: WebCryptoService;
+ private readonly legacy_key_config: LegacyKeyConfig;
constructor(config?: WebAesGcmCipherConfig) {
const idb_config = config?.idb_config ?? {};
@@ -38,20 +38,30 @@ export class WebAesGcmCipher implements IWebAesGcmCipher {
this.store_name = idb_config.store ?? DEFAULT_IDB_CONFIG.store;
this.key_name = config?.key_name ?? DEFAULT_WEB_AES_GCM_CONFIG.key_name;
this.algorithm_name = config?.algorithm ?? DEFAULT_WEB_AES_GCM_CONFIG.algorithm;
- this.key_usages = DEFAULT_KEY_USAGES;
this.iv_length = Number.isInteger(config?.iv_length) && (config?.iv_length ?? 0) > 0
? config?.iv_length ?? DEFAULT_WEB_AES_GCM_CONFIG.iv_length
: DEFAULT_WEB_AES_GCM_CONFIG.iv_length;
- this.key_length = Number.isInteger(config?.key_length) && (config?.key_length ?? 0) > 0
- ? config?.key_length ?? DEFAULT_WEB_AES_GCM_CONFIG.key_length
- : DEFAULT_WEB_AES_GCM_CONFIG.key_length;
if (typeof indexedDB === "undefined") throw new Error(cl_cipher_error.idb_undefined);
if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_cipher_error.crypto_undefined);
- this.store = createStore(this.db_name, this.store_name);
- this.cached_key = null;
- this.key_promise = null;
+ this.legacy_store = createStore(this.db_name, this.store_name);
+ this.store_id = this.key_name;
+ this.crypto = new WebCryptoService();
+ this.legacy_key_config = {
+ idb_config: {
+ database: this.db_name,
+ store: this.store_name
+ },
+ key_name: this.key_name,
+ iv_length: this.iv_length,
+ algorithm: this.algorithm_name
+ };
+ this.crypto.register_store_config({
+ store_id: this.store_id,
+ legacy_key: this.legacy_key_config,
+ iv_length: this.iv_length
+ });
}
public get_config(): IdbClientConfig {
@@ -61,106 +71,23 @@ export class WebAesGcmCipher implements IWebAesGcmCipher {
};
}
- 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 slice = new Uint8Array(stored.buffer, stored.byteOffset, stored.byteLength);
- const bytes = new Uint8Array(slice);
- return this.import_key(bytes);
- }
- 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);
+ const index = await crypto_registry_get_store_index(this.store_id);
+ if (index) {
+ await crypto_registry_clear_store_index(this.store_id);
+ for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id);
+ }
+ await idb_del(this.key_name, this.legacy_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;
+ return await this.crypto.encrypt(this.store_id, data);
}
public async decrypt(blob: Uint8Array): Promise<Uint8Array> {
if (blob.byteLength <= this.iv_length) throw new Error(cl_cipher_error.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(cl_cipher_error.decrypt_failure);
- }
+ const outcome = await this.crypto.decrypt_record(this.store_id, blob);
+ return outcome.plaintext;
}
}
diff --git a/client/src/crypto/envelope.ts b/client/src/crypto/envelope.ts
@@ -0,0 +1,72 @@
+import { cl_crypto_error } from "./error.js";
+import type { CryptoEnvelope } from "./types.js";
+
+const ENVELOPE_MAGIC = new Uint8Array([0x52, 0x52, 0x43, 0x45]);
+const ENVELOPE_VERSION = 1;
+const ENVELOPE_HEADER_LENGTH = 4 + 1 + 1 + 1 + 8;
+
+const bytes_equal = (left: Uint8Array, right: Uint8Array): boolean => {
+ if (left.length !== right.length) return false;
+ for (let i = 0; i < left.length; i++) if (left[i] !== right[i]) return false;
+ return true;
+};
+
+export const crypto_envelope_encode = (envelope: CryptoEnvelope): Uint8Array => {
+ const encoder = new TextEncoder();
+ const key_bytes = encoder.encode(envelope.key_id);
+ if (key_bytes.length > 255) throw new Error(cl_crypto_error.invalid_key_id);
+ const total_len = ENVELOPE_HEADER_LENGTH + key_bytes.length + envelope.iv.length + envelope.ciphertext.length;
+ const out = new Uint8Array(total_len);
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
+ let offset = 0;
+ out.set(ENVELOPE_MAGIC, offset);
+ offset += ENVELOPE_MAGIC.length;
+ out[offset] = ENVELOPE_VERSION;
+ offset += 1;
+ out[offset] = key_bytes.length;
+ offset += 1;
+ out[offset] = envelope.iv.length;
+ offset += 1;
+ view.setBigUint64(offset, BigInt(envelope.created_at), false);
+ offset += 8;
+ out.set(key_bytes, offset);
+ offset += key_bytes.length;
+ out.set(envelope.iv, offset);
+ offset += envelope.iv.length;
+ out.set(envelope.ciphertext, offset);
+ return out;
+};
+
+export const crypto_envelope_decode = (blob: Uint8Array): CryptoEnvelope | null => {
+ if (blob.byteLength < ENVELOPE_HEADER_LENGTH) return null;
+ const magic = blob.slice(0, ENVELOPE_MAGIC.length);
+ if (!bytes_equal(magic, ENVELOPE_MAGIC)) return null;
+ const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
+ let offset = ENVELOPE_MAGIC.length;
+ const version = view.getUint8(offset);
+ offset += 1;
+ if (version !== ENVELOPE_VERSION) throw new Error(cl_crypto_error.invalid_envelope);
+ const key_len = view.getUint8(offset);
+ offset += 1;
+ const iv_len = view.getUint8(offset);
+ offset += 1;
+ const created_at = Number(view.getBigUint64(offset, false));
+ offset += 8;
+ const remaining = blob.byteLength - offset;
+ if (remaining < key_len + iv_len + 1) throw new Error(cl_crypto_error.invalid_envelope);
+ const key_bytes = blob.slice(offset, offset + key_len);
+ offset += key_len;
+ const iv = blob.slice(offset, offset + iv_len);
+ offset += iv_len;
+ const ciphertext = blob.slice(offset);
+ const decoder = new TextDecoder();
+ const key_id = decoder.decode(key_bytes);
+ if (!key_id) throw new Error(cl_crypto_error.invalid_key_id);
+ return {
+ version,
+ key_id,
+ iv,
+ created_at,
+ ciphertext
+ };
+};
diff --git a/client/src/crypto/error.ts b/client/src/crypto/error.ts
@@ -0,0 +1,17 @@
+export const cl_crypto_error = {
+ idb_undefined: "error.client.crypto.idb_undefined",
+ crypto_undefined: "error.client.crypto.crypto_undefined",
+ invalid_envelope: "error.client.crypto.invalid_envelope",
+ invalid_key_id: "error.client.crypto.invalid_key_id",
+ key_not_found: "error.client.crypto.key_not_found",
+ unwrap_failure: "error.client.crypto.unwrap_failure",
+ wrap_failure: "error.client.crypto.wrap_failure",
+ legacy_key_missing: "error.client.crypto.legacy_key_missing",
+ encrypt_failure: "error.client.crypto.encrypt_failure",
+ decrypt_failure: "error.client.crypto.decrypt_failure",
+ kdf_failure: "error.client.crypto.kdf_failure",
+ registry_failure: "error.client.crypto.registry_failure"
+} as const;
+
+export type ClientCryptoError = keyof typeof cl_crypto_error;
+export type ClientCryptoErrorMessage = (typeof cl_crypto_error)[ClientCryptoError];
diff --git a/client/src/crypto/index.ts b/client/src/crypto/index.ts
@@ -0,0 +1,8 @@
+export * from "./error.js";
+export * from "./types.js";
+export * from "./envelope.js";
+export * from "./kdf.js";
+export * from "./keys.js";
+export * from "./registry.js";
+export * from "./provider.js";
+export * from "./service.js";
diff --git a/client/src/crypto/kdf.ts b/client/src/crypto/kdf.ts
@@ -0,0 +1,43 @@
+import { cl_crypto_error } from "./error.js";
+
+const DEFAULT_KDF_ITERATIONS = 210000;
+const KDF_HASH = "SHA-256";
+
+export const crypto_kdf_iterations_default = (): number => DEFAULT_KDF_ITERATIONS;
+
+export const crypto_kdf_salt_create = (length: number = 16): Uint8Array => {
+ if (!globalThis.crypto) throw new Error(cl_crypto_error.crypto_undefined);
+ const salt = new Uint8Array(length);
+ crypto.getRandomValues(salt);
+ return salt;
+};
+
+export const crypto_kdf_derive_kek = async (
+ material: Uint8Array,
+ salt: Uint8Array,
+ iterations: number
+): Promise<CryptoKey> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ try {
+ const material_bytes = new Uint8Array(material);
+ const salt_bytes = new Uint8Array(salt);
+ const base_key = await crypto.subtle.importKey("raw", material_bytes, "PBKDF2", false, ["deriveKey"]);
+ return await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: salt_bytes,
+ iterations,
+ hash: KDF_HASH
+ },
+ base_key,
+ {
+ name: "AES-GCM",
+ length: 256
+ },
+ false,
+ ["encrypt", "decrypt"]
+ );
+ } catch {
+ throw new Error(cl_crypto_error.kdf_failure);
+ }
+};
diff --git a/client/src/crypto/keys.ts b/client/src/crypto/keys.ts
@@ -0,0 +1,95 @@
+import { as_array_buffer } from "@radroots/utils";
+import { cl_crypto_error } from "./error.js";
+
+const KEY_ID_BYTES_LENGTH = 16;
+const WRAP_IV_LENGTH = 12;
+
+const bytes_to_hex = (bytes: Uint8Array): string => {
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ const part = bytes[i].toString(16).padStart(2, "0");
+ out += part;
+ }
+ return out;
+};
+
+export const crypto_key_id_create = (): string => {
+ if (!globalThis.crypto) throw new Error(cl_crypto_error.crypto_undefined);
+ const bytes = new Uint8Array(KEY_ID_BYTES_LENGTH);
+ crypto.getRandomValues(bytes);
+ return bytes_to_hex(bytes);
+};
+
+export const crypto_key_generate = async (): Promise<CryptoKey> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ return await crypto.subtle.generateKey(
+ {
+ name: "AES-GCM",
+ length: 256
+ },
+ true,
+ ["encrypt", "decrypt"]
+ );
+};
+
+export const crypto_key_export_raw = async (key: CryptoKey): Promise<Uint8Array> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ const raw = await crypto.subtle.exportKey("raw", key);
+ return new Uint8Array(raw);
+};
+
+export const crypto_key_import_raw = async (raw: Uint8Array): Promise<CryptoKey> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ return await crypto.subtle.importKey(
+ "raw",
+ as_array_buffer(raw),
+ "AES-GCM",
+ false,
+ ["encrypt", "decrypt"]
+ );
+};
+
+export const crypto_key_wrap = async (
+ kek: CryptoKey,
+ raw_key: Uint8Array
+): Promise<{ wrapped_key: Uint8Array; wrap_iv: Uint8Array; }> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ try {
+ const wrap_iv = new Uint8Array(WRAP_IV_LENGTH);
+ crypto.getRandomValues(wrap_iv);
+ const cipher_buf = await crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(wrap_iv)
+ },
+ kek,
+ as_array_buffer(raw_key)
+ );
+ const wrapped_key = new Uint8Array(cipher_buf);
+ raw_key.fill(0);
+ return { wrapped_key, wrap_iv };
+ } catch {
+ throw new Error(cl_crypto_error.wrap_failure);
+ }
+};
+
+export const crypto_key_unwrap = async (
+ kek: CryptoKey,
+ wrapped_key: Uint8Array,
+ wrap_iv: Uint8Array
+): Promise<CryptoKey> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+ try {
+ const raw = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(wrap_iv)
+ },
+ kek,
+ as_array_buffer(wrapped_key)
+ );
+ return await crypto_key_import_raw(new Uint8Array(raw));
+ } catch {
+ throw new Error(cl_crypto_error.unwrap_failure);
+ }
+};
diff --git a/client/src/crypto/provider.ts b/client/src/crypto/provider.ts
@@ -0,0 +1,24 @@
+import { cl_crypto_error } from "./error.js";
+import type { KeyMaterialProvider } from "./types.js";
+import { crypto_registry_get_device_material, crypto_registry_set_device_material } from "./registry.js";
+
+const DEVICE_PROVIDER_ID = "device";
+const DEVICE_MATERIAL_BYTES = 32;
+
+export interface IDeviceKeyMaterialProvider extends KeyMaterialProvider { }
+
+export class DeviceKeyMaterialProvider implements IDeviceKeyMaterialProvider {
+ public async get_key_material(): Promise<Uint8Array> {
+ if (!globalThis.crypto) throw new Error(cl_crypto_error.crypto_undefined);
+ const existing = await crypto_registry_get_device_material();
+ if (existing) return new Uint8Array(existing);
+ const material = new Uint8Array(DEVICE_MATERIAL_BYTES);
+ crypto.getRandomValues(material);
+ await crypto_registry_set_device_material(material);
+ return new Uint8Array(material);
+ }
+
+ public async get_provider_id(): Promise<string> {
+ return DEVICE_PROVIDER_ID;
+ }
+}
diff --git a/client/src/crypto/registry.ts b/client/src/crypto/registry.ts
@@ -0,0 +1,141 @@
+import { createStore, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set } from "idb-keyval";
+import type { IdbClientConfig } from "@radroots/utils";
+import { cl_crypto_error } from "./error.js";
+import type { CryptoKeyEntry, CryptoRegistryExport, CryptoStoreIndex } from "./types.js";
+
+const CRYPTO_IDB_CONFIG: IdbClientConfig = {
+ database: "radroots-client-crypto",
+ store: "default"
+};
+
+const CRYPTO_STORE = createStore(CRYPTO_IDB_CONFIG.database, CRYPTO_IDB_CONFIG.store);
+const STORE_INDEX_PREFIX = "store:";
+const KEY_ENTRY_PREFIX = "key:";
+const DEVICE_MATERIAL_KEY = "device:material";
+
+const ensure_idb = (): void => {
+ if (typeof indexedDB === "undefined") throw new Error(cl_crypto_error.idb_undefined);
+};
+
+const store_index_key = (store_id: string): string => `${STORE_INDEX_PREFIX}${store_id}`;
+const key_entry_key = (key_id: string): string => `${KEY_ENTRY_PREFIX}${key_id}`;
+
+const is_string_array = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every((item) => typeof item === "string");
+
+const is_record = (value: unknown): value is Record<string, unknown> =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const is_crypto_store_index = (value: unknown): value is CryptoStoreIndex => {
+ if (!is_record(value)) return false;
+ return typeof value.store_id === "string"
+ && typeof value.active_key_id === "string"
+ && typeof value.created_at === "number"
+ && is_string_array(value.key_ids);
+};
+
+const is_crypto_key_entry = (value: unknown): value is CryptoKeyEntry => {
+ if (!is_record(value)) return false;
+ return typeof value.key_id === "string"
+ && typeof value.store_id === "string"
+ && typeof value.created_at === "number"
+ && typeof value.status === "string"
+ && value.wrapped_key instanceof Uint8Array
+ && value.wrap_iv instanceof Uint8Array
+ && value.kdf_salt instanceof Uint8Array
+ && typeof value.kdf_iterations === "number"
+ && typeof value.iv_length === "number"
+ && typeof value.algorithm === "string"
+ && typeof value.provider_id === "string";
+};
+
+export const crypto_registry_get_store_index = async (store_id: string): Promise<CryptoStoreIndex | null> => {
+ ensure_idb();
+ const record = await idb_get(store_index_key(store_id), CRYPTO_STORE);
+ if (!record) return null;
+ if (!is_crypto_store_index(record)) throw new Error(cl_crypto_error.registry_failure);
+ return record;
+};
+
+export const crypto_registry_set_store_index = async (index: CryptoStoreIndex): Promise<void> => {
+ ensure_idb();
+ await idb_set(store_index_key(index.store_id), index, CRYPTO_STORE);
+};
+
+export const crypto_registry_get_key_entry = async (key_id: string): Promise<CryptoKeyEntry | null> => {
+ ensure_idb();
+ const record = await idb_get(key_entry_key(key_id), CRYPTO_STORE);
+ if (!record) return null;
+ if (!is_crypto_key_entry(record)) throw new Error(cl_crypto_error.registry_failure);
+ return record;
+};
+
+export const crypto_registry_set_key_entry = async (entry: CryptoKeyEntry): Promise<void> => {
+ ensure_idb();
+ await idb_set(key_entry_key(entry.key_id), entry, CRYPTO_STORE);
+};
+
+export const crypto_registry_list_store_indices = async (): Promise<CryptoStoreIndex[]> => {
+ ensure_idb();
+ const keys = await idb_keys(CRYPTO_STORE);
+ const store_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(STORE_INDEX_PREFIX));
+ const out: CryptoStoreIndex[] = [];
+ for (const key of store_keys) {
+ const record = await idb_get(key, CRYPTO_STORE);
+ if (!record) continue;
+ if (!is_crypto_store_index(record)) throw new Error(cl_crypto_error.registry_failure);
+ out.push(record);
+ }
+ return out;
+};
+
+export const crypto_registry_list_key_entries = async (): Promise<CryptoKeyEntry[]> => {
+ ensure_idb();
+ const keys = await idb_keys(CRYPTO_STORE);
+ const entry_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(KEY_ENTRY_PREFIX));
+ const out: CryptoKeyEntry[] = [];
+ for (const key of entry_keys) {
+ const record = await idb_get(key, CRYPTO_STORE);
+ if (!record) continue;
+ if (!is_crypto_key_entry(record)) throw new Error(cl_crypto_error.registry_failure);
+ out.push(record);
+ }
+ return out;
+};
+
+export const crypto_registry_export = async (): Promise<CryptoRegistryExport> => {
+ const stores = await crypto_registry_list_store_indices();
+ const keys = await crypto_registry_list_key_entries();
+ return { stores, keys };
+};
+
+export const crypto_registry_import = async (registry: CryptoRegistryExport): Promise<void> => {
+ ensure_idb();
+ for (const store of registry.stores) await crypto_registry_set_store_index(store);
+ for (const entry of registry.keys) await crypto_registry_set_key_entry(entry);
+};
+
+export const crypto_registry_get_device_material = async (): Promise<Uint8Array | null> => {
+ ensure_idb();
+ const record = await idb_get(DEVICE_MATERIAL_KEY, CRYPTO_STORE);
+ if (!record) return null;
+ if (record instanceof Uint8Array) return record;
+ if (record instanceof ArrayBuffer) return new Uint8Array(record);
+ if (ArrayBuffer.isView(record)) return new Uint8Array(record.buffer, record.byteOffset, record.byteLength);
+ throw new Error(cl_crypto_error.registry_failure);
+};
+
+export const crypto_registry_set_device_material = async (material: Uint8Array): Promise<void> => {
+ ensure_idb();
+ await idb_set(DEVICE_MATERIAL_KEY, material, CRYPTO_STORE);
+};
+
+export const crypto_registry_clear_store_index = async (store_id: string): Promise<void> => {
+ ensure_idb();
+ await idb_del(store_index_key(store_id), CRYPTO_STORE);
+};
+
+export const crypto_registry_clear_key_entry = async (key_id: string): Promise<void> => {
+ ensure_idb();
+ await idb_del(key_entry_key(key_id), CRYPTO_STORE);
+};
diff --git a/client/src/crypto/service.ts b/client/src/crypto/service.ts
@@ -0,0 +1,279 @@
+import { createStore, get as idb_get } from "idb-keyval";
+import { as_array_buffer } from "@radroots/utils";
+import { cl_crypto_error } from "./error.js";
+import { crypto_envelope_decode, crypto_envelope_encode } from "./envelope.js";
+import { crypto_kdf_derive_kek, crypto_kdf_iterations_default, crypto_kdf_salt_create } from "./kdf.js";
+import { crypto_key_export_raw, crypto_key_generate, crypto_key_id_create, crypto_key_import_raw, crypto_key_unwrap, crypto_key_wrap } from "./keys.js";
+import { crypto_registry_export, crypto_registry_get_key_entry, crypto_registry_get_store_index, crypto_registry_import, crypto_registry_set_key_entry, crypto_registry_set_store_index } from "./registry.js";
+import { DeviceKeyMaterialProvider } from "./provider.js";
+import type { CryptoDecryptOutcome, CryptoKeyEntry, CryptoRegistryExport, CryptoStoreConfig, CryptoStoreIndex, IWebCryptoService, KeyMaterialProvider, LegacyKeyConfig } from "./types.js";
+
+const DEFAULT_IV_LENGTH = 12;
+
+const ensure_crypto = (): void => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined);
+};
+
+const merge_key_ids = (key_ids: string[], next_key_id: string): string[] => {
+ if (key_ids.includes(next_key_id)) return key_ids;
+ return [...key_ids, next_key_id];
+};
+
+const bytes_from_value = (value: ArrayBuffer | ArrayBufferView): Uint8Array => {
+ if (value instanceof Uint8Array) return value;
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ return new Uint8Array(value);
+};
+
+export class WebCryptoService implements IWebCryptoService {
+ private store_configs: Map<string, CryptoStoreConfig>;
+ private key_material_provider: KeyMaterialProvider;
+
+ constructor(config?: { key_material_provider?: KeyMaterialProvider }) {
+ this.store_configs = new Map();
+ this.key_material_provider = config?.key_material_provider ?? new DeviceKeyMaterialProvider();
+ ensure_crypto();
+ }
+
+ public register_store_config(config: CryptoStoreConfig): void {
+ const existing = this.store_configs.get(config.store_id);
+ if (existing) {
+ this.store_configs.set(config.store_id, {
+ store_id: config.store_id,
+ iv_length: config.iv_length ?? existing.iv_length,
+ legacy_key: config.legacy_key ?? existing.legacy_key
+ });
+ return;
+ }
+ this.store_configs.set(config.store_id, {
+ store_id: config.store_id,
+ iv_length: config.iv_length ?? DEFAULT_IV_LENGTH,
+ legacy_key: config.legacy_key
+ });
+ }
+
+ public async encrypt(store_id: string, plaintext: Uint8Array): Promise<Uint8Array> {
+ ensure_crypto();
+ const { key, entry } = await this.resolve_active_key(store_id);
+ const iv_length = entry.iv_length || DEFAULT_IV_LENGTH;
+ const iv = new Uint8Array(iv_length);
+ crypto.getRandomValues(iv);
+ try {
+ const cipher_buf = await crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(iv)
+ },
+ key,
+ as_array_buffer(plaintext)
+ );
+ const envelope = {
+ version: 1,
+ key_id: entry.key_id,
+ iv,
+ created_at: Date.now(),
+ ciphertext: new Uint8Array(cipher_buf)
+ };
+ return crypto_envelope_encode(envelope);
+ } catch {
+ throw new Error(cl_crypto_error.encrypt_failure);
+ }
+ }
+
+ public async decrypt(store_id: string, blob: Uint8Array): Promise<Uint8Array> {
+ const outcome = await this.decrypt_record(store_id, blob);
+ return outcome.plaintext;
+ }
+
+ public async decrypt_record(store_id: string, blob: Uint8Array): Promise<CryptoDecryptOutcome> {
+ ensure_crypto();
+ const config = this.resolve_store_config(store_id);
+ const envelope = crypto_envelope_decode(blob);
+ if (envelope) return await this.decrypt_envelope(store_id, envelope);
+ return await this.decrypt_legacy(store_id, blob, config.legacy_key, config.iv_length ?? DEFAULT_IV_LENGTH);
+ }
+
+ public async rotate_store_key(store_id: string): Promise<string> {
+ const config = this.resolve_store_config(store_id);
+ const index = await crypto_registry_get_store_index(store_id);
+ if (!index) {
+ const created = await this.create_store_key(store_id, config);
+ return created.entry.key_id;
+ }
+ const prev_entry = await crypto_registry_get_key_entry(index.active_key_id);
+ if (prev_entry) {
+ const rotated_entry: CryptoKeyEntry = {
+ ...prev_entry,
+ status: "rotated"
+ };
+ await crypto_registry_set_key_entry(rotated_entry);
+ }
+ const created = await this.create_key_entry(store_id, config);
+ const next_index: CryptoStoreIndex = {
+ ...index,
+ active_key_id: created.entry.key_id,
+ key_ids: merge_key_ids(index.key_ids, created.entry.key_id)
+ };
+ await crypto_registry_set_store_index(next_index);
+ return created.entry.key_id;
+ }
+
+ public async export_registry(): Promise<CryptoRegistryExport> {
+ return await crypto_registry_export();
+ }
+
+ public async import_registry(registry: CryptoRegistryExport): Promise<void> {
+ await crypto_registry_import(registry);
+ }
+
+ private resolve_store_config(store_id: string): CryptoStoreConfig {
+ const existing = this.store_configs.get(store_id);
+ if (existing) return existing;
+ const config = {
+ store_id,
+ iv_length: DEFAULT_IV_LENGTH
+ };
+ this.store_configs.set(store_id, config);
+ return config;
+ }
+
+ private async resolve_active_key(store_id: string): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> {
+ const index = await crypto_registry_get_store_index(store_id);
+ if (!index) return await this.create_store_key(store_id, this.resolve_store_config(store_id));
+ const entry = await crypto_registry_get_key_entry(index.active_key_id);
+ if (!entry) return await this.create_store_key(store_id, this.resolve_store_config(store_id));
+ const key = await this.unwrap_key_entry(entry);
+ return { key, entry, index };
+ }
+
+ private async resolve_key_by_id(store_id: string, key_id: string): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> {
+ const entry = await crypto_registry_get_key_entry(key_id);
+ if (!entry) throw new Error(cl_crypto_error.key_not_found);
+ let index = await crypto_registry_get_store_index(store_id);
+ if (!index) {
+ index = {
+ store_id,
+ active_key_id: entry.key_id,
+ key_ids: [entry.key_id],
+ created_at: entry.created_at
+ };
+ await crypto_registry_set_store_index(index);
+ }
+ const key = await this.unwrap_key_entry(entry);
+ return { key, entry, index };
+ }
+
+ private async create_store_key(store_id: string, config: CryptoStoreConfig): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> {
+ const created = await this.create_key_entry(store_id, config);
+ const index: CryptoStoreIndex = {
+ store_id,
+ active_key_id: created.entry.key_id,
+ key_ids: [created.entry.key_id],
+ created_at: created.entry.created_at
+ };
+ await crypto_registry_set_store_index(index);
+ return {
+ key: created.key,
+ entry: created.entry,
+ index
+ };
+ }
+
+ private async create_key_entry(store_id: string, config: CryptoStoreConfig): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; }> {
+ const key_id = crypto_key_id_create();
+ const created_at = Date.now();
+ const kdf_salt = crypto_kdf_salt_create();
+ const kdf_iterations = crypto_kdf_iterations_default();
+ const material = await this.key_material_provider.get_key_material();
+ const provider_id = await this.key_material_provider.get_provider_id();
+ const kek = await crypto_kdf_derive_kek(material, kdf_salt, kdf_iterations);
+ material.fill(0);
+ const data_key = await crypto_key_generate();
+ const raw_key = await crypto_key_export_raw(data_key);
+ const wrapped = await crypto_key_wrap(kek, raw_key);
+ const entry: CryptoKeyEntry = {
+ key_id,
+ store_id,
+ created_at,
+ status: "active",
+ wrapped_key: wrapped.wrapped_key,
+ wrap_iv: wrapped.wrap_iv,
+ kdf_salt,
+ kdf_iterations,
+ iv_length: config.iv_length ?? DEFAULT_IV_LENGTH,
+ algorithm: "AES-GCM",
+ provider_id
+ };
+ await crypto_registry_set_key_entry(entry);
+ return { key: data_key, entry };
+ }
+
+ private async unwrap_key_entry(entry: CryptoKeyEntry): Promise<CryptoKey> {
+ const material = await this.key_material_provider.get_key_material();
+ const kek = await crypto_kdf_derive_kek(material, entry.kdf_salt, entry.kdf_iterations);
+ material.fill(0);
+ return await crypto_key_unwrap(kek, entry.wrapped_key, entry.wrap_iv);
+ }
+
+ private async decrypt_envelope(store_id: string, envelope: { key_id: string; iv: Uint8Array; ciphertext: Uint8Array; }): Promise<CryptoDecryptOutcome> {
+ const resolved = await this.resolve_key_by_id(store_id, envelope.key_id);
+ try {
+ const plain_buf = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: as_array_buffer(envelope.iv)
+ },
+ resolved.key,
+ as_array_buffer(envelope.ciphertext)
+ );
+ const plaintext = new Uint8Array(plain_buf);
+ const needs_reencrypt = resolved.index.active_key_id !== envelope.key_id;
+ if (!needs_reencrypt) return { plaintext, needs_reencrypt };
+ const reencrypted = await this.encrypt(store_id, plaintext);
+ return { plaintext, needs_reencrypt, reencrypted };
+ } catch {
+ throw new Error(cl_crypto_error.decrypt_failure);
+ }
+ }
+
+ private async decrypt_legacy(
+ store_id: string,
+ blob: Uint8Array,
+ legacy_key: LegacyKeyConfig | undefined,
+ iv_length: number
+ ): Promise<CryptoDecryptOutcome> {
+ if (!legacy_key) throw new Error(cl_crypto_error.legacy_key_missing);
+ const legacy_crypto_key = await this.load_legacy_key(legacy_key);
+ if (!legacy_crypto_key) throw new Error(cl_crypto_error.legacy_key_missing);
+ if (blob.byteLength <= iv_length) throw new Error(cl_crypto_error.invalid_envelope);
+ const iv = blob.slice(0, iv_length);
+ const ciphertext = blob.slice(iv_length);
+ try {
+ const plain_buf = await crypto.subtle.decrypt(
+ {
+ name: legacy_key.algorithm,
+ iv: as_array_buffer(iv)
+ },
+ legacy_crypto_key,
+ as_array_buffer(ciphertext)
+ );
+ const plaintext = new Uint8Array(plain_buf);
+ const reencrypted = await this.encrypt(store_id, plaintext);
+ return { plaintext, needs_reencrypt: true, reencrypted };
+ } catch {
+ throw new Error(cl_crypto_error.decrypt_failure);
+ }
+ }
+
+ private async load_legacy_key(legacy: LegacyKeyConfig): Promise<CryptoKey | null> {
+ if (typeof indexedDB === "undefined") return null;
+ const legacy_store = createStore(legacy.idb_config.database, legacy.idb_config.store);
+ const stored = await idb_get(legacy.key_name, legacy_store);
+ if (!stored) return null;
+ if (stored instanceof CryptoKey) return stored;
+ if (stored instanceof Uint8Array) return await crypto_key_import_raw(stored);
+ if (stored instanceof ArrayBuffer) return await crypto_key_import_raw(new Uint8Array(stored));
+ if (ArrayBuffer.isView(stored)) return await crypto_key_import_raw(bytes_from_value(stored));
+ return null;
+ }
+}
diff --git a/client/src/crypto/types.ts b/client/src/crypto/types.ts
@@ -0,0 +1,71 @@
+import type { IdbClientConfig } from "@radroots/utils";
+
+export type CryptoKeyStatus = "active" | "rotated";
+
+export type CryptoEnvelope = {
+ version: number;
+ key_id: string;
+ iv: Uint8Array;
+ created_at: number;
+ ciphertext: Uint8Array;
+};
+
+export type CryptoKeyEntry = {
+ key_id: string;
+ store_id: string;
+ created_at: number;
+ status: CryptoKeyStatus;
+ wrapped_key: Uint8Array;
+ wrap_iv: Uint8Array;
+ kdf_salt: Uint8Array;
+ kdf_iterations: number;
+ iv_length: number;
+ algorithm: "AES-GCM";
+ provider_id: string;
+};
+
+export type CryptoStoreIndex = {
+ store_id: string;
+ active_key_id: string;
+ key_ids: string[];
+ created_at: number;
+};
+
+export type CryptoRegistryExport = {
+ stores: CryptoStoreIndex[];
+ keys: CryptoKeyEntry[];
+};
+
+export type CryptoDecryptOutcome = {
+ plaintext: Uint8Array;
+ needs_reencrypt: boolean;
+ reencrypted?: Uint8Array;
+};
+
+export type LegacyKeyConfig = {
+ idb_config: IdbClientConfig;
+ key_name: string;
+ iv_length: number;
+ algorithm: string;
+};
+
+export type CryptoStoreConfig = {
+ store_id: string;
+ legacy_key?: LegacyKeyConfig;
+ iv_length?: number;
+};
+
+export interface KeyMaterialProvider {
+ get_key_material(): Promise<Uint8Array>;
+ get_provider_id(): Promise<string>;
+}
+
+export interface IWebCryptoService {
+ register_store_config(config: CryptoStoreConfig): void;
+ encrypt(store_id: string, plaintext: Uint8Array): Promise<Uint8Array>;
+ decrypt(store_id: string, blob: Uint8Array): Promise<Uint8Array>;
+ decrypt_record(store_id: string, blob: Uint8Array): Promise<CryptoDecryptOutcome>;
+ rotate_store_key(store_id: string): Promise<string>;
+ export_registry(): Promise<CryptoRegistryExport>;
+ import_registry(registry: CryptoRegistryExport): Promise<void>;
+}
diff --git a/client/src/datastore/types.ts b/client/src/datastore/types.ts
@@ -1,4 +1,5 @@
import type { IdbClientConfig, ResolveError, ResultObj, ResultPass, ResultsList } from "@radroots/utils";
+import type { BackupDatastorePayload } from "../backup/types.js";
export type IClientDatastoreValue = string | null;
@@ -21,10 +22,11 @@ export interface IClientDatastore<
> {
init(): Promise<ResolveError<void>>;
get_config(): IdbClientConfig;
+ get_store_id(): string;
set(key: keyof TKeyMap, value: string): Promise<ResolveError<ResultObj<string>>>;
get(key: keyof TKeyMap): Promise<ResolveError<ResultObj<string>>>;
- set_obj(key: keyof TKeyObjMap, value: TKeyObjMap): Promise<ResolveError<ResultObj<TKeyObjMap>>>;
- update_obj(key: keyof TKeyObjMap, value: Partial<TKeyObjMap>): Promise<ResolveError<ResultObj<TKeyObjMap>>>;
+ set_obj<T>(key: keyof TKeyObjMap, value: T): Promise<ResolveError<ResultObj<T>>>;
+ update_obj<T extends Record<string, unknown>>(key: keyof TKeyObjMap, value: Partial<T>): Promise<ResolveError<ResultObj<T>>>;
get_obj<T>(key: keyof TKeyObjMap): Promise<ResolveError<ResultObj<T>>>;
del_obj(key: keyof TKeyObjMap): Promise<ResolveError<ResultObj<string>>>;
del(key: keyof TKeyMap): Promise<IClientDatastoreDelResolve>;
@@ -33,4 +35,6 @@ export interface IClientDatastore<
getp<K extends keyof TKeyParamMap>(key: K, key_param: Parameters<TKeyParamMap[K]>[0]): Promise<ResolveError<ResultObj<string>>>;
keys(): Promise<ResolveError<ResultsList<string>>>;
reset(): Promise<ResolveError<ResultPass>>;
+ export_backup(): Promise<ResolveError<BackupDatastorePayload>>;
+ import_backup(payload: BackupDatastorePayload): Promise<ResolveError<void>>;
}
diff --git a/client/src/datastore/web.ts b/client/src/datastore/web.ts
@@ -1,5 +1,8 @@
-import { err_msg, handle_err, type IdbClientConfig, type ResolveError, type ResultObj, type ResultPass, type ResultsList } from "@radroots/utils";
+import { err_msg, handle_err, text_dec, text_enc, type IdbClientConfig, type ResolveError, type ResultObj, type ResultPass, type ResultsList } 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 { BackupDatastorePayload } from "../backup/types.js";
+import { WebCryptoService } from "../crypto/service.js";
+import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js";
import { cl_datastore_error } from "./error.js";
import type {
IClientDatastore,
@@ -34,6 +37,8 @@ export class WebDatastore<
private _key_map: Tk;
private _key_param_map: Tp;
private _key_obj_map: TkO;
+ private crypto: WebCryptoService;
+ private store_id: string;
constructor(key_map: Tk, key_param_map: Tp, key_obj_map: TkO, config?: Partial<IdbClientConfig>) {
this.db_name = config?.database ?? DEFAULT_IDB_CONFIG.database;
@@ -42,6 +47,12 @@ export class WebDatastore<
this._key_map = key_map;
this._key_param_map = key_param_map;
this._key_obj_map = key_obj_map;
+ this.store_id = `datastore:${this.db_name}:${this.store_name}`;
+ this.crypto = new WebCryptoService();
+ this.crypto.register_store_config({
+ store_id: this.store_id,
+ iv_length: 12
+ });
}
private get_store(): UseStore {
@@ -52,6 +63,26 @@ export class WebDatastore<
return this.store;
}
+ private as_bytes(value: unknown): Uint8Array | null {
+ if (value instanceof Uint8Array) return value;
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ return null;
+ }
+
+ private async decrypt_value(store_key: string, stored: unknown): Promise<ResolveError<ResultObj<string>>> {
+ if (typeof stored === "string") {
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(stored));
+ await idb_set(store_key, encrypted, this.get_store());
+ return { result: stored };
+ }
+ const bytes = this.as_bytes(stored);
+ if (!bytes) return err_msg(cl_datastore_error.no_result);
+ const outcome = await this.crypto.decrypt_record(this.store_id, bytes);
+ if (outcome.reencrypted) await idb_set(store_key, outcome.reencrypted, this.get_store());
+ return { result: text_dec(outcome.plaintext) };
+ }
+
public get_config(): IdbClientConfig {
return {
database: this.db_name,
@@ -59,6 +90,10 @@ export class WebDatastore<
};
}
+ public get_store_id(): string {
+ return this.store_id;
+ }
+
public async init(): Promise<ResolveError<void>> {
try {
this.get_store();
@@ -69,7 +104,8 @@ export class WebDatastore<
public async set(key: keyof Tk, value: string): Promise<ResolveError<ResultObj<string>>> {
try {
- await idb_set(this._key_map[key], value, this.get_store());
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(value));
+ await idb_set(this._key_map[key], encrypted, this.get_store());
return { result: value };
} catch (e) {
return handle_err(e);
@@ -78,9 +114,10 @@ export class WebDatastore<
public async get(key: keyof Tk): Promise<ResolveError<ResultObj<string>>> {
try {
- const value = await idb_get(this._key_map[key], this.get_store());
+ const store_key = this._key_map[key];
+ const value = await idb_get(store_key, this.get_store());
if (!value) return err_msg(cl_datastore_error.no_result);
- return { result: value };
+ return await this.decrypt_value(store_key, value);
} catch (e) {
return handle_err(e);
}
@@ -95,26 +132,32 @@ export class WebDatastore<
}
}
- public async set_obj<T extends TkO>(key: keyof TkO, value: T): Promise<ResolveError<ResultObj<TkO>>> {
+ public async set_obj<T>(key: keyof TkO, value: T): Promise<ResolveError<ResultObj<T>>> {
try {
- await idb_set(this._key_obj_map[key], JSON.stringify(value), this.get_store());
+ const serialized = JSON.stringify(value);
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(serialized));
+ await idb_set(this._key_obj_map[key], encrypted, this.get_store());
return { result: value };
} catch (e) {
return handle_err(e);
}
}
- public async update_obj<T extends TkO>(key: keyof TkO, value: Partial<T>): Promise<ResolveError<ResultObj<TkO>>> {
+ public async update_obj<T extends Record<string, unknown>>(key: keyof TkO, value: Partial<T>): Promise<ResolveError<ResultObj<T>>> {
try {
const k = this._key_obj_map[key];
const obj_curr: Record<string, unknown> = {};
const curr = await idb_get(k, this.get_store());
if (curr) {
- const parsed: unknown = JSON.parse(curr);
+ const decrypted = await this.decrypt_value(k, curr);
+ if ("err" in decrypted) return decrypted;
+ const parsed: unknown = JSON.parse(decrypted.result);
if (is_record(parsed)) for (const [curr_key, curr_val] of Object.entries(parsed)) if (curr_val) obj_curr[curr_key] = curr_val;
}
- const obj: T = { ...obj_curr, ...value } as any;
- await idb_set(k, JSON.stringify(obj), this.get_store());
+ const obj: T = { ...obj_curr, ...value } as T;
+ const serialized = JSON.stringify(obj);
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(serialized));
+ await idb_set(k, encrypted, this.get_store());
return { result: obj };
} catch (e) {
return handle_err(e);
@@ -123,9 +166,12 @@ export class WebDatastore<
public async get_obj<T>(key: keyof TkO): Promise<ResolveError<ResultObj<T>>> {
try {
- const value = await idb_get(this._key_obj_map[key], this.get_store());
+ const store_key = this._key_obj_map[key];
+ const value = await idb_get(store_key, this.get_store());
if (!value) return err_msg(cl_datastore_error.no_result);
- return { result: JSON.parse(value) };
+ const decrypted = await this.decrypt_value(store_key, value);
+ if ("err" in decrypted) return decrypted;
+ return { result: JSON.parse(decrypted.result) };
} catch (e) {
return handle_err(e);
}
@@ -146,7 +192,9 @@ export class WebDatastore<
value: string
): Promise<ResolveError<ResultObj<string>>> {
try {
- await idb_set(this._key_param_map[key](key_param), value, this.get_store());
+ const store_key = this._key_param_map[key](key_param);
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(value));
+ await idb_set(store_key, encrypted, this.get_store());
return { result: value };
} catch (e) {
return handle_err(e);
@@ -158,9 +206,10 @@ export class WebDatastore<
key_param: Parameters<Tp[K]>[0]
): Promise<ResolveError<ResultObj<string>>> {
try {
- const value = await idb_get(this._key_param_map[key](key_param), this.get_store());
+ const store_key = this._key_param_map[key](key_param);
+ const value = await idb_get(store_key, this.get_store());
if (!value) return err_msg(cl_datastore_error.no_result);
- return { result: value };
+ return await this.decrypt_value(store_key, value);
} catch (e) {
return handle_err(e);
}
@@ -188,9 +237,44 @@ export class WebDatastore<
}
}
+ public async export_backup(): Promise<ResolveError<BackupDatastorePayload>> {
+ try {
+ const all_keys = await idb_keys(this.get_store());
+ const entries: BackupDatastorePayload["entries"] = [];
+ for (const key of all_keys) {
+ if (typeof key !== "string") continue;
+ const value = await idb_get(key, this.get_store());
+ if (!value) continue;
+ const decrypted = await this.decrypt_value(key, value);
+ if ("err" in decrypted) return decrypted;
+ entries.push({ key, value: decrypted.result });
+ }
+ return { entries };
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
+ public async import_backup(payload: BackupDatastorePayload): Promise<ResolveError<void>> {
+ try {
+ for (const entry of payload.entries) {
+ const encrypted = await this.crypto.encrypt(this.store_id, text_enc(entry.value));
+ await idb_set(entry.key, encrypted, this.get_store());
+ }
+ return;
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
public async reset(): Promise<ResolveError<ResultPass>> {
try {
await idb_clear(this.get_store());
+ const index = await crypto_registry_get_store_index(this.store_id);
+ if (index) {
+ await crypto_registry_clear_store_index(this.store_id);
+ for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id);
+ }
return { pass: true } as const;
} catch (e) {
return handle_err(e);
diff --git a/client/src/error.ts b/client/src/error.ts
@@ -1,4 +1,6 @@
export * from "./cipher/error.js";
+export * from "./crypto/error.js";
+export * from "./backup/error.js";
export * from "./datastore/error.js";
export * from "./fs/error.js";
export * from "./geolocation/error.js";
diff --git a/client/src/index.ts b/client/src/index.ts
@@ -1 +1,3 @@
export * as error from "./error.js";
+export * as crypto from "./crypto/index.js";
+export * as backup from "./backup/index.js";
diff --git a/client/src/keystore/types.ts b/client/src/keystore/types.ts
@@ -1,13 +1,17 @@
import type { ResolveError, ResultObj, ResultPass, ResultPublicKey, ResultSecretKey, ResultsList } from "@radroots/utils";
+import type { BackupKeystorePayload } from "../backup/types.js";
export type IClientKeystoreValue = string | null;
export interface IClientKeystore {
add(key: string, value: string): Promise<ResolveError<ResultObj<string>>>;
remove(key: string): Promise<ResolveError<ResultObj<string>>>;
- read(key?: string | null): Promise<ResolveError<ResultObj<string>>>;
+ read(key?: string | null): Promise<ResolveError<ResultObj<IClientKeystoreValue>>>;
keys(): Promise<ResolveError<ResultsList<string>>>;
reset(): Promise<ResolveError<ResultPass>>;
+ get_store_id(): string;
+ export_backup(): Promise<ResolveError<BackupKeystorePayload>>;
+ import_backup(payload: BackupKeystorePayload): Promise<ResolveError<void>>;
}
export interface IClientKeystoreNostr {
diff --git a/client/src/keystore/web-nostr.ts b/client/src/keystore/web-nostr.ts
@@ -63,6 +63,7 @@ export class WebKeystoreNostr implements IWebKeystoreNostr {
try {
const resolve = await this._keystore.read(public_key);
if ("err" in resolve) return resolve;
+ if (!resolve.result) return err_msg(cl_keystore_error.missing_key);
return { secret_key: resolve.result };
} catch (e) {
return handle_err(e);
diff --git a/client/src/keystore/web.ts b/client/src/keystore/web.ts
@@ -10,9 +10,12 @@ import {
type ResultsList
} 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 { WebAesGcmCipher, } from "../cipher/web.js";
+import type { BackupKeystorePayload } from "../backup/types.js";
+import { WebCryptoService } from "../crypto/service.js";
+import type { LegacyKeyConfig } from "../crypto/types.js";
+import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js";
import { cl_keystore_error } from "./error.js";
-import type { IClientKeystore } from "./types.js";
+import type { IClientKeystore, IClientKeystoreValue } from "./types.js";
export type WebAesGcmCipherConfig = {
idb_config?: Partial<IdbClientConfig>;
@@ -24,12 +27,17 @@ export type WebAesGcmCipherConfig = {
export interface IWebKeystore extends IClientKeystore {
get_config(): IdbClientConfig;
+ get_store_id(): string;
+ export_backup(): Promise<ResolveError<BackupKeystorePayload>>;
+ import_backup(payload: BackupKeystorePayload): Promise<ResolveError<void>>;
}
export class WebKeystore implements IWebKeystore {
private config: IdbClientConfig;
private store: UseStore | null;
- private cipher: WebAesGcmCipher;
+ private crypto: WebCryptoService;
+ private store_id: string;
+ private legacy_key_config: LegacyKeyConfig;
constructor(config?: IdbClientConfig) {
this.config = {
@@ -37,28 +45,41 @@ export class WebKeystore implements IWebKeystore {
store: config?.store || "default"
};
this.store = null;
+ this.store_id = `keystore:${this.config.database}:${this.config.store}`;
+ this.crypto = new WebCryptoService();
- const cipher_config: WebAesGcmCipherConfig = {
+ this.legacy_key_config = {
idb_config: {
database: `${this.config.database}-cipher`,
store: this.config.store
},
- key_name: `radroots.keystore.${this.config.store}.aes-gcm.key`
+ key_name: `radroots.keystore.${this.config.store}.aes-gcm.key`,
+ iv_length: 12,
+ algorithm: "AES-GCM"
};
- this.cipher = new WebAesGcmCipher(cipher_config);
+ this.crypto.register_store_config({
+ store_id: this.store_id,
+ legacy_key: this.legacy_key_config,
+ iv_length: 12
+ });
}
private get_store(): UseStore {
if (!this.store) {
- if (typeof indexedDB === "undefined") {
- throw new Error(cl_keystore_error.idb_undefined);
- }
+ if (typeof indexedDB === "undefined") throw new Error(cl_keystore_error.idb_undefined);
this.store = createStore(this.config.database, this.config.store);
}
return this.store;
}
+ private as_bytes(value: unknown): Uint8Array | null {
+ if (value instanceof Uint8Array) return value;
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ return null;
+ }
+
public get_config(): IdbClientConfig {
return {
database: this.config.database,
@@ -66,10 +87,14 @@ export class WebKeystore implements IWebKeystore {
};
}
+ public get_store_id(): string {
+ return this.store_id;
+ }
+
public async add(key: string, value: string): Promise<ResolveError<ResultObj<string>>> {
try {
const bytes = text_enc(value);
- const cipher_bytes = await this.cipher.encrypt(bytes);
+ const cipher_bytes = await this.crypto.encrypt(this.store_id, bytes);
await idb_set(key, cipher_bytes, this.get_store());
return { result: key };
} catch (e) {
@@ -86,13 +111,15 @@ export class WebKeystore implements IWebKeystore {
}
}
- public async read(key?: string | null): Promise<ResolveError<ResultObj<string>>> {
+ public async read(key?: string | null): Promise<ResolveError<ResultObj<IClientKeystoreValue>>> {
try {
if (!key) return err_msg(cl_keystore_error.missing_key);
- const cipher_bytes = await idb_get<Uint8Array | null>(key, this.get_store());
- if (!(cipher_bytes instanceof Uint8Array)) return err_msg(cl_keystore_error.corrupt_data);
- const bytes = await this.cipher.decrypt(cipher_bytes);
- const plain = text_dec(bytes);
+ const cipher_value = await idb_get(key, this.get_store());
+ const cipher_bytes = this.as_bytes(cipher_value);
+ if (!cipher_bytes) return err_msg(cl_keystore_error.corrupt_data);
+ const outcome = await this.crypto.decrypt_record(this.store_id, cipher_bytes);
+ if (outcome.reencrypted) await idb_set(key, outcome.reencrypted, this.get_store());
+ const plain = text_dec(outcome.plaintext);
return { result: plain };
} catch (e) {
return handle_err(e);
@@ -108,10 +135,43 @@ export class WebKeystore implements IWebKeystore {
}
}
+ public async export_backup(): Promise<ResolveError<BackupKeystorePayload>> {
+ try {
+ const all_keys = await idb_keys(this.get_store());
+ const entries: BackupKeystorePayload["entries"] = [];
+ for (const key of all_keys) {
+ if (typeof key !== "string") continue;
+ const value = await this.read(key);
+ if ("err" in value) return value;
+ if (!value.result) return err_msg(cl_keystore_error.corrupt_data);
+ entries.push({ key, value: value.result });
+ }
+ return { entries };
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
+ public async import_backup(payload: BackupKeystorePayload): Promise<ResolveError<void>> {
+ try {
+ for (const entry of payload.entries) {
+ const res = await this.add(entry.key, entry.value);
+ if ("err" in res) return res;
+ }
+ return;
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
public async reset(): Promise<ResolveError<ResultPass>> {
try {
await idb_clear(this.get_store());
- await this.cipher.reset();
+ const index = await crypto_registry_get_store_index(this.store_id);
+ if (index) {
+ await crypto_registry_clear_store_index(this.store_id);
+ for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id);
+ }
return { pass: true } as const;
} catch (e) {
return handle_err(e);
diff --git a/client/src/notifications/web.ts b/client/src/notifications/web.ts
@@ -12,7 +12,7 @@ import type {
IClientNotificationsNotifySendResolve
} from "./types.js";
-export interface IWebNotifications extends IClientNotifications {}
+export interface IWebNotifications extends IClientNotifications { }
export class WebNotifications implements IWebNotifications {
private _config: IClientNotificationsConfig;
diff --git a/client/src/sql/types.ts b/client/src/sql/types.ts
@@ -1,4 +1,6 @@
+import type { ResolveError } from "@radroots/utils";
import type { SqlValue } from "sql.js";
+import type { BackupSqlPayload } from "../backup/types.js";
export type SqlJsExecOutcome = {
changes: number;
@@ -28,3 +30,15 @@ export interface IClientSqlEncryptedStore {
save(bytes: Uint8Array): Promise<void>;
remove(): Promise<void>;
}
+
+export interface IWebSqlEngine {
+ close(): Promise<void>;
+ purge_storage(): Promise<void>;
+ exec(sql: string, params: SqlJsParams): SqlJsExecOutcome;
+ query(sql: string, params: SqlJsParams): SqlJsResultRow[];
+ export_bytes(): Uint8Array;
+ import_bytes(bytes: Uint8Array): Promise<void>;
+ export_backup(): Promise<ResolveError<BackupSqlPayload>>;
+ import_backup(payload: BackupSqlPayload): Promise<ResolveError<void>>;
+ get_store_id(): string;
+}
diff --git a/client/src/sql/web.ts b/client/src/sql/web.ts
@@ -1,35 +1,68 @@
-import { IdbClientConfig } from "@radroots/utils";
+import { handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils";
import { del as idb_del, get as idb_get, set as idb_set } 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 { WebAesGcmCipher } from "../cipher/web.js";
-import type { IClientSqlEncryptedStore, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow } from "./types.js";
+import { backup_b64_to_bytes, backup_bytes_to_b64 } from "../backup/codec.js";
+import type { BackupSqlPayload } from "../backup/types.js";
+import { WebCryptoService } from "../crypto/service.js";
+import type { LegacyKeyConfig } from "../crypto/types.js";
+import type { IClientSqlEncryptedStore, IWebSqlEngine, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow } from "./types.js";
+
+const DEFAULT_SQL_CIPHER_CONFIG: IdbClientConfig = {
+ database: "radroots-web-sql-cipher",
+ store: "default"
+};
+
+interface IWebSqlEngineEncryptedStore extends IClientSqlEncryptedStore {
+ get_store_id(): string;
+}
-class WebSqlEngineEncryptedStore implements IClientSqlEncryptedStore {
+class WebSqlEngineEncryptedStore implements IWebSqlEngineEncryptedStore {
private readonly db_key: string;
- private readonly cipher: WebAesGcmCipher;
+ private readonly store_id: string;
+ private readonly crypto: WebCryptoService;
- constructor(key: string, cipher?: WebAesGcmCipher) {
+ constructor(key: string, cipher_config: IdbClientConfig | null) {
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`
+ this.store_id = `sql:${key}`;
+ this.crypto = new WebCryptoService();
+ const legacy_config: LegacyKeyConfig = {
+ idb_config: cipher_config ?? DEFAULT_SQL_CIPHER_CONFIG,
+ key_name: `radroots.sql.${key}.aes-gcm.key`,
+ iv_length: 12,
+ algorithm: "AES-GCM"
+ };
+ this.crypto.register_store_config({
+ store_id: this.store_id,
+ legacy_key: legacy_config,
+ iv_length: 12
});
}
+ public get_store_id(): string {
+ return this.store_id;
+ }
+
+ private as_bytes(value: unknown): Uint8Array | null {
+ if (value instanceof Uint8Array) return value;
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ return null;
+ }
+
async load(): Promise<Uint8Array | null> {
if (typeof indexedDB === "undefined") return null;
const data = await idb_get(this.db_key);
- if (data instanceof Uint8Array) return this.cipher.decrypt(data);
- return null;
+ const bytes = this.as_bytes(data);
+ if (!bytes) return null;
+ const outcome = await this.crypto.decrypt_record(this.store_id, bytes);
+ if (outcome.reencrypted) await idb_set(this.db_key, outcome.reencrypted);
+ return outcome.plaintext;
}
async save(bytes: Uint8Array): Promise<void> {
if (typeof indexedDB === "undefined") return;
- const enc = await this.cipher.encrypt(bytes);
+ const enc = await this.crypto.encrypt(this.store_id, bytes);
await idb_set(this.db_key, enc);
}
@@ -38,39 +71,25 @@ class WebSqlEngineEncryptedStore implements IClientSqlEncryptedStore {
}
}
-export interface IWebSqlEngine {
- close(): Promise<void>;
- purge_storage(): Promise<void>;
- exec(sql: string, params: SqlJsParams): SqlJsExecOutcome;
- query(sql: string, params: SqlJsParams): SqlJsResultRow[];
- export_bytes(): Uint8Array;
-}
-
export class WebSqlEngine implements IWebSqlEngine {
private save_timer: number | undefined;
+ private db: Database;
+ private readonly store_id: string;
private constructor(
private readonly sqljs: SqlJsStatic,
- private readonly db: Database,
+ db: Database,
private readonly store: WebSqlEngineEncryptedStore
- ) { }
+ ) {
+ this.db = db;
+ this.store_id = store.get_store_id();
+ }
static async create(store_key: string, cipher_config: IdbClientConfig | null): Promise<WebSqlEngine> {
const sql = await init_sql_js({ locateFile: f => `/assets/${f}` });
-
- 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 kv = new WebSqlEngineEncryptedStore(store_key, cipher_config);
const existing = await kv.load();
const db = existing ? new sql.Database(existing) : new sql.Database();
-
return new WebSqlEngine(sql, db, kv);
}
@@ -82,6 +101,10 @@ export class WebSqlEngine implements IWebSqlEngine {
await this.store.remove();
}
+ public get_store_id(): string {
+ return this.store_id;
+ }
+
private schedule_persist(): void {
if (this.save_timer) return;
this.save_timer = self.setTimeout(async () => {
@@ -112,6 +135,31 @@ export class WebSqlEngine implements IWebSqlEngine {
return this.db.export();
}
+ public async import_bytes(bytes: Uint8Array): Promise<void> {
+ this.db.close();
+ this.db = new this.sqljs.Database(bytes);
+ await this.store.save(bytes);
+ }
+
+ public async export_backup(): Promise<ResolveError<BackupSqlPayload>> {
+ try {
+ const bytes = this.export_bytes();
+ return { bytes_b64: backup_bytes_to_b64(bytes) };
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
+ public async import_backup(payload: BackupSqlPayload): Promise<ResolveError<void>> {
+ try {
+ const bytes = backup_b64_to_bytes(payload.bytes_b64);
+ await this.import_bytes(bytes);
+ return;
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
private prepare(sql: string): Statement {
return this.db.prepare(sql);
}