Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation #1

Merged
merged 27 commits into from Sep 3, 2019
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
93a8642
Wrap EJSON library with KMS integration
stevehodgkiss Sep 2, 2019
e32f8fd
Extract ejsonkms functions and add tests
stevehodgkiss Sep 2, 2019
06dee79
Move function
stevehodgkiss Sep 2, 2019
4774d2f
Make methods public
stevehodgkiss Sep 2, 2019
7e28504
Update tests
stevehodgkiss Sep 2, 2019
b6d0288
Add env command
stevehodgkiss Sep 2, 2019
dbb0801
Allow specifying fake kms endpoint
stevehodgkiss Sep 2, 2019
957cb1a
Expand readme
stevehodgkiss Sep 2, 2019
f579d9c
Add note on using env command
stevehodgkiss Sep 2, 2019
c4dc96a
Update actions.go
stevehodgkiss Sep 3, 2019
c1c9320
Use fmt.Println
stevehodgkiss Sep 3, 2019
4a8bc2f
Tidy up params
stevehodgkiss Sep 3, 2019
cc1f3d4
Update ejsonkms_test.go
stevehodgkiss Sep 3, 2019
ea51558
Merge branch 'initial-impl' of github.com:envato/ejsonkms into initia…
stevehodgkiss Sep 3, 2019
96ccca6
Lowercase
stevehodgkiss Sep 3, 2019
ac0ea38
Remove test conditional hack and rely on env var being present
stevehodgkiss Sep 3, 2019
dc905c3
Unmarshal to a struct
stevehodgkiss Sep 3, 2019
e1e88cd
Return an error rather than fail
stevehodgkiss Sep 3, 2019
af90c89
Remove unused var
stevehodgkiss Sep 3, 2019
343f709
Mount single config file in compose
stevehodgkiss Sep 3, 2019
fef2575
Rely on kms interface
stevehodgkiss Sep 3, 2019
8d57158
Extract object EjsonKmsKeys
stevehodgkiss Sep 3, 2019
c99ad37
Pretty print json in keygen action
stevehodgkiss Sep 3, 2019
0e2f7e7
Serialize only pub and privatekeyenc for ejsonfile
stevehodgkiss Sep 3, 2019
a760602
Add .go-version
stevehodgkiss Sep 3, 2019
233ff52
Lowercase error
stevehodgkiss Sep 3, 2019
8818ebd
Add MIT license
stevehodgkiss Sep 3, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
/build/
6 changes: 6 additions & 0 deletions Dockerfile
@@ -0,0 +1,6 @@
FROM golang:1.12-alpine
ENV GO111MODULE=on
WORKDIR /go/src/github.com/envato/ejsonkms
COPY . .
RUN apk add git gcc musl-dev
RUN go get
24 changes: 24 additions & 0 deletions Makefile
@@ -0,0 +1,24 @@
NAME=ejsonkms
PACKAGE=github.com/envato/ejsonkms
VERSION=$(shell cat VERSION)
GOFILES=$(shell find . -type f -name '*.go')

.PHONY: default all binaries clean

default: all
all: binaries
binaries: build/bin/linux-amd64 build/bin/darwin-amd64

build/bin/linux-amd64: $(GOFILES)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also mitchellh/gox if you don't want to roll this by hand or start to use cross platform libraries.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach here is basically a bare bones version of ejson's Makefile. Will check out gox.

mkdir -p "$(@D)"
GOOS=linux GOARCH=amd64 go build \
-ldflags '-s -w -X main.version="$(VERSION)"' \
-o "$@"

build/bin/darwin-amd64: $(GOFILES)
GOOS=darwin GOARCH=amd64 go build \
-ldflags '-s -w -X main.version="$(VERSION)"' \
-o "$@"

clean:
rm -rf build
51 changes: 50 additions & 1 deletion README.md
@@ -1,3 +1,52 @@
# ejsonkms

