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:
parent
eb79d2df4a
commit
bd02a0ff50
3 changed files with 53 additions and 87 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue