/** * Encrypted-at-rest registry credential vault. * * AES-256-GCM; key derived from SERVER_SEED (or PEARDOCK_VAULT_KEY). * KDF: scrypt when available (Node), else PBKDF2-SHA256 (bare-crypto has no scrypt). * File: PEARDOCK_VAULT_PATH (default ./peardock-vault.json) */ import fs from 'fs' import path from 'path' import crypto from 'crypto' import logger from '../utils/logger.js' const SCRYPT_N = 16384 const SCRYPT_R = 8 const SCRYPT_P = 1 const PBKDF2_ITERATIONS = 120_000 const KEY_LEN = 32 function vaultFilePath() { return process.env.PEARDOCK_VAULT_PATH || path.join(process.cwd(), 'peardock-vault.json') } /** @type {{ key: string, buf: Buffer }|null} */ let cachedKey = null /** * Prefer scrypt on Node; bare-crypto only exposes pbkdf2(Sync). * @returns {'scrypt'|'pbkdf2'} */ function preferredKdf() { return typeof crypto.scryptSync === 'function' ? 'scrypt' : 'pbkdf2' } /** * @param {string|Buffer} seed * @param {string|Buffer} salt * @param {'scrypt'|'pbkdf2'} kdf * @returns {Buffer} */ function deriveKeyMaterial(seed, salt, kdf) { const seedBuf = Buffer.isBuffer(seed) ? seed : Buffer.from(String(seed), 'utf8') const saltBuf = Buffer.isBuffer(salt) ? salt : Buffer.from(String(salt), 'utf8') if (kdf === 'scrypt') { if (typeof crypto.scryptSync !== 'function') { throw new Error('scryptSync is not available in this runtime') } return crypto.scryptSync(seedBuf, saltBuf, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, }) } // PBKDF2-SHA256 — supported by Node and bare-crypto if (typeof crypto.pbkdf2Sync === 'function') { const out = crypto.pbkdf2Sync(seedBuf, saltBuf, PBKDF2_ITERATIONS, KEY_LEN, 'sha256') return Buffer.from(out) } // Last-resort HKDF-style expand via HMAC (createHmac is always present) const prk = crypto.createHmac('sha256', saltBuf).update(seedBuf).digest() const info = Buffer.concat([Buffer.from('peardock-vault-v1', 'utf8'), Buffer.from([0x01])]) return crypto.createHmac('sha256', prk).update(info).digest() } /** * @param {string} [seedHex] * @param {'scrypt'|'pbkdf2'} [kdf] * @returns {Buffer} */ function deriveKey(seedHex, kdf = preferredKdf()) { const explicit = process.env.PEARDOCK_VAULT_KEY const seed = seedHex || process.env.SERVER_SEED || process.env.SERVER_KEY || 'peardock-dev-vault' const salt = process.env.PEARDOCK_VAULT_SALT || 'peardock-vault-v1' const cacheKey = explicit && /^[0-9a-fA-F]{64}$/.test(explicit) ? `k:${explicit}` : `s:${kdf}:${seed}:${salt}` if (cachedKey?.key === cacheKey) return cachedKey.buf let buf if (explicit && /^[0-9a-fA-F]{64}$/.test(explicit)) { buf = Buffer.from(explicit, 'hex') } else { buf = deriveKeyMaterial(String(seed), salt, kdf) } cachedKey = { key: cacheKey, buf } return buf } /** * @param {object} plaintext * @param {string} [seedHex] */ function encrypt(plaintext, seedHex) { const kdf = preferredKdf() const key = deriveKey(seedHex, kdf) const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) const json = Buffer.from(JSON.stringify(plaintext), 'utf8') // bare-crypto GCM buffers until final(); Node returns ciphertext from update() const part1 = cipher.update(json) || Buffer.alloc(0) const part2 = cipher.final() || Buffer.alloc(0) const enc = Buffer.concat([ Buffer.isBuffer(part1) ? part1 : Buffer.from(part1), Buffer.isBuffer(part2) ? part2 : Buffer.from(part2), ]) const tag = cipher.getAuthTag() return { v: 1, kdf, iv: iv.toString('base64'), tag: tag.toString('base64'), data: enc.toString('base64'), } } /** * @param {object} blob * @param {string} [seedHex] * @param {'scrypt'|'pbkdf2'} kdf */ function decryptWithKdf(blob, seedHex, kdf) { const key = deriveKey(seedHex, kdf) const iv = Buffer.from(blob.iv, 'base64') const tag = Buffer.from(blob.tag, 'base64') const data = Buffer.from(blob.data, 'base64') const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) decipher.setAuthTag(tag) const part1 = decipher.update(data) || Buffer.alloc(0) const part2 = decipher.final() || Buffer.alloc(0) const dec = Buffer.concat([ Buffer.isBuffer(part1) ? part1 : Buffer.from(part1), Buffer.isBuffer(part2) ? part2 : Buffer.from(part2), ]) return JSON.parse(dec.toString('utf8')) } /** * @param {object} blob * @param {string} [seedHex] */ function decrypt(blob, seedHex) { if (!blob?.iv || !blob?.tag || !blob?.data) throw new Error('Corrupt vault blob') // Prefer kdf recorded at encrypt time; legacy blobs assumed scrypt (Node-only era) const primary = blob.kdf === 'pbkdf2' || blob.kdf === 'scrypt' ? blob.kdf : typeof crypto.scryptSync === 'function' ? 'scrypt' : 'pbkdf2' const fallback = primary === 'scrypt' ? 'pbkdf2' : 'scrypt' try { return decryptWithKdf(blob, seedHex, primary) } catch (err) { // Migration / cross-runtime: try the other KDF when possible const canFallback = (fallback === 'scrypt' && typeof crypto.scryptSync === 'function') || fallback === 'pbkdf2' if (!canFallback || blob.kdf) throw err try { return decryptWithKdf(blob, seedHex, fallback) } catch { throw err } } } /** * @returns {{ credentials: Record }} */ function loadVault() { const VAULT_PATH = vaultFilePath() if (!fs.existsSync(VAULT_PATH)) { return { credentials: {} } } try { const raw = JSON.parse(fs.readFileSync(VAULT_PATH, 'utf8')) if (raw.encrypted) { return decrypt(raw.encrypted) } // Legacy plaintext migration if (raw.credentials) { saveVault(raw) return raw } } catch (err) { logger.warn('vault load failed', { error: err.message }) } return { credentials: {} } } /** * @param {{ credentials: Record }} vault */ function saveVault(vault) { const VAULT_PATH = vaultFilePath() const encrypted = encrypt(vault) const out = { version: 1, updatedAt: new Date().toISOString(), encrypted, } const dir = path.dirname(VAULT_PATH) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(VAULT_PATH, JSON.stringify(out, null, 2), { mode: 0o600 }) try { fs.chmodSync(VAULT_PATH, 0o600) } catch { // ignore } } /** * Store registry credentials. * @param {{ id?: string, username: string, password: string, serveraddress?: string, label?: string }} cred */ export function storeCredential(cred) { const vault = loadVault() const id = cred.id || crypto .createHash('sha256') .update(`${cred.serveraddress || 'docker.io'}:${cred.username}:${Date.now()}`) .digest('hex') .slice(0, 16) vault.credentials[id] = { id, username: String(cred.username), password: String(cred.password), serveraddress: cred.serveraddress || 'https://index.docker.io/v1/', label: cred.label || cred.username, createdAt: vault.credentials[id]?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), } saveVault(vault) return { id, username: vault.credentials[id].username, serveraddress: vault.credentials[id].serveraddress, label: vault.credentials[id].label } } /** * @param {string} id */ export function deleteCredential(id) { const vault = loadVault() if (!vault.credentials[id]) throw new Error('Credential not found') delete vault.credentials[id] saveVault(vault) return { success: true, id } } /** * List credentials without passwords. */ export function listCredentials() { const vault = loadVault() return Object.values(vault.credentials).map((c) => ({ id: c.id, username: c.username, serveraddress: c.serveraddress, label: c.label, createdAt: c.createdAt, updatedAt: c.updatedAt, })) } /** * @param {string} id * @returns {{ username: string, password: string, serveraddress: string }|null} */ export function getCredential(id) { const vault = loadVault() const c = vault.credentials[id] if (!c) return null return { username: c.username, password: c.password, serveraddress: c.serveraddress, } } /** * Find a vault credential matching a registry host (best-effort). * @param {string} serverHint e.g. ghcr.io or https://index.docker.io/v1/ * @returns {{ id: string, username: string, password: string, serveraddress: string }|null} */ export function findCredentialForServer(serverHint) { if (!serverHint) return null const hint = String(serverHint).toLowerCase().replace(/\/+$/, '') const vault = loadVault() const entries = Object.values(vault.credentials) // Prefer exact serveraddress match for (const c of entries) { const sa = String(c.serveraddress || '').toLowerCase().replace(/\/+$/, '') if (sa && (sa === hint || sa.includes(hint) || hint.includes(sa))) { return { id: c.id, username: c.username, password: c.password, serveraddress: c.serveraddress, } } } // Host-only match (strip scheme) const hostOnly = hint.replace(/^https?:\/\//, '').split('/')[0] for (const c of entries) { const sa = String(c.serveraddress || '') .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] if (sa && hostOnly && (sa === hostOnly || sa.endsWith(`.${hostOnly}`) || hostOnly.endsWith(`.${sa}`))) { return { id: c.id, username: c.username, password: c.password, serveraddress: c.serveraddress, } } } return null } export function vaultPath() { return vaultFilePath() }