Skip to content

[Security][Critical] Weak AES key derivation for user API key encryption (SHA-256, no KDF) #67

@bmersereau

Description

@bmersereau

Severity: Critical

File: backend/src/lib/userApiKeys.ts:39-48
CWE: CWE-916 — Use of Password Hash With Insufficient Computational Effort
OWASP: A02:2021 — Cryptographic Failures

Description

The AES-256-GCM encryption key for stored user API keys is derived with a raw SHA-256 hash — no KDF, no salt, no iteration count:

function encryptionKey(): Buffer {
    const secret = process.env.USER_API_KEYS_ENCRYPTION_SECRET || ...
    return crypto.createHash("sha256").update(secret).digest();
}

Impact

Anyone who obtains the secret computes the identical AES key in a single SHA-256 operation and immediately decrypts every row in user_api_keys. There is no per-entry salt, so all rows are decryptable in one pass — no per-entry brute force required.

Users store Anthropic, OpenAI, and Gemini API keys here. These keys have direct monetary value and can be used to run inference workloads billed to the victim.

Fix

Replace SHA-256 with HKDF (RFC 5869) and store a per-row random salt:

import { hkdfSync, randomBytes } from "crypto";

function deriveKey(secret: string, salt: Buffer): Buffer {
    return Buffer.from(hkdfSync("sha256", secret, salt, "mike-api-keys-v1", 32));
}

function encrypt(value: string): Omit<EncryptedKeyRow, "provider"> {
    const salt = randomBytes(16);
    const key = deriveKey(getEncryptionSecret(), salt);
    const iv = randomBytes(12);
    const cipher = createCipheriv("aes-256-gcm", key, iv);
    const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
    return {
        encrypted_key: encrypted.toString("base64"),
        iv: iv.toString("base64"),
        auth_tag: cipher.getAuthTag().toString("base64"),
        salt: salt.toString("base64"),
    };
}

A salt column must be added to the user_api_keys table. Existing rows will need re-encryption during migration.

Remediation tier: Immediate — block all production deploys until resolved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions