Files
peardock/server/core/registry-vault.js
T
snxraven 0ee9b67834 Complete production roadmap: vault, peers, swarm, streams, ops
Ship remaining roadmap items: encrypted registry vault, peer invite/revoke,
Swarm/plugins behind flags, binary streams, engine create validation, deploy
rollback, schema validation, fleet/access UI, metrics, fuzz/load/soak tests,
systemd packaging, and release tooling. Mark ROADMAP fully complete.
2026-07-10 20:50:55 -04:00

199 lines
5.3 KiB
JavaScript

/**
* Encrypted-at-rest registry credential vault.
*
* AES-256-GCM; key derived from SERVER_SEED via scrypt (or PEARDOCK_VAULT_KEY).
* 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 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
/**
* @param {string} [seedHex]
* @returns {Buffer}
*/
function deriveKey(seedHex) {
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:${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 = crypto.scryptSync(String(seed), Buffer.from(salt, 'utf8'), KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
})
}
cachedKey = { key: cacheKey, buf }
return buf
}
/**
* @param {object} plaintext
* @param {string} [seedHex]
*/
function encrypt(plaintext, seedHex) {
const key = deriveKey(seedHex)
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const json = Buffer.from(JSON.stringify(plaintext), 'utf8')
const enc = Buffer.concat([cipher.update(json), cipher.final()])
const tag = cipher.getAuthTag()
return {
v: 1,
iv: iv.toString('base64'),
tag: tag.toString('base64'),
data: enc.toString('base64'),
}
}
/**
* @param {object} blob
* @param {string} [seedHex]
*/
function decrypt(blob, seedHex) {
if (!blob?.iv || !blob?.tag || !blob?.data) throw new Error('Corrupt vault blob')
const key = deriveKey(seedHex)
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 dec = Buffer.concat([decipher.update(data), decipher.final()])
return JSON.parse(dec.toString('utf8'))
}
/**
* @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,
}
}
export function vaultPath() {
return vaultFilePath()
}