forked from snxraven/peardock
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.
199 lines
5.3 KiB
JavaScript
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()
|
|
}
|