fix: replace crypto.subtle with node-forge for HTTP compatibility

crypto.subtle is only available in secure contexts (HTTPS/localhost),
which breaks provider API key encryption when Studio is accessed over
plain HTTP on remote GPU VMs. Switch to node-forge for RSA-OAEP and
AES-256-GCM operations — same algorithms, works on any origin.
This commit is contained in:
Roland Tannous 2026-04-08 10:35:54 +04:00
commit bd02a0ff50
3 changed files with 53 additions and 87 deletions

View file

@ -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",

View file

@ -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<CryptoKey> {
if (!forceRefresh && cachedCryptoKey) {
return cachedCryptoKey;
): Promise<forge.pki.rsa.PublicKey> {
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<string> {
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<ProviderRegistryEntry[]> {

View file

@ -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<CryptoKey> {
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<string> {
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<string> {
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());
}