diff --git a/studio/frontend/package.json b/studio/frontend/package.json index d9bab4de7e..3f3bf33228 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -58,6 +58,7 @@ "motion": "^12.34.0", "next": "^16.1.6", "next-themes": "^0.4.6", + "node-forge": "^1.4.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-day-picker": "^9.13.2", @@ -80,6 +81,7 @@ "@eslint/js": "^9.39.1", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/studio/frontend/src/features/chat/api/providers-api.ts b/studio/frontend/src/features/chat/api/providers-api.ts index 40408bc490..4e1c126ff0 100644 --- a/studio/frontend/src/features/chat/api/providers-api.ts +++ b/studio/frontend/src/features/chat/api/providers-api.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import forge from "node-forge"; import { authFetch } from "@/features/auth"; export interface ProviderRegistryEntry { @@ -75,39 +76,19 @@ export function isProviderKeyRotationError(error: unknown): boolean { ); } -function pemToBuffer(pem: string): ArrayBuffer { - const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""); - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i += 1) { - out[i] = bin.charCodeAt(i); - } - return out.buffer; -} - -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); - } - return btoa(binary); -} - let cachedPublicKeyPem: string | null = null; -let cachedCryptoKey: CryptoKey | null = null; +let cachedForgeKey: forge.pki.rsa.PublicKey | null = null; export function clearProviderPublicKeyCache(): void { cachedPublicKeyPem = null; - cachedCryptoKey = null; + cachedForgeKey = null; } async function importProviderPublicKey( forceRefresh = false, -): Promise { - if (!forceRefresh && cachedCryptoKey) { - return cachedCryptoKey; +): Promise { + if (!forceRefresh && cachedForgeKey) { + return cachedForgeKey; } const response = await authFetch("/api/providers/public-key"); const body = await parseJsonOrThrow<{ public_key: string }>(response); @@ -115,19 +96,13 @@ async function importProviderPublicKey( if (!publicKeyPem) { throw new Error("Provider public key is missing."); } - if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedCryptoKey) { - return cachedCryptoKey; + if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedForgeKey) { + return cachedForgeKey; } - const cryptoKey = await crypto.subtle.importKey( - "spki", - pemToBuffer(publicKeyPem), - { name: "RSA-OAEP", hash: "SHA-256" }, - false, - ["encrypt"], - ); + const forgeKey = forge.pki.publicKeyFromPem(publicKeyPem); cachedPublicKeyPem = publicKeyPem; - cachedCryptoKey = cryptoKey; - return cryptoKey; + cachedForgeKey = forgeKey; + return forgeKey; } export async function encryptProviderApiKey( @@ -135,13 +110,11 @@ export async function encryptProviderApiKey( forceRefresh = false, ): Promise { const key = await importProviderPublicKey(forceRefresh); - const encoded = new TextEncoder().encode(plaintextApiKey); - const encrypted = await crypto.subtle.encrypt( - { name: "RSA-OAEP" }, - key, - encoded, - ); - return arrayBufferToBase64(encrypted); + const encrypted = key.encrypt(plaintextApiKey, "RSA-OAEP", { + md: forge.md.sha256.create(), + mgf1: { md: forge.md.sha256.create() }, + }); + return forge.util.encode64(encrypted); } export async function listProviderRegistry(): Promise { diff --git a/studio/frontend/src/features/chat/crypto-storage.ts b/studio/frontend/src/features/chat/crypto-storage.ts index 9d9748fbdb..5387b4a12e 100644 --- a/studio/frontend/src/features/chat/crypto-storage.ts +++ b/studio/frontend/src/features/chat/crypto-storage.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import forge from "node-forge"; + /** * Password-derived AES-256-GCM encryption for API keys at rest in localStorage. * @@ -63,53 +65,37 @@ export function clearSessionPassword(): void { // ── Key derivation ─────────────────────────────────────────────── -/** Derive an AES-256-GCM CryptoKey from a password and salt via PBKDF2. */ -export async function deriveKey( +/** Derive a 256-bit AES key from a password and salt via PBKDF2-SHA256. */ +export function deriveKeyBytes( password: string, salt: Uint8Array, -): Promise { - const keyMaterial = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(password), - "PBKDF2", - false, - ["deriveKey"], - ); - return crypto.subtle.deriveKey( - { name: "PBKDF2", salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); +): string { + const saltStr = forge.util.binary.raw.encode(salt); + return forge.pkcs5.pbkdf2(password, saltStr, PBKDF2_ITERATIONS, 32, forge.md.sha256.create()); } // ── Encrypt / Decrypt ──────────────────────────────────────────── /** * Encrypt a plaintext string. - * Returns a base64 string containing: salt (16 B) ‖ iv (12 B) ‖ ciphertext. + * Returns a base64 string containing: salt (16 B) ‖ iv (12 B) ‖ ciphertext ‖ tag (16 B). */ export async function encryptValue( plaintext: string, password: string, ): Promise { - const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); - const key = await deriveKey(password, salt); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - new TextEncoder().encode(plaintext), - ); - // Concatenate salt + iv + ciphertext into one ArrayBuffer - const combined = new Uint8Array( - SALT_BYTES + IV_BYTES + ciphertext.byteLength, - ); - combined.set(salt, 0); - combined.set(iv, SALT_BYTES); - combined.set(new Uint8Array(ciphertext), SALT_BYTES + IV_BYTES); - return btoa(String.fromCharCode(...combined)); + const salt = forge.random.getBytesSync(SALT_BYTES); + const iv = forge.random.getBytesSync(IV_BYTES); + const keyBytes = deriveKeyBytes(password, Uint8Array.from(salt, (c: string) => c.charCodeAt(0))); + const cipher = forge.cipher.createCipher("AES-GCM", keyBytes); + cipher.start({ iv, tagLength: 128 }); + cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(plaintext))); + cipher.finish(); + const ciphertext = cipher.output.getBytes(); + const tag = cipher.mode.tag.getBytes(); + // Concatenate salt + iv + ciphertext + tag + const combined = salt + iv + ciphertext + tag; + return forge.util.encode64(combined); } /** @@ -120,18 +106,23 @@ export async function decryptValue( encrypted: string, password: string, ): Promise { - const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); - if (combined.byteLength < SALT_BYTES + IV_BYTES + 1) { + const combined = forge.util.decode64(encrypted); + if (combined.length < SALT_BYTES + IV_BYTES + 1 + 16) { throw new Error("Invalid encrypted value: too short"); } const salt = combined.slice(0, SALT_BYTES); const iv = combined.slice(SALT_BYTES, SALT_BYTES + IV_BYTES); - const ciphertext = combined.slice(SALT_BYTES + IV_BYTES); - const key = await deriveKey(password, salt); - const plainBuffer = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - ciphertext, - ); - return new TextDecoder().decode(plainBuffer); + const ciphertextAndTag = combined.slice(SALT_BYTES + IV_BYTES); + const ciphertext = ciphertextAndTag.slice(0, ciphertextAndTag.length - 16); + const tag = ciphertextAndTag.slice(ciphertextAndTag.length - 16); + const saltBytes = Uint8Array.from(salt, (c: string) => c.charCodeAt(0)); + const keyBytes = deriveKeyBytes(password, saltBytes); + const decipher = forge.cipher.createDecipher("AES-GCM", keyBytes); + decipher.start({ iv, tag: forge.util.createBuffer(tag) }); + decipher.update(forge.util.createBuffer(ciphertext)); + const ok = decipher.finish(); + if (!ok) { + throw new Error("Decryption failed: wrong password or tampered data"); + } + return forge.util.decodeUtf8(decipher.output.getBytes()); }