File size: 3,620 Bytes
3bbe317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * MedOS at-rest encryption helpers.
 *
 * Used to wrap user-provided secrets (BYO Hugging Face token, etc.)
 * before persisting them in SQLite. The master key is derived from
 * `ENCRYPTION_KEY` (preferred) or `ADMIN_PASSWORD` (dev fallback).
 *
 * Algorithm: AES-256-GCM, 12-byte IV, 16-byte auth tag.
 * Wire format: "v1:<iv-b64>:<tag-b64>:<data-b64>"
 *
 * The version prefix is intentional: it lets us migrate to a new
 * algorithm or KDF later ("v2:...") without breaking previously
 * encrypted rows. decryptString() falls back to plaintext if it
 * cannot recognise the version prefix — useful when migrating an
 * existing database that stored values in the clear.
 */

import crypto from 'crypto';

const ALG = 'aes-256-gcm';
const IV_LEN = 12;
const VERSION = 'v1';

let _cachedKey: Buffer | null = null;

function getMasterKey(): Buffer {
  if (_cachedKey) return _cachedKey;
  const raw =
    process.env.ENCRYPTION_KEY ||
    process.env.ADMIN_PASSWORD ||
    'medos-default-dev-key-please-set-ENCRYPTION_KEY';
  if (!process.env.ENCRYPTION_KEY) {
    // Loud, one-time warning so operators know the deployment is using
    // the development fallback. Never silently accept this in prod.
    console.warn(
      '[crypto] ENCRYPTION_KEY is not set — derived a fallback key from ADMIN_PASSWORD. Set ENCRYPTION_KEY (32 random bytes hex) before going to production.',
    );
  }
  _cachedKey = crypto.createHash('sha256').update(raw).digest();
  return _cachedKey;
}

/** Encrypt a UTF-8 string. Returns '' for empty input. */
export function encryptString(plain: string): string {
  if (!plain) return '';
  const key = getMasterKey();
  const iv = crypto.randomBytes(IV_LEN);
  const cipher = crypto.createCipheriv(ALG, key, iv);
  const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return `${VERSION}:${iv.toString('base64')}:${tag.toString('base64')}:${enc.toString('base64')}`;
}

/**
 * Decrypt a string produced by encryptString(). If the payload does not
 * carry a recognised version prefix we assume it is legacy plaintext and
 * return it unchanged — supports zero-downtime backfill.
 */
export function decryptString(payload: string): string {
  if (!payload) return '';
  const parts = payload.split(':');
  if (parts[0] !== VERSION || parts.length !== 4) return payload;
  try {
    const [, ivB, tagB, dataB] = parts;
    const key = getMasterKey();
    const iv = Buffer.from(ivB, 'base64');
    const tag = Buffer.from(tagB, 'base64');
    const data = Buffer.from(dataB, 'base64');
    const decipher = crypto.createDecipheriv(ALG, key, iv);
    decipher.setAuthTag(tag);
    const dec = Buffer.concat([decipher.update(data), decipher.final()]);
    return dec.toString('utf8');
  } catch (e: any) {
    console.error('[crypto] decryptString failed:', e?.message);
    return '';
  }
}

/**
 * Mask a secret for display. Keeps the last `keepLast` characters so the
 * operator can recognise which token is configured without exposing it.
 *
 *   redact('hf_aBcDeFgHiJ') -> '••••HiJ'
 */
export function redact(value: string | undefined | null, keepLast = 4): string {
  if (!value) return '';
  if (value.length <= keepLast) return '•'.repeat(8);
  return `${'•'.repeat(8)}${value.slice(-keepLast)}`;
}

/** Constant-time string comparison — use for token/secret equality checks. */
export function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a, 'utf8');
  const bb = Buffer.from(b, 'utf8');
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}