├── .circleci └── config.yml ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── changelog.md ├── package.json ├── renovate.json ├── rollup.config.js ├── src ├── cli.test.ts ├── cli.ts ├── decrypt.test.ts ├── decrypt.ts ├── encrypt.test.ts ├── encrypt.ts ├── index.test.ts ├── index.ts └── types.ts ├── test ├── setup.ts └── test.d.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # 4 | # Jobs 5 | # 6 | 7 | jobs: 8 | 9 | # This job builds the base project directory (e.g. ~/package.json) 10 | build: 11 | docker: 12 | - image: circleci/node:8 13 | steps: 14 | - checkout 15 | - restore_cache: 16 | key: dependency-cache-{{ checksum "package.json" }} 17 | - run: npm install 18 | - save_cache: 19 | key: dependency-cache-{{ checksum "package.json" }} 20 | paths: 21 | - node_modules 22 | 23 | # This job runs the lint tool on the whole repository 24 | lint: 25 | docker: 26 | - image: circleci/node:8 27 | steps: 28 | - checkout 29 | - restore_cache: 30 | key: dependency-cache-{{ checksum "package.json" }} 31 | - run: 32 | name: Security check 33 | command: npm run security-check 34 | - run: 35 | name: Lint 36 | command: npm run lint 37 | 38 | # runs the unit tests 39 | unit-test: 40 | docker: 41 | - image: circleci/node:8 42 | steps: 43 | - checkout 44 | - restore_cache: 45 | key: dependency-cache-{{ checksum "package.json" }} 46 | - run: 47 | name: Unit tests 48 | command: | 49 | npm run test && 50 | ./node_modules/.bin/nyc report --temp-directory=coverage --reporter=text-lcov | ./node_modules/.bin/coveralls 51 | 52 | release: 53 | docker: 54 | - image: circleci/node:8@sha256:6c751e82876608a535426c12257956fd0f47e29c745b5028012944896faf6867 55 | steps: 56 | - checkout 57 | - restore_cache: 58 | key: dependency-cache-{{ checksum "package.json" }} 59 | - run: npm run semantic-release 60 | 61 | # 62 | # Workflows 63 | # 64 | 65 | workflows: 66 | version: 2 67 | 68 | build_test_release: 69 | jobs: 70 | - build 71 | - lint: 72 | requires: 73 | - build 74 | - unit-test: 75 | requires: 76 | - build 77 | - release: 78 | requires: 79 | - lint 80 | - unit-test 81 | filters: 82 | branches: 83 | only: master 84 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: adieuadieu 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific 2 | dist/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.test.js 2 | *.test.d.ts 3 | *.js.map 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | dist/ 3 | package.json 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "prettier.eslintIntegration": false, 4 | "eslint.enable": false 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Marco Lüthy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-kms-thingy 2 | 3 | Convenience wrapper around the AWS Node.js SDK to simplify encrypting/decrypting secrets with the AWS KMS service. Suitable for use with AWS Lambda. 4 | 5 | [![CircleCI](https://img.shields.io/circleci/project/github/adieuadieu/aws-kms-thingy/master.svg?style=flat-square)](https://circleci.com/gh/adieuadieu/aws-kms-thingy) 6 | [![Coveralls](https://img.shields.io/coveralls/adieuadieu/aws-kms-thingy/master.svg?style=flat-square)](https://coveralls.io/github/adieuadieu/aws-kms-thingy) 7 | [![David](https://img.shields.io/david/adieuadieu/aws-kms-thingy.svg?style=flat-square)]() 8 | [![David](https://img.shields.io/david/dev/adieuadieu/aws-kms-thingy.svg?style=flat-square)]() 9 | [![GitHub release](https://img.shields.io/github/release/adieuadieu/aws-kms-thingy.svg?style=flat-square)](https://github.com/adieuadieu/aws-kms-thingy) 10 | 11 | ## Contents 12 | 13 | 1. [Features](#features) 14 | 1. [Usage](#usage) 15 | 1. [With the CLI](#with-the-cli) 16 | 1. [With AWS Lambda](#with-aws-lambda) 17 | 1. [With Multiple Secrets](#with-multiple-secrets) 18 | 1. [Locally In Development](#locally-in-development) 19 | 1. [API](#api) 20 | 1. [Related Thingies](#related-thingies) 21 | 1. [License](#license) 22 | 23 | ### Features 24 | 25 | * Unencrypted strings simply returned, useful for testing/local development 26 | * Encrypt/decrypt multiple values in one go 27 | * Results are cached, so multiple decrypt/encrypt calls incur only a single call to the AWS SDK 28 | * CLI to encrypt/decrypt secrets 29 | * Well tested 30 | 31 | ## Usage 32 | 33 | The module assumes that the Amazon SDK has access to AWS credentials that are able to access the KMS key used for encryption and decryption. 34 | 35 | ```bash 36 | npm install aws-kms-thingy aws-sdk@^2 37 | ``` 38 | 39 | ### With the CLI 40 | 41 | Encrypt with: 42 | 43 | ```bash 44 | aws-kms-thingy encrypt 45 | ``` 46 | 47 | You'll be prompted for the string to encrypt. 48 | 49 | Decrypt with: 50 | 51 | ```bash 52 | aws-kms-thingy decrypt 53 | ``` 54 | 55 | You'll be prompted for the encrypted string to decrypt. 56 | 57 | ### With AWS Lambda 58 | 59 | Safe to use within a Lambda handler. After cold-start, decrypted values are cached so subsequent invocations won't incur an AWS KMS API call: 60 | 61 | ```javascript 62 | const { decrypt } = require('aws-kms-thingy') 63 | 64 | module.exports.myLambdaHandler = (event, context, callback) => { 65 | decrypt(process.env.SOME_API_TOKEN) // Only incurs network call on cold-start 66 | .then(doStuffWithDecryptedApiToken) 67 | .then(resultOrWhatever => callback(null, resultOrWhatever)) 68 | .catch(callback) 69 | } 70 | ``` 71 | 72 | ### With Multiple Secrets 73 | 74 | Decrypt multiple values in parallel 75 | 76 | ```typescript 77 | import { decrypt } from 'aws-kms-thingy' 78 | 79 | const [ 80 | decryptedApiToken1, 81 | decryptedApiToken2, 82 | decryptedDatabasePassword, 83 | somethingElseSecret, 84 | ] = await decrypt([ 85 | process.env.API_TOKEN_1, 86 | process.env.API_TOKEN_2, 87 | process.env.DATABASE_PASSWORD, 88 | process.env.SOMETHING_ELSE_SECRET, 89 | ]) 90 | ``` 91 | 92 | ### Locally In Development 93 | 94 | Providing a non-base64 encoded value will skip en/decrypting with AWS KMS and just return the same value. This is useful in local development where you may not be necessary to have your secrets encrypted. This helps to avoid the need to write development environment exception code: 95 | 96 | ```typescript 97 | import { decrypt } from 'aws-kms-thingy' 98 | 99 | process.env.DATABASE_PASSWORD = 'foobar' 100 | 101 | const dbPassword = await decrypt(process.env.DATABASE_PASSWORD) 102 | 103 | console.log(dbPassword) // "foobar" 104 | ``` 105 | 106 | An `undefined` value is also OK. This does nothing and returns undefined. Useful when environment variables are unset in local development. 107 | 108 | ```typescript 109 | process.env.DATABASE_PASSWORD = undefined // e.g. not set in development 110 | 111 | const dbPassword = await decrypt(process.env.DATABASE_PASSWORD) 112 | 113 | console.log(dbPassword) // undefined 114 | ``` 115 | 116 | Alternatively, one can also disable en/decryption entirely with `DISABLE_AWS_KMS_THINGY` environment variable: 117 | 118 | ```typescript 119 | import { decrypt } from 'aws-kms-thingy' 120 | 121 | process.env.DISABLE_AWS_KMS_THINGY = 'true' 122 | 123 | const token = await decrypt('aHR0cDovL2JpdC5seS8xVHFjd243') 124 | 125 | console.log(token) // "aHR0cDovL2JpdC5seS8xVHFjd243" 126 | ``` 127 | 128 | ## API 129 | 130 | **Methods** 131 | 132 | * [`encrypt(parameters)`](#api-encrypt) 133 | * [`decrypt(ciphertext)`](#api-decrypt) 134 | 135 | --- 136 | 137 | 138 | 139 | ### encrypt(parameters) 140 | 141 | ```typescript 142 | interface InterfaceEncryptParameters { 143 | readonly plaintext: string 144 | readonly keyId: string 145 | } 146 | 147 | async function encrypt( 148 | parameters: 149 | | InterfaceEncryptParameters 150 | | ReadonlyArray, 151 | ): Promise> 152 | ``` 153 | 154 | Encrypt a plaintext string. Requires a AWS KMS key ID (or key Arn). 155 | 156 | ```js 157 | const ciphertext = await encrypt({ 158 | plaintext: 'secret text', 159 | keyId: 160 | 'arn:aws:kms:eu-west-1:000000000000:key/55kkmm11-aann-99ff-mmaa-3322115566hh', 161 | }) 162 | ``` 163 | 164 | --- 165 | 166 | 167 | 168 | ### decrypt(ciphertext) 169 | 170 | AWS KMS encrypted ciphertext contains metadata so it is not necessary to provide context or key ID. 171 | 172 | ```typescript 173 | async function decrypt( 174 | ciphertext: undefined | string | ReadonlyArray, 175 | ): Promise> 176 | ``` 177 | 178 | Decrypt KMS-encrypted ciphertext. 179 | 180 | ```js 181 | const plaintext = await decrypt('aHR0cDovL2JpdC5seS8xVHFjd243') 182 | ``` 183 | 184 | ## Related Thingies 185 | 186 | * [aws-s3-thingy](https://github.com/adieuadieu/aws-s3-thingy) 187 | * [alagarr](https://github.com/adieuadieu/alagarr) — AWS Lambda/API Gateway Request/Response Thingy 188 | * [aws-kms-crypt](https://github.com/sjakthol/aws-kms-crypt) 189 | 190 | ## License 191 | 192 | **aws-kms-thingy** © [Marco Lüthy](https://github.com/adieuadieu). Released under the [MIT](./LICENSE) license.
193 | Authored and maintained by Marco Lüthy with help from [contributors](https://github.com/adieuadieu/aws-kms-thingy/contributors). 194 | 195 | > [github.com/adieuadieu](https://github.com/adieuadieu) · GitHub [@adieuadieu](https://github.com/adieuadieu) · Twitter [@adieuadieu](https://twitter.com/adieuadieu) · Medium [@marco.luethy](https://medium.com/@marco.luethy) 196 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-kms-thingy", 3 | "version": "0.0.0", 4 | "description": "A wrapper/helper utility for encrypting/decrypting with AWS KMS", 5 | "keywords": [ 6 | "aws", 7 | "kms", 8 | "encrypt", 9 | "decrypt", 10 | "helper", 11 | "utility", 12 | "cli", 13 | "command", 14 | "secrets", 15 | "tool" 16 | ], 17 | "engines": { 18 | "npm": ">= 3.0.0", 19 | "node": ">= 6.10.0" 20 | }, 21 | "main": "dist/lib.cjs.js", 22 | "module": "dist/lib.es.js", 23 | "types": "dist/src/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "bin": { 28 | "akt": "dist/cli.js", 29 | "aws-kms-thingy": "dist/cli.js" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/adieuadieu/aws-kms-thingy.git" 34 | }, 35 | "author": "Marco Lüthy (https://github.com/adieuadieu)", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/adieuadieu/aws-kms-thingy/issues" 39 | }, 40 | "homepage": "https://github.com/adieuadieu/aws-kms-thingy", 41 | "scripts": { 42 | "prebuild": "npm run clean", 43 | "build": "tsc -d && rollup -c", 44 | "clean": "rm -Rf dist", 45 | "dev": "tsc -w", 46 | "precommit": "lint-staged", 47 | "prettier": "prettier --write", 48 | "test": "jest", 49 | "watch:test": "jest --watch", 50 | "lint": "npm run lint:tsc && npm run lint:tslint", 51 | "lint:tslint": "tslint -p tsconfig.json -t stylish", 52 | "lint:tsc": "tsc -p tsconfig.json --noEmit --pretty", 53 | "preversion": "git pull && yarn check --integrity && yarn security-check && yarn lint && yarn test", 54 | "postversion": "git push --tags origin HEAD", 55 | "prepublishOnly": "npm run lint && npm test && npm run security-check && npm run build", 56 | "security-check": "nsp check", 57 | "upgrade-dependencies": "yarn upgrade-interactive --latest --exact", 58 | "commitmsg": "commitlint -e $GIT_PARAMS", 59 | "semantic-release": "semantic-release" 60 | }, 61 | "dependencies": {}, 62 | "devDependencies": { 63 | "@commitlint/cli": "7.2.1", 64 | "@commitlint/config-conventional": "7.1.2", 65 | "@types/aws-lambda": "8.10.15", 66 | "@types/jest": "23.3.10", 67 | "@types/node": "9.6.40", 68 | "aws-sdk-mock": "4.3.0", 69 | "coveralls": "3.0.2", 70 | "husky": "0.14.3", 71 | "jest": "23.6.0", 72 | "lint-staged": "8.1.0", 73 | "nsp": "3.2.1", 74 | "nyc": "13.0.1", 75 | "prettier": "1.15.3", 76 | "rollup": "0.67.4", 77 | "rollup-plugin-hashbang": "1.0.1", 78 | "rollup-plugin-node-resolve": "3.4.0", 79 | "semantic-release": "15.12.4", 80 | "ts-jest": "23.10.5", 81 | "tslint": "5.11.0", 82 | "tslint-functional-preset": "1.5.0", 83 | "typescript": "3.0.3" 84 | }, 85 | "peerDependencies": { 86 | "aws-sdk": "^2.197.0" 87 | }, 88 | "commitlint": { 89 | "extends": [ 90 | "@commitlint/config-conventional" 91 | ] 92 | }, 93 | "prettier": { 94 | "printWidth": 80, 95 | "semi": false, 96 | "singleQuote": true, 97 | "trailingComma": "all", 98 | "useTabs": false 99 | }, 100 | "lint-staged": { 101 | "*.{ts,tsx}": [ 102 | "yarn prettier", 103 | "yarn lint", 104 | "git add" 105 | ], 106 | "*.{json}": [ 107 | "yarn prettier", 108 | "git add" 109 | ] 110 | }, 111 | "jest": { 112 | "bail": false, 113 | "collectCoverage": true, 114 | "roots": [ 115 | "src/" 116 | ], 117 | "setupTestFrameworkScriptFile": "/test/setup.ts", 118 | "transform": { 119 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 120 | }, 121 | "testEnvironment": "node", 122 | "testRegex": "\\.test\\.ts$", 123 | "moduleFileExtensions": [ 124 | "ts", 125 | "js" 126 | ], 127 | "coverageThreshold": { 128 | "global": { 129 | "branches": 100, 130 | "functions": 100, 131 | "lines": 100, 132 | "statements": 100 133 | } 134 | }, 135 | "coveragePathIgnorePatterns": [ 136 | "/node_modules/", 137 | "/test/" 138 | ] 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "docker:disable"], 3 | "automerge": true, 4 | "automergeType": "branch-push", 5 | "commitMessagePrefix": "⬆️ ", 6 | "major": { 7 | "automerge": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import hashbang from 'rollup-plugin-hashbang' 2 | import resolve from 'rollup-plugin-node-resolve' 3 | 4 | const external = ['aws-sdk', 'readline'] 5 | 6 | const plugins = [ 7 | hashbang(), 8 | resolve({ 9 | extensions: ['.js'], 10 | jsnext: true, 11 | main: true, 12 | module: true, 13 | }), 14 | ] 15 | 16 | export default [ 17 | { 18 | external, 19 | input: 'dist/src/index.js', 20 | output: [ 21 | { file: 'dist/lib.cjs.js', format: 'cjs' }, 22 | { file: 'dist/lib.es.js', format: 'es' }, 23 | ], 24 | plugins, 25 | }, 26 | { 27 | external, 28 | input: 'dist/src/cli.js', 29 | output: [{ file: 'dist/cli.js', format: 'cjs' }], 30 | plugins, 31 | }, 32 | ] 33 | -------------------------------------------------------------------------------- /src/cli.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable no-expression-statement 2 | import { main } from './cli' 3 | 4 | const argvBase: ReadonlyArray = [ 5 | '/usr/local/bin/node', 6 | '/home/mockUser', 7 | ] 8 | 9 | const mockDecryptedValue = 'foobar' 10 | const mockEncryptedValue = Buffer.from(mockDecryptedValue).toString('base64') 11 | 12 | jest.mock('readline', () => ({ 13 | clearLine: jest.fn(), 14 | createInterface: () => ({ 15 | close: jest.fn(), 16 | question: (questionText: any, callback: any) => 17 | callback( 18 | questionText.match('encrypted') 19 | ? mockEncryptedValue 20 | : mockDecryptedValue, 21 | ), 22 | }), 23 | moveCursor: jest.fn(), 24 | })) 25 | 26 | describe('cli', () => { 27 | it('should be able to encrypt a string', async () => { 28 | const result = await main([...argvBase, 'encrypt']) 29 | 30 | expect(result).toBe(mockEncryptedValue) 31 | }) 32 | 33 | it('should be able to decrypt an encrypted string', async () => { 34 | const result = await main([...argvBase, 'decrypt']) 35 | 36 | expect(result).toBe(mockDecryptedValue) 37 | }) 38 | 39 | it('should print usage info if no command specified', async () => { 40 | process.stdout.columns = undefined // tslint:disable-line no-object-mutation 41 | 42 | jest.resetAllMocks() 43 | jest.resetModules() 44 | jest.resetModuleRegistry() 45 | 46 | const consoleSpy = jest.spyOn(console, 'info') 47 | const cli = require('./cli') 48 | 49 | const result = await cli.main() 50 | 51 | expect(result).toBe(undefined) 52 | expect(consoleSpy).toHaveBeenCalled() 53 | expect(consoleSpy.mock.calls[0][0]).toContain('Usage:') 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // tslint:disable no-console 3 | import * as readline from 'readline' 4 | import * as lib from './' 5 | import { IndexSignature } from './types' 6 | 7 | const COLUMNS = process.stdout.columns || 32 8 | 9 | const askQuestion = ( 10 | question: string, 11 | prompt = '→', 12 | sensitive = false, 13 | ): Promise => { 14 | const line = readline.createInterface({ 15 | input: process.stdin, 16 | output: process.stdout, 17 | }) 18 | 19 | return new Promise(resolve => 20 | line.question( 21 | `\n${question}:\n\n${prompt} `, 22 | answer => 23 | !line.close() && 24 | (sensitive 25 | ? !readline.moveCursor(process.stdin, 0, -1) && 26 | !readline.clearLine(process.stdin, 0) && 27 | !console.log(`${prompt} ${'*'.repeat(answer.length)}`) 28 | : true) && 29 | resolve(answer.trim()), 30 | ), 31 | ) 32 | } 33 | 34 | const printUsageText = () => 35 | console.info(` 36 | Usage: aws-kms-thingy [command] 37 | 38 | Commands: 39 | - encrypt Encrypt a string with an AWS KMS key 40 | - decrypt Decrypt ciphertext encrypted with AWS KMS 41 | `) 42 | 43 | const prettyResult = (title: string, text: string) => 44 | !console.log( 45 | `\n${'〰️'.repeat( 46 | (COLUMNS - (title.length + 2)) / 4, 47 | )} ${title} ${'〰️'.repeat((COLUMNS - (title.length + 2)) / 4)}\n`, 48 | text, 49 | `\n${'〰️'.repeat(COLUMNS / 2)}\n`, 50 | ) && text 51 | 52 | async function encrypt(): Promise> { 53 | const keyId = 54 | process.env.AWS_KMS_KEY_ARN || 55 | (await askQuestion('Please enter AWS KMS Key ARN to use')) 56 | const plaintext = await askQuestion('Enter plain text to encrypt', '🔓', true) 57 | const result = (await lib.encrypt({ keyId, plaintext })) as string 58 | 59 | return prettyResult('🔐 Encrypted', result) 60 | } 61 | 62 | async function decrypt(): Promise> { 63 | const ciphertext = await askQuestion('Enter ciphertext to decrypt', '🔐') 64 | const result = (await lib.decrypt(ciphertext)) as string 65 | 66 | return prettyResult('🔓 Decrypted', result) 67 | } 68 | 69 | export async function main([, , action] = process.argv): Promise { 70 | const actions: IndexSignature = { 71 | decrypt, 72 | encrypt, 73 | } 74 | 75 | return action && action in actions 76 | ? !console.log(`aws-kms-thingy ${action}`) && actions[action]() 77 | : printUsageText() 78 | } 79 | 80 | // tslint:disable-next-line no-unused-expression no-expression-statement 81 | main().catch(console.error) // ¯\_(ツ)_/¯ 82 | -------------------------------------------------------------------------------- /src/decrypt.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable no-expression-statement 2 | import kmsDecrypt, { dictionary } from './decrypt' 3 | 4 | const mockDecryptedValue = 'foobar' 5 | const mockEncryptedValue = Buffer.from(mockDecryptedValue).toString('base64') 6 | 7 | describe('decrypt()', () => { 8 | it('should decrypt an encrypted string and store it in cache', async () => { 9 | const result = await kmsDecrypt(mockEncryptedValue) 10 | 11 | expect(result).toBe(mockDecryptedValue) 12 | expect(dictionary.get(mockEncryptedValue)).toBe(mockDecryptedValue) 13 | }) 14 | 15 | it('should use cache if item exists in cache', async () => { 16 | const encryptedValue = Buffer.from('foobar').toString('base64') 17 | 18 | dictionary.clear() 19 | dictionary.set(encryptedValue, 'cached') 20 | 21 | const result = await kmsDecrypt(encryptedValue) 22 | 23 | expect(result).toBe('cached') 24 | }) 25 | 26 | it('should return value as-is if not a base64 encoded secret', async () => { 27 | const result = await kmsDecrypt(mockDecryptedValue) 28 | 29 | expect(result).toBe(mockDecryptedValue) 30 | }) 31 | 32 | it('should return original value if error occurred decrypting with SDK', async () => { 33 | const mockError = Buffer.from('mockError').toString('base64') 34 | const result = await kmsDecrypt(mockError) 35 | 36 | expect(result).toBe(mockError) 37 | }) 38 | 39 | it('should return value as is if DISABLE_AWS_KMS_THINGY is set', async () => { 40 | // tslint:disable-next-line no-object-mutation 41 | process.env.DISABLE_AWS_KMS_THINGY = 'true' 42 | 43 | const result = await kmsDecrypt(mockEncryptedValue) 44 | 45 | expect(result).toBe(mockEncryptedValue) 46 | 47 | // tslint:disable-next-line no-object-mutation no-delete 48 | delete process.env.DISABLE_AWS_KMS_THINGY 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/decrypt.ts: -------------------------------------------------------------------------------- 1 | import { KMS } from 'aws-sdk' // tslint:disable-line:no-implicit-dependencies 2 | 3 | const kms = new KMS() 4 | 5 | const isBase64 = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/ 6 | 7 | export const dictionary = new Map() 8 | 9 | async function decrypt(ciphertext: string): Promise { 10 | const result = await kms 11 | .decrypt({ CiphertextBlob: Buffer.from(ciphertext, 'base64') }) 12 | .promise() 13 | const plaintext = result.Plaintext ? result.Plaintext.toString() : ciphertext 14 | 15 | return dictionary.set(ciphertext, plaintext) && plaintext 16 | } 17 | 18 | export default (ciphertext: string): Promise | string => 19 | ciphertext.length === 0 || // empty string? 20 | process.env.DISABLE_AWS_KMS_THINGY || // we shouldn't decrypt? 21 | !isBase64.test(ciphertext) // not a base64 encoded ciphertext? 22 | ? String(ciphertext) 23 | : // previously decrypted and in cache? 24 | dictionary.get(ciphertext) || 25 | // decrypt it 26 | decrypt(ciphertext) 27 | -------------------------------------------------------------------------------- /src/encrypt.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable no-expression-statement 2 | import kmsEncrypt, { dictionary } from './encrypt' 3 | 4 | const mockDecryptedValue = 'foobar' 5 | const mockEncryptedValue = Buffer.from(mockDecryptedValue).toString('base64') 6 | const keyId = 'foobar-key' 7 | 8 | const mock = (plaintext: string) => ({ keyId, plaintext }) 9 | 10 | describe('encrypt()', () => { 11 | it('should encrypt a string and store it in cache', async () => { 12 | const result = await kmsEncrypt(mock(mockDecryptedValue)) 13 | 14 | expect(result).toBe(mockEncryptedValue) 15 | expect(dictionary.get(mockDecryptedValue)).toBe(mockEncryptedValue) 16 | }) 17 | 18 | it('should use cache if item exists in cache', async () => { 19 | dictionary.clear() 20 | dictionary.set(keyId + mockDecryptedValue, 'cached') 21 | 22 | const result = await kmsEncrypt(mock(mockDecryptedValue)) 23 | 24 | expect(result).toBe('cached') 25 | }) 26 | 27 | it('should return original value if error occurred encrypting with SDK', async () => { 28 | const mockError = 'mockError' 29 | const result = await kmsEncrypt(mock(mockError)) 30 | 31 | expect(result).toBe(mockError) 32 | }) 33 | 34 | it('should return value as is if DISABLE_AWS_KMS_THINGY is set', async () => { 35 | // tslint:disable-next-line no-object-mutation 36 | process.env.DISABLE_AWS_KMS_THINGY = 'true' 37 | 38 | const result = await kmsEncrypt(mock(mockDecryptedValue)) 39 | 40 | expect(result).toBe(mockDecryptedValue) 41 | 42 | // tslint:disable-next-line no-object-mutation no-delete 43 | delete process.env.DISABLE_AWS_KMS_THINGY 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/encrypt.ts: -------------------------------------------------------------------------------- 1 | import { KMS } from 'aws-sdk' // tslint:disable-line:no-implicit-dependencies 2 | 3 | const kms = new KMS() 4 | 5 | export const dictionary = new Map() 6 | 7 | export interface InterfaceEncryptParameters { 8 | readonly plaintext: string 9 | readonly keyId: string 10 | } 11 | 12 | async function encrypt({ 13 | plaintext, 14 | keyId, 15 | }: InterfaceEncryptParameters): Promise { 16 | const result = await kms 17 | .encrypt({ KeyId: keyId, Plaintext: plaintext }) 18 | .promise() 19 | 20 | const ciphertext = result.CiphertextBlob 21 | ? (result.CiphertextBlob as Buffer).toString('base64') 22 | : plaintext 23 | 24 | return dictionary.set(plaintext, ciphertext) && ciphertext 25 | } 26 | 27 | export default ({ 28 | plaintext, 29 | keyId, 30 | }: InterfaceEncryptParameters): Promise | string => 31 | process.env.DISABLE_AWS_KMS_THINGY // we shouldn't encrypt? 32 | ? String(plaintext) 33 | : // previously encrypted and in cache? 34 | dictionary.get(keyId + plaintext) || 35 | // encrypt it 36 | encrypt({ plaintext, keyId }) 37 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable no-expression-statement 2 | import { decrypt, encrypt } from './' 3 | 4 | const mockDecryptedValue = 'foobar' 5 | const mockEncryptedValue = Buffer.from(mockDecryptedValue).toString('base64') 6 | 7 | const mockArrayOfDecryptedValues: ReadonlyArray = [ 8 | 'foobar-1', 9 | 'foobar-2', 10 | 'foobar-3', 11 | 'foobar-4', 12 | ] 13 | 14 | const mockArrayOfEncryptedValues: ReadonlyArray = [ 15 | Buffer.from(mockArrayOfDecryptedValues[0]).toString('base64'), 16 | Buffer.from(mockArrayOfDecryptedValues[1]).toString('base64'), 17 | Buffer.from(mockArrayOfDecryptedValues[2]).toString('base64'), 18 | Buffer.from(mockArrayOfDecryptedValues[3]).toString('base64'), 19 | ] 20 | 21 | describe('lib', () => { 22 | it('should encrypt an string', async () => { 23 | const result = await encrypt({ 24 | keyId: 'foobar-key', 25 | plaintext: mockDecryptedValue, 26 | }) 27 | 28 | expect(result).toBe(mockEncryptedValue) 29 | }) 30 | 31 | it('should encrypt an array of strings', async () => { 32 | const result = await encrypt( 33 | mockArrayOfDecryptedValues.map(item => ({ 34 | keyId: 'foobar-key', 35 | plaintext: item, 36 | })), 37 | ) 38 | 39 | expect(result).toEqual(mockArrayOfEncryptedValues) 40 | 41 | // Tests the ReadonlyArray overload 42 | const [zero, one, two, three] = result 43 | 44 | expect(zero).toEqual(mockArrayOfEncryptedValues[0]) 45 | expect(one).toEqual(mockArrayOfEncryptedValues[1]) 46 | expect(two).toEqual(mockArrayOfEncryptedValues[2]) 47 | expect(three).toEqual(mockArrayOfEncryptedValues[3]) 48 | }) 49 | 50 | it('should decrypt an encrypted string', async () => { 51 | const result = await decrypt(mockEncryptedValue) 52 | 53 | expect(result).toBe(mockDecryptedValue) 54 | }) 55 | 56 | it('should decrypt an array of encrypted strings', async () => { 57 | const result = await decrypt(mockArrayOfEncryptedValues) 58 | 59 | expect(result).toEqual(mockArrayOfDecryptedValues) 60 | 61 | // Tests the ReadonlyArray overload 62 | const [zero, one, two, three] = result 63 | 64 | expect(zero).toEqual(mockArrayOfDecryptedValues[0]) 65 | expect(one).toEqual(mockArrayOfDecryptedValues[1]) 66 | expect(two).toEqual(mockArrayOfDecryptedValues[2]) 67 | expect(three).toEqual(mockArrayOfDecryptedValues[3]) 68 | }) 69 | 70 | it('should be able to handle empty or undefined parameters', async () => { 71 | expect(await decrypt(undefined)).toBe(undefined) 72 | expect(await decrypt('')).toBe('') 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import kmsDecrypt from './decrypt' 2 | import kmsEncrypt, { InterfaceEncryptParameters } from './encrypt' 3 | 4 | export async function encrypt( 5 | parameters: InterfaceEncryptParameters, 6 | ): Promise 7 | export async function encrypt( 8 | parameters: ReadonlyArray, 9 | ): Promise> 10 | export async function encrypt(parameters: any): Promise { 11 | return 'plaintext' in parameters 12 | ? kmsEncrypt(parameters) 13 | : Promise.all(parameters.map(kmsEncrypt)) 14 | } 15 | 16 | export async function decrypt(ciphertext: undefined): Promise 17 | export async function decrypt(ciphertext: string): Promise 18 | export async function decrypt( 19 | ciphertext: ReadonlyArray, 20 | ): Promise> 21 | export async function decrypt(ciphertext: any): Promise { 22 | return typeof ciphertext === 'undefined' 23 | ? undefined // useful in development when process.env.SECRET may be unset 24 | : typeof ciphertext === 'string' 25 | ? kmsDecrypt(ciphertext) 26 | : Promise.all(ciphertext.map(kmsDecrypt)) 27 | } 28 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IndexSignature { 2 | readonly [key: string]: any 3 | } 4 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-expression-statement 2 | import * as AWS from 'aws-sdk' 3 | import * as mockAWS from 'aws-sdk-mock' 4 | 5 | mockAWS.setSDKInstance(AWS) 6 | 7 | mockAWS.mock('KMS', 'decrypt', (params: any, callback: any) => { 8 | const plain = params.CiphertextBlob.toString() 9 | 10 | return plain === 'mockError' 11 | ? callback(null, {}) 12 | : callback(null, { Plaintext: plain }) 13 | }) 14 | 15 | mockAWS.mock( 16 | 'KMS', 17 | 'encrypt', 18 | ({ Plaintext: plaintext }: any, callback: any) => { 19 | return plaintext === 'mockError' 20 | ? callback(null, {}) 21 | : callback(null, { 22 | CiphertextBlob: Buffer.from(plaintext), 23 | }) 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /test/test.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'aws-sdk-mock' 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": false, 5 | "declaration": true, 6 | "esModuleInterop": false, 7 | "forceConsistentCasingInFileNames": true, 8 | "lib": ["es2018", "esnext"], 9 | "module": "es2015", 10 | "moduleResolution": "node", 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noUnusedParameters": true, 15 | "noUnusedLocals": true, 16 | "outDir": "dist", 17 | "pretty": true, 18 | "removeComments": true, 19 | "rootDir": ".", 20 | "skipLibCheck": false, 21 | "sourceMap": false, 22 | "strictNullChecks": true, 23 | "target": "es2017" 24 | }, 25 | "exclude": ["node_modules", "dist", "test"] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-functional-preset"] 3 | } 4 | --------------------------------------------------------------------------------