Skip to content

Commit e3103f9

Browse files
author
Drew MacInnis
authored
Add ejson support (#8)
Adds support to `sync` command for Shopify EJSON format
1 parent f696fc1 commit e3103f9

27 files changed

+505
-71
lines changed

.circleci/config.yml

+5-4
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ jobs:
66
build:
77
docker:
88
# specify the version
9-
- image: circleci/golang:1.9
9+
- image: circleci/golang:1.10
1010
working_directory: /go/src/github.com/drmdrew/syncrets
1111
steps:
1212
- checkout
13+
- setup_remote_docker
1314
- run:
1415
name: Update VERSION file
1516
command: |
@@ -18,6 +19,6 @@ jobs:
1819
(cat VERSION.orig | sed s/BUILD/${CIRCLE_BUILD_NUM}/g) > VERSION
1920
echo "Updating VERSION: $(cat VERSION)"
2021
fi
21-
- run: go get -v -t -d ./...
22-
- run: make build
23-
- run: go test -v ./...
22+
- run: docker build -f Dockerfile.build -t drmdrew/syncrets-build:latest .
23+
- run: mkdir testoutput/
24+
- run: docker-compose up integration-test

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
syncrets
22
debug
33
debug.*
4+
secrets.json
5+
secrets.ejson
6+
testoutput/*
47
.vault*
58
.vscode/
69

CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @drmdrew

Dockerfile.build

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM golang:1.10-alpine AS build
2+
RUN apk --no-cache add git make
3+
WORKDIR /go/src/github.com/drmdrew/syncrets
4+
COPY . .
5+
RUN go get -v ./...
6+
RUN make build

Dockerfile.runtime

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM scratch
2+
COPY --from=drmdrew/syncrets-build /go/src/github.com/drmdrew/syncrets/syncrets /syncrets
3+
CMD ["/syncrets"]

Dockerfile.test

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM drmdrew/syncrets-build
2+
3+
WORKDIR /go/src/github.com/drmdrew/syncrets
4+
5+
COPY integration_test.go .
6+
COPY testdata/syncrets-integration.yml syncrets.yml
7+
RUN go get -t ./...
8+
9+
CMD go test -tags=integration -run ^TestIntegration

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
VERSION=$(shell cat VERSION)
33

44
build:
5-
go build -ldflags "-X github.com/drmdrew/syncrets/cmd.version=$(VERSION)"
5+
CGO_ENABLED=0 go build -ldflags "-X github.com/drmdrew/syncrets/cmd.version=$(VERSION)"
66

README.md

+50-19
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
# syncrets
2-
A utility for synchronizing secrets between systems like
3-
[Hashicorp vault][VAULT] and formats like ejson/eyaml. Think of it like an
2+
*WIP*: This project is a *WORK IN PROGRESS*, so consider it useful for
3+
experimentation but not ready for production use. Use at your own risk
4+
but if you *do* use it I would love to hear what you think so please
5+
log issues for anything you would like to see fixed/improved.
6+
7+
syncrets is a little utility for synchronizing secrets between systems like
8+
[Hashicorp vault][VAULT] and formats like [ejson][EJSON]. Think of it like an
49
_rsync for secrets_. Secrets need to be handled carefully and syncrets can
510
help transfer, list, export, and otherwise manage secrets between systems
6-
and formats.
11+
and formats. The name `syncrets` is a portmanteau of `secrets` and `sync` ...
12+
obligatory [xkcd][XKCD-739].
713

814
Here is a simple example of using syncrets to copy secrets between two
915
vault servers running locally:
1016
```
11-
syncrets sync vault://localhost:8200/secrets/ vault://localhost:8201/secrets/
17+
syncrets sync vault://vault-a/secrets/ vault://vault-b/secrets/
1218
```
13-
*NOTE*: This project is a *WORK IN PROGRESS*, so consider it useful for
14-
experimentation but not ready for production use. Use at your own risk.
1519

1620
## syncrets config file
17-
Outside of test scenarios, it isn't likely that two instances of vault would
18-
be running on localhost. To make working with multiple vaults easier,
19-
syncrets supports a `~/.syncrets/syncrets.yml` configuration file, for
20-
example:
21+
22+
To faciliate working with multiple vaults, syncrets looks for a `syncrets.yml`
23+
configuration file in the working directory as well as `~/.syncrets/syncrets.yml`.
24+
Here is an example:
25+
2126
```
2227
vault:
2328
vault-a:
@@ -34,17 +39,42 @@ vault:
3439
file: ~/.syncrets/.vault-b-token
3540
```
3641
Using a configuration file allows you to refer to servers using the name
37-
(alias) present in their section of the configuration file.
42+
(alias) present in their section of the configuration file, so you can
43+
refer to `vault://vault-a/secrets` rather than `http://localhost:8200/secrets`.
3844

39-
For example, using the configuration above we can now rewrite the
40-
previous syncrets example like so:
41-
```
42-
syncrets sync vault://vault-a/secrets/foo/ vault://vault-b/secrets/bar/
43-
```
44-
Using our configuration file syncrets will now know to reach `vault-a` using
45+
This example configuration file configures syncrets to reach `vault-a` using
4546
`http://localhost:8200` and to reach `vault-b` using `http://localhost:8201`
4647
which saves you from having to type out the full scheme, hostname, and port
47-
when building URLs to pass to syncrets.
48+
when building URLs to pass to syncrets. The configuration also tells syncrets to
49+
load vault auth tokens from file (assuming that these tokens have been obtained
50+
previously).
51+
52+
## syncrets ejson
53+
54+
syncrets can directly `sync` secrets between two vault servers but can also
55+
be used to `sync` secrets to a local file (preferrably in ejson format ...
56+
these are _secrets_ after all).
57+
58+
If the source or target of a syncrets `sync` ends with `.ejson` then
59+
syncrets will use the `ejson` configuration section of `syncrets.yml` to
60+
configure the default encryption public key to use:
61+
```
62+
ejson:
63+
public_key: a9d52487a1232e5c292a9680f4a44a84ea302ba05ff12d2e9d11662d20fc0139
64+
```
65+
66+
For both encryption and decryption syncrets assumes that the ejson `EJSON_KEYDIR`
67+
environment has been set if the ejson keys are not present in their default location.
68+
69+
*Example*:
70+
```
71+
syncrets sync vault://vault-a/secret/ ./secrets.ejson
72+
```
73+
74+
Note: syncrets will write _unencrypted_ secrets to files ending with `.json` but
75+
this regular JSON format is included primarily for testing/debugging purposes and
76+
shouldn't be used for anything that is sensitive if the underlying filesystem isn't
77+
trustworthy.
4878

4979
## syncrets commands
5080
### auth
@@ -75,5 +105,6 @@ syncrets rm vault://localhost:8200/secrets/
75105
```
76106
*CAUTION*: Use the `rm` command _carefully_, it can be a potent footgun.
77107

78-
79108
[VAULT]: https://www.vaultproject.io/
109+
[EJSON]: https://github.com/Shopify/ejson
110+
[XKCD-739]: https://xkcd.com/739/

backend/ejson.go

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package backend
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"log"
8+
9+
"github.com/Shopify/ejson"
10+
"github.com/drmdrew/syncrets/core"
11+
"github.com/spf13/viper"
12+
)
13+
14+
type EJSONEndpoint struct {
15+
kv map[string]interface{}
16+
}
17+
18+
// NewEJSONEndpoint ...
19+
func NewEJSONEndpoint() *EJSONEndpoint {
20+
return &EJSONEndpoint{make(map[string]interface{})}
21+
}
22+
23+
// Visit ...
24+
func (j *EJSONEndpoint) Visit(s core.Secret) {
25+
AddSecretToKV(s, j.kv)
26+
}
27+
28+
// Marshal ...
29+
func (j *EJSONEndpoint) Marshal(out io.Writer) error {
30+
var jsonBytes []byte
31+
var err error
32+
ejsonPubkey := viper.GetString("ejson.public_key")
33+
j.kv["_public_key"] = ejsonPubkey
34+
if jsonBytes, err = json.Marshal(j.kv); err != nil {
35+
log.Printf("ERROR: %v\n", err)
36+
return err
37+
}
38+
in := bytes.NewReader(jsonBytes)
39+
_, err = ejson.Encrypt(in, out)
40+
if err != nil {
41+
log.Printf("ERROR: %v\n", err)
42+
return err
43+
}
44+
return nil
45+
}

backend/json.go

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package backend
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"log"
8+
"strings"
9+
10+
"github.com/drmdrew/syncrets/core"
11+
)
12+
13+
// JSONEndpoint ...
14+
type JSONEndpoint struct {
15+
kv map[string]interface{}
16+
}
17+
18+
// NewJSONEndpoint ...
19+
func NewJSONEndpoint() *JSONEndpoint {
20+
return &JSONEndpoint{make(map[string]interface{})}
21+
}
22+
23+
// AddSecretToKV ...
24+
func AddSecretToKV(s core.Secret, kv map[string]interface{}) {
25+
steps := strings.Split(s.Path, "/")
26+
for _, step := range steps[:len(steps)-1] {
27+
if step == "" {
28+
continue
29+
}
30+
if _, ok := kv[step]; !ok {
31+
kv[step] = make(map[string]interface{})
32+
}
33+
if m, ok := kv[step].(map[string]interface{}); !ok {
34+
m = make(map[string]interface{})
35+
m["."] = kv[step]
36+
kv[step] = m
37+
kv = m
38+
} else {
39+
kv = m
40+
}
41+
}
42+
lastStep := steps[len(steps)-1]
43+
kv[lastStep] = s.Value
44+
}
45+
46+
// Visit ...
47+
func (j *JSONEndpoint) Visit(s core.Secret) {
48+
AddSecretToKV(s, j.kv)
49+
}
50+
51+
// Marshal ...
52+
func (j *JSONEndpoint) Marshal(out io.Writer) error {
53+
b, err := json.Marshal(j.kv)
54+
if err != nil {
55+
log.Print(err)
56+
return err
57+
}
58+
fmt.Fprintf(out, "%s\n", string(b[:]))
59+
return nil
60+
}

backend/json_test.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package backend
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/drmdrew/syncrets/core"
9+
)
10+
11+
var jsonTests = []struct {
12+
secrets []core.Secret
13+
expected string
14+
}{
15+
{[]core.Secret{core.Secret{"secret/citizen", "four"}},
16+
`{"secret":{"citizen":"four"}}`},
17+
{[]core.Secret{core.Secret{"secret/citizen/kane", "Rosebud"}},
18+
`{"secret":{"citizen":{"kane":"Rosebud"}}}`},
19+
{[]core.Secret{core.Secret{"secret/citizen", "four"}, core.Secret{"secret/citizen/kane", "Rosebud"}},
20+
`{"secret":{"citizen":{".":"four","kane":"Rosebud"}}}`},
21+
}
22+
23+
func TestJSON_Marshal(t *testing.T) {
24+
for _, testcase := range jsonTests {
25+
buf := new(bytes.Buffer)
26+
j := NewJSONEndpoint()
27+
for _, s := range testcase.secrets {
28+
j.Visit(s)
29+
}
30+
j.Marshal(buf)
31+
result := strings.TrimSpace(buf.String())
32+
if result != testcase.expected {
33+
t.Fail()
34+
t.Fatalf("Expected: '%s' but result was: '%s'\n", testcase.expected, result)
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)