diff --git a/app/middleware/auth.js b/app/middleware/auth.js index 4b6bedf..42946e9 100644 --- a/app/middleware/auth.js +++ b/app/middleware/auth.js @@ -27,16 +27,33 @@ * keeps the cheap check cheap (one DB hit, not three). */ +const crypto = require('crypto'); const { sequelize } = require('../config/db.config.js'); const db = require('../config/db.config.js'); const log = require('../config/logger.js'); +/** + * Hash an authKey for lookup. Migration 20260518000000 converted + * ApiKey.akKEY / ApiMaster.amKEY columns from UUID to TEXT and + * replaced row values with SHA-256 hex digests. Operator tokens + * issued before the migration keep working because the API hashes + * the incoming header here before the SQL lookup. + * + * SHA-256 unsalted (vs bcrypt/argon2id) because API tokens are + * high-entropy (UUID v4 = 122 bits); brute force against a leaked + * hash table is impractical. Hashing is to prevent direct replay + * if the DB leaks, not to protect a low-entropy password. + */ +function hashKey(rawKey) { + return crypto.createHash('sha256').update(String(rawKey)).digest('hex'); +} + async function isMaster(authKey) { if (!authKey || authKey.length === 0) return false; try { const r = await db.sequelize.query( 'SELECT * FROM "dbo"."ApiMaster" WHERE "amKEY" = ? AND "ApiMaster"."amArchive" = false;', - { replacements: [authKey], type: sequelize.QueryTypes.SELECT }, + { replacements: [hashKey(authKey)], type: sequelize.QueryTypes.SELECT }, ); if (!r || r.length === 0) return false; return typeof r[0].amId === 'number' && r[0].amId > 0; @@ -51,7 +68,7 @@ async function getCompanyId(authKey) { try { const r = await db.sequelize.query( 'SELECT * FROM "dbo"."ApiKey" WHERE "akKEY" = ? AND "ApiKey"."akArchive" = false;', - { replacements: [authKey], type: sequelize.QueryTypes.SELECT }, + { replacements: [hashKey(authKey)], type: sequelize.QueryTypes.SELECT }, ); if (!r || r.length === 0) return -1; const cid = r[0].akCompanyId; @@ -220,4 +237,5 @@ module.exports = { getCompanyIdByPohId, requireAuthKey, resolveAuth, + hashKey, }; diff --git a/app/migrations/20260518000000-hash-api-keys.js b/app/migrations/20260518000000-hash-api-keys.js new file mode 100644 index 0000000..2da97d8 --- /dev/null +++ b/app/migrations/20260518000000-hash-api-keys.js @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Hash ApiKey.akKEY + ApiMaster.amKEY at rest. +// +// Background (audit issue #73, P1-A): +// The two key columns were UUID-typed and stored the raw token +// verbatim. Any DB leak or read-replica snapshot meant every +// active operator's credential was immediately usable. After +// this migration the columns store a SHA-256 hex digest of the +// raw token; auth.js hashes the incoming header before lookup, +// so operator-held tokens keep working without re-issue. +// +// Migration steps: +// 1. Change column types from UUID to TEXT (USING cast() so PG +// doesn't error on existing rows). Drop NOT NULL temporarily +// so we can write the hash without race-conditions on length. +// 2. UPDATE each row's to SHA256() in JS. +// Skip rows whose value is already 64 hex chars (operator +// may have run a partial migration manually). +// 3. Re-apply NOT NULL. +// +// Rollback (down): +// Cannot recover plaintext from a SHA-256 hash. The down step +// only reverts the column TYPE to UUID; rows will then need to +// be manually rotated. Document that operationally. + +'use strict'; + +const crypto = require('crypto'); + +function sha256(s) { + return crypto.createHash('sha256').update(String(s)).digest('hex'); +} + +module.exports = { + async up(queryInterface, Sequelize) { + const SCHEMA = 'dbo'; + const sequelize = queryInterface.sequelize; + + // --- ApiKey --- + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiKey" + ALTER COLUMN "akKEY" DROP NOT NULL`, + ); + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiKey" + ALTER COLUMN "akKEY" TYPE TEXT USING "akKEY"::text`, + ); + + const apiKeyRows = await sequelize.query( + `SELECT "akId", "akKEY" FROM "${SCHEMA}"."ApiKey"`, + { type: Sequelize.QueryTypes.SELECT }, + ); + for (const r of apiKeyRows) { + const v = r.akKEY || ''; + // Skip already-hashed rows: SHA-256 hex is exactly 64 chars and + // matches /^[0-9a-f]+$/. Real UUIDs (36 chars w/ dashes) don't. + if (v.length === 64 && /^[0-9a-f]+$/.test(v)) continue; + const hashed = sha256(v); + await sequelize.query( + `UPDATE "${SCHEMA}"."ApiKey" SET "akKEY" = :hashed WHERE "akId" = :id`, + { replacements: { hashed, id: r.akId } }, + ); + } + + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiKey" + ALTER COLUMN "akKEY" SET NOT NULL`, + ); + // Index the hashed key so WHERE "akKEY" = ? lookups stay + // fast. Non-unique on purpose: archived rows from key + // rotation may share a hash with their non-archived + // replacement until they're physically deleted, and uniqueness + // was never enforced as a constraint pre-migration. + await sequelize.query( + `CREATE INDEX IF NOT EXISTS "ApiKey_keyHash_idx" ON "${SCHEMA}"."ApiKey" ("akKEY")`, + ); + + // --- ApiMaster --- + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiMaster" + ALTER COLUMN "amKEY" DROP NOT NULL`, + ); + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiMaster" + ALTER COLUMN "amKEY" TYPE TEXT USING "amKEY"::text`, + ); + + const apiMasterRows = await sequelize.query( + `SELECT "amId", "amKEY" FROM "${SCHEMA}"."ApiMaster"`, + { type: Sequelize.QueryTypes.SELECT }, + ); + for (const r of apiMasterRows) { + const v = r.amKEY || ''; + if (v.length === 64 && /^[0-9a-f]+$/.test(v)) continue; + const hashed = sha256(v); + await sequelize.query( + `UPDATE "${SCHEMA}"."ApiMaster" SET "amKEY" = :hashed WHERE "amId" = :id`, + { replacements: { hashed, id: r.amId } }, + ); + } + + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiMaster" + ALTER COLUMN "amKEY" SET NOT NULL`, + ); + await sequelize.query( + `CREATE INDEX IF NOT EXISTS "ApiMaster_keyHash_idx" ON "${SCHEMA}"."ApiMaster" ("amKEY")`, + ); + }, + + async down(queryInterface, Sequelize) { + const SCHEMA = 'dbo'; + const sequelize = queryInterface.sequelize; + + // Drop the lookup indexes first; can't change column type while + // an index references it. + await sequelize.query(`DROP INDEX IF EXISTS "${SCHEMA}"."ApiKey_keyHash_idx"`); + await sequelize.query(`DROP INDEX IF EXISTS "${SCHEMA}"."ApiMaster_keyHash_idx"`); + + // Convert TYPE back to UUID. Any rows whose current value is a + // SHA-256 hex string will fail the cast — the operator has to + // rotate them to fresh UUIDs first. + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiKey" + ALTER COLUMN "akKEY" TYPE uuid USING "akKEY"::uuid`, + ); + await sequelize.query( + `ALTER TABLE "${SCHEMA}"."ApiMaster" + ALTER COLUMN "amKEY" TYPE uuid USING "amKEY"::uuid`, + ); + }, +}; diff --git a/tests/unit/auth-hash.test.js b/tests/unit/auth-hash.test.js new file mode 100644 index 0000000..14a9417 --- /dev/null +++ b/tests/unit/auth-hash.test.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Unit tests for auth.hashKey + the short-circuit paths of +// isMaster / getCompanyId. +// +// We can't drive the "hashed value reaches the SQL query" assertion +// from here because vi.mock on db.config.js doesn't intercept the +// nested CJS require chain inside auth.js (documented in audit +// issue #73 P5-M). Behavioral coverage of the hash-on-lookup path +// lives in the integration suite; what this file verifies is the +// pure-Node hash logic + the no-DB short-circuit paths. + +import { describe, test, expect } from 'vitest'; + +const { hashKey, isMaster, getCompanyId } = require('../../app/middleware/auth.js'); + +describe('auth.hashKey', () => { + test('returns a 64-char lowercase hex SHA-256 digest', () => { + const h = hashKey('any-token'); + expect(h).toMatch(/^[0-9a-f]{64}$/); + }); + + test('deterministic: same input → same hash', () => { + expect(hashKey('abc')).toBe(hashKey('abc')); + }); + + test('different inputs → different hashes', () => { + expect(hashKey('abc')).not.toBe(hashKey('abd')); + }); + + test('known vector: sha256("") matches RFC reference', () => { + // Empty-string SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + expect(hashKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + test('handles non-string input via String() coercion', () => { + expect(() => hashKey(12345)).not.toThrow(); + expect(hashKey(12345)).toBe(hashKey('12345')); + }); +}); + +describe('auth.isMaster — short-circuit paths', () => { + test('returns false on empty / null input without hitting the DB', async () => { + expect(await isMaster('')).toBe(false); + expect(await isMaster(null)).toBe(false); + expect(await isMaster(undefined)).toBe(false); + }); +}); + +describe('auth.getCompanyId — short-circuit paths', () => { + test('returns -1 sentinel on empty / null input without hitting the DB', async () => { + expect(await getCompanyId('')).toBe(-1); + expect(await getCompanyId(null)).toBe(-1); + expect(await getCompanyId(undefined)).toBe(-1); + }); +});