├── VERSION ├── .gitignore ├── Dockerfile ├── testdata ├── test_no_private_key.ejson └── test.ejson ├── local_kms └── seed.yaml ├── .pre-commit-hooks.yaml ├── .github ├── dependabot.yml └── workflows │ └── release.yml ├── .goreleaser.yaml ├── compose.yaml ├── actions_test.go ├── go.mod ├── LICENSE.txt ├── Makefile ├── kms.go ├── ejsonkms_test.go ├── ejsonkms.go ├── README.md ├── actions.go ├── cmd └── ejsonkms │ └── main.go └── go.sum /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.9 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine 2 | WORKDIR /go/src/github.com/envato/ejsonkms 3 | COPY . . 4 | RUN apk add git gcc musl-dev 5 | RUN go get 6 | -------------------------------------------------------------------------------- /testdata/test_no_private_key.ejson: -------------------------------------------------------------------------------- 1 | { 2 | "_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52", 3 | "environment": { 4 | "my_secret": "EJ[1:oAT3giWcK72oUnxPv3lLy5QdC96loVTIAZ3HslNxMkw=:zjrRXdWKBLTWJN8WkmJrXKC2mTTy7XaJ:HSzpCJj5jXudq9ByKm412bRo5Rs7KKkIzg==]" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /local_kms/seed.yaml: -------------------------------------------------------------------------------- 1 | Keys: 2 | - Metadata: 3 | KeyId: bc436485-5092-42b8-92a3-0aa8b93536dc 4 | BackingKeys: 5 | - 5cdaead27fe7da2de47945d73cd6d79e36494e73802f3cd3869f1d2cb0b5d7a9 6 | 7 | Aliases: 8 | - AliasName: alias/testing 9 | TargetKeyId: bc436485-5092-42b8-92a3-0aa8b93536dc 10 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | # Defines supported pre-commit hooks for this project. 2 | 3 | - id: run-ejsonkms-encrypt 4 | name: run-ejsonkms-encrypt 5 | description: Run ejsonkms encrypt on all ejson files in your repository. 6 | entry: ejsonkms encrypt 7 | language: golang 8 | types: [file] 9 | files: '\.ejson$' -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | allow: 8 | - dependency-type: all 9 | groups: 10 | go: 11 | patterns: 12 | - '*' 13 | commit-message: 14 | prefix: Dependabot 15 | -------------------------------------------------------------------------------- /testdata/test.ejson: -------------------------------------------------------------------------------- 1 | { 2 | "_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52", 3 | "_private_key_enc": "S2Fybjphd3M6a21zOnVzLWVhc3QtMToxMTExMjIyMjMzMzM6a2V5L2JjNDM2NDg1LTUwOTItNDJiOC05MmEzLTBhYThiOTM1MzZkYwAAAAAycRX5OBx6xGuYOPAmDJ1FombB1lFybMP42s7PGmoa24bAesPMMZtI9V0w0p0lEgLeeSvYdsPuoPROa4bwnQxJB28eC6fHgfWgY7jgDWY9uP/tgzuWL3zuIaq+9Q==", 4 | "environment": { 5 | "my_secret": "EJ[1:oAT3giWcK72oUnxPv3lLy5QdC96loVTIAZ3HslNxMkw=:zjrRXdWKBLTWJN8WkmJrXKC2mTTy7XaJ:HSzpCJj5jXudq9ByKm412bRo5Rs7KKkIzg==]" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | version: 1 3 | builds: 4 | - main: ./cmd/ejsonkms 5 | env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - linux 10 | - windows 11 | goarch: 12 | - amd64 13 | - arm64 14 | - ppc64le 15 | ignore: 16 | - goos: darwin 17 | goarch: ppc64le 18 | - goos: windows 19 | goarch: ppc64le 20 | archives: 21 | - format: tar.gz 22 | format_overrides: 23 | - goos: windows 24 | format: zip 25 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | awskms: 4 | image: "nsmithuk/local-kms" 5 | environment: 6 | REGION: us-east-1 7 | KMS_REGION: us-east-1 8 | KMS_ACCOUNT_ID: 111122223333 9 | volumes: 10 | - "./local_kms/:/init/" 11 | expose: 12 | - 8080 13 | tests: 14 | build: . 15 | volumes: 16 | - "./:/go/src/github.com/envato/ejsonkms" 17 | command: ["go", "test"] 18 | environment: 19 | AWS_ACCESS_KEY_ID: '123' 20 | AWS_SECRET_ACCESS_KEY: xyz 21 | AWS_REGION: us-east-1 22 | FAKE_AWSKMS_URL: http://awskms:8080 23 | links: 24 | - awskms 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 20 | with: 21 | go-version-file: go.mod 22 | check-latest: true 23 | - uses: goreleaser/goreleaser-action@5742e2a039330cbb23ebf35f046f814d4c6ff811 # v5.1.0 24 | with: 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /actions_test.go: -------------------------------------------------------------------------------- 1 | package ejsonkms 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kami-zh/go-capturer" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestEnv(t *testing.T) { 11 | Convey("Env", t, func() { 12 | out := capturer.CaptureOutput(func() { 13 | err := EnvAction("testdata/test.ejson", "us-east-1", false) 14 | So(err, ShouldBeNil) 15 | }) 16 | 17 | Convey("should return decrypted values as shell exports", func() { 18 | So(out, ShouldContainSubstring, "export my_secret=secret123") 19 | }) 20 | }) 21 | 22 | Convey("Env with no private key", t, func() { 23 | err := EnvAction("testdata/test_no_private_key.ejson", "us-east-1", false) 24 | 25 | Convey("should fail", func() { 26 | So(err, ShouldNotBeNil) 27 | So(err.Error(), ShouldContainSubstring, "missing _private_key_enc") 28 | }) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/envato/ejsonkms 2 | 3 | go 1.24.2 4 | 5 | toolchain go1.24.9 6 | 7 | require ( 8 | github.com/Shopify/ejson v1.5.4 9 | github.com/Shopify/ejson2env/v2 v2.0.8 10 | github.com/aws/aws-sdk-go v1.55.8 11 | github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d 12 | github.com/smartystreets/goconvey v1.8.1 13 | github.com/urfave/cli v1.22.17 14 | ) 15 | 16 | require ( 17 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 18 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 19 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect 20 | github.com/gopherjs/gopherjs v1.17.2 // indirect 21 | github.com/jmespath/go-jmespath v0.4.0 // indirect 22 | github.com/jtolds/gls v4.20.0+incompatible // indirect 23 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 24 | github.com/smarty/assertions v1.16.0 // indirect 25 | golang.org/x/crypto v0.45.0 // indirect 26 | golang.org/x/sys v0.38.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Envato 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=ejsonkms 2 | PACKAGE=github.com/envato/ejsonkms 3 | VERSION=$(shell cat VERSION) 4 | GOFILES=$(shell find . -type f -name '*.go') 5 | 6 | .PHONY: default all binaries clean 7 | 8 | default: all 9 | all: binaries 10 | binaries: build/bin/linux-ppc64le build/bin/linux-amd64 build/bin/linux-arm64 build/bin/darwin-amd64 build/bin/darwin-arm64 11 | 12 | build/bin/linux-ppc64le: $(GOFILES) 13 | mkdir -p "$(@D)" 14 | GOOS=linux GOARCH=ppc64le go build \ 15 | -ldflags '-s -w -X main.version="$(VERSION)"' \ 16 | -o "$@" \ 17 | "$(PACKAGE)/cmd/$(NAME)" 18 | 19 | build/bin/linux-amd64: $(GOFILES) 20 | mkdir -p "$(@D)" 21 | GOOS=linux GOARCH=amd64 go build \ 22 | -ldflags '-s -w -X main.version="$(VERSION)"' \ 23 | -o "$@" \ 24 | "$(PACKAGE)/cmd/$(NAME)" 25 | 26 | build/bin/linux-arm64: $(GOFILES) 27 | mkdir -p "$(@D)" 28 | GOOS=linux GOARCH=arm64 go build \ 29 | -ldflags '-s -w -X main.version="$(VERSION)"' \ 30 | -o "$@" \ 31 | "$(PACKAGE)/cmd/$(NAME)" 32 | 33 | build/bin/darwin-amd64: $(GOFILES) 34 | GOOS=darwin GOARCH=amd64 go build \ 35 | -ldflags '-s -w -X main.version="$(VERSION)"' \ 36 | -o "$@" \ 37 | "$(PACKAGE)/cmd/$(NAME)" 38 | 39 | build/bin/darwin-arm64: $(GOFILES) 40 | GOOS=darwin GOARCH=arm64 go build \ 41 | -ldflags '-s -w -X main.version="$(VERSION)"' \ 42 | -o "$@" \ 43 | "$(PACKAGE)/cmd/$(NAME)" 44 | 45 | clean: 46 | rm -rf build 47 | -------------------------------------------------------------------------------- /kms.go: -------------------------------------------------------------------------------- 1 | package ejsonkms 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/kms" 11 | "github.com/aws/aws-sdk-go/service/kms/kmsiface" 12 | ) 13 | 14 | func decryptPrivateKeyWithKMS(privateKeyEnc, awsRegion string) (key string, err error) { 15 | kmsSvc := newKmsClient(awsRegion) 16 | 17 | encryptedValue, err := base64.StdEncoding.DecodeString(privateKeyEnc) 18 | 19 | params := &kms.DecryptInput{ 20 | CiphertextBlob: []byte(encryptedValue), 21 | } 22 | resp, err := kmsSvc.Decrypt(params) 23 | if err != nil { 24 | return "", fmt.Errorf("unable to decrypt parameter: %v", err) 25 | } 26 | return string(resp.Plaintext), nil 27 | } 28 | 29 | func encryptPrivateKeyWithKMS(privateKey, kmsKeyID, awsRegion string) (key string, err error) { 30 | kmsSvc := newKmsClient(awsRegion) 31 | params := &kms.EncryptInput{ 32 | KeyId: &kmsKeyID, 33 | Plaintext: []byte(privateKey), 34 | } 35 | resp, err := kmsSvc.Encrypt(params) 36 | if err != nil { 37 | return "", fmt.Errorf("unable to encrypt parameter: %v", err) 38 | } 39 | 40 | encodedPrivKey := base64.StdEncoding.EncodeToString(resp.CiphertextBlob) 41 | return encodedPrivKey, nil 42 | } 43 | 44 | func newKmsClient(awsRegion string) kmsiface.KMSAPI { 45 | awsSession := session.Must(session.NewSession()) 46 | awsSession.Config.WithRegion(awsRegion) 47 | fakeKmsEndpoint := os.Getenv("FAKE_AWSKMS_URL") 48 | if len(fakeKmsEndpoint) != 0 { 49 | return kms.New(awsSession, aws.NewConfig().WithEndpoint(fakeKmsEndpoint)) 50 | } 51 | return kms.New(awsSession) 52 | } 53 | -------------------------------------------------------------------------------- /ejsonkms_test.go: -------------------------------------------------------------------------------- 1 | package ejsonkms 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | // type mockKMSClient struct { 10 | // kmsiface.KMSAPI 11 | // } 12 | 13 | // func (m *mockKMSClient) Encrypt(input *kms.EncryptInput) (*kms.EncryptOutput, error) { 14 | // output := &kms.EncryptOutput{ 15 | // CiphertextBlob: input.Plaintext, 16 | // } 17 | // return output, nil 18 | // } 19 | 20 | // func (m *mockKMSClient) Decrypt(input *kms.DecryptInput) (*kms.DecryptOutput, error) { 21 | // output := &kms.DecryptOutput{ 22 | // Plaintext: input.CiphertextBlob, 23 | // } 24 | // return output, nil 25 | // } 26 | 27 | func TestKeygen(t *testing.T) { 28 | Convey("Keygen", t, func() { 29 | ejsonKmsKeys, err := Keygen("bc436485-5092-42b8-92a3-0aa8b93536dc", "us-east-1") 30 | Convey("should return three strings that look key-like", func() { 31 | So(err, ShouldBeNil) 32 | So(ejsonKmsKeys.PublicKey, ShouldNotEqual, ejsonKmsKeys.PrivateKey) 33 | So(ejsonKmsKeys.PublicKey, ShouldNotContainSubstring, "00000") 34 | So(ejsonKmsKeys.PrivateKey, ShouldNotContainSubstring, "00000") 35 | So(ejsonKmsKeys.PrivateKeyEnc, ShouldNotContainSubstring, "00000") 36 | }) 37 | }) 38 | } 39 | 40 | func TestDecrypt(t *testing.T) { 41 | Convey("Decrypt", t, func() { 42 | decrypted, err := Decrypt("testdata/test.ejson", "us-east-1") 43 | Convey("should return decrypted values", func() { 44 | So(err, ShouldBeNil) 45 | json := string(decrypted[:]) 46 | So(json, ShouldContainSubstring, `"my_secret": "secret123"`) 47 | }) 48 | }) 49 | Convey("Decrypt with no private key", t, func() { 50 | _, err := Decrypt("testdata/test_no_private_key.ejson", "us-east-1") 51 | Convey("should fail", func() { 52 | So(err, ShouldNotBeNil) 53 | So(err.Error(), ShouldContainSubstring, "missing _private_key_enc") 54 | }) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /ejsonkms.go: -------------------------------------------------------------------------------- 1 | package ejsonkms 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/Shopify/ejson" 10 | ) 11 | 12 | // EjsonKmsKeys - keys used in an EjsonKms file 13 | type EjsonKmsKeys struct { 14 | PublicKey string `json:"_public_key"` 15 | PrivateKeyEnc string `json:"_private_key_enc"` 16 | PrivateKey string 17 | } 18 | 19 | // Keygen generates keys and prepares an EJSON file with them 20 | func Keygen(kmsKeyID, awsRegion string) (EjsonKmsKeys, error) { 21 | var ejsonKmsKeys EjsonKmsKeys 22 | pub, priv, err := ejson.GenerateKeypair() 23 | if err != nil { 24 | return ejsonKmsKeys, err 25 | } 26 | 27 | privKeyEnc, err := encryptPrivateKeyWithKMS(priv, kmsKeyID, awsRegion) 28 | if err != nil { 29 | return ejsonKmsKeys, err 30 | } 31 | 32 | ejsonKmsKeys = EjsonKmsKeys{ 33 | PublicKey: pub, 34 | PrivateKeyEnc: privKeyEnc, 35 | PrivateKey: priv, 36 | } 37 | 38 | return ejsonKmsKeys, nil 39 | } 40 | 41 | // Decrypt decrypts an EJSON file 42 | func Decrypt(ejsonFilePath, awsRegion string) ([]byte, error) { 43 | privateKeyEnc, err := findPrivateKeyEnc(ejsonFilePath) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | kmsDecryptedPrivateKey, err := decryptPrivateKeyWithKMS(privateKeyEnc, awsRegion) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | decrypted, err := ejson.DecryptFile(ejsonFilePath, "", kmsDecryptedPrivateKey) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return decrypted, nil 59 | } 60 | 61 | func findPrivateKeyEnc(ejsonFilePath string) (key string, err error) { 62 | var ( 63 | ejsonKmsKeys EjsonKmsKeys 64 | ) 65 | 66 | file, err := os.Open(ejsonFilePath) 67 | if err != nil { 68 | return "", err 69 | } 70 | defer file.Close() 71 | 72 | data, err := ioutil.ReadAll(file) 73 | if err != nil { 74 | return "", err 75 | } 76 | 77 | err = json.Unmarshal(data, &ejsonKmsKeys) 78 | if err != nil { 79 | return "", err 80 | } 81 | 82 | if len(ejsonKmsKeys.PrivateKeyEnc) == 0 { 83 | return "", errors.New("missing _private_key_enc field") 84 | } 85 | 86 | return ejsonKmsKeys.PrivateKeyEnc, nil 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ejsonkms 2 | 3 | `ejsonkms` combines the [ejson library](https://github.com/Shopify/ejson) with [AWS Key Management 4 | Service](https://aws.amazon.com/kms/) to simplify deployments on AWS. The EJSON private key is encrypted with 5 | KMS and stored inside the EJSON file as `_private_key_enc`. Access to decrypt secrets can be controlled with IAM 6 | permissions on the KMS key. 7 | 8 | ## Install 9 | 10 | Precompiled binaries can be downloaded from [releases](https://github.com/envato/ejsonkms/releases). 11 | 12 | ### Go 13 | 14 | ``` 15 | go install github.com/envato/ejsonkms/cmd/ejsonkms@latest 16 | 17 | # Move binary to somewhere on $PATH. E.g., 18 | sudo cp "${GOBIN:-$HOME/go/bin}/ejsonkms" /usr/local/bin/ 19 | 20 | ejsonkms 21 | ``` 22 | 23 | This will install the binary to `$GOBIN/ejsonkms`. 24 | 25 | ## Usage 26 | 27 | Generating an EJSON file: 28 | 29 | ``` 30 | $ ejsonkms keygen --aws-region us-east-1 --kms-key-id bc436485-5092-42b8-92a3-0aa8b93536dc -o secrets.ejson 31 | Private Key: ae5969d1fb70faab76198ee554bf91d2fffc44d027ea3d804a7c7f92876d518b 32 | $ cat secrets.ejson 33 | { 34 | "_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52", 35 | "_private_key_enc": "S2Fybjphd3M6a21zOnVzLWVhc3QtMToxMTExMjIyMjMzMzM6a2V5L2JjNDM2NDg1LTUwOTItNDJiOC05MmEzLTBhYThiOTM1MzZkYwAAAAAycRX5OBx6xGuYOPAmDJ1FombB1lFybMP42s7PGmoa24bAesPMMZtI9V0w0p0lEgLeeSvYdsPuoPROa4bwnQxJB28eC6fHgfWgY7jgDWY9uP/tgzuWL3zuIaq+9Q==" 36 | } 37 | ``` 38 | 39 | Encrypting: 40 | 41 | ``` 42 | $ ejsonkms encrypt secrets.ejson 43 | ``` 44 | 45 | Decrypting: 46 | 47 | ``` 48 | $ ejsonkms decrypt secrets.ejson 49 | { 50 | "_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52", 51 | "_private_key_enc": "S2Fybjphd3M6a21zOnVzLWVhc3QtMToxMTExMjIyMjMzMzM6a2V5L2JjNDM2NDg1LTUwOTItNDJiOC05MmEzLTBhYThiOTM1MzZkYwAAAAAycRX5OBx6xGuYOPAmDJ1FombB1lFybMP42s7PGmoa24bAesPMMZtI9V0w0p0lEgLeeSvYdsPuoPROa4bwnQxJB28eC6fHgfWgY7jgDWY9uP/tgzuWL3zuIaq+9Q==", 52 | "environment": { 53 | "my_secret": "secret123" 54 | } 55 | } 56 | ``` 57 | 58 | Exporting shell variables (from [ejson2env](https://github.com/Shopify/ejson2env)): 59 | 60 | ``` 61 | $ exports=$(ejsonkms env secrets.ejson) 62 | $ echo $exports 63 | export my_secret=secret123 64 | $ eval $exports 65 | $ echo my_secret 66 | secret123 67 | ``` 68 | 69 | Note that only secrets under the "environment" key will be exported using the `env` command. 70 | 71 | ## pre-commit hook 72 | 73 | A [pre-commit](https://pre-commit.com/) hook is also supported to automatically run `ejsonkms encrypt` on all `.ejson` files in a repository. 74 | 75 | To use, add the following to a `.pre-commit-config.yaml` file in your repository: 76 | 77 | ```yaml 78 | repos: 79 | - repo: https://github.com/envato/ejsonkms 80 | hooks: 81 | - id: run-ejsonkms-encrypt 82 | ``` 83 | -------------------------------------------------------------------------------- /actions.go: -------------------------------------------------------------------------------- 1 | package ejsonkms 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/Shopify/ejson" 9 | "github.com/Shopify/ejson2env/v2" 10 | ) 11 | 12 | func EncryptAction(args []string) error { 13 | if len(args) < 1 { 14 | return fmt.Errorf("at least one file path must be given") 15 | } 16 | for _, filePath := range args { 17 | n, err := ejson.EncryptFileInPlace(filePath) 18 | if err != nil { 19 | return err 20 | } 21 | fmt.Printf("Wrote %d bytes to %s.\n", n, filePath) 22 | } 23 | return nil 24 | } 25 | 26 | func DecryptAction(args []string, awsRegion, outFile string) error { 27 | if len(args) != 1 { 28 | return fmt.Errorf("exactly one file path must be given") 29 | } 30 | ejsonFilePath := args[0] 31 | 32 | decrypted, err := Decrypt(ejsonFilePath, awsRegion) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | target := os.Stdout 38 | if outFile != "" { 39 | target, err = os.Create(outFile) 40 | if err != nil { 41 | return err 42 | } 43 | defer target.Close() 44 | } 45 | 46 | _, err = target.Write(decrypted) 47 | return err 48 | } 49 | 50 | // ejsonKmsFile - an ejson file 51 | type ejsonKmsFile struct { 52 | PublicKey string `json:"_public_key"` 53 | PrivateKeyEnc string `json:"_private_key_enc"` 54 | } 55 | 56 | func KeygenAction(args []string, kmsKeyID, awsRegion, outFile string) error { 57 | ejsonKmsKeys, err := Keygen(kmsKeyID, awsRegion) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | ejsonKmsFile := ejsonKmsFile{ 63 | PublicKey: ejsonKmsKeys.PublicKey, 64 | PrivateKeyEnc: ejsonKmsKeys.PrivateKeyEnc, 65 | } 66 | 67 | ejsonFile, err := json.MarshalIndent(ejsonKmsFile, "", " ") 68 | if err != nil { 69 | return err 70 | } 71 | 72 | fmt.Println("Private Key:", ejsonKmsKeys.PrivateKey) 73 | target := os.Stdout 74 | if outFile != "" { 75 | target, err = os.Create(outFile) 76 | if err != nil { 77 | return err 78 | } 79 | defer func() { _ = target.Close() }() 80 | } else { 81 | fmt.Println("EJSON File:") 82 | } 83 | 84 | _, err = target.Write(ejsonFile) 85 | if err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | func EnvAction(ejsonFilePath, awsRegion string, quiet bool) error { 92 | exportFunc := ejson2env.ExportEnv 93 | if quiet { 94 | exportFunc = ejson2env.ExportQuiet 95 | } 96 | privateKeyEnc, err := findPrivateKeyEnc(ejsonFilePath) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | kmsDecryptedPrivateKey, err := decryptPrivateKeyWithKMS(privateKeyEnc, awsRegion) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | envValues, err := ejson2env.ReadAndExtractEnv(ejsonFilePath, "", kmsDecryptedPrivateKey) 107 | 108 | if nil != err && !ejson2env.IsEnvError(err) { 109 | return fmt.Errorf("could not load environment from file: %s", err) 110 | } 111 | 112 | exportFunc(os.Stdout, envValues) 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /cmd/ejsonkms/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/envato/ejsonkms" 8 | "github.com/urfave/cli" 9 | ) 10 | 11 | // version information. This will be overridden by the ldflags 12 | var version = "dev" 13 | 14 | // fail prints the error message to stderr, then ends execution. 15 | func fail(err error) { 16 | fmt.Fprintf(os.Stderr, "error: %s\n", err) 17 | os.Exit(1) 18 | } 19 | 20 | func main() { 21 | app := cli.NewApp() 22 | app.Usage = "manage encrypted secrets using EJSON & AWS KMS" 23 | app.Version = version 24 | app.Author = "Steve Hodgkiss" 25 | app.Email = "steve@envato.com" 26 | app.Commands = []cli.Command{ 27 | { 28 | Name: "encrypt", 29 | Usage: "(re-)encrypt one or more EJSON files", 30 | Action: func(c *cli.Context) { 31 | if err := ejsonkms.EncryptAction(c.Args()); err != nil { 32 | fmt.Fprintln(os.Stderr, "Encryption failed:", err) 33 | os.Exit(1) 34 | } 35 | }, 36 | }, 37 | { 38 | Name: "decrypt", 39 | Usage: "decrypt an EJSON file", 40 | Flags: []cli.Flag{ 41 | cli.StringFlag{ 42 | Name: "o", 43 | Usage: "print output to the provided file, rather than stdout", 44 | }, 45 | cli.StringFlag{ 46 | Name: "aws-region", 47 | Usage: "AWS Region", 48 | }, 49 | }, 50 | Action: func(c *cli.Context) { 51 | if err := ejsonkms.DecryptAction(c.Args(), c.String("aws-region"), c.String("o")); err != nil { 52 | fmt.Fprintln(os.Stderr, "Decryption failed:", err) 53 | os.Exit(1) 54 | } 55 | }, 56 | }, 57 | { 58 | Name: "keygen", 59 | Usage: "generate a new EJSON keypair", 60 | Flags: []cli.Flag{ 61 | cli.StringFlag{ 62 | Name: "kms-key-id", 63 | Usage: "KMS Key ID to encrypt the private key with", 64 | }, 65 | cli.StringFlag{ 66 | Name: "aws-region", 67 | Usage: "AWS Region", 68 | }, 69 | cli.StringFlag{ 70 | Name: "o", 71 | Usage: "write EJSON file to a file rather than stdout", 72 | }, 73 | }, 74 | Action: func(c *cli.Context) { 75 | if err := ejsonkms.KeygenAction(c.Args(), c.String("kms-key-id"), c.String("aws-region"), c.String("o")); err != nil { 76 | fmt.Fprintln(os.Stderr, "Key generation failed:", err) 77 | os.Exit(1) 78 | } 79 | }, 80 | }, 81 | { 82 | Name: "env", 83 | Usage: "print shell export statements", 84 | Flags: []cli.Flag{ 85 | cli.BoolFlag{ 86 | Name: "quiet, q", 87 | Usage: "Suppress export statement", 88 | }, 89 | cli.StringFlag{ 90 | Name: "aws-region", 91 | Usage: "AWS Region", 92 | }, 93 | }, 94 | Action: func(c *cli.Context) { 95 | var filename string 96 | 97 | quiet := c.Bool("quiet") 98 | awsRegion := c.String("aws-region") 99 | 100 | if 1 <= len(c.Args()) { 101 | filename = c.Args().Get(0) 102 | } 103 | 104 | if "" == filename { 105 | fail(fmt.Errorf("no secrets.ejson filename passed")) 106 | } 107 | 108 | if err := ejsonkms.EnvAction(filename, awsRegion, quiet); nil != err { 109 | fail(err) 110 | } 111 | }, 112 | }, 113 | } 114 | 115 | if err := app.Run(os.Args); err != nil { 116 | fmt.Fprintln(os.Stderr, "Unexpected failure:", err) 117 | os.Exit(1) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= 2 | al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 4 | github.com/Shopify/ejson v1.5.4 h1:rE3THgxBjdSUcJTNTn1SYaAzaGyxvjkEssAZEJ+zD+s= 5 | github.com/Shopify/ejson v1.5.4/go.mod h1:GZg88n4LpYqp92+tzWjvj+1aaiDJn7F1uWebQb4HbeQ= 6 | github.com/Shopify/ejson2env/v2 v2.0.8 h1:RwkcZ0pGGwvCOWTFYa3M9Folwu/XRpywluN/FCDJVfY= 7 | github.com/Shopify/ejson2env/v2 v2.0.8/go.mod h1:SKGGJ3EEvX+87bZnyhn09jZQP6fJZlHgozInHz2yJFk= 8 | github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= 9 | github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= 16 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= 17 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 18 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 19 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 20 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 21 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 22 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 23 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 24 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 25 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 26 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 27 | github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d h1:cVtBfNW5XTHiKQe7jDaDBSh/EVM4XLPutLAGboIXuM0= 28 | github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d/go.mod h1:P2viExyCEfeWGU259JnaQ34Inuec4R38JCyBx2edgD0= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 32 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= 34 | github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= 35 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 36 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 39 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 40 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 41 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 42 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 43 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 44 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 45 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 | github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= 47 | github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= 48 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 49 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 50 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 51 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 54 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 55 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 56 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 58 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | --------------------------------------------------------------------------------