Skip to content

Commit 4ed544b

Browse files
chapterjasonclaude
andcommitted
feat(api): hash API keys at rest with SHA-256
- Keys stored as SHA-256 hashes instead of plaintext - authenticate() uses timingSafeEqual for constant-time comparison - Auto-migrates existing plaintext keys on first successful auth - create-project stores hash, notes key is not recoverable after creation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f99d0fa commit 4ed544b

2 files changed

Lines changed: 40 additions & 8 deletions

File tree

packages/api/src/auth/auth.js

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2-
import { randomBytes } from "crypto";
2+
import { randomBytes, createHash, timingSafeEqual } from "crypto";
33
import { logger } from "@sourecode/agent-backlog-core/logger.js";
44
import { API_DATA_DIR, API_DATA_KEYS_PATH } from "@sourecode/agent-backlog-core/config.js";
55

6+
export function hashKey(key) {
7+
return createHash("sha256").update(key).digest("hex");
8+
}
9+
610
export function loadApiKeys() {
711
if (!existsSync(API_DATA_KEYS_PATH)) return {};
812
try {
@@ -26,8 +30,36 @@ export function authenticate(req) {
2630
const auth = req.headers.authorization;
2731
if (!auth || !auth.startsWith("Bearer ")) return null;
2832
const key = auth.slice(7);
33+
const keyHash = hashKey(key);
34+
const keyHashBuf = Buffer.from(keyHash, "hex");
35+
2936
const keys = loadApiKeys();
30-
const entry = keys[key];
31-
if (!entry) return null;
32-
return entry.slug;
37+
let migrated = false;
38+
39+
for (const [stored, entry] of Object.entries(keys)) {
40+
// Auto-migrate plaintext keys (they start with "sk-proj-")
41+
if (stored.startsWith("sk-proj-")) {
42+
const storedHash = hashKey(stored);
43+
delete keys[stored];
44+
keys[storedHash] = entry;
45+
migrated = true;
46+
logger.info("api-keys:migrated-plaintext-key", { slug: entry.slug });
47+
if (storedHash === keyHash) return entry.slug;
48+
continue;
49+
}
50+
51+
// Constant-time comparison of hashes
52+
try {
53+
const storedBuf = Buffer.from(stored, "hex");
54+
if (storedBuf.length === keyHashBuf.length && timingSafeEqual(storedBuf, keyHashBuf)) {
55+
if (migrated) saveApiKeys(keys);
56+
return entry.slug;
57+
}
58+
} catch {
59+
// stored value is not valid hex — skip
60+
}
61+
}
62+
63+
if (migrated) saveApiKeys(keys);
64+
return null;
3365
}

packages/api/src/cli/create-project.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync } from "fs";
22
import { join } from "path";
3-
import { loadApiKeys, saveApiKeys, generateApiKey } from "../auth/auth.js";
3+
import { loadApiKeys, saveApiKeys, generateApiKey, hashKey } from "../auth/auth.js";
44
import { API_DATA_DB_DIR } from "@sourecode/agent-backlog-core/config.js";
55
import { getStoreForSlug } from "../store/store.js";
66

@@ -17,16 +17,16 @@ if (!/^[a-z0-9-]+$/.test(slug)) {
1717
mkdirSync(API_DATA_DB_DIR, { recursive: true });
1818
const keys = loadApiKeys();
1919

20-
for (const [key, entry] of Object.entries(keys)) {
20+
for (const [, entry] of Object.entries(keys)) {
2121
if (entry.slug === slug) {
2222
console.log(`Project "${slug}" already exists.`);
23-
console.log(`API Key: ${key}`);
23+
console.log(`(API key is hashed at rest and cannot be recovered. Delete and recreate the project to issue a new key.)`);
2424
process.exit(0);
2525
}
2626
}
2727

2828
const apiKey = generateApiKey();
29-
keys[apiKey] = { slug, created: new Date().toISOString() };
29+
keys[hashKey(apiKey)] = { slug, created: new Date().toISOString() };
3030
saveApiKeys(keys);
3131

3232
const store = getStoreForSlug(slug);

0 commit comments

Comments
 (0)