web_lib

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

codec.ts (4713B)


      1 import { as_array_buffer } from "@radroots/utils";
      2 import { cl_backup_error } from "./error.js";
      3 import { crypto_kdf_derive_kek, crypto_kdf_iterations_default, crypto_kdf_salt_create } from "../crypto/kdf.js";
      4 import type { BackupBundle, BackupBundleEnvelope } from "./types.js";
      5 import type { KeyMaterialProvider } from "../crypto/types.js";
      6 
      7 const ensure_crypto = (): void => {
      8     if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_backup_error.crypto_undefined);
      9 };
     10 
     11 export const backup_bytes_to_b64 = (bytes: Uint8Array): string => {
     12     if (typeof btoa === "undefined") throw new Error(cl_backup_error.encode_failure);
     13     const chars: string[] = new Array(bytes.length);
     14     for (let i = 0; i < bytes.length; i++) chars[i] = String.fromCharCode(bytes[i]);
     15     return btoa(chars.join(""));
     16 };
     17 
     18 export const backup_b64_to_bytes = (value: string): Uint8Array => {
     19     if (typeof atob === "undefined") throw new Error(cl_backup_error.decode_failure);
     20     const binary = atob(value);
     21     const bytes = new Uint8Array(binary.length);
     22     for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
     23     return bytes;
     24 };
     25 
     26 const is_record = (value: unknown): value is Record<string, unknown> =>
     27     typeof value === "object" && value !== null && !Array.isArray(value);
     28 
     29 const is_backup_bundle_envelope = (value: unknown): value is BackupBundleEnvelope => {
     30     if (!is_record(value)) return false;
     31     return typeof value.version === "number"
     32         && typeof value.created_at === "number"
     33         && typeof value.kdf_salt_b64 === "string"
     34         && typeof value.kdf_iterations === "number"
     35         && typeof value.iv_b64 === "string"
     36         && typeof value.ciphertext_b64 === "string";
     37 };
     38 
     39 const is_backup_bundle = (value: unknown): value is BackupBundle => {
     40     if (!is_record(value)) return false;
     41     if (!is_record(value.manifest)) return false;
     42     if (!Array.isArray(value.payloads)) return false;
     43     return typeof value.manifest.version === "number"
     44         && typeof value.manifest.created_at === "number"
     45         && Array.isArray(value.manifest.stores);
     46 };
     47 
     48 export const backup_bundle_encode = async (bundle: BackupBundle, provider: KeyMaterialProvider): Promise<Uint8Array> => {
     49     ensure_crypto();
     50     try {
     51         const json = JSON.stringify(bundle);
     52         const plaintext = new TextEncoder().encode(json);
     53         const salt = crypto_kdf_salt_create();
     54         const iterations = crypto_kdf_iterations_default();
     55         const material = await provider.get_key_material();
     56         const kek = await crypto_kdf_derive_kek(material, salt, iterations);
     57         material.fill(0);
     58         const iv = new Uint8Array(12);
     59         crypto.getRandomValues(iv);
     60         const cipher_buf = await crypto.subtle.encrypt(
     61             {
     62                 name: "AES-GCM",
     63                 iv: as_array_buffer(iv)
     64             },
     65             kek,
     66             as_array_buffer(plaintext)
     67         );
     68         const envelope: BackupBundleEnvelope = {
     69             version: 1,
     70             created_at: Date.now(),
     71             kdf_salt_b64: backup_bytes_to_b64(salt),
     72             kdf_iterations: iterations,
     73             iv_b64: backup_bytes_to_b64(iv),
     74             ciphertext_b64: backup_bytes_to_b64(new Uint8Array(cipher_buf))
     75         };
     76         const encoded = JSON.stringify(envelope);
     77         return new TextEncoder().encode(encoded);
     78     } catch {
     79         throw new Error(cl_backup_error.encode_failure);
     80     }
     81 };
     82 
     83 export const backup_bundle_decode = async (blob: Uint8Array, provider: KeyMaterialProvider): Promise<BackupBundle> => {
     84     ensure_crypto();
     85     try {
     86         const json = new TextDecoder().decode(blob);
     87         const parsed = JSON.parse(json);
     88         if (!is_backup_bundle_envelope(parsed)) throw new Error(cl_backup_error.invalid_bundle);
     89         const salt = backup_b64_to_bytes(parsed.kdf_salt_b64);
     90         const iv = backup_b64_to_bytes(parsed.iv_b64);
     91         const ciphertext = backup_b64_to_bytes(parsed.ciphertext_b64);
     92         const material = await provider.get_key_material();
     93         const kek = await crypto_kdf_derive_kek(material, salt, parsed.kdf_iterations);
     94         material.fill(0);
     95         const plain_buf = await crypto.subtle.decrypt(
     96             {
     97                 name: "AES-GCM",
     98                 iv: as_array_buffer(iv)
     99             },
    100             kek,
    101             as_array_buffer(ciphertext)
    102         );
    103         const plaintext = new TextDecoder().decode(new Uint8Array(plain_buf));
    104         const bundle_parsed = JSON.parse(plaintext);
    105         if (!is_backup_bundle(bundle_parsed)) throw new Error(cl_backup_error.invalid_bundle);
    106         return bundle_parsed;
    107     } catch {
    108         throw new Error(cl_backup_error.decode_failure);
    109     }
    110 };