File size: 10,278 Bytes
dcedb25 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { restrictToOwner } from './file-permissions.js';
import type { Db } from '../db/types.js';
const ALGORITHM = 'aes-256-gcm';
let cachedKey: Buffer | null = null;
/**
* AES-256-GCM uses a 32-byte key, hex-encoded as 64 chars.
* A typo'd ENCRYPTION_KEY (e.g. "abc") would historically fall through
* the placeholder check, get truncated to 1.5 bytes, and only fail at
* the first encrypt() call with a cryptic node:crypto error. Validate
* the length up front and fail fast with an actionable message.
*/
const KEY_BYTES = 32;
const KEY_HEX_LEN = KEY_BYTES * 2;
const PLACEHOLDER_KEY = 'your-64-char-hex-key-here';
// The dev-fallback key lives in a file next to the DB, NOT in the DB itself.
// Storing it beside the ciphertext it protects (the old `settings` row) meant
// encryption-at-rest protected nothing for a default install: whoever copied
// the DB also copied the key. The file sits in the same directory but is a
// separate artifact, and is chmod 0600 so it isn't world-readable.
const KEY_FILE_NAME = '.encryption-key';
function parseHexKey(value: string, source: 'env' | 'db' | 'file'): Buffer {
if (value.length !== KEY_HEX_LEN || !/^[0-9a-fA-F]+$/.test(value)) {
throw new Error(
`Invalid ENCRYPTION_KEY (${source}): expected ${KEY_HEX_LEN} hex chars (32 bytes), got ${value.length} chars. ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
);
}
return Buffer.from(value, 'hex');
}
// Outside production we auto-generate and persist a key so a fresh clone
// (`npm run dev`) boots without manual setup β the placeholder ENCRYPTION_KEY
// in .env.example would otherwise crash the server on boot, which surfaces in
// the client as "Can't reach the server". Production still requires an explicit
// env key: a generated key lives only on the local disk and silently losing it
// would make every stored API key undecryptable.
function isDevFallbackAllowed(): boolean {
return process.env.NODE_ENV !== 'production';
}
function missingKeyError(): Error {
return new Error(
'ENCRYPTION_KEY is required in production for API key encryption. ' +
`Set a ${KEY_HEX_LEN}-char hex key (generate one with: ` +
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"). ` +
'Outside production a local key file is auto-generated next to the database.',
);
}
// The directory that holds the DB is the only place we can keep the key file
// next to (but not inside) the database. In-memory and anonymous databases have
// no such directory (db.memory is true, db.name is ":memory:" or ""), and Db
// backings without file metadata (memory undefined) have none either, so those
// callers fall back to the legacy settings-table behavior.
function keyFilePathFor(db: Db): string | null {
if (db.memory !== false) return null;
const dbPath = db.name;
if (!dbPath || dbPath === ':memory:') return null;
return path.join(path.dirname(dbPath), KEY_FILE_NAME);
}
// Write the hex key with a temp-file-and-rename so a crash never leaves a
// half-written key, and restrict it to the owner so it isn't readable by other
// local users. The temp file is restricted BEFORE the rename so the key is
// never briefly world-readable under its final name; a rename carries the DACL
// with it, and the second call is idempotent belt-and-braces.
function writeKeyFileAtomic(keyFile: string, hex: string): void {
const dir = path.dirname(keyFile);
const tmp = path.join(dir, `${KEY_FILE_NAME}.tmp-${crypto.randomBytes(6).toString('hex')}`);
fs.writeFileSync(tmp, hex, { mode: 0o600 });
restrictToOwner(tmp);
fs.renameSync(tmp, keyFile);
if (!restrictToOwner(keyFile)) {
console.warn(`[crypto] could not restrict permissions on ${KEY_FILE_NAME} β it may be readable by other local accounts`);
}
}
/**
* Initialize encryption key from env or an explicit local-dev fallback.
* Must be called after DB is initialized.
*
* Precedence (dev fallback): ENCRYPTION_KEY env > existing key file next to the
* DB > legacy `settings` table row (migrated to the file, then deleted) >
* freshly generated key written to the file.
*/
export function initEncryptionKey(db: Db): void {
// 1. Explicit env key always wins.
const envKey = process.env.ENCRYPTION_KEY;
if (envKey && envKey !== PLACEHOLDER_KEY) {
cachedKey = parseHexKey(envKey, 'env');
return;
}
if (!isDevFallbackAllowed()) {
throw missingKeyError();
}
const keyFile = keyFilePathFor(db);
// In-memory / anonymous DBs have no directory to hold a key file, so keep the
// legacy settings-table behavior for them (ephemeral runs, most tests).
if (!keyFile) {
const row = db.prepare("SELECT value FROM settings WHERE key = 'encryption_key'").get() as { value: string } | undefined;
if (row) {
cachedKey = parseHexKey(row.value, 'db');
console.warn('[crypto] No ENCRYPTION_KEY set β using an auto-generated in-memory key (dev only).');
return;
}
cachedKey = crypto.randomBytes(KEY_BYTES);
db.prepare("INSERT INTO settings (key, value) VALUES ('encryption_key', ?)").run(cachedKey.toString('hex'));
console.warn('[crypto] No ENCRYPTION_KEY set β generated an in-memory dev key. Set ENCRYPTION_KEY for production.');
return;
}
// 2. An existing key file next to the DB.
if (fs.existsSync(keyFile)) {
const value = fs.readFileSync(keyFile, 'utf8').trim();
cachedKey = parseHexKey(value, 'file');
console.warn(`[crypto] No ENCRYPTION_KEY set β using the auto-generated key at ${keyFile} (dev only). Set ENCRYPTION_KEY for production.`);
return;
}
// 3. A legacy key still sitting in the settings table (older dev installs).
// Migrate it to the key file so the DB stops storing the key that protects
// it, confirm a decrypt round-trip with the migrated key, then drop the row.
const legacy = db.prepare("SELECT value FROM settings WHERE key = 'encryption_key'").get() as { value: string } | undefined;
if (legacy) {
const migrated = parseHexKey(legacy.value, 'db');
writeKeyFileAtomic(keyFile, migrated.toString('hex'));
cachedKey = migrated;
const probe = encrypt('roundtrip');
if (decrypt(probe.encrypted, probe.iv, probe.authTag) !== 'roundtrip') {
cachedKey = null;
throw new Error('[crypto] Failed to migrate the DB-stored key to a key file: round-trip check failed.');
}
db.prepare("DELETE FROM settings WHERE key = 'encryption_key'").run();
console.warn(`[crypto] Migrated the legacy DB-stored key to ${keyFile} and removed it from the database (dev only). Set ENCRYPTION_KEY for production.`);
return;
}
// 4. Generate a fresh key and persist it to the file.
cachedKey = crypto.randomBytes(KEY_BYTES);
writeKeyFileAtomic(keyFile, cachedKey.toString('hex'));
console.warn(`[crypto] No ENCRYPTION_KEY set β generated a local dev key at ${keyFile}. Set ENCRYPTION_KEY for production.`);
}
function getEncryptionKey(): Buffer {
if (!cachedKey) {
throw new Error('Encryption key not initialized. Call initEncryptionKey() first.');
}
return cachedKey;
}
/**
* A short, non-reversible identifier for the key currently in use.
*
* Database dumps record it in their header so a restore can tell, before it
* touches a row, whether the api_keys ciphertext in the file was written under
* this key. sha256 truncated to 64 bits: enough to catch a different key,
* nowhere near enough to help recover the key itself, and safe to show in an
* error message or write to a file an operator emails around.
*
* Returns null before initEncryptionKey() has run.
*/
export function encryptionKeyFingerprint(): string | null {
if (!cachedKey) return null;
return `sha256:${crypto.createHash('sha256').update(cachedKey).digest('hex').slice(0, 16)}`;
}
export function isEncryptionKeyInitialized(): boolean {
return cachedKey !== null;
}
export function encrypt(text: string): { encrypted: string; iv: string; authTag: string } {
const key = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return {
encrypted,
iv: iv.toString('hex'),
authTag,
};
}
// AUTH_TAG_BYTES pins the GCM tag length to 16 bytes. Without this option Node
// will accept any tag of length 4β16 bytes (RFC 5116 Β§3.2), which lets anyone
// who can rewrite a row in `api_keys` swap in a 4-byte tag and start brute-
// forcing forgeries at 2^32 attempts. Pinning closes that truncation path.
const AUTH_TAG_BYTES = 16;
export function decrypt(encrypted: string, iv: string, authTag: string): string {
const key = getEncryptionKey();
const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'), { authTagLength: AUTH_TAG_BYTES });
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// A masked key is display-only β it feeds the `maskedKey` field every key,
// media, embedding and client-profile route hands to the dashboard β so it must
// never be enough to reconstruct the secret. The old short-key branch was
// `'****' + key.slice(-4)`, and slice(-4) keeps the last four characters no
// matter how short the input is: a 4-character key was echoed back IN FULL, and
// a 5-character key gave away 4 of its 5 characters. Short keys are not
// hypothetical β proxy credentials, self-hosted Ollama tokens and local dev
// keys all land here.
//
// So reveal nothing at all below 5 characters, and at most the last TWO up to
// 8: enough tail to tell two rows apart in the UI, not enough to be a
// meaningful share of the secret. Long keys keep the familiar prefix+suffix
// form, where 8 revealed characters out of 20+ is a fixed, small fraction.
export function maskKey(key: string): string {
if (key.length < 5) return '****';
if (key.length <= 8) return '****' + key.slice(-2);
return key.slice(0, 4) + '...' + key.slice(-4);
}
|