`ejsonkms` is a tool to based on the [ejson library](https://github.com/Shopify/ejson) with integration with AWS KMS.
`ejsonkms` combines the [ejson library](https://github.com/Shopify/ejson) with [AWS Key Management
Service](https://aws.amazon.com/kms/) to simplify deployments on AWS. The EJSON private key is encrypted with
KMS and stored inside the EJSON file as `_private_key_enc`. Access to decrypt secrets can be controlled with IAM
permissions on the KMS key.

## Usage

Generating an EJSON file:

```
$ ejsonkms keygen --aws-region us-east-1 --kms-key-id bc436485-5092-42b8-92a3-0aa8b93536dc -o secrets.ejson
Private Key: ae5969d1fb70faab76198ee554bf91d2fffc44d027ea3d804a7c7f92876d518b
$ cat secrets.ejson
{
"_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52",
"_private_key_enc": "S2Fybjphd3M6a21zOnVzLWVhc3QtMToxMTExMjIyMjMzMzM6a2V5L2JjNDM2NDg1LTUwOTItNDJiOC05MmEzLTBhYThiOTM1MzZkYwAAAAAycRX5OBx6xGuYOPAmDJ1FombB1lFybMP42s7PGmoa24bAesPMMZtI9V0w0p0lEgLeeSvYdsPuoPROa4bwnQxJB28eC6fHgfWgY7jgDWY9uP/tgzuWL3zuIaq+9Q=="
}
```

Encrypting:

```
$ ejsonkms encrypt secrets.ejson
```

Decrypting:

```
$ ejsonkms decrypt secrets.ejson
{
"_public_key": "6b8280f86aff5f48773f63d60e655e2f3dd0dd7c14f5fecb5df22936e5a3be52",
"_private_key_enc": "S2Fybjphd3M6a21zOnVzLWVhc3QtMToxMTExMjIyMjMzMzM6a2V5L2JjNDM2NDg1LTUwOTItNDJiOC05MmEzLTBhYThiOTM1MzZkYwAAAAAycRX5OBx6xGuYOPAmDJ1FombB1lFybMP42s7PGmoa24bAesPMMZtI9V0w0p0lEgLeeSvYdsPuoPROa4bwnQxJB28eC6fHgfWgY7jgDWY9uP/tgzuWL3zuIaq+9Q==",
"environment": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears like the format is the same as the ejson2env tool, which has a top level environment key with key/value pairs.

What's the thinking behind using the top level environment key instead of straight key/values at the top level like the ejson format?

IMHO, I don't think we need the environment key at all. If folks want to export all secrets as environment variables, then they can use the ejsonkms env command.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decrypt command doesn't care if there's a nested environment key. It will just decrypt the file and output its contents. This example uses an environment key because it's demonstrated in the example below. The env command would be the main way we decrypt files using this tool, and that currently looks inside a special environment key (code is from Shopify/ejson2env).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in Slack, I though you wrote ejson2env 🤦‍♂ . And so i thought you could change the way it worked.

But it makes sense that you can add top level key/value pairs but only the key/value pairs under environment are used by the env command.

"my_secret": "secret123"
}
}
```

Exporting shell variables (from [ejson2env](https://github.com/Shopify/ejson2env)):

```
$ exports=$(ejsonkms env secrets.ejson)
$ echo $exports
export my_secret=secret123
$ eval $exports
$ echo my_secret
secret123
```

Note that only secrets under the "environment" key will be exported using the `env` command.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

1 change: 1 addition & 0 deletions VERSION
@@ -0,0 +1 @@
0.1.0
89 changes: 89 additions & 0 deletions actions.go
@@ -0,0 +1,89 @@
package main

import (
"fmt"
"os"

"github.com/Shopify/ejson"
)

func encryptAction(args []string) error {
if len(args) < 1 {
return fmt.Errorf("at least one file path must be given")
}
for _, filePath := range args {
n, err := ejson.EncryptFileInPlace(filePath)
if err != nil {
return err
}
fmt.Printf("Wrote %d bytes to %s.\n", n, filePath)
}
return nil
}

func decryptAction(args []string, awsRegion string, outFile string) error {
if len(args) != 1 {
return fmt.Errorf("exactly one file path must be given")
}
ejsonFilePath := args[0]

decrypted, err := Decrypt(ejsonFilePath, awsRegion)
if err != nil {
return err
}

target := os.Stdout
if outFile != "" {
target, err = os.Create(outFile)
if err != nil {
return err
}
defer func() { _ = target.Close() }()
stevehodgkiss marked this conversation as resolved.
Show resolved Hide resolved
}

_, err = target.Write(decrypted)
return err
}

func keygenAction(args []string, kmsKeyID string, awsRegion string, outFile string) error {
pub, priv, privKeyEnc, err := Keygen(kmsKeyID, awsRegion)
if err != nil {
return err
}

fmt.Printf("Private Key: %s\n", priv)
stevehodgkiss marked this conversation as resolved.
Show resolved Hide resolved
target := os.Stdout
if outFile != "" {
target, err = os.Create(outFile)
if err != nil {
return err
}
defer func() { _ = target.Close() }()
} else {
fmt.Printf("EJSON File:\n")
stevehodgkiss marked this conversation as resolved.
Show resolved Hide resolved
}

_, err = fmt.Fprintf(target, "{\n \"_public_key\": \"%s\",\n \"_private_key_enc\": \"%s\"\n}", pub, privKeyEnc)
stevehodgkiss marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}
return nil
}

func envAction(ejsonFilePath string, quiet bool, awsRegion string) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a personal thing but you can also combine the type definitions to save some duplication if you don't care about re-ordering the parameters.

Suggested change
func envAction(ejsonFilePath string, quiet bool, awsRegion string) error {
func envAction(ejsonFilePath, awsRegion string, quiet bool) error {

exportFunc := ExportEnv
if quiet {
exportFunc = ExportQuiet
}
privateKeyEnc, err := findPrivateKeyEnc(ejsonFilePath)
if err != nil {
return err
}

kmsDecryptedPrivateKey, err := decryptPrivateKeyWithKMS(privateKeyEnc, awsRegion)
if err != nil {
return err
}

return ExportSecrets(ejsonFilePath, kmsDecryptedPrivateKey, exportFunc)
}
38 changes: 38 additions & 0 deletions actions_test.go
@@ -0,0 +1,38 @@
package main

import (
"bytes"
"os"
"testing"

. "github.com/smartystreets/goconvey/convey"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something I've used but looks good 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

)

func TestEnv(t *testing.T) {
outputBuffer := new(bytes.Buffer)
output = outputBuffer

// ensure that output returns to os.Stdout
defer func() {
output = os.Stdout
}()

Convey("Env", t, func() {
err := envAction("testdata/test.ejson", false, "us-east-1")

Convey("should return decrypted values as shell exports", func() {
So(err, ShouldBeNil)
actualOutput := outputBuffer.String()
So(actualOutput, ShouldContainSubstring, "export my_secret=secret123")
})
})

Convey("Env with no private key", t, func() {
err := envAction("testdata/test_no_private_key.ejson", false, "us-east-1")

Convey("should fail", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "Missing _private_key_enc")
})
})
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can achieve similar functionality with Example Tests which check the standard out is what is expected. But this works too

21 changes: 21 additions & 0 deletions docker-compose.yml
@@ -0,0 +1,21 @@
version: "3"
services:
awskms:
image: "nsmithuk/local-kms"
environment:
REGION: us-east-1
volumes:
- "./local_kms:/init"
expose:
- 8080
tests:
build: .
volumes:
- "./:/go/src/github.com/envato/ejsonkms"
command: ["go", "test"]
environment:
AWS_ACCESS_KEY_ID: '123'
AWS_SECRET_ACCESS_KEY: xyz
FAKE_AWSKMS_URL: http://awskms:8080
links:
- awskms
78 changes: 78 additions & 0 deletions ejsonkms.go
@@ -0,0 +1,78 @@
package main

import (
"encoding/json"
"errors"
"io/ioutil"
"os"

"github.com/Shopify/ejson"
)

// Keygen generates keys and prepares an EJSON file with them
func Keygen(kmsKeyID string, awsRegion string) (string, string, string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can combine the type definitions here

Suggested change
func Keygen(kmsKeyID string, awsRegion string) (string, string, string, error) {
func Keygen(kmsKeyID, awsRegion string) (string, string, string, error) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else to note here is that I will generally look at lots of return values like this (string, string, string, error) as perhaps a signal to pass back a particular type and then let the client pick off the methods or fields it needs. In this case, we can use my code example from another comment and instead pass back the struct defined or individual fields on it instead.

This isn't a big deal as this will work, it's just not using the type system to the fullest.

pub, priv, err := ejson.GenerateKeypair()
if err != nil {
return "", "", "", err
}

privKeyEnc, err := encryptPrivateKeyWithKMS(priv, kmsKeyID, awsRegion)
if err != nil {
return "", "", "", err
}

return pub, priv, privKeyEnc, nil
}

// Decrypt decrypts an EJSON file
func Decrypt(ejsonFilePath string, awsRegion string) ([]byte, error) {
privateKeyEnc, err := findPrivateKeyEnc(ejsonFilePath)
if err != nil {
return nil, err
}

kmsDecryptedPrivateKey, err := decryptPrivateKeyWithKMS(privateKeyEnc, awsRegion)
if err != nil {
return nil, err
}

decrypted, err := ejson.DecryptFile(ejsonFilePath, "", kmsDecryptedPrivateKey)
if err != nil {
return nil, err
}

return decrypted, nil
}

func findPrivateKeyEnc(ejsonFilePath string) (key string, err error) {
var (
obj map[string]interface{}
ks string
)

file, err := os.Open(ejsonFilePath)
if err != nil {
return "", err
}
defer file.Close()

data, err := ioutil.ReadAll(file)
if err != nil {
return "", err
}

err = json.Unmarshal(data, &obj)
if err != nil {
return "", err
}

k, ok := obj["_private_key_enc"]
if !ok {
return "", errors.New("Missing _private_key_enc field")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errors in Go generally start lowercased (which ends up matching stack traces, etc. nicely)

Suggested change
return "", errors.New("Missing _private_key_enc field")
return "", errors.New("missing _private_key_enc field")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check can be avoided by making obj a struct:

type ejsonFile {
    encryptedPrivateKey string `json:"_private_key_enc"`
}

Then when you unmarshal, it will throw an error if that field is not present.

}
ks, ok = k.(string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I would use the shorthand version of the Go error handling seeing how the line is quite short.

Suggested change
ks, ok = k.(string)
if ks, ok = k.(string); !ok {
return "", errors.New("Couldn't cast _private_key_enc to string")
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, json unmarshal will do the type coercion for you

if !ok {
return "", errors.New("Couldn't cast _private_key_enc to string")
}
return ks, nil
}
38 changes: 38 additions & 0 deletions ejsonkms_test.go
@@ -0,0 +1,38 @@
package main

import (
"testing"

. "github.com/smartystreets/goconvey/convey"
)

func TestKeygen(t *testing.T) {
Convey("Keygen", t, func() {
pub, priv, privEnc, err := Keygen("bc436485-5092-42b8-92a3-0aa8b93536dc", "us-east-1")
Convey("should return three strings that look key-like", func() {
So(err, ShouldBeNil)
So(pub, ShouldNotEqual, priv)
So(pub, ShouldNotContainSubstring, "00000")
So(priv, ShouldNotContainSubstring, "00000")
So(privEnc, ShouldNotContainSubstring, "00000")
})
})
}

func TestDecrypt(t *testing.T) {
Convey("Decrypt", t, func() {
decrypted, err := Decrypt("testdata/test.ejson", "us-east-1")
Convey("should return decrypted values", func() {
So(err, ShouldBeNil)
json := string(decrypted[:])
So(json, ShouldContainSubstring, "\"my_secret\": \"secret123\"")
stevehodgkiss marked this conversation as resolved.
Show resolved Hide resolved
})
})
Convey("Decrypt with no private key", t, func() {
_, err := Decrypt("testdata/test_no_private_key.ejson", "us-east-1")
Convey("should fail", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "Missing _private_key_enc")
})
})
}