Security Advisory: API Key Hashing (SEC-24)
Security Advisory: API Key Hashing — SEC-24
Severity: Medium
Control: SEC-24
Category: Data Protection
Affected file: src/lib/pay/auth.ts
Status: Open — remediation pending
Summary
Calmony Pay API keys (sk_live_… and sk_test_…) are hashed with SHA-256 before being stored. While the entropy of the keys themselves is high (32 random bytes = 64 hex characters), SHA-256 is a fast general-purpose hash function. Should the hash database ever be leaked, an attacker with GPU hardware could mount an offline brute-force attack at significantly higher throughput than would be possible against a slow KDF.
Additionally, key rotation — generating a new key to replace an existing one as a single atomic operation — is not currently implemented. Only revocation is available.
Background
How API keys are currently stored
When a new API key is issued, the raw key is shown to the user once and never stored in plaintext. Instead, a SHA-256 digest of the key is persisted to the database and used for subsequent lookup:
// src/lib/pay/auth.ts (current behaviour)
import { createHash } from 'crypto';
const keyHash = createHash('sha256').update(rawKey).digest('hex');
// keyHash is stored; rawKey is discarded
On every authenticated request, the presented key is hashed with the same algorithm and compared against stored digests.
Why SHA-256 alone is a concern
- Speed is the problem. SHA-256 is designed to be fast. A modern GPU can compute billions of SHA-256 digests per second, making offline dictionary or brute-force attacks practical if hashes are leaked.
- OWASP guidance. OWASP recommends a slow KDF (bcrypt, Argon2, PBKDF2) for any stored secret that could be attacked offline. The high entropy of the key values mitigates — but does not eliminate — this risk.
- No rotation. Without a
rotateendpoint, teams must revoke a key and issue a completely new one manually, increasing the window of disruption during a suspected compromise.
Risk Assessment
| Factor | Detail |
|---|---|
| Key entropy | High — 32 random bytes (64 hex chars). Brute-force is expensive even with SHA-256. |
| Attack vector | Requires database compromise (hash leak) before offline attack is possible. |
| Attack feasibility | Feasible with GPU acceleration if hashes are leaked and attacker has partial key knowledge. |
| Missing control | No atomic key rotation endpoint. |
Recommended Remediation
Option 1 — HMAC-SHA256 with a server-side secret (preferred)
Replace bare SHA-256 with an HMAC keyed on a server-side secret stored in an environment variable. This means an attacker who obtains only the hash database cannot mount an offline attack without also compromising the server secret.
// Proposed implementation
import { createHmac } from 'crypto';
const hmacSecret = process.env.API_KEY_HMAC_SECRET; // 32+ random bytes, stored securely
function hashApiKey(rawKey: string): string {
return createHmac('sha256', hmacSecret)
.update(rawKey)
.digest('hex');
}
Trade-offs:
- Lookup remains O(1) and fast — no per-request KDF cost.
- Offline brute-force is infeasible without knowledge of
API_KEY_HMAC_SECRET. - Requires secure storage and rotation of the HMAC secret itself.
- Requires a one-time migration to re-hash all existing stored keys.
Option 2 — Slow KDF (bcrypt / Argon2 / PBKDF2)
Use a slow KDF as recommended by OWASP. This adds per-verification latency proportional to the cost factor.
// Example with bcrypt
import bcrypt from 'bcrypt';
// On key creation
const keyHash = await bcrypt.hash(rawKey, 12);
// On key verification
const isValid = await bcrypt.compare(presentedKey, storedHash);
Trade-offs:
- Strong offline attack resistance regardless of key entropy.
- Adds meaningful per-request latency (bcrypt at cost 12 ≈ 200–400 ms).
- Cannot use a simple equality lookup — must iterate candidate keys for a given account.
Add a Key Rotation Endpoint
A rotate admin procedure should atomically create a new key and revoke the old one:
// Proposed rotate endpoint sketch
rotateProcedure: adminProcedure
.input(z.object({ keyId: z.string() }))
.mutation(async ({ input, ctx }) => {
const newRawKey = generateApiKey(); // generate new sk_live_ / sk_test_
const newHash = hashApiKey(newRawKey); // hash with chosen algorithm
await ctx.db.$transaction([
ctx.db.apiKey.update({ // revoke old key
where: { id: input.keyId },
data: { revokedAt: new Date() },
}),
ctx.db.apiKey.create({ // store new key
data: { hash: newHash, /* … */ },
}),
]);
return { key: newRawKey }; // returned once; never stored
}),
The old and new key operations must be wrapped in a single database transaction to prevent a gap where neither key is valid.
Environment Variable Required
If the HMAC-SHA256 approach is adopted, the following environment variable must be provisioned:
| Variable | Description |
|---|---|
API_KEY_HMAC_SECRET | A cryptographically random secret (minimum 32 bytes, base64 or hex encoded) used to HMAC API key values before storage. Store in a secrets manager; do not commit to source control. |