Fix registry vault KDF on Bare (no scryptSync).
Release rolling / release (push) Successful in 8m29s
Release rolling / release (push) Successful in 8m29s
bare-crypto lacks scrypt; derive vault keys with PBKDF2-SHA256 there, keep scrypt on Node, and record the KDF on encrypted blobs.
This commit is contained in:
+103
-15
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Encrypted-at-rest registry credential vault.
|
||||
*
|
||||
* AES-256-GCM; key derived from SERVER_SEED via scrypt (or PEARDOCK_VAULT_KEY).
|
||||
* 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'
|
||||
@@ -12,6 +13,7 @@ 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() {
|
||||
@@ -22,25 +24,66 @@ function vaultFilePath() {
|
||||
let cachedKey = null
|
||||
|
||||
/**
|
||||
* @param {string} [seedHex]
|
||||
* 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 deriveKey(seedHex) {
|
||||
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:${seed}:${salt}`
|
||||
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 = crypto.scryptSync(String(seed), Buffer.from(salt, 'utf8'), KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
})
|
||||
buf = deriveKeyMaterial(String(seed), salt, kdf)
|
||||
}
|
||||
cachedKey = { key: cacheKey, buf }
|
||||
return buf
|
||||
@@ -51,14 +94,22 @@ function deriveKey(seedHex) {
|
||||
* @param {string} [seedHex]
|
||||
*/
|
||||
function encrypt(plaintext, seedHex) {
|
||||
const key = deriveKey(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')
|
||||
const enc = Buffer.concat([cipher.update(json), cipher.final()])
|
||||
// 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'),
|
||||
@@ -68,19 +119,56 @@ function encrypt(plaintext, seedHex) {
|
||||
/**
|
||||
* @param {object} blob
|
||||
* @param {string} [seedHex]
|
||||
* @param {'scrypt'|'pbkdf2'} kdf
|
||||
*/
|
||||
function decrypt(blob, seedHex) {
|
||||
if (!blob?.iv || !blob?.tag || !blob?.data) throw new Error('Corrupt vault blob')
|
||||
const key = deriveKey(seedHex)
|
||||
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 dec = Buffer.concat([decipher.update(data), decipher.final()])
|
||||
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> }}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user