@dotenvx/dotenvx-pro
Version:
Secrets Manager for Env Files
234 lines (188 loc) • 8.78 kB
JavaScript
const fs = require('fs')
const path = require('path')
const dotenv = require('dotenv')
const { PrivateKey } = require('eciesjs')
// helpers
const gitUrl = require('./../helpers/gitUrl')
const gitRoot = require('./../helpers/gitRoot')
const ValidateGit = require('./../helpers/validateGit')
const ValidateLoggedIn = require('./../helpers/validateLoggedIn')
const ValidatePublicKey = require('./../helpers/validatePublicKey')
const extractSlug = require('./../helpers/extractSlug')
const extractUsernameName = require('./../helpers/extractUsernameName')
const forgivingDirectory = require('./../helpers/forgivingDirectory')
const removeKeyFromEnvFile = require('./../helpers/removeKeyFromEnvFile')
const Errors = require('./../helpers/errors')
const encryptValue = require('./../helpers/encryptValue')
const decryptValue = require('./../helpers/decryptValue')
// services
const SyncMe = require('./../../lib/services/syncMe')
const SyncPublicKey = require('./../../lib/services/syncPublicKey')
const SyncOrganization = require('./syncOrganization')
const SyncOrganizationPublicKey = require('./../../lib/services/syncOrganizationPublicKey')
const Keypair = require('./keypair')
const DbKeypair = require('./dbKeypair')
// db
const current = require('./../../db/current')
const Organization = require('./../../db/organization')
// api calls
const PostPush = require('./../../lib/api/postPush')
class Cloak {
constructor (hostname = current.hostname(), envFile = '.env', directory = '.') {
this.hostname = hostname
this.envFile = envFile
this.directory = forgivingDirectory(directory)
this._mem = {}
this.processedEnvs = []
this.changedFilepaths = new Set()
this.unchangedFilepaths = new Set()
}
async run () {
// validate repo
new ValidateGit().run()
// logged in
new ValidateLoggedIn().run()
this.user = await new SyncMe(this.hostname, current.token()).run()
// verify/sync public key
new ValidatePublicKey().run()
this.user = await new SyncPublicKey(this.hostname, current.token(), this.user.publicKey()).run()
// organization(s)
const _organizationIds = this.user.organizationIds()
if (!_organizationIds || _organizationIds.length < 1) {
throw new Errors({ username: this.user.username() }).missingOrganization()
}
let currentOrganizationId
for (const organizationId of this.user.organizationIds()) {
let organization = await new SyncOrganization(this.hostname, current.token(), organizationId).run()
if (organization.slug().toLowerCase() !== this.slug().toLowerCase()) continue // filters to repo's organization
currentOrganizationId = organizationId // set new current organization
// generate org keypair for the first time
const organizationHasPublicKey = organization.publicKey() && organization.publicKey().length > 0
if (!organizationHasPublicKey) {
const kp = new PrivateKey()
const genPublicKey = kp.publicKey.toHex()
const genPrivateKey = kp.secret.toString('hex')
const genPrivateKeyEncrypted = this.user.encrypt(genPrivateKey) // encrypt org private key with user's public key
organization = await new SyncOrganizationPublicKey(this.hostname, current.token(), organizationId, genPublicKey, genPrivateKeyEncrypted).run()
this.user = await new SyncPublicKey(this.hostname, current.token(), this.user.publicKey()).run()
}
const meHasPrivateKeyEncrypted = organization.privateKeyEncrypted() && organization.privateKeyEncrypted().length > 0
if (!meHasPrivateKeyEncrypted) {
throw new Errors({ slug: organization.slug() }).missingOrganizationPrivateKey()
}
const canDecryptOrganization = decryptValue(encryptValue('true', organization.publicKey()), organization.privateKey(this.user.privateKey()))
if (canDecryptOrganization !== 'true') {
throw new Errors({ slug: organization.slug() }).decryptionFailed()
}
await new SyncOrganization(current.hostname(), current.token(), organizationId).run()
}
if (!currentOrganizationId) {
throw new Errors({ username: this.user.username(), slug: this.slug() }).organizationNotConnected()
}
// select current organization
current.selectOrganization(currentOrganizationId) // TODO: should we switch back to the original current org after the cloak/push?
const organization = new Organization()
for (const envFilepath of this._envFilepaths()) {
const row = {
changed: false
} // used later for reporting to cli
const filepath = path.resolve(envFilepath)
row.filepath = filepath
row.filename = envFilepath
if (!fs.existsSync(filepath)) {
throw new Errors({ filename: envFilepath, filepath }).missingEnvFile()
}
const keypairs = new Keypair(envFilepath).run() // file AND db keypairs. db wins.
// publicKey must exist
const publicKeyName = Object.keys(keypairs).find(key => key.startsWith('DOTENV_PUBLIC_KEY'))
const publicKey = keypairs[publicKeyName]
row.publicKeyName = publicKeyName
row.publicKey = publicKey
if (!publicKey) {
throw new Errors({ filename: envFilepath, filepath, publicKeyName }).missingDotenvPublicKey()
}
// privateKey must exist
const privateKeyName = Object.keys(keypairs).find(key => key.startsWith('DOTENV_PRIVATE_KEY'))
const privateKey = keypairs[privateKeyName]
row.privateKeyName = privateKeyName
row.privateKey = privateKey
if (!privateKey) {
throw new Errors({ filename: envFilepath, filepath, privateKeyName }).missingDotenvPrivateKey()
}
const dbkeypairs = new DbKeypair(envFilepath).run()
if (dbkeypairs[privateKeyName] !== privateKey) {
row.changed = true // row is changing
const relativeFilepath = path.relative(gitRoot(), path.join(process.cwd(), this.directory, envFilepath)).replace(/\\/g, '/') // smartly determine path/to/.env file from repository root - where user is cd-ed inside a folder or at repo root
const text = fs.readFileSync(filepath, 'utf8')
const privateKeyEncryptedWithOrganizationPublicKey = organization.encrypt(privateKey)
await new PostPush(this.hostname, current.token(), 'github', organization.publicKey(), this.usernameName(), relativeFilepath, publicKeyName, privateKeyName, publicKey, privateKeyEncryptedWithOrganizationPublicKey, text).run()
// sync up for good measure
await new SyncOrganization(this.hostname, current.token(), this.organizationId()).run()
await new SyncMe(this.hostname, current.token()).run()
// deal with .env.keys file
const envKeysFilepath = path.join(path.dirname(filepath), '.env.keys')
if (fs.existsSync(envKeysFilepath)) {
// remove DOTENV_PRIVATE_KEY from .env.keys file
removeKeyFromEnvFile(envKeysFilepath, privateKeyName)
// remove .env.keys file if not more private keys left
const env = fs.readFileSync(envKeysFilepath, 'utf8')
const parsedKeys = dotenv.parse(env)
if (Object.keys(parsedKeys).length <= 0) {
fs.unlinkSync(envKeysFilepath)
}
}
}
if (row.changed) {
this.changedFilepaths.add(envFilepath)
} else {
this.unchangedFilepaths.add(envFilepath)
}
this.processedEnvs.push(row)
}
return {
processedEnvs: this.processedEnvs,
changedFilepaths: [...this.changedFilepaths],
unchangedFilepaths: [...this.unchangedFilepaths]
}
}
slug () {
if (this._mem.slug) {
return this._mem.slug
}
const result = extractSlug(this.usernameName())
this._mem.slug = result
return result
}
usernameName () {
if (this._mem.usernameName) {
return this._mem.usernameName
}
const result = extractUsernameName(gitUrl())
this._mem.usernameName = result
return result
}
lookups () {
if (this._mem.lookups) {
return this._mem.lookups
}
const result = this.user.lookups()
this._mem.lookups = result
return result
}
organizationId () {
const id = this.lookups()[`lookup/organizationIdBySlug/${this.slug()}`]
if (!id) {
const error = new Error(`connect your account to organization [@${this.slug()}]`)
error.help = '? connect it with one of the following\n\n 1. run [dotenvx pro sync]\n 2. or connect it [dotenvx pro settings orgconnect]'
throw error
}
return id
}
_envFilepaths () {
if (!Array.isArray(this.envFile)) {
return [path.join(this.directory, this.envFile)]
}
return this.envFile.map(file => path.join(this.directory, file))
}
}
module.exports = Cloak