Claude skill key management security
Claude skills that encrypt data, sign tokens, or handle TLS connections must manage cryptographic key material correctly. The four patterns in SA-SKL-CRY-005 through CRY-008 cover the key management layer: using broken hash functions as key derivation functions, storing derived keys in environment variables alongside their initialization vectors, reusing the same secret across incompatible cryptographic primitives (HMAC and JWT signing), and accidentally logging private key material at debug verbosity. Each pattern creates a different failure mode: brute-force on weak keys, ciphertext compromise from IV co-location, signature forgery through algorithm confusion, and key exfiltration through structured log aggregation.
Cryptographic key management errors are among the most consequential vulnerabilities in MCP skills because their impact is durable: a skill that encrypts data with a weak key may have encrypted months of user data that can be retrospectively decrypted when the key is brute-forced. A JWT signing key that has been logged and exfiltrated allows unlimited token forgery until the key is rotated. Unlike input validation vulnerabilities that can be patched before exploitation, key management vulnerabilities often leave previously encrypted data or issued tokens compromised even after the bug is fixed. This makes early detection — before data is encrypted with a weak key or tokens are signed with a leaked secret — especially valuable in the MCP skill context.
SA-SKL-CRY-005: MD5 or SHA-1 used as key derivation function instead of PBKDF2 or Argon2
A key derivation function (KDF) converts a human-memorable password into a fixed-length cryptographic key. MD5 and SHA-1 are hash functions designed for speed; they can compute billions of hashes per second on a GPU. PBKDF2, bcrypt, and Argon2 are purpose-built KDFs designed for slowness: they apply the hash function tens of thousands to hundreds of thousands of times (PBKDF2 iterations) or require large amounts of memory (Argon2) specifically to make brute-force guessing expensive. A skill that computes createHash('sha1').update(password).digest() to derive an AES key gives an attacker who obtains the derived key or ciphertext the ability to brute-force the password at GPU speed — typically billions of attempts per second for SHA-1 — making any password up to 10 characters recoverable in seconds to minutes.
// VULNERABLE: MD5 or SHA-1 as KDF — brute-force at GPU speed
import crypto from 'crypto';
function deriveKeyFromPassword_VULNERABLE(password) {
// MD5: ~10 billion hashes/sec on consumer GPU → 8-char lowercase password: <1 second
const key = crypto.createHash('md5').update(password).digest();
return key; // 16 bytes (128-bit) AES key derived in one hash operation
}
function deriveKeyFromPassword_ALSO_VULNERABLE(password) {
// SHA-1: ~4 billion hashes/sec → same brute-force timeline as MD5
const key = crypto.createHash('sha1').update(password).digest().slice(0, 16);
return key; // still trivially brute-forceable
}
// ALSO VULNERABLE: SHA-256 without salt and iterations
function deriveKeyFromPassword_WEAK(password) {
// SHA-256 without iterations: ~1 billion hashes/sec → still fast enough to brute-force
return crypto.createHash('sha256').update(password).digest().slice(0, 32);
}
// SECURE: PBKDF2 with 600,000+ iterations (NIST 2023 recommendation for PBKDF2-HMAC-SHA256)
async function deriveKeyFromPassword_SECURE(password, salt) {
// salt must be random, 16+ bytes, stored alongside the encrypted data
const keyBuffer = await new Promise((resolve, reject) => {
crypto.pbkdf2(
password,
salt,
600_000, // iterations — ~200ms on modern hardware per derivation
32, // key length in bytes (256-bit AES key)
'sha256',
(err, key) => err ? reject(err) : resolve(key)
);
});
return keyBuffer; // 200ms per attempt → GPU brute-force: ~5 attempts/sec → infeasible
}
// EVEN BETTER: Argon2 (memory-hard — resistant to GPU and ASIC attacks)
// npm install argon2
import argon2 from 'argon2';
async function deriveKeyFromPassword_ARGON2(password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id, // argon2id: resistant to both side-channel and GPU attacks
memoryCost: 65536, // 64 MB memory requirement — each GPU core needs 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel threads
hashLength: 32 // 256-bit output
});
return hash;
}
// DETECTION (SkillAudit):
// createHash('md5') | createHash('sha1') | createHash('sha256')
// used as key derivation (output fed to createCipheriv or stored as encryption key)
const weakKdf = searchAST(
"createHash('md5') | createHash('sha1') | createHash('sha256') " +
"where output is used as AES key (fed to createCipheriv second argument)"
);
if (weakKdf.length > 0) → CRITICAL // SA-SKL-CRY-005
Salts are mandatory for KDFs — reusing the same salt defeats the purpose: a KDF without a unique random salt allows pre-computation attacks (rainbow tables) where the attacker computes a dictionary of password → key mappings offline and looks up any derived key instantly. Each encrypted record must use a different random salt generated at encryption time with crypto.randomBytes(16), stored alongside the ciphertext, and fed to the KDF at decryption time. Never use a fixed salt like "skillaudit" or the user’s identifier as the salt.
createHash('md5'|'sha1') output used as cipher key → CRITICAL.SA-SKL-CRY-006: AES key stored in plaintext environment variable alongside IV
AES encryption requires two secrets: the key (which must remain secret) and an initialization vector or nonce (which must be unique per encryption but is typically not secret). A common mistake in MCP skill deployment is storing both the AES key and a reused IV together in environment variables: AES_KEY=<hex> and AES_IV=<hex>. This concentrates both the key and the IV in the same location (the environment), so that any attacker who reads the environment — via a credential-leaking SA-SKL-ENV vulnerability, a misrouted error message, or a cloud misconfiguration exposing environment variables — obtains everything needed to decrypt all previously encrypted data. Additionally, reusing the same IV with the same key in AES-CBC mode allows a known-plaintext attack to recover the key by XOR-ing two ciphertexts encrypted with the same IV.
// VULNERABLE: AES key and IV both stored in env vars — co-located secrets
import crypto from 'crypto';
const AES_KEY = Buffer.from(process.env.AES_KEY, 'hex'); // 32 bytes
const AES_IV = Buffer.from(process.env.AES_IV, 'hex'); // 16 bytes — REUSED for every encryption
function encryptData_VULNERABLE(plaintext) {
const cipher = crypto.createCipheriv('aes-256-cbc', AES_KEY, AES_IV);
// Same IV on every call: AES-CBC with same IV and key → XOR of two ciphertexts
// reveals XOR of plaintexts (same-IV XOR attack)
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted; // No IV in output — receiver also uses the static env var IV
}
// ALSO VULNERABLE: deriving IV from static data (timestamp, counter)
const counter = { n: 0 };
function encryptData_COUNTER_IV(plaintext) {
const iv = Buffer.alloc(16, counter.n++); // sequential IVs — predictable, reusable
const cipher = crypto.createCipheriv('aes-256-cbc', AES_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}`;
}
// SECURE: AES-GCM with random IV per encryption — IV stored with ciphertext
function encryptData_SECURE(plaintext) {
const iv = crypto.randomBytes(12); // 96-bit random IV for AES-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', AES_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag(); // GCM authentication tag — verifies integrity on decrypt
// Store IV and auth tag with ciphertext — neither is secret
return JSON.stringify({
iv: iv.toString('hex'),
ciphertext: encrypted,
authTag: authTag.toString('hex')
});
}
function decryptData_SECURE(encryptedJson) {
const { iv, ciphertext, authTag } = JSON.parse(encryptedJson);
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
AES_KEY,
Buffer.from(iv, 'hex')
);
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// For the key itself: use a secrets manager (AWS Secrets Manager, HashiCorp Vault,
// Doppler) rather than environment variables — env vars are accessible to any
// code running in the process and appear in error messages, logs, and /proc/*/environ
// DETECTION (SkillAudit):
// createCipheriv(*, process.env.AES_KEY, process.env.AES_IV) — static IV from env
const staticIvKey = searchAST(
'createCipheriv(*, process.env.*, process.env.*) ' +
'OR createCipheriv(*, *, staticIvVar) where staticIvVar is module-level constant'
);
if (staticIvKey.length > 0) → HIGH // SA-SKL-CRY-006
Prefer AES-GCM over AES-CBC for new implementations: AES-CBC requires a random IV and proper padding, and is vulnerable to padding oracle attacks if decryption errors are surfaced to callers. AES-GCM is authenticated (the authTag detects tampering), requires a random nonce, and does not use padding. For MCP skills encrypting user data, use aes-256-gcm with crypto.randomBytes(12) as the nonce. Store {nonce, ciphertext, authTag} together — all three are required for decryption and only the key needs to be secret.
createCipheriv(*, process.env.*, process.env.*) → HIGH.SA-SKL-CRY-007: HMAC secret reused as JWT signing secret enables cross-algorithm forgery
JWTs can be signed with symmetric HMAC algorithms (HS256, HS384, HS512) using a shared secret, or with asymmetric RSA/EC algorithms (RS256, ES256) using a key pair. When the same secret value is used for both HMAC-based JWT signing and for a separate HMAC-based authentication mechanism (API request signing, webhook verification, CSRF tokens), an attacker who obtains the HMAC secret through any path can also forge JWTs. More specifically, a cross-algorithm confusion attack works when a JWT library accepts the algorithm from the token header rather than requiring the algorithm to be specified by the verifier: an attacker crafts a JWT with alg: HS256 and signs it with the server’s RSA public key (which is public). The library, instructed to accept “any algorithm,” verifies the HMAC against what is actually a public key, and the forgery succeeds.
// VULNERABLE: same secret for HMAC webhook verification and JWT signing
const SECRET = process.env.APP_SECRET; // single shared secret
// Used for HMAC webhook signature verification
function verifyWebhook(payload, signature) {
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}
// Same SECRET used for JWT signing
import jwt from 'jsonwebtoken';
function signToken(userId) {
return jwt.sign({ userId }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
}
function verifyToken_VULNERABLE(token) {
// Algorithm not pinned — accepts any algorithm the token header specifies
// Attacker sends JWT with alg:'none' → verifies without signature
// Or: if RS256 key pair exists, attacker signs with public key and specifies HS256
return jwt.verify(token, SECRET); // ← algorithm not specified → accepts alg from header
}
// SECURE: separate secrets per purpose + pin algorithm in verification
const JWT_SECRET = process.env.JWT_SECRET; // separate from other secrets
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // independent rotation cadence
function verifyToken_SECURE(token) {
// algorithms array: only accept HS256 — rejects 'none', RS256, and any other alg
return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
}
// For asymmetric JWT (recommended for production): use RS256 or ES256
// Key pair: private key signs (kept secret), public key verifies (can be distributed)
// No shared secret to leak — even HMAC secret exposure doesn't affect RS256 verification
function signToken_ASYMMETRIC(userId) {
return jwt.sign({ userId }, process.env.JWT_PRIVATE_KEY, {
algorithm: 'RS256',
expiresIn: '1h'
});
}
function verifyToken_ASYMMETRIC(token) {
return jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ['RS256'] });
}
// DETECTION (SkillAudit):
// jwt.verify(token, secret) without algorithms option
// OR same env var used in both createHmac() and jwt.sign()
const jwtConfusion = searchAST(
'jwt.verify(token, *) without {algorithms: [...]} option ' +
'OR same process.env.* used in both createHmac() and jwt.sign()'
);
if (jwtConfusion.length > 0) → HIGH // SA-SKL-CRY-007
Always pin the algorithm in jwt.verify(): the algorithms option in jsonwebtoken’s verify() call is not optional in secure configurations. Without it, the library accepts the algorithm from the token’s own header, which the attacker controls. Set it to exactly one algorithm: { algorithms: ['HS256'] } for HMAC tokens or { algorithms: ['RS256'] } for RSA tokens. Never use a wildcard or omit the option. The alg: none attack — a JWT with no signature that passes verification when the algorithm is not pinned — has been known since 2015 and is still found in production code today.
jwt.verify() allows alg:none forgery; cross-algorithm confusion enables forging tokens signed with RSA public key. Detection: jwt.verify() without algorithms option → HIGH.SA-SKL-CRY-008: private key material logged at DEBUG level during TLS handshake debugging
MCP skills that establish mutual TLS connections or manage their own TLS certificates sometimes enable verbose TLS debugging when diagnosing connection issues. Node.js exposes TLS key material through the SSLKEYLOGFILE environment variable (used by Wireshark for TLS decryption), the NODE_DEBUG=tls flag, and custom debug logging in TLS event handlers. When a developer enables these features to debug a connection issue and the debug output reaches a structured log aggregation system (Datadog, Splunk, Elastic), the private key material is indexed and queryable by anyone with log access. Unlike application secrets stored in environment variables, key material in logs is often retained for 30-90 days in log rotation policies and replicated across multiple storage tiers, making rotation after discovery insufficient without also purging the logs.
// VULNERABLE: TLS debug logging that exposes key material
import tls from 'tls';
import fs from 'fs';
// PATTERN 1: SSLKEYLOGFILE env var — logs all TLS session keys to file
// Never set in production — enables post-hoc decryption of all captured traffic
process.env.SSLKEYLOGFILE = '/tmp/ssl-keys.log'; // ← keys written to filesystem
// PATTERN 2: Custom keylog event handler writing to application logger
const tlsSocket = tls.connect({
host: 'api.example.com',
port: 443,
key: fs.readFileSync('/etc/ssl/private/client.key'),
cert: fs.readFileSync('/etc/ssl/certs/client.crt')
});
tlsSocket.on('keylog', (line) => {
// keylog event fires with TLS session key material (NSS Key Log Format)
// Writing to application logger → key material indexed in log aggregation
logger.debug(`TLS keylog: ${line}`); // ← private session key in debug logs
});
// PATTERN 3: Logging the private key itself during certificate loading (common debugging mistake)
const privateKey = fs.readFileSync(process.env.TLS_KEY_PATH, 'utf8');
logger.debug('Loaded TLS config', { privateKey }); // ← private key serialized to log
// SECURE: never log key material; use structured TLS debugging without key exposure
// If TLS debugging is genuinely needed (CI/test only, never production):
// 1. Use a separate test certificate and key pair — not production keys
// 2. Write keylog to a file, not to application logger
// 3. Delete the keylog file immediately after debugging session
// 4. Never commit SSLKEYLOGFILE configuration to version control
// For production TLS troubleshooting without key exposure:
// Log only: protocol version, cipher suite, certificate subject/issuer/expiry
tlsSocket.on('secureConnect', () => {
const { protocol, cipher, getPeerCertificate } = tlsSocket;
const cert = tlsSocket.getPeerCertificate();
logger.info('TLS handshake complete', {
protocol: tlsSocket.getProtocol(),
cipher: tlsSocket.getCipher()?.name,
serverCertSubject: cert?.subject?.CN,
serverCertExpiry: cert?.valid_to
// No key material, no session keys, no private key
});
});
// Guard TLS debugging behind an explicit flag that defaults off in production
if (process.env.TLS_DEBUG === 'true' && process.env.NODE_ENV !== 'production') {
tlsSocket.on('keylog', (line) => {
fs.appendFileSync('/tmp/debug-keys.log', line + '\n');
// Note: still risky — ensure /tmp/debug-keys.log is cleaned up after use
});
}
// DETECTION (SkillAudit):
// process.env.SSLKEYLOGFILE assignment
// OR tlsSocket.on('keylog', handler) without NODE_ENV !== 'production' guard
// OR logger.*(privateKey) where privateKey loaded from PEM file
const keylogRisk = searchAST(
"process.env.SSLKEYLOGFILE = * " +
"OR on('keylog', * => logger.*(*)) without !production guard " +
"OR logger.*({privateKey: *}) | logger.*({key: pemContent})"
);
if (keylogRisk.length > 0) → HIGH // SA-SKL-CRY-008
Log retention makes key rotation insufficient after a keylog incident: if private key material reaches a log aggregation system, rotating the key stops future exploitation but does not remove the key from historical logs. An attacker with log access can use the logged key to decrypt any previously captured traffic for the log retention period (typically 30-90 days). After discovering that a private key was logged, the complete remediation requires: (1) rotate the key immediately, (2) purge the specific log entries from all log storage tiers and indices, (3) assume all traffic captured during the exposure window is compromised and audit for signs of decryption. Preventing the key from reaching logs in the first place is the only safe approach.
SSLKEYLOGFILE or keylog event handler writes TLS session keys to application logger; private key value serialized in log call; key material indexed in log aggregation system and retained for retention period. Detection: process.env.SSLKEYLOGFILE or logger.*(privateKey) → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| CRY-005: MD5/SHA-1 as KDF | CRITICAL | Replace with PBKDF2 (600k iterations, SHA-256) or Argon2id; use unique random salt per encrypted record |
| CRY-006: AES key + static IV in env vars | HIGH | Use AES-256-GCM with crypto.randomBytes(12) nonce; store key in secrets manager not env var |
| CRY-007: HMAC/JWT secret reuse + unpinned algorithm | HIGH | Separate secrets per purpose; pin algorithm in jwt.verify({algorithms:['HS256']}) |
| CRY-008: private key in debug logs | HIGH | Remove SSLKEYLOGFILE; guard keylog event behind !production; never serialize key material in log calls |
SkillAudit’s static analyzer detects all four CRY key management patterns through AST-based detection of cryptographic API usage in skill source code. This page continues the SA-SKL-CRY family started in SA-SKL-CRY: cryptographic implementation errors. The full family is part of our complete 15-family attack surface reference. Related families: SA-SKL-ENV environment variable exposure, SA-SKL-LOG logging exposure, SA-SKL-TLS certificate validation bypass.
Scan your Claude skill for cryptographic vulnerabilities
SkillAudit detects weak KDFs, static IVs, JWT algorithm confusion, and key logging across the full SA-SKL-CRY family in 60 seconds.
Get your free audit