import type { EncryptionEnvelope } from "@obsidian-sync/sync-protocol"; export interface VaultKeyHandle { keyId: string; key: CryptoKey; } export interface GeneratedVaultKey extends VaultKeyHandle { exportedKey: string; } const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function getCryptoApi(): Crypto { if (!globalThis.crypto?.subtle) { throw new Error("Web Crypto API is unavailable in this environment."); } return globalThis.crypto; } function bytesToBase64(bytes: Uint8Array): string { let output = ""; for (let index = 0; index < bytes.length; index += 3) { const first = bytes[index] ?? 0; const second = bytes[index + 1] ?? 0; const third = bytes[index + 2] ?? 0; const combined = (first << 16) | (second << 8) | third; output += BASE64_ALPHABET[(combined >> 18) & 63]; output += BASE64_ALPHABET[(combined >> 12) & 63]; output += index + 1 < bytes.length ? BASE64_ALPHABET[(combined >> 6) & 63] : "="; output += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] : "="; } return output; } function base64ToBytes(value: string): Uint8Array { const normalized = value.replace(/\s+/g, ""); const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; const outputLength = Math.floor((normalized.length * 3) / 4) - padding; const bytes = new Uint8Array(outputLength); let byteIndex = 0; for (let index = 0; index < normalized.length; index += 4) { const first = BASE64_ALPHABET.indexOf(normalized[index] ?? "A"); const second = BASE64_ALPHABET.indexOf(normalized[index + 1] ?? "A"); const thirdChar = normalized[index + 2] ?? "="; const fourthChar = normalized[index + 3] ?? "="; const third = thirdChar === "=" ? 0 : BASE64_ALPHABET.indexOf(thirdChar); const fourth = fourthChar === "=" ? 0 : BASE64_ALPHABET.indexOf(fourthChar); const combined = (first << 18) | (second << 12) | (third << 6) | fourth; bytes[byteIndex] = (combined >> 16) & 255; byteIndex += 1; if (thirdChar !== "=" && byteIndex < outputLength + 1) { bytes[byteIndex] = (combined >> 8) & 255; byteIndex += 1; } if (fourthChar !== "=" && byteIndex < outputLength + 1) { bytes[byteIndex] = combined & 255; byteIndex += 1; } } return bytes; } function toOwnedBytes(bytes: Uint8Array): Uint8Array { const buffer = new ArrayBuffer(bytes.byteLength); const view = new Uint8Array(buffer); view.set(bytes); return view; } function normalizeBytes(value: ArrayBuffer | Uint8Array): Uint8Array { return value instanceof Uint8Array ? toOwnedBytes(value) : toOwnedBytes(new Uint8Array(value)); } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return toOwnedBytes(bytes).buffer; } export async function generateVaultKey(keyId = globalThis.crypto.randomUUID()): Promise { const cryptoApi = getCryptoApi(); const key = await cryptoApi.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); const rawKey = await cryptoApi.subtle.exportKey("raw", key); return { keyId, key, exportedKey: bytesToBase64(new Uint8Array(rawKey)) }; } export async function importVaultKey(exportedKey: string, keyId: string): Promise { const cryptoApi = getCryptoApi(); const rawKey = base64ToBytes(exportedKey); const key = await cryptoApi.subtle.importKey( "raw", toArrayBuffer(rawKey), { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); return { keyId, key }; } export async function encryptBytes(plaintext: ArrayBuffer | Uint8Array, vaultKey: VaultKeyHandle): Promise { const cryptoApi = getCryptoApi(); const iv = cryptoApi.getRandomValues(new Uint8Array(12)); const ciphertext = await cryptoApi.subtle.encrypt( { name: "AES-GCM", iv }, vaultKey.key, normalizeBytes(plaintext) ); return { algorithm: "AES-GCM-256", keyId: vaultKey.keyId, iv: bytesToBase64(iv), ciphertext: bytesToBase64(new Uint8Array(ciphertext)) }; } export async function decryptBytes(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise { const cryptoApi = getCryptoApi(); const plaintext = await cryptoApi.subtle.decrypt( { name: "AES-GCM", iv: toOwnedBytes(base64ToBytes(envelope.iv)) }, vaultKey.key, toOwnedBytes(base64ToBytes(envelope.ciphertext)) ); return plaintext.slice(0); } export async function encryptText(plaintext: string, vaultKey: VaultKeyHandle): Promise { return encryptBytes(new TextEncoder().encode(plaintext), vaultKey); } export async function decryptText(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise { return new TextDecoder().decode(await decryptBytes(envelope, vaultKey)); } async function computeHash(bytes: Uint8Array): Promise { const cryptoApi = getCryptoApi(); const digest = await cryptoApi.subtle.digest("SHA-256", toOwnedBytes(bytes)); return bytesToBase64(new Uint8Array(digest)); } export async function computeTextHash(value: string): Promise { return computeHash(new TextEncoder().encode(value)); } export async function computeBinaryHash(value: ArrayBuffer | Uint8Array): Promise { return computeHash(normalizeBytes(value)); }