#!/usr/bin/env node /* secrets management program Secrets are stored encrypted on S3 in a bucket called ${profile}.config. The secrets are defined in gitlab/keys and are copied to s3 via "MakeMe" */ const USAGE = 'Usage: secrets --aws-profile profile [--env prefix] [--key file] [--key-string key] [--profile profile] [set filename| get fields...]' require('module').Module._initPaths() var Spawn = require('child_process') var Fs = require('fs') var Crypto = require('crypto') const IV_LENGTH = 16 const CIPHER = 'aes-256-gcm' const SECRETS_FILE = 'secrets.json' const SALT = 'dead-sea:' const RECURSE_LIMIT = 75 var awsProfile = null var env = null var json = false var command var fields var key = "/etc/farm/secret.key" var keyString var profile global.dump = (...args) => { for (let item of args) print(JSON.stringify(item, null, 4)) } global.print = (...args) => console.log(...args) function usage() { process.stderr.write(USAGE + '\n') process.exit(1) } function parseArgs() { let argv = process.argv.slice(2) for (let i = 0; i < argv.length; i++) { let arg = argv[i] if (arg == '--env') { env = argv[++i] } else if (arg == '-a' || arg == '--aws-profile') { awsProfile = argv[++i] } else if (arg == '-j' || arg == '--json') { json = true } else if (arg == '-k' || arg == '--key') { key = argv[++i] } else if (arg == '--key-string') { keyString = argv[++i] } else if (arg == '-p' || arg == '--profile') { profile = argv[++i] } else { argv = argv.slice(i) break } } command = argv[0] if (!command || !awsProfile) { usage() } if (command == 'get') { fields = argv.slice(1) } else if (command == 'set') { filename = argv[1] if (!filename) { usage() } } } function decrypt(text, password, inCode = 'base64', outCode = 'utf8') { if (text) { let [cipher, tag, iv, data] = text.split(':') iv = Buffer.from(iv, inCode) let secret = Crypto.createHash('sha256').update(SALT + password, 'utf8').digest() let crypt = Crypto.createDecipheriv(CIPHER, secret, iv) if (tag) { tag = Buffer.from(tag, inCode) crypt.setAuthTag(tag) } text = crypt.update(data.toString(), inCode, outCode) + crypt.final(outCode) } return JSON.parse(text.toString()) } function encrypt(text, password, inCode = 'utf8', outCode = 'base64') { if (text) { let iv = Crypto.randomBytes(IV_LENGTH) let secret = Crypto.createHash('sha256').update(SALT + password, 'utf8').digest() let crypt = Crypto.createCipheriv(CIPHER, secret, iv) let crypted = crypt.update(text, inCode, outCode) + crypt.final(outCode) let tag = (CIPHER.indexOf('-gcm') > 0) ? crypt.getAuthTag().toString(outCode) : '' text = `${CIPHER}:${tag}:${iv.toString(outCode)}:${crypted}` } return text } function getSecrets() { let args = ['s3', 'cp', `s3://${awsProfile}.config/${awsProfile}/${SECRETS_FILE}`, '-', '--profile', awsProfile] let cmd = Spawn.spawnSync('aws', args) if (cmd.status != 0) { process.stderr.write(`Command failure: aws ${args.join(' ')}\n`) process.stderr.write(cmd.stderr.toString() + '\n') process.exit(2) } let data = cmd.stdout.toString() let password = (keyString) ? keyString : Fs.readFileSync(key) data = decrypt(data, password) return data } async function setSecrets() { let password = (keyString) ? keyString : Fs.readFileSync(key) let data = Fs.readFileSync(filename) JSON.parse(data) data = encrypt(data, password) await new Promise((resolve, reject) => { let args = ['s3', 'cp', '-', `s3://${awsProfile}.config/${awsProfile}/${SECRETS_FILE}`, '--profile', awsProfile] let cmd = Spawn.spawn('aws', args) cmd.stdin.write(data) cmd.stdin.end() cmd.stderr.on('data', (data) => { process.stderr.write(`Command failure: aws ${args.join(' ')}\n`) process.stderr.write(data + '\n') }) cmd.on('close', (status) => { if (status != 0) { reject('aws command failed') } else { resolve(true) } }) }) } function printEnv(obj, prefix = '', vars = {}) { try { for (let name of Object.keys(obj)) { let value = obj[name] if (name == 'profiles') { continue } if (typeof value == 'object') { printEnv(value, prefix + name.toUpperCase() + '_', vars) } else { name = (prefix + name).toUpperCase().replace(/\./g, '_').replace(/-/g, '_') // vars[name] = value print('export ' + name + '="' + value + '"') } } } catch (e) { process.stderr.write("CATCH" + e.toString() + '\n') process.exit(2) } } async function run() { if (command == 'get') { let data = getSecrets() if (profile) { data = blendProfile(data, profile) } data = selectFields(data, fields) if (json) { dump(data) } else if (env != null) { printEnv(data, env) } else { print(data) } } else if (command == 'set') { await setSecrets() } else { usage() } } function blendProfile(obj, fields) { if (obj.profiles[profile]) { obj = blend(obj, obj.profiles[profile]) } return obj } function selectFields(obj, fields) { let result = {} if (!fields || fields.length == 0) { return obj } for (field of Object.values(fields)) { let set = obj for (let part of field.split('.')) { set = set[part] if (!set) { break } } let type = typeof set if (type == 'string' || type == 'boolean' || type == 'number' || set == null) { result = set break } else { result = Object.assign(result, set) } } return result } function cleanup() { } async function main() { parseArgs() await run() cleanup() } async function start() { try { await main() } catch (e) { process.stderr.write(e.toString() + '\n') process.exit(1) } } start() function blend(dest, src, combine = '', recurse = 0) { if (recurse > RECURSE_LIMIT) { return } if (!src) { return dest } if (!dest || typeof dest != 'object' || Array.isArray(dest)) { return dest } for (let key of Object.getOwnPropertyNames(src)) { let property = key let op = key[0] if (op == '+') { property = key.slice(1) } else if (op == '-') { property = key.slice(1) } else if (op == '?') { property = key.slice(1) } else if (op == '=') { property = key.slice(1) } else if (combine) { op = combine } else { /* Default is to blend objects and assign arrays */ op = '' } let s = src[key] let d = dest[property] if (!dest.hasOwnProperty(property)) { if (op == '-') { continue } dest[property] = clone(s) continue } else if (op == '?' && d != null) { continue } if (Array.isArray(d)) { if (op == '=') { /* op == '=' */ dest[property] = clone(s) } else if (op == '-') { if (Array.isArray(s)) { for (let item of s) { let index = d.indexOf(item) if (index >= 0) d.slice(index, 1) } } else { let index = d.indexOf(s) if (index >= 0) d.slice(index, 1) } } else if (op == '+') { /* This was the default, but blending Package.sensors.http.path from PackageOverride needs to overwrite and not union. */ if (Array.isArray(s)) { for (let item of s) { if (d.indexOf(s) < 0) d.push(item) } } else { d.push(s) } } else { dest[property] = clone(s) } } else if (d instanceof Date) { if (op == '+') { dest[property] += s } else if (op == '-') { dest[property] -= s } else { /* op == '=' */ dest[property] = s } } else if (typeof d == 'object' && d !== null && d !== undefined) { if (op == '=') { dest[property] = clone(s) } else if (op == '-') { delete dest[property] } else if (s === null) { dest[property] = s } else if (typeof s == 'object') { blend(d, s, op, recurse + 1) } else { dest[property] = s } } else if (typeof d == 'string') { if (op == '+') { dest[property] += ' ' + s } else if (op == '-') { if (d == s) { delete dest[property] } else { dest[property] = d.replace(s, '') } } else { /* op == '=' */ dest[property] = s } } else if (typeof d == 'number') { if (op == '+') { dest[property] += s } else if (op == '-') { dest[property] -= s } else { /* op == '=' */ dest[property] = s } } else { if (op == '=') { dest[property] = s } else if (op == '-') { delete dest[property] } else { dest[property] = s } } } return dest }