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 };