forked from snxraven/peardock
bare-crypto lacks scrypt; derive vault keys with PBKDF2-SHA256 there, keep scrypt on Node, and record the KDF on encrypted blobs.
328 lines
9.4 KiB
JavaScript
328 lines
9.4 KiB
JavaScript
/**
|
|
* 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<string, object> }}
|
|
*/
|
|
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<string, object> }} 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()
|
|
}
|