Skip to content

Commit

Permalink
Add support for .env.vault files
Browse files Browse the repository at this point in the history
  • Loading branch information
motdotla committed Apr 7, 2023
1 parent 5037df1 commit f4cb2dc
Show file tree
Hide file tree
Showing 8 changed files with 998 additions and 13 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## [Unreleased](https://github.com/motdotla/dotenv/compare/v16.0.3...master)
## [Unreleased](https://github.com/motdotla/dotenv/compare/v16.1.0...master)

## [16.1.0](https://github.com/motdotla/dotenv/compare/v16.0.3...v16.1.0) (2023-04-01)

### Added

- Add `.env.vault` support. 🎉 ([#730](https://github.com/motdotla/dotenv/pull/730))

ℹ️ `.env.vault` extends the `.env` file format standard with a localized encrypted vault file. Package it securely with your production code deploys. It's cloud agnostic so that you can deploy your secrets anywhere – without [risky third-party integrations](https://techcrunch.com/2023/01/05/circleci-breach/).

## [16.0.3](https://github.com/motdotla/dotenv/compare/v16.0.2...v16.0.3) (2022-09-29)

Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@

Dotenv is a zero-dependency module that loads environment variables from a `.env` file into [`process.env`](https://nodejs.org/docs/latest/api/process.html#process_process_env). Storing configuration in the environment separate from code is based on [The Twelve-Factor App](http://12factor.net/config) methodology.

[![BuildStatus](https://img.shields.io/travis/motdotla/dotenv/master.svg?style=flat-square)](https://travis-ci.org/motdotla/dotenv)
[![Build status](https://ci.appveyor.com/api/projects/status/github/motdotla/dotenv?svg=true)](https://ci.appveyor.com/project/motdotla/dotenv/branch/master)
[![NPM version](https://img.shields.io/npm/v/dotenv.svg?style=flat-square)](https://www.npmjs.com/package/dotenv)
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard)
[![Coverage Status](https://img.shields.io/coveralls/motdotla/dotenv/master.svg?style=flat-square)](https://coveralls.io/github/motdotla/dotenv?branch=coverall-intergration)
[![LICENSE](https://img.shields.io/github/license/motdotla/dotenv.svg)](LICENSE)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
[![Featured on Openbase](https://badges.openbase.com/js/featured/dotenv.svg?token=eE0hWPkhC2JGSD4G9hwg5C54EBxjJAyvurGfQsYoKiQ=)](https://openbase.com/js/dotenv?utm_source=embedded&utm_medium=badge&utm_campaign=featured-badge&utm_term=js/dotenv)
[![Limited Edition Tee Original](https://img.shields.io/badge/Limited%20Edition%20Tee%20%F0%9F%91%95-Original-yellow?labelColor=black&style=plastic)](https://dotenv.gumroad.com/l/original)
[![Limited Edition Tee Redacted](https://img.shields.io/badge/Limited%20Edition%20Tee%20%F0%9F%91%95-Redacted-gray?labelColor=black&style=plastic)](https://dotenv.gumroad.com/l/redacted)

* [Install & Usage (development)](#install)
* [Deploying (production)](#deploying)
* [Docs](#documentation)
* [FAQ](#faq)
* [Changelog](./CHANGELOG.md)

## Install

Expand Down Expand Up @@ -455,3 +455,7 @@ See [CHANGELOG.md](CHANGELOG.md)
[These npm modules depend on it.](https://www.npmjs.com/browse/depended/dotenv)

Projects that expand it often use the [keyword "dotenv" on npm](https://www.npmjs.com/search?q=keywords:dotenv).

[![Limited Edition Tee Original](https://img.shields.io/badge/Limited%20Edition%20Tee%20%F0%9F%91%95-Original-yellow?labelColor=black&style=plastic)](https://dotenv.gumroad.com/l/original)
[![Limited Edition Tee Redacted](https://img.shields.io/badge/Limited%20Edition%20Tee%20%F0%9F%91%95-Redacted-gray?labelColor=black&style=plastic)](https://dotenv.gumroad.com/l/redacted)

203 changes: 197 additions & 6 deletions lib/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const crypto = require('crypto')
const packageJson = require('../package.json')

const version = packageJson.version
Expand Down Expand Up @@ -46,16 +47,147 @@ function parse (src) {
return obj
}

function _parseVault (options) {
const vaultPath = _vaultPath(options)

// Parse .env.vault
const result = DotenvModule._configDotenv({ path: vaultPath })
if (!result.parsed) {
throw new Error(`MISSING_DATA: Cannot parse ${vaultPath} for an unknown reason`)
}

// handle scenario for comma separated keys - for use with key rotation
// example: DOTENV_KEY="dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=prod,dotenv://:key_7890@dotenv.org/vault/.env.vault?environment=prod"
const keys = _dotenvKey().split(',')
const length = keys.length

let decrypted
for (let i = 0; i < length; i++) {
try {
// Get full key
const key = keys[i].trim()

// Get instructions for decrypt
const attrs = _instructions(result, key)

// Decrypt
decrypted = DotenvModule.decrypt(attrs.ciphertext, attrs.key)

break
} catch (error) {
// last key
if (i + 1 >= length) {
throw error
}
// try next key
}
}

// Parse decrypted .env string
return DotenvModule.parse(decrypted)
}

function _log (message) {
console.log(`[dotenv@${version}][INFO] ${message}`)
}

function _warn (message) {
console.log(`[dotenv@${version}][WARN] ${message}`)
}

function _debug (message) {
console.log(`[dotenv@${version}][DEBUG] ${message}`)
}

function _dotenvKey () {
if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
return process.env.DOTENV_KEY
}

return ''
}

function _instructions (result, dotenvKey) {
// Parse DOTENV_KEY. Format is a URI
let uri
try {
uri = new URL(dotenvKey)
} catch (error) {
if (error.code === 'ERR_INVALID_URL') {
throw new Error('INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development')
}

throw error
}

// Get decrypt key
const key = uri.password
if (!key) {
throw new Error('INVALID_DOTENV_KEY: Missing key part')
}

// Get environment
const environment = uri.searchParams.get('environment')
if (!environment) {
throw new Error('INVALID_DOTENV_KEY: Missing environment part')
}

// Get ciphertext payload
const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`
const ciphertext = result.parsed[environmentKey] // DOTENV_VAULT_PRODUCTION
if (!ciphertext) {
throw new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${environmentKey} in your .env.vault file.`)
}

return { ciphertext, key }
}

function _vaultPath (options) {
let dotenvPath = path.resolve(process.cwd(), '.env')

if (options && options.path && options.path.length > 0) {
dotenvPath = options.path
}

// Locate .env.vault
return dotenvPath.endsWith('.vault') ? dotenvPath : `${dotenvPath}.vault`
}

function _resolveHome (envPath) {
return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

// Populates process.env from .env file
function config (options) {
function _configVault (options) {
_log('Loading env from encrypted .env.vault')

const parsed = DotenvModule._parseVault(options)

const debug = Boolean(options && options.debug)
const override = Boolean(options && options.override)

// Set process.env
for (const key of Object.keys(parsed)) {
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
if (override === true) {
process.env[key] = parsed[key]
}

if (debug) {
if (override === true) {
_debug(`"${key}" is already defined in \`process.env\` and WAS overwritten`)
} else {
_debug(`"${key}" is already defined in \`process.env\` and was NOT overwritten`)
}
}
} else {
process.env[key] = parsed[key]
}
}

return { parsed }
}

function _configDotenv (options) {
let dotenvPath = path.resolve(process.cwd(), '.env')
let encoding = 'utf8'
const debug = Boolean(options && options.debug)
Expand All @@ -72,7 +204,7 @@ function config (options) {

try {
// Specifying an encoding returns a string instead of a buffer
const parsed = DotenvModule.parse(fs.readFileSync(dotenvPath, { encoding }))
const parsed = parse(fs.readFileSync(dotenvPath, { encoding }))

Object.keys(parsed).forEach(function (key) {
if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
Expand All @@ -84,9 +216,9 @@ function config (options) {

if (debug) {
if (override === true) {
_log(`"${key}" is already defined in \`process.env\` and WAS overwritten`)
_debug(`"${key}" is already defined in \`process.env\` and WAS overwritten`)
} else {
_log(`"${key}" is already defined in \`process.env\` and was NOT overwritten`)
_debug(`"${key}" is already defined in \`process.env\` and was NOT overwritten`)
}
}
}
Expand All @@ -95,18 +227,77 @@ function config (options) {
return { parsed }
} catch (e) {
if (debug) {
_log(`Failed to load ${dotenvPath} ${e.message}`)
_debug(`Failed to load ${dotenvPath} ${e.message}`)
}

return { error: e }
}
}

// Populates process.env from .env file
function config (options) {
const vaultPath = _vaultPath(options)

// fallback to original dotenv if DOTENV_KEY is not set
if (_dotenvKey().length === 0) {
return DotenvModule._configDotenv(options)
}

// dotenvKey exists but .env.vault file does not exist
if (!fs.existsSync(vaultPath)) {
_warn(`You set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}. Did you forget to build it?`)

return DotenvModule._configDotenv(options)
}

return DotenvModule._configVault(options)
}

function decrypt (encrypted, keyStr) {
const key = Buffer.from(keyStr.slice(-64), 'hex')
let ciphertext = Buffer.from(encrypted, 'base64')

const nonce = ciphertext.slice(0, 12)
const authTag = ciphertext.slice(-16)
ciphertext = ciphertext.slice(12, -16)

try {
const aesgcm = crypto.createDecipheriv('aes-256-gcm', key, nonce)
aesgcm.setAuthTag(authTag)
return `${aesgcm.update(ciphertext)}${aesgcm.final()}`
} catch (error) {
const isRange = error instanceof RangeError
const invalidKeyLength = error.message === 'Invalid key length'
const decryptionFailed = error.message === 'Unsupported state or unable to authenticate data'

if (isRange || invalidKeyLength) {
const msg = 'INVALID_DOTENV_KEY: It must be 64 characters long (or more)'
throw new Error(msg)
} else if (decryptionFailed) {
const msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'
throw new Error(msg)
} else {
console.error('Error: ', error.code)
console.error('Error: ', error.message)
throw error
}
}
}

const DotenvModule = {
_configDotenv,
_configVault,
_parseVault,
config,
decrypt,
parse
}

module.exports._configDotenv = DotenvModule._configDotenv
module.exports._configVault = DotenvModule._configVault
module.exports._parseVault = DotenvModule._parseVault
module.exports.config = DotenvModule.config
module.exports.decrypt = DotenvModule.decrypt
module.exports.parse = DotenvModule.parse

module.exports = DotenvModule

0 comments on commit f4cb2dc

Please sign in to comment.