web_lib

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

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 }