web_lib

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

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:
Mclient/package.json | 13+++++++++++--
Aclient/src/backup/codec.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/backup/error.ts | 10++++++++++
Aclient/src/backup/index.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/backup/types.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/cipher/web.ts | 145++++++++++++++++++++-----------------------------------------------------------
Aclient/src/crypto/envelope.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/crypto/error.ts | 17+++++++++++++++++
Aclient/src/crypto/index.ts | 8++++++++
Aclient/src/crypto/kdf.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Aclient/src/crypto/keys.ts | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/crypto/provider.ts | 24++++++++++++++++++++++++
Aclient/src/crypto/registry.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/crypto/service.ts | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/crypto/types.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/datastore/types.ts | 8++++++--
Mclient/src/datastore/web.ts | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mclient/src/error.ts | 2++
Mclient/src/index.ts | 2++
Mclient/src/keystore/types.ts | 6+++++-
Mclient/src/keystore/web-nostr.ts | 1+
Mclient/src/keystore/web.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mclient/src/notifications/web.ts | 2+-
Mclient/src/sql/types.ts | 14++++++++++++++
Mclient/src/sql/web.ts | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
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); }