Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -220,4 +237,5 @@ module.exports = {
getCompanyIdByPohId,
requireAuthKey,
resolveAuth,
hashKey,
};
134 changes: 134 additions & 0 deletions app/migrations/20260518000000-hash-api-keys.js
Original file line number Diff line number Diff line change
@@ -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 <key> to SHA256(<old value>) 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`,
);
},
};
57 changes: 57 additions & 0 deletions tests/unit/auth-hash.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading