/** * Reimplementation of string-crypto v2 using SubtleCrypto (Web Crypto API) * Works in browsers, Bun, Deno, Cloudflare Workers, etc. */ const KEYLEN = 256; // bits async function deriveKey( password: string, salt: string = "s41t", iterations: number = 1 ): Promise { const enc = new TextEncoder(); // Import password as raw key material const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"] ); // Derive AES-GCM key using PBKDF2 return crypto.subtle.deriveKey( { name: "PBKDF2", salt: enc.encode(salt), iterations, hash: "SHA-512", }, keyMaterial, { name: "AES-GCM", length: KEYLEN }, false, ["encrypt", "decrypt"] ); } export async function encryptString( str: string, password: string ): Promise { const key = await deriveKey(password); const enc = new TextEncoder(); // Generate random IV const iv = crypto.getRandomValues(new Uint8Array(16)); // Encrypt const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, enc.encode(str) ); // SubtleCrypto appends 16-byte auth tag, but v2 string-crypto ignores it // Strip the last 16 bytes to match v2 behavior const encryptedWithoutTag = new Uint8Array(encrypted).slice(0, -16); // Convert to base64, then to hex (v2 quirk) const encryptedBase64 = btoa( String.fromCharCode(...encryptedWithoutTag) ); const encryptedHex = Buffer.from(encryptedBase64).toString("hex"); // IV to hex const ivHex = Array.from(iv) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return `${ivHex}:${encryptedHex}`; } export async function decryptString( encryptedStr: string, password: string ): Promise { const key = await deriveKey(password); const [ivHex, encryptedHex] = encryptedStr.split(":"); // Hex to IV const iv = new Uint8Array( ivHex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)) ); // Hex to base64 to bytes const encryptedBase64 = Buffer.from(encryptedHex, "hex").toString(); const encryptedBytes = Uint8Array.from(atob(encryptedBase64), (c) => c.charCodeAt(0) ); // Decrypt const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, encryptedBytes ); return new TextDecoder().decode(decrypted); } // Test if (import.meta.main) { // Load .env const envFile = Bun.file(".env"); if (await envFile.exists()) { const envContent = await envFile.text(); for (const line of envContent.split("\n")) { const [key, ...valueParts] = line.split("="); if (key && valueParts.length) { process.env[key.trim()] = valueParts.join("=").trim(); } } } const email = process.env.ANYCLIP_EMAIL || process.env.ANYCLIP_USER; const plaintext = process.env.ANYCLIP_PASSWORD; if (!email || !plaintext) { console.error("Set ANYCLIP_EMAIL and ANYCLIP_PASSWORD in .env"); process.exit(1); } const salt = "$2b$04$wwky7rvtr6BFNaCqntwyie"; const encrypted = await encryptString(plaintext, salt); console.log("Encrypted:", encrypted); // Test with API console.log("\nTesting with AnyClip API..."); const response = await fetch( "https://videomanager-api.anyclip.com/public/auth/login", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ email, password: encrypted, }), } ); console.log("Status:", response.status); if (response.ok) { console.log("✅ Works with SubtleCrypto!"); } else { const text = await response.text(); console.log("Response:", text); } }