web.ts (5487B)
1 import { err_msg, handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; 2 import { createStore, del as idb_del, type UseStore } from "idb-keyval"; 3 import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js"; 4 import { WebCryptoService } from "../crypto/service.js"; 5 import type { LegacyKeyConfig } from "../crypto/types.js"; 6 import { IDB_CONFIG_CIPHER_AES_GCM } from "../idb/config.js"; 7 import { idb_store_ensure, idb_store_exists } from "../idb/store.js"; 8 import { is_error } from "../utils/resolve.js"; 9 import { cl_cipher_error } from "./error.js"; 10 import type { ClientCipherDecryptResolve, ClientCipherEncryptResolve, ClientCipherResetResolve, IClientCipher, WebAesGcmCipherConfig } from "./types.js"; 11 12 const DEFAULT_IDB_CONFIG: IdbClientConfig = IDB_CONFIG_CIPHER_AES_GCM; 13 14 const DEFAULT_WEB_AES_GCM_CONFIG = { 15 key_name: "radroots.aes-gcm.key", 16 algorithm: "AES-GCM", 17 key_length: 256, 18 iv_length: 12 19 } as const; 20 21 export interface IWebAesGcmCipher extends IClientCipher { } 22 23 export class WebAesGcmCipher implements IWebAesGcmCipher { 24 private readonly db_name: string; 25 private readonly store_name: string; 26 private readonly key_name: string; 27 private readonly algorithm_name: string; 28 private readonly iv_length: number; 29 private legacy_store: UseStore | null; 30 private readonly store_id: string; 31 private readonly crypto: WebCryptoService; 32 private readonly legacy_key_config: LegacyKeyConfig; 33 private store_ready: Promise<void> | null = null; 34 35 constructor(config?: WebAesGcmCipherConfig) { 36 const idb_config = config?.idb_config ?? {}; 37 this.db_name = idb_config.database ?? DEFAULT_IDB_CONFIG.database; 38 this.store_name = idb_config.store ?? DEFAULT_IDB_CONFIG.store; 39 this.key_name = config?.key_name ?? DEFAULT_WEB_AES_GCM_CONFIG.key_name; 40 this.algorithm_name = config?.algorithm ?? DEFAULT_WEB_AES_GCM_CONFIG.algorithm; 41 this.iv_length = Number.isInteger(config?.iv_length) && (config?.iv_length ?? 0) > 0 42 ? config?.iv_length ?? DEFAULT_WEB_AES_GCM_CONFIG.iv_length 43 : DEFAULT_WEB_AES_GCM_CONFIG.iv_length; 44 45 this.legacy_store = null; 46 this.store_id = this.key_name; 47 this.crypto = new WebCryptoService(); 48 this.legacy_key_config = { 49 idb_config: { 50 database: this.db_name, 51 store: this.store_name 52 }, 53 key_name: this.key_name, 54 iv_length: this.iv_length, 55 algorithm: this.algorithm_name 56 }; 57 this.crypto.register_store_config({ 58 store_id: this.store_id, 59 legacy_key: this.legacy_key_config, 60 iv_length: this.iv_length 61 }); 62 } 63 64 public get_config(): IdbClientConfig { 65 return { 66 database: this.db_name, 67 store: this.store_name 68 }; 69 } 70 71 private ensure_env(): ResolveError<void> { 72 if (typeof indexedDB === "undefined") return err_msg(cl_cipher_error.idb_undefined); 73 if (!globalThis.crypto || !globalThis.crypto.subtle) return err_msg(cl_cipher_error.crypto_undefined); 74 return; 75 } 76 77 private async get_store(): Promise<ResolveError<UseStore>> { 78 const env_err = this.ensure_env(); 79 if (env_err) return env_err; 80 try { 81 if (!this.store_ready) this.store_ready = idb_store_ensure(this.db_name, this.store_name); 82 await this.store_ready; 83 if (!this.legacy_store) this.legacy_store = createStore(this.db_name, this.store_name); 84 return this.legacy_store; 85 } catch (e) { 86 return handle_err(e); 87 } 88 } 89 90 public async reset(): Promise<ClientCipherResetResolve> { 91 const env_err = this.ensure_env(); 92 if (env_err) return env_err; 93 try { 94 const index = await crypto_registry_get_store_index(this.store_id); 95 if (is_error(index)) return index; 96 if (index) { 97 const cleared = await crypto_registry_clear_store_index(this.store_id); 98 if (is_error(cleared)) return cleared; 99 for (const key_id of index.key_ids) { 100 const res = await crypto_registry_clear_key_entry(key_id); 101 if (is_error(res)) return res; 102 } 103 } 104 const has_store = await idb_store_exists(this.db_name, this.store_name); 105 if (has_store) { 106 const store = await this.get_store(); 107 if (is_error(store)) return store; 108 await idb_del(this.key_name, store); 109 } 110 return { pass: true } as const; 111 } catch (e) { 112 return handle_err(e); 113 } 114 } 115 116 public async encrypt(data: Uint8Array): Promise<ClientCipherEncryptResolve> { 117 const env_err = this.ensure_env(); 118 if (env_err) return env_err; 119 return await this.crypto.encrypt(this.store_id, data); 120 } 121 122 public async decrypt(blob: Uint8Array): Promise<ClientCipherDecryptResolve> { 123 const env_err = this.ensure_env(); 124 if (env_err) return env_err; 125 if (blob.byteLength <= this.iv_length) return err_msg(cl_cipher_error.invalid_ciphertext); 126 const outcome = await this.crypto.decrypt_record(this.store_id, blob); 127 if (is_error(outcome)) return outcome; 128 return outcome.plaintext; 129 } 130 }