Skip to content

fix(security): hash ApiKey/ApiMaster tokens at rest#74

Merged
CryptoJones merged 1 commit into
masterfrom
fix/security-hash-api-keys-at-rest
May 18, 2026
Merged

fix(security): hash ApiKey/ApiMaster tokens at rest#74
CryptoJones merged 1 commit into
masterfrom
fix/security-hash-api-keys-at-rest

Conversation

@CryptoJones
Copy link
Copy Markdown
Owner

Closes #73 (P1-A — the most critical audit finding).

Problem

ApiKey.akKEY and ApiMaster.amKEY were UUID-typed columns storing the raw token verbatim. Any DB leak / backup / replica snapshot meant every operator's credential was immediately usable.

Fix

Migration 20260518000000-hash-api-keys converts the columns to TEXT and replaces each row with SHA-256(value). auth.isMaster + auth.getCompanyId hash the incoming header via a new hashKey() helper before the SQL lookup — operator tokens issued before the migration keep working without re-issue.

Unsalted SHA-256 chosen over bcrypt/argon2id: API tokens are high-entropy (UUID v4 = 122 bits), so brute force against a leaked hash table is impractical; per-request bcrypt cost doesn't earn anything against the actual threat (DB-leak replay). Same approach Stripe / GitHub use.

Test plan

  • vitest: 276 + 4 integration skipped (was 269 + 4 skipped); 7 new hashKey + short-circuit cases
  • Live migration apply against the compose Postgres (28 ms after the type change; 8 ApiKey rows hashed)
  • Migration is idempotent — already-hashed rows (64 lowercase hex chars) get skipped on re-run
  • Operator workflow: a UUID token in authKey: header is hashed server-side, matches the migrated row, request succeeds

Migration safety

  • up: ALTER COLUMN UUID → TEXT, hash each row, add lookup index. Idempotent.
  • down: drops the index, attempts UUID cast. Any post-up row still in 64-hex form fails the cast — operator must rotate to fresh UUIDs first. Documented in the migration header.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

Critical finding from the architect audit (#73): both
ApiKey.akKEY and ApiMaster.amKEY columns were UUID-typed and
stored the raw token verbatim. A DB leak, replica snapshot, or
backup-tarball compromise meant every active operator's
credential was immediately replayable.

Migration 20260518000000-hash-api-keys:
  - ALTER COLUMN akKEY / amKEY: UUID → TEXT (USING ::text cast).
  - JS-side UPDATE each row to SHA-256(value). Idempotent — rows
    that look already-hashed (64 lowercase hex chars) are skipped,
    so a partial-failed migration can be re-run safely.
  - Lookup index on the hashed column (non-unique because archived
    rows from key rotation may share a hash with their non-archived
    replacement until physically deleted; uniqueness was never
    constraint-enforced pre-migration).

auth.isMaster + auth.getCompanyId hash the incoming `authKey`
header via the new `hashKey()` helper before the SQL lookup, so
operator-held tokens issued pre-migration keep working without
re-issue. The hash is unsalted SHA-256 (vs. bcrypt/argon2id)
because API tokens are high-entropy (UUID v4 = 122 bits); brute
force against a leaked hash table is impractical, and bcrypt's
per-request work-factor cost doesn't earn anything against the
actual threat (DB-leak replay prevention). Same pattern Stripe /
GitHub / most well-known API services use.

Down migration: reverts column type to UUID. Any rows still in
SHA-256 hex form will fail the cast — operator must rotate to
fresh UUIDs first. Documented in the migration header.

Verified locally against the compose Postgres: migration applies
in 28 ms after the column-type change, 8 ApiKey rows + matching
ApiMaster rows now hashed.

Tests: 7 unit cases for `hashKey` + the no-DB short-circuit paths
of isMaster/getCompanyId. Behavioral test of the hash-on-lookup
flow needs the integration suite (vi.mock on db.config.js doesn't
intercept the nested CJS require chain — documented limitation in
audit #73 P5-M).

Closes #73 P1-A.

Suite: 276 / 276 + 4 integration skipped (was 269 / 269).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CryptoJones CryptoJones merged commit 1b7c051 into master May 18, 2026
2 of 3 checks passed
@CryptoJones CryptoJones deleted the fix/security-hash-api-keys-at-rest branch May 18, 2026 02:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Architecture audit: prioritized list of security + functionality follow-ups

1 participant