service.ts (14516B)
1 import { createStore, get as idb_get } from "idb-keyval"; 2 import { as_array_buffer, err_msg, handle_err, type ResolveError } from "@radroots/utils"; 3 import { idb_store_ensure, idb_store_exists } from "../idb/store.js"; 4 import { idb_value_as_bytes } from "../idb/value.js"; 5 import { is_error } from "../utils/resolve.js"; 6 import { cl_crypto_error } from "./error.js"; 7 import { crypto_envelope_decode, crypto_envelope_encode } from "./envelope.js"; 8 import { crypto_kdf_derive_kek, crypto_kdf_iterations_default, crypto_kdf_salt_create } from "./kdf.js"; 9 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"; 10 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"; 11 import { DeviceKeyMaterialProvider } from "./provider.js"; 12 import type { CryptoDecryptOutcome, CryptoKeyEntry, CryptoRegistryExport, CryptoStoreConfig, CryptoStoreIndex, IWebCryptoService, KeyMaterialProvider, LegacyKeyConfig } from "./types.js"; 13 14 const DEFAULT_IV_LENGTH = 12; 15 16 const ensure_crypto = (): ResolveError<void> => { 17 if (!globalThis.crypto || !globalThis.crypto.subtle) return err_msg(cl_crypto_error.crypto_undefined); 18 return; 19 }; 20 21 const merge_key_ids = (key_ids: string[], next_key_id: string): string[] => { 22 if (key_ids.includes(next_key_id)) return key_ids; 23 return [...key_ids, next_key_id]; 24 }; 25 26 export class WebCryptoService implements IWebCryptoService { 27 private store_configs: Map<string, CryptoStoreConfig>; 28 private key_material_provider: KeyMaterialProvider; 29 30 constructor(config?: { key_material_provider?: KeyMaterialProvider }) { 31 this.store_configs = new Map(); 32 this.key_material_provider = config?.key_material_provider ?? new DeviceKeyMaterialProvider(); 33 } 34 35 public register_store_config(config: CryptoStoreConfig): void { 36 const existing = this.store_configs.get(config.store_id); 37 if (existing) { 38 this.store_configs.set(config.store_id, { 39 store_id: config.store_id, 40 iv_length: config.iv_length ?? existing.iv_length, 41 legacy_key: config.legacy_key ?? existing.legacy_key 42 }); 43 return; 44 } 45 this.store_configs.set(config.store_id, { 46 store_id: config.store_id, 47 iv_length: config.iv_length ?? DEFAULT_IV_LENGTH, 48 legacy_key: config.legacy_key 49 }); 50 } 51 52 public async encrypt(store_id: string, plaintext: Uint8Array): Promise<ResolveError<Uint8Array>> { 53 const env_err = ensure_crypto(); 54 if (env_err) return env_err; 55 const resolved = await this.resolve_active_key(store_id); 56 if (is_error(resolved)) return resolved; 57 const { key, entry } = resolved; 58 const iv_length = entry.iv_length || DEFAULT_IV_LENGTH; 59 const iv = new Uint8Array(iv_length); 60 crypto.getRandomValues(iv); 61 try { 62 const cipher_buf = await crypto.subtle.encrypt( 63 { 64 name: "AES-GCM", 65 iv: as_array_buffer(iv) 66 }, 67 key, 68 as_array_buffer(plaintext) 69 ); 70 const envelope = { 71 version: 1, 72 key_id: entry.key_id, 73 iv, 74 created_at: Date.now(), 75 ciphertext: new Uint8Array(cipher_buf) 76 }; 77 return crypto_envelope_encode(envelope); 78 } catch { 79 return err_msg(cl_crypto_error.encrypt_failure); 80 } 81 } 82 83 public async decrypt(store_id: string, blob: Uint8Array): Promise<ResolveError<Uint8Array>> { 84 const outcome = await this.decrypt_record(store_id, blob); 85 if (is_error(outcome)) return outcome; 86 return outcome.plaintext; 87 } 88 89 public async decrypt_record(store_id: string, blob: Uint8Array): Promise<ResolveError<CryptoDecryptOutcome>> { 90 const env_err = ensure_crypto(); 91 if (env_err) return env_err; 92 const config = this.resolve_store_config(store_id); 93 let envelope: ReturnType<typeof crypto_envelope_decode>; 94 try { 95 envelope = crypto_envelope_decode(blob); 96 } catch (e) { 97 return handle_err(e); 98 } 99 if (envelope) return await this.decrypt_envelope(store_id, envelope); 100 return await this.decrypt_legacy(store_id, blob, config.legacy_key, config.iv_length ?? DEFAULT_IV_LENGTH); 101 } 102 103 public async rotate_store_key(store_id: string): Promise<ResolveError<string>> { 104 const config = this.resolve_store_config(store_id); 105 const index = await crypto_registry_get_store_index(store_id); 106 if (is_error(index)) return index; 107 if (!index) { 108 const created = await this.create_store_key(store_id, config); 109 if (is_error(created)) return created; 110 return created.entry.key_id; 111 } 112 const prev_entry = await crypto_registry_get_key_entry(index.active_key_id); 113 if (is_error(prev_entry)) return prev_entry; 114 if (prev_entry) { 115 const rotated_entry: CryptoKeyEntry = { 116 ...prev_entry, 117 status: "rotated" 118 }; 119 const set_entry = await crypto_registry_set_key_entry(rotated_entry); 120 if (is_error(set_entry)) return set_entry; 121 } 122 const created = await this.create_key_entry(store_id, config); 123 if (is_error(created)) return created; 124 const next_index: CryptoStoreIndex = { 125 ...index, 126 active_key_id: created.entry.key_id, 127 key_ids: merge_key_ids(index.key_ids, created.entry.key_id) 128 }; 129 const set_index = await crypto_registry_set_store_index(next_index); 130 if (is_error(set_index)) return set_index; 131 return created.entry.key_id; 132 } 133 134 public async export_registry(): Promise<ResolveError<CryptoRegistryExport>> { 135 return await crypto_registry_export(); 136 } 137 138 public async import_registry(registry: CryptoRegistryExport): Promise<ResolveError<void>> { 139 return await crypto_registry_import(registry); 140 } 141 142 private resolve_store_config(store_id: string): CryptoStoreConfig { 143 const existing = this.store_configs.get(store_id); 144 if (existing) return existing; 145 const config = { 146 store_id, 147 iv_length: DEFAULT_IV_LENGTH 148 }; 149 this.store_configs.set(store_id, config); 150 return config; 151 } 152 153 private async resolve_active_key(store_id: string): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { 154 const index = await crypto_registry_get_store_index(store_id); 155 if (is_error(index)) return index; 156 if (!index) return await this.create_store_key(store_id, this.resolve_store_config(store_id)); 157 const entry = await crypto_registry_get_key_entry(index.active_key_id); 158 if (is_error(entry)) return entry; 159 if (!entry) return await this.create_store_key(store_id, this.resolve_store_config(store_id)); 160 const key = await this.unwrap_key_entry(entry); 161 if (is_error(key)) return key; 162 return { key, entry, index }; 163 } 164 165 private async resolve_key_by_id(store_id: string, key_id: string): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { 166 const entry = await crypto_registry_get_key_entry(key_id); 167 if (is_error(entry)) return entry; 168 if (!entry) return err_msg(cl_crypto_error.key_not_found); 169 let index = await crypto_registry_get_store_index(store_id); 170 if (is_error(index)) return index; 171 if (!index) { 172 index = { 173 store_id, 174 active_key_id: entry.key_id, 175 key_ids: [entry.key_id], 176 created_at: entry.created_at 177 }; 178 const set_index = await crypto_registry_set_store_index(index); 179 if (is_error(set_index)) return set_index; 180 } 181 const key = await this.unwrap_key_entry(entry); 182 if (is_error(key)) return key; 183 return { key, entry, index }; 184 } 185 186 private async create_store_key(store_id: string, config: CryptoStoreConfig): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { 187 const created = await this.create_key_entry(store_id, config); 188 if (is_error(created)) return created; 189 const index: CryptoStoreIndex = { 190 store_id, 191 active_key_id: created.entry.key_id, 192 key_ids: [created.entry.key_id], 193 created_at: created.entry.created_at 194 }; 195 const set_index = await crypto_registry_set_store_index(index); 196 if (is_error(set_index)) return set_index; 197 return { 198 key: created.key, 199 entry: created.entry, 200 index 201 }; 202 } 203 204 private async create_key_entry(store_id: string, config: CryptoStoreConfig): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; }>> { 205 try { 206 const key_id = crypto_key_id_create(); 207 const created_at = Date.now(); 208 const kdf_salt = crypto_kdf_salt_create(); 209 const kdf_iterations = crypto_kdf_iterations_default(); 210 const material = await this.key_material_provider.get_key_material(); 211 const provider_id = await this.key_material_provider.get_provider_id(); 212 const kek = await crypto_kdf_derive_kek(material, kdf_salt, kdf_iterations); 213 material.fill(0); 214 const data_key = await crypto_key_generate(); 215 const raw_key = await crypto_key_export_raw(data_key); 216 const wrapped = await crypto_key_wrap(kek, raw_key); 217 const entry: CryptoKeyEntry = { 218 key_id, 219 store_id, 220 created_at, 221 status: "active", 222 wrapped_key: wrapped.wrapped_key, 223 wrap_iv: wrapped.wrap_iv, 224 kdf_salt, 225 kdf_iterations, 226 iv_length: config.iv_length ?? DEFAULT_IV_LENGTH, 227 algorithm: "AES-GCM", 228 provider_id 229 }; 230 const set_entry = await crypto_registry_set_key_entry(entry); 231 if (is_error(set_entry)) return set_entry; 232 return { key: data_key, entry }; 233 } catch (e) { 234 return handle_err(e); 235 } 236 } 237 238 private async unwrap_key_entry(entry: CryptoKeyEntry): Promise<ResolveError<CryptoKey>> { 239 try { 240 const material = await this.key_material_provider.get_key_material(); 241 const kek = await crypto_kdf_derive_kek(material, entry.kdf_salt, entry.kdf_iterations); 242 material.fill(0); 243 return await crypto_key_unwrap(kek, entry.wrapped_key, entry.wrap_iv); 244 } catch (e) { 245 return handle_err(e); 246 } 247 } 248 249 private async decrypt_envelope(store_id: string, envelope: { key_id: string; iv: Uint8Array; ciphertext: Uint8Array; }): Promise<ResolveError<CryptoDecryptOutcome>> { 250 const resolved = await this.resolve_key_by_id(store_id, envelope.key_id); 251 if (is_error(resolved)) return resolved; 252 try { 253 const plain_buf = await crypto.subtle.decrypt( 254 { 255 name: "AES-GCM", 256 iv: as_array_buffer(envelope.iv) 257 }, 258 resolved.key, 259 as_array_buffer(envelope.ciphertext) 260 ); 261 const plaintext = new Uint8Array(plain_buf); 262 const needs_reencrypt = resolved.index.active_key_id !== envelope.key_id; 263 if (!needs_reencrypt) return { plaintext, needs_reencrypt }; 264 const reencrypted = await this.encrypt(store_id, plaintext); 265 if (is_error(reencrypted)) return reencrypted; 266 return { plaintext, needs_reencrypt, reencrypted }; 267 } catch { 268 return err_msg(cl_crypto_error.decrypt_failure); 269 } 270 } 271 272 private async decrypt_legacy( 273 store_id: string, 274 blob: Uint8Array, 275 legacy_key: LegacyKeyConfig | undefined, 276 iv_length: number 277 ): Promise<ResolveError<CryptoDecryptOutcome>> { 278 if (!legacy_key) return err_msg(cl_crypto_error.legacy_key_missing); 279 const legacy_crypto_key = await this.load_legacy_key(legacy_key); 280 if (is_error(legacy_crypto_key)) return legacy_crypto_key; 281 if (!legacy_crypto_key) return err_msg(cl_crypto_error.legacy_key_missing); 282 if (blob.byteLength <= iv_length) return err_msg(cl_crypto_error.invalid_envelope); 283 const iv = blob.subarray(0, iv_length); 284 const ciphertext = blob.subarray(iv_length); 285 try { 286 const plain_buf = await crypto.subtle.decrypt( 287 { 288 name: legacy_key.algorithm, 289 iv: as_array_buffer(iv) 290 }, 291 legacy_crypto_key, 292 as_array_buffer(ciphertext) 293 ); 294 const plaintext = new Uint8Array(plain_buf); 295 const reencrypted = await this.encrypt(store_id, plaintext); 296 if (is_error(reencrypted)) return reencrypted; 297 return { plaintext, needs_reencrypt: true, reencrypted }; 298 } catch { 299 return err_msg(cl_crypto_error.decrypt_failure); 300 } 301 } 302 303 private async load_legacy_key(legacy: LegacyKeyConfig): Promise<ResolveError<CryptoKey | null>> { 304 if (typeof indexedDB === "undefined") return err_msg(cl_crypto_error.idb_undefined); 305 const exists = await idb_store_exists(legacy.idb_config.database, legacy.idb_config.store); 306 if (!exists) return null; 307 try { 308 await idb_store_ensure(legacy.idb_config.database, legacy.idb_config.store); 309 const legacy_store = createStore(legacy.idb_config.database, legacy.idb_config.store); 310 const stored = await idb_get(legacy.key_name, legacy_store); 311 if (!stored) return null; 312 if (stored instanceof CryptoKey) return stored; 313 const bytes = idb_value_as_bytes(stored); 314 if (!bytes) return null; 315 return await crypto_key_import_raw(bytes); 316 } catch (e) { 317 return handle_err(e); 318 } 319 } 320 }