├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── add.go ├── add_test.go ├── app.go ├── app_test.go ├── drop.go ├── drop_test.go ├── exec.go ├── exec_test.go ├── get.go ├── get_test.go ├── list.go ├── list_test.go ├── main.go ├── read.go ├── read_test.go ├── test_common.go ├── version.go ├── write.go └── write_test.go ├── envy.go ├── envy_test.go ├── go.mod ├── go.sum ├── hack └── main.go ├── internal ├── db.go ├── db_test.go ├── extract.go ├── extract_test.go ├── ring.go ├── ring_test.go ├── sealer.go └── sealer_test.go └── test └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | envy 3 | child 4 | 5 | # Test binary, built with `go test -c` 6 | *.test 7 | 8 | # Output of the go coverage tool, specifically when used with LiteIDE 9 | *.out 10 | 11 | # Dependency directories (remove the comment below to include it) 12 | # vendor/ 13 | 14 | #Project files 15 | .idea/* 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Holiday 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 | version=$(shell git describe --tags --long --always --dirty 2>/dev/null) 2 | SOURCES := $(wildcard internal/*.go cmd/*.go) 3 | 4 | ## NOTE: we can't use go install because it 5 | ## doesn't have the -o option to name the file 6 | 7 | envy: envy.go $(SOURCES) 8 | go build -ldflags "-X main.version=$(version)" -o $@ ./cmd 9 | install -d $(GOPATH)/bin 10 | install $@ $(GOPATH)/bin 11 | 12 | child: hack/main.go 13 | go build -o $@ ./hack 14 | 15 | lint: 16 | golangci-lint run 17 | 18 | test: envy.go $(SOURCES) 19 | go test -v ./... -coverprofile=c.out -covermode=count 20 | 21 | demo: envy child 22 | envy add top a=b 23 | envy exec top ./child 24 | 25 | clean: 26 | rm -rf envy child 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/matt4biz/envy)](https://goreportcard.com/report/github.com/matt4biz/envy) 2 | 3 | # envy 4 | Use `envy` to manage environment variables with your OS keychain 5 | 6 | To use the tool, clone the repo and run `make`. To use the library, run `go get` (or just build an app which imports the library using Go modules). 7 | 8 | ## History 9 | There have been several tools for injecting environment variables from files into a command. These can broken down into two categories, broadly speaking: 10 | 11 | 1. dotenv and copycats, which take key-value pairs from a dot-file and inject the contents into environment variables 12 | 2. envy and similar tools which store key-value pairs in a secure database of some type, ditto 13 | 14 | The basic idea is, you execute the env-variable managing tool, which gets key-value pairs from somewhere, and then runs another command for you: 15 | 16 | ``` 17 | $ envy exec command [args ...] 18 | ``` 19 | 20 | for example, 21 | 22 | ``` 23 | $ envy exec dev curl -s -H "Authorization: Bearer $(token)" \ 24 | https://my.server.com/add -X POST -d '{"item": "spork"}' 25 | ``` 26 | 27 | where we've previously added the token 28 | 29 | ``` 30 | $ envy add dev token=8inlknmdgoi8uap8ow3hw3.pws9jpo9jskgs....sldkfs 31 | ``` 32 | 33 | Many times the variables needed are secrets, such as a credential needed to renew an OAuth token, or perhaps the token itself. As a result, even though dot-files can be set with 0600 or 0400 permissions (only the owner has privileges), there's some risk to having these credentials in plaintext form. 34 | 35 | Envy certainly isn't unique, but I needed one or two capabilities not found elsewhere, and 36 | 37 | * I wanted to keep the implementation simple 38 | * it was also a good opportunity to build an example app in Go 39 | 40 | I have deliberately minimized the dependencies, which are basically the [Bolt database](https://github.com/boltdb/bolt) and [go-keyring](https://github.com/zalando/go-keyring). I have also avoided the many layers of abstraction typical of ["enterprise Fizzbuzz"](https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpriseEdition) style development. 41 | 42 | ## How it works 43 | Variables (key-value pairs) are grouped into "realms" which is just a shorter way to type "namespaces". Because these variables are primarily used as environment variables, they're stored in a map of string keys to string values. 44 | 45 | Envy maintains a Bolt database in the "user config" directory, for example, `$HOME/Library/Application Support` on macOS. That database has a bucket for each realm, and an entry in the bucket for each key-value pair. 46 | 47 | With each variable is some metadata: we keep the last-modified timestamp, size, and a secure hash of the value part of the key-value pair. The hash is also used with AES-GCM when that value is encrypted. The encrypted data and the metadata in JSON form are converted to Base64 encoding and then stored together a single object identified by the key. Only the (possibly secret) value is encrypted; the metadata isn't, but if the hash is changed, decryption fails. 48 | 49 | The secret key needed to run AES-GCM is stored in your system's secure keychain, which on macOS means the default login keychain that's visible in Keychain Access. (Note that you can see and even edit the secret key in Keychain Access or using the `security` command -- but if you change or delete that key, you'll never get your data back out of the Bolt database.) 50 | 51 | The secret key is added once to the keychain when you first run Envy. If you want to wipe everything and start over, then 52 | 53 | 1. remove the key named `matt4biz-envy-secret-key` from your keychain 54 | 2. remove the database, which is `envy/envy.db` in your config directory 55 | 56 | ## Commands 57 | There are seven commands, but one of them just lists the version of the program; you can also type `envy -h` to see usage: 58 | 59 | ``` 60 | envy: a tool to securely store and retrieve environment variables. 61 | 62 | ... 63 | 64 | Usage: envy [opts] subcommand 65 | -h show this help message and exit 66 | 67 | add realm key=value [key=value ...] 68 | drop realm[/key] 69 | exec realm[/key] command [args ...] 70 | list [opts] [realm[/key]] 71 | -d show decrypted secrets also 72 | read [opts] realm file ('-' for stdout) 73 | -q unquote embedded JSON in values 74 | write [opts] realm file ('-' for stdin) 75 | -clear overwrite contents 76 | version 77 | ``` 78 | 79 | ### Add 80 | The `add` subcommand adds one or more keys to a realm. The realm will be created if it doesn't exist. If it exists already, the key(s) you set will overwrite any matching key in the realm. 81 | 82 | For example, assuming a new database: 83 | 84 | ``` 85 | $ envy add test a=1 b=2 86 | ``` 87 | 88 | will set up two key-value pairs. Note that keys are case-sensitive. 89 | 90 | If you then run 91 | 92 | ``` 93 | $ envy add test a=3 94 | ``` 95 | 96 | the value for key "a" will change, but other keys will not be disturbed. 97 | 98 | ### List 99 | The `list` subcommand lists the keys in a realm, or the available realms in the database if none is specified. For example, after the commands above, 100 | 101 | ``` 102 | $ envy list 103 | test 104 | ``` 105 | 106 | and 107 | 108 | ``` 109 | $ envy list test 110 | a 2020-10-11T23:28:05-06:00 1 3cf3aef 111 | b 2020-10-11T23:28:05-06:00 1 39c6844 112 | ``` 113 | 114 | where just the first seven characters of the hash are shown. 115 | 116 | The `-d` option will also show the decrypted data: 117 | 118 | ``` 119 | $ envy list -d test 120 | a 2020-10-11T23:28:05-06:00 1 3cf3aef 3 121 | b 2020-10-11T23:28:05-06:00 1 39c6844 2 122 | ``` 123 | 124 | ### Drop 125 | The `drop` subcommand can delete one key from a realm, or the entire realm. 126 | 127 | For example, 128 | 129 | ``` 130 | $ envy drop test/b 131 | $ envy list test 132 | a 2020-10-11T23:28:05-06:00 1 3cf3aef 133 | ``` 134 | 135 | while 136 | 137 | ``` 138 | $ envy drop test 139 | $ envy list test 140 | fetching test: realm test: not found 141 | $ envy list 142 | ``` 143 | 144 | shows that we've returned the database to its empty state. 145 | 146 | ### Exec 147 | Of course, the `exec` subcommand is the main reason for this tool. Given a realm (or a specific key from a realm), Envy will execute another command with its environment variables augmented by data that Envy stores. (See the example above.) 148 | 149 | Envy can pass (some) signals through to its child process, particularly control-C, so it's possible to kill off the child if you need to. The childs standard input, output, and error output mirror Envy's environment. 150 | 151 | ### Write and read 152 | The `write` and `read` subcommands allow a realm to be updated or written out using JSON. If the filename is "-" then `stdin` or `stdout` are used. 153 | 154 | For example, 155 | 156 | ``` 157 | $ echo '{"b":"14", "a":"21"}' | envy write test - 158 | $ envy list test 159 | a 2020-10-13T07:14:56-06:00 2 317dd18 160 | b 2020-10-13T07:14:56-06:00 2 f27d5f6 161 | $ envy read test - 162 | {"a":"21","b":"14"} 163 | ``` 164 | 165 | Normally, writing JSON into a realm adds or overwrites existing keys, but otherwise leaves the existing data in place. Using the `-clear` option causes the realm to be purged first. 166 | 167 | In some cases, stored data is JSON that ends up being "double-quoted" when saved as a string. 168 | 169 | ``` 170 | $ envy add test a='{"one":{"a":"1","b":"2"}, "two":{"a":"5","b":"6"}}' 171 | $ envy read test - | jq 172 | { 173 | "a": "{\"one\":{\"a\":\"1\",\"b\":\"2\"}, \"two\":{\"a\":\"5\",\"b\":\"6\"}}" 174 | } 175 | $ envy read -q test - | jq 176 | { 177 | "a": { 178 | "one": { 179 | "a": "1", 180 | "b": "2" 181 | }, 182 | "two": { 183 | "a": "5", 184 | "b": "6" 185 | } 186 | } 187 | } 188 | ``` 189 | 190 | The embedded JSON can't be processed without having the extra quote marks removed, which is what the `-q` option does (it also removes embedded newlines for convenience). 191 | 192 | ### Get 193 | The `get` command just reads out a key (or keys of a realm) directly to stdout. The `-n` option avoids a final newline (which may cause an issue with passwords). 194 | 195 | For example, you can use it this way to populate a command's parameter on the command line (as opposed to using an environment variable): 196 | 197 | ``` 198 | $ my-command -a=`envy get -n test/a` 199 | ``` 200 | 201 | ## As a library 202 | Envy is not just a command-line tool, it's also a library that can be used in building another tool. 203 | 204 | To get started, you just need to create the `Envy` object: 205 | 206 | ```go 207 | package main 208 | 209 | import ( 210 | "fmt" 211 | "log" 212 | "os" 213 | "path" 214 | 215 | "github.com/matt4biz/envy" 216 | ) 217 | 218 | func main() { 219 | e, err := envy.New() 220 | 221 | if err != nil { 222 | log.Fatal(err) 223 | } 224 | 225 | // the standard location is config-dir/envy 226 | fmt.Println(e.Directory()) 227 | 228 | m, err := e.FetchAsJSON("test") 229 | 230 | if err != nil { 231 | log.Fatal(err) 232 | } 233 | 234 | fmt.Println(string(m)) 235 | } 236 | ``` 237 | 238 | with the output on macOS (given the examples above): 239 | 240 | ``` 241 | $ go run . 242 | /Users//Library/Application Support/envy 243 | {"a":"3","b":"2"} 244 | ``` 245 | 246 | ## Details 247 | The repo is organized simply: 248 | 249 | The top-level library API is in `envy.go`; everything it needs is in the `internal` sub-package. The CLI and subcommands are in `cmd`. 250 | 251 | ``` 252 | $ tree 253 | . 254 | ├── LICENSE 255 | ├── Makefile 256 | ├── README.md 257 | ├── c.out 258 | ├── cmd 259 | │   ├── add.go 260 | │   ├── add_test.go 261 | │   ├── app.go 262 | │   ├── app_test.go 263 | │   ├── drop.go 264 | │   ├── drop_test.go 265 | │   ├── exec.go 266 | │   ├── exec_test.go 267 | │   ├── list.go 268 | │   ├── list_test.go 269 | │   ├── main.go 270 | │   ├── read.go 271 | │   ├── read_test.go 272 | │   ├── test_common.go 273 | │   ├── version.go 274 | │   ├── write.go 275 | │   └── write_test.go 276 | ├── envy.go 277 | ├── envy_test.go 278 | ├── go.mod 279 | ├── go.sum 280 | ├── hack 281 | │   └── main.go 282 | ├── internal 283 | │   ├── db.go 284 | │   ├── db_test.go 285 | │   ├── extract.go 286 | │   ├── extract_test.go 287 | │   ├── ring.go 288 | │   ├── ring_test.go 289 | │   ├── sealer.go 290 | │   └── sealer_test.go 291 | └── test 292 | └── test.sh 293 | ``` 294 | 295 | The makefile has only a few targets: 296 | 297 | - envy (the default) 298 | - lint, assuming you have [golangci-lint](https://github.com/golangci/golangci-lint) installed (at the moment there's no special config file, so linting uses the defaults) 299 | - test, which runs all UTs with code coverage 300 | - demo, which depends on another target, `child` (from `hack/main.go`) 301 | 302 | The demo will run `child` as a subcommand; the child will print some environment variables and exit in 10 seconds unless it gets a control-C sooner. 303 | 304 | The test script in `test/test.sh` is used by unit tests, and shouldn't be changed. 305 | 306 | The unit tests use a mock keyring in memory and auto-delete their temporary Bolt DB, so they have no effect on your "real" Envy secret key and secure DB. 307 | 308 | Code coverage is around 70% (more error path coverage needed). 309 | 310 | The design of the CLI was influenced by Carl Johnson's [_Writing Go CLIs With Just Enough Architecture_](https://blog.carlmjohnson.net/post/2020/go-cli-how-to-and-advice/). 311 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/matt4biz/envy/internal" 7 | ) 8 | 9 | type AddCommand struct { 10 | *App 11 | } 12 | 13 | func (cmd *AddCommand) Run() int { 14 | e, err := internal.NewExtractor(cmd.args) 15 | 16 | if err != nil { 17 | fmt.Fprintf(cmd.stderr, "extract: %s\n", err) 18 | return -1 19 | } 20 | 21 | cmd.args = e.Args() 22 | 23 | if err = cmd.Add(e.Realm(), e.Values()); err != nil { 24 | fmt.Fprintln(cmd.stderr, err) 25 | return -1 26 | } 27 | 28 | return 0 29 | } 30 | -------------------------------------------------------------------------------- /cmd/add_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestAdd(t *testing.T) { 10 | stdout := new(bytes.Buffer) 11 | stderr := new(bytes.Buffer) 12 | app := NewTestApp(t, stdout, stderr) 13 | 14 | app.args = []string{"top", "a=b"} 15 | 16 | cmd := AddCommand{app} 17 | o := cmd.Run() 18 | 19 | if o != 0 { 20 | t.Errorf("errors: %s", stderr.String()) 21 | t.Fatalf("invalid return: %d", o) 22 | } else { 23 | t.Logf("output: %s", stdout.String()) 24 | } 25 | 26 | m, err := cmd.Fetch("top") 27 | 28 | if err != nil { 29 | t.Fatalf("can't fetch: %s", err) 30 | } 31 | 32 | exp := map[string]string{"a": "b"} 33 | 34 | if !reflect.DeepEqual(m, exp) { 35 | t.Errorf("invalid values: %#v", m) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cmd/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "github.com/matt4biz/envy" 11 | ) 12 | 13 | type App struct { 14 | *envy.Envy 15 | 16 | args []string 17 | version string 18 | stdin io.Reader 19 | stdout io.Writer 20 | stderr io.Writer 21 | } 22 | 23 | type Command interface { 24 | Run() int 25 | NeedsDB() bool 26 | } 27 | 28 | var ( 29 | ErrUsage = errors.New("usage") 30 | ErrUnknownCommand = errors.New("unknown command") 31 | ) 32 | 33 | // NeedsDB operates on the opt-out theory; a subcommand 34 | // should override this if it doesn't need a DB set up. 35 | func (a *App) NeedsDB() bool { 36 | return true 37 | } 38 | 39 | func (a *App) fromArgs(args []string) error { 40 | fs := flag.NewFlagSet("", flag.ContinueOnError) 41 | help := fs.Bool("h", false, "") 42 | 43 | fs.Usage = a.usage 44 | 45 | if err := fs.Parse(args); err != nil { 46 | return ErrUsage 47 | } else if *help { 48 | a.usage() 49 | return ErrUsage 50 | } 51 | 52 | a.args = fs.Args() 53 | return nil 54 | } 55 | 56 | func (a *App) getCommand() (Command, error) { 57 | if len(a.args) == 0 { 58 | return nil, ErrUnknownCommand 59 | } 60 | 61 | s := a.args[0] 62 | a.args = a.args[1:] 63 | 64 | switch s { 65 | case "add": 66 | return &AddCommand{a}, nil 67 | case "drop": 68 | return &DropCommand{a}, nil 69 | case "exec": 70 | return &ExecCommand{a}, nil 71 | case "get": 72 | return &GetCommand{a}, nil 73 | case "list": 74 | return &ListCommand{a}, nil 75 | case "read": 76 | return &ReadCommand{a}, nil 77 | case "version": 78 | return &VersionCommand{a}, nil 79 | case "write": 80 | return &WriteCommand{a}, nil 81 | } 82 | 83 | return nil, fmt.Errorf("%s: %w", s, ErrUnknownCommand) 84 | } 85 | 86 | func (a *App) usage() { 87 | msg := strings.TrimSpace(` 88 | envy: a tool to securely store and retrieve environment variables. 89 | 90 | Variables are key-value pairs stored in a "realm" (or "namespace") of which 91 | there may be one or more. All data is stored in a DB within the user's "config" 92 | directory, encrypted with a per-user secret key stored in the system keychain. 93 | 94 | All operations take place in one of the subcommands. Add will create a realm 95 | if it doesn't exist, or overwrite keys in a realm that already exists. Drop 96 | may be used to delete one key or an entire realm. Exec will execute a command 97 | with arguments, with value(s) from the realm injected as environment variables. 98 | Get will return the stored value (string for a key, JSON for an entire realm). 99 | Read and write allow a realm's data to be exported or imported in JSON format. 100 | 101 | Usage: envy [opts] subcommand 102 | -h show this help message and exit 103 | 104 | add realm key=value [key=value ...] 105 | get realm[/key] 106 | -n don't add a trailing newline 107 | drop realm[/key] 108 | exec realm[/key] command [args ...] 109 | list [opts] [realm[/key]] 110 | -d show decrypted secrets also 111 | read [opts] realm file ('-' for stdout) 112 | -q unquote embedded JSON in values 113 | write [opts] realm file ('-' for stdin) 114 | -clear overwrite contents 115 | version 116 | 117 | Listing a realm displays a timestamp, size, and hash for each key-value pair. 118 | `) 119 | 120 | fmt.Fprintln(a.stderr, msg) 121 | } 122 | 123 | func runApp(args []string, version string, stdin io.Reader, stdout, stderr io.Writer) int { 124 | a := App{version: version, stdin: stdin, stdout: stdout, stderr: stderr} 125 | 126 | if err := a.fromArgs(args); err != nil { 127 | if err == ErrUsage { 128 | return 0 129 | } 130 | 131 | fmt.Fprintln(stderr, err) 132 | return 1 133 | } 134 | 135 | cmd, err := a.getCommand() 136 | 137 | if err != nil { 138 | fmt.Fprintln(stderr, err) 139 | return -1 140 | } 141 | 142 | if cmd.NeedsDB() { 143 | a.Envy, err = envy.New() 144 | 145 | if err != nil { 146 | fmt.Fprintln(stderr, err) 147 | return -1 148 | } 149 | 150 | defer a.Envy.Close() 151 | } 152 | 153 | return cmd.Run() 154 | } 155 | -------------------------------------------------------------------------------- /cmd/app_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // All these tests are safe because they should exit 10 | // the app before a database may be created 11 | 12 | func TestAppNoCommand(t *testing.T) { 13 | stdout := new(bytes.Buffer) 14 | stderr := new(bytes.Buffer) 15 | 16 | o := runApp(nil, "666", nil, stdout, stderr) 17 | 18 | t.Log(stderr.String()) 19 | 20 | if o != -1 { 21 | t.Errorf("invalid code: %d", o) 22 | } 23 | } 24 | 25 | func TestAppInvalidCommand(t *testing.T) { 26 | stdout := new(bytes.Buffer) 27 | stderr := new(bytes.Buffer) 28 | 29 | o := runApp([]string{"empty"}, "666", nil, stdout, stderr) 30 | 31 | t.Log(stderr.String()) 32 | 33 | if o != -1 { 34 | t.Errorf("invalid code: %d", o) 35 | } 36 | } 37 | 38 | func TestAppUsage(t *testing.T) { 39 | stdout := new(bytes.Buffer) 40 | stderr := new(bytes.Buffer) 41 | 42 | o := runApp([]string{"-h"}, "666", nil, stdout, stderr) 43 | 44 | if o != 0 { 45 | t.Errorf("invalid code: %d", o) 46 | } 47 | 48 | lines := strings.Split(stderr.String(), "\n") 49 | 50 | if len(lines) < 1 || !strings.HasPrefix(lines[0], "envy: a tool") { 51 | t.Errorf("invalid stderr: %s", stderr.String()) 52 | } 53 | } 54 | 55 | func TestAppVersion(t *testing.T) { 56 | stdout := new(bytes.Buffer) 57 | stderr := new(bytes.Buffer) 58 | 59 | o := runApp([]string{"version"}, "666", nil, stdout, stderr) 60 | 61 | if o != 0 { 62 | t.Errorf("invalid code: %d", o) 63 | } 64 | 65 | lines := strings.Split(stdout.String(), "\n") 66 | 67 | if len(lines) < 1 || lines[0] != "666" { 68 | t.Errorf("invalid stdout: %s", stdout.String()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/drop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type DropCommand struct { 9 | *App 10 | } 11 | 12 | func (cmd *DropCommand) Run() int { 13 | if len(cmd.args) < 1 { 14 | cmd.usage() 15 | return 1 16 | } 17 | 18 | var err error 19 | 20 | parts := strings.Split(cmd.args[0], "/") 21 | 22 | if len(parts) == 1 { 23 | err = cmd.Purge(cmd.args[0]) 24 | } else { 25 | err = cmd.Drop(parts[0], parts[1]) 26 | } 27 | 28 | if err != nil { 29 | fmt.Fprintln(cmd.stderr, err) 30 | return -1 31 | } 32 | 33 | return 0 34 | } 35 | -------------------------------------------------------------------------------- /cmd/drop_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestDrop(t *testing.T) { 9 | stdout := new(bytes.Buffer) 10 | stderr := new(bytes.Buffer) 11 | app := NewTestApp(t, stdout, stderr) 12 | 13 | app.args = []string{"top/a"} 14 | 15 | cmd := DropCommand{app} 16 | data := map[string]string{"a": "XX", "b": "YY"} 17 | 18 | if err := app.Add("top", data); err != nil { 19 | t.Fatal("setup", err) 20 | } 21 | 22 | o := cmd.Run() 23 | 24 | if o != 0 { 25 | t.Errorf("errors: %s", stderr.String()) 26 | t.Fatalf("invalid 1st return: %d", o) 27 | } 28 | 29 | m, err := cmd.Fetch("top") 30 | 31 | if err != nil { 32 | t.Fatal("fetch", err) 33 | } 34 | 35 | if m["b"] != "YY" { 36 | t.Errorf("invalid data: %#v", m) 37 | } 38 | 39 | app.args = []string{"top"} 40 | 41 | o = cmd.Run() 42 | 43 | if o != 0 { 44 | t.Errorf("errors: %s", stderr.String()) 45 | t.Fatalf("invalid 2nd return: %d", o) 46 | } 47 | 48 | _, err = cmd.Fetch("top") 49 | 50 | if err == nil { 51 | t.Errorf("no error after purge!") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "os/signal" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | type ExecCommand struct { 13 | *App 14 | } 15 | 16 | func (cmd *ExecCommand) Run() int { 17 | if len(cmd.args) < 2 { 18 | cmd.usage() 19 | return 1 20 | } 21 | 22 | var ( 23 | m []string 24 | k, v string 25 | err error 26 | ) 27 | 28 | parts := strings.Split(cmd.args[0], "/") 29 | 30 | if len(parts) == 1 { 31 | m, err = cmd.FetchAsVarList(cmd.args[0]) 32 | } else { 33 | k = parts[1] 34 | v, err = cmd.Get(parts[0], parts[1]) 35 | } 36 | 37 | if err != nil { 38 | fmt.Fprintln(cmd.stderr, err) 39 | return -1 40 | } 41 | 42 | if v != "" { 43 | m = append(m, k+"="+v) 44 | } 45 | 46 | done := make(chan os.Signal, 1) 47 | sub := exec.Command(cmd.args[1], cmd.args[2:]...) 48 | 49 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 50 | 51 | sub.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 52 | sub.Stdout = cmd.stdout 53 | sub.Stderr = cmd.stderr 54 | sub.Env = append(os.Environ(), m...) 55 | 56 | go func() { 57 | s := <-done 58 | 59 | if err := sub.Process.Signal(s); err != nil { 60 | fmt.Fprintln(cmd.stderr, "can't send signal", s) 61 | 62 | if err = syscall.Kill(-sub.Process.Pid, syscall.SIGKILL); err != nil { 63 | fmt.Fprintf(cmd.stderr, "failed to stop emulator pid=%d\n", sub.Process.Pid) 64 | } 65 | } 66 | }() 67 | 68 | if err := sub.Start(); err != nil { 69 | fmt.Fprintln(cmd.stderr, err) 70 | return -1 71 | } 72 | 73 | if err := sub.Wait(); err != nil { 74 | fmt.Fprintln(cmd.stderr, "can't wait", err) 75 | } 76 | 77 | return sub.ProcessState.ExitCode() 78 | } 79 | -------------------------------------------------------------------------------- /cmd/exec_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestExec(t *testing.T) { 10 | stdout := new(bytes.Buffer) 11 | stderr := new(bytes.Buffer) 12 | app := NewTestApp(t, stdout, stderr) 13 | 14 | app.args = []string{"top", "../test/test.sh"} 15 | 16 | cmd := ExecCommand{app} 17 | data := map[string]string{"a": "b", "b": "1"} 18 | 19 | if err := app.Add("top", data); err != nil { 20 | t.Fatal("setup", err) 21 | } 22 | 23 | o := cmd.Run() 24 | 25 | if o != 0 { 26 | t.Errorf("errors: %s", stderr.String()) 27 | t.Fatalf("invalid 1st return: %d", o) 28 | } else { 29 | t.Log(strings.TrimSpace(stdout.String())) 30 | } 31 | 32 | lines := strings.Split(stdout.String(), "\n") 33 | 34 | // trailing '\n' makes an extra (blank) line 35 | 36 | if len(lines) != 2 { 37 | t.Fatalf("invalid 1st count: %v", lines) 38 | } 39 | 40 | if lines[0] != "b 1" { 41 | t.Fatalf("invalid 1st output: %q", lines) 42 | } 43 | } 44 | 45 | func TestExecOne(t *testing.T) { 46 | stdout := new(bytes.Buffer) 47 | stderr := new(bytes.Buffer) 48 | app := NewTestApp(t, stdout, stderr) 49 | 50 | app.args = []string{"top/b", "../test/test.sh"} 51 | 52 | cmd := ExecCommand{app} 53 | data := map[string]string{"a": "b", "b": "1"} 54 | 55 | if err := app.Add("top", data); err != nil { 56 | t.Fatal("setup", err) 57 | } 58 | 59 | o := cmd.Run() 60 | 61 | if o != 0 { 62 | t.Errorf("errors: %s", stderr.String()) 63 | t.Fatalf("invalid 2nd return: %d", o) 64 | } else { 65 | t.Log(strings.TrimSpace(stdout.String())) 66 | } 67 | 68 | lines := strings.Split(stdout.String(), "\n") 69 | 70 | // trailing '\n' makes an extra (blank) line 71 | 72 | if len(lines) != 2 { 73 | t.Fatalf("invalid 2nd count: %v", lines) 74 | } 75 | 76 | if lines[0] != "1" { 77 | t.Fatalf("invalid 2nd output: %q", lines) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | type GetCommand struct { 11 | *App 12 | } 13 | 14 | func (cmd *GetCommand) Run() int { 15 | fs := flag.NewFlagSet("get", flag.ContinueOnError) 16 | raw := fs.Bool("n", false, "remove trailing newline") 17 | 18 | fs.Usage = cmd.usage 19 | 20 | if err := fs.Parse(cmd.args); err != nil { 21 | cmd.usage() 22 | return 1 23 | } 24 | 25 | cmd.args = fs.Args() 26 | 27 | if len(cmd.args) < 1 { 28 | cmd.usage() 29 | return 1 30 | } 31 | 32 | var err error 33 | 34 | parts := strings.Split(cmd.args[0], "/") 35 | 36 | if len(parts) == 1 { 37 | var m json.RawMessage 38 | 39 | if m, err = cmd.FetchAsJSON(cmd.args[0]); err == nil { 40 | if *raw { 41 | fmt.Fprint(cmd.stdout, string(m)) 42 | } else { 43 | fmt.Fprintln(cmd.stdout, string(m)) 44 | } 45 | 46 | } 47 | } else { 48 | var s string 49 | 50 | if s, err = cmd.Get(parts[0], parts[1]); err == nil { 51 | if *raw { 52 | fmt.Fprint(cmd.stdout, s) 53 | } else { 54 | fmt.Fprintln(cmd.stdout, s) 55 | } 56 | } 57 | } 58 | 59 | if err != nil { 60 | fmt.Fprintln(cmd.stderr, err) 61 | return -1 62 | } 63 | 64 | return 0 65 | } 66 | -------------------------------------------------------------------------------- /cmd/get_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestGet(t *testing.T) { 10 | stdout := new(bytes.Buffer) 11 | stderr := new(bytes.Buffer) 12 | app := NewTestApp(t, stdout, stderr) 13 | 14 | app.args = []string{"top/a"} 15 | 16 | cmd := GetCommand{app} 17 | data := map[string]string{"a": "XX", "b": "YY"} 18 | 19 | if err := app.Add("top", data); err != nil { 20 | t.Fatal("setup", err) 21 | } 22 | 23 | o := cmd.Run() 24 | 25 | if o != 0 { 26 | t.Errorf("errors: %s", stderr.String()) 27 | t.Fatalf("invalid 1st return: %d", o) 28 | } 29 | 30 | if s := stdout.String(); s != "XX\n" { 31 | t.Errorf("wrong data, got %q", s) 32 | } 33 | 34 | stdout.Reset() 35 | 36 | app.args = []string{"top"} 37 | o = cmd.Run() 38 | 39 | if o != 0 { 40 | t.Errorf("errors: %s", stderr.String()) 41 | t.Fatalf("invalid 1st return: %d", o) 42 | } 43 | 44 | wanted := fmt.Sprintf("%s\n", `{"a":"XX","b":"YY"}`) 45 | 46 | if s := stdout.String(); s != wanted { 47 | t.Errorf("wrong data, got %q", s) 48 | } 49 | } 50 | 51 | func TestGetRaw(t *testing.T) { 52 | stdout := new(bytes.Buffer) 53 | stderr := new(bytes.Buffer) 54 | app := NewTestApp(t, stdout, stderr) 55 | 56 | app.args = []string{"-n", "top/a"} 57 | 58 | cmd := GetCommand{app} 59 | data := map[string]string{"a": "XX", "b": "YY"} 60 | 61 | if err := app.Add("top", data); err != nil { 62 | t.Fatal("setup", err) 63 | } 64 | 65 | o := cmd.Run() 66 | 67 | if o != 0 { 68 | t.Errorf("errors: %s", stderr.String()) 69 | t.Fatalf("invalid 1st return: %d", o) 70 | } 71 | 72 | if s := stdout.String(); s != "XX" { 73 | t.Errorf("wrong data, got %q", s) 74 | } 75 | 76 | stdout.Reset() 77 | 78 | app.args = []string{"-n", "top"} 79 | o = cmd.Run() 80 | 81 | if o != 0 { 82 | t.Errorf("errors: %s", stderr.String()) 83 | t.Fatalf("invalid 1st return: %d", o) 84 | } 85 | 86 | wanted := `{"a":"XX","b":"YY"}` 87 | 88 | if s := stdout.String(); s != wanted { 89 | t.Errorf("wrong data, got %q", s) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type ListCommand struct { 10 | *App 11 | } 12 | 13 | func (cmd *ListCommand) Run() int { 14 | fs := flag.NewFlagSet("list", flag.ContinueOnError) 15 | decrypt := fs.Bool("d", false, "show decrypted values") 16 | 17 | fs.Usage = cmd.usage 18 | 19 | if err := fs.Parse(cmd.args); err != nil { 20 | cmd.usage() 21 | return 1 22 | } 23 | 24 | cmd.args = fs.Args() 25 | 26 | if len(cmd.args) < 1 { 27 | realms, err := cmd.Realms() 28 | 29 | if err != nil { 30 | fmt.Fprintln(cmd.stderr, err) 31 | return -1 32 | } 33 | 34 | for _, r := range realms { 35 | fmt.Fprintln(cmd.stdout, r) 36 | } 37 | 38 | return 0 39 | } 40 | 41 | var err error 42 | 43 | parts := strings.Split(cmd.args[0], "/") 44 | 45 | if len(parts) == 1 { 46 | err = cmd.List(cmd.stdout, cmd.args[0], "", *decrypt) 47 | } else { 48 | err = cmd.List(cmd.stdout, parts[0], parts[1], *decrypt) 49 | } 50 | 51 | if err != nil { 52 | fmt.Fprintln(cmd.stderr, err) 53 | return -1 54 | } 55 | 56 | return 0 57 | } 58 | -------------------------------------------------------------------------------- /cmd/list_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestList(t *testing.T) { 10 | stdout := new(bytes.Buffer) 11 | stderr := new(bytes.Buffer) 12 | app := NewTestApp(t, stdout, stderr) 13 | 14 | app.args = []string{"top"} 15 | 16 | cmd := ListCommand{app} 17 | data := map[string]string{"a": "xxx", "b": "yyy"} 18 | 19 | if err := app.Add("top", data); err != nil { 20 | t.Fatal("setup", err) 21 | } 22 | 23 | o := cmd.Run() 24 | 25 | if o != 0 { 26 | t.Errorf("errors: %s", stderr.String()) 27 | t.Fatalf("invalid return: %d", o) 28 | } 29 | 30 | lines := strings.Split(stdout.String(), "\n") 31 | 32 | // trailing '\n' makes an extra (blank) line 33 | 34 | if len(lines) != 3 { 35 | t.Fatalf("invalid count: %v", lines) 36 | } 37 | 38 | if lines[0][0] != 'a' || lines[1][0] != 'b' { 39 | t.Fatalf("invalid output: %q", lines) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var version string // do not remove or change 8 | 9 | func main() { 10 | os.Exit(runApp(os.Args[1:], version, os.Stdin, os.Stdout, os.Stderr)) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/read.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "strings" 8 | ) 9 | 10 | type ReadCommand struct { 11 | *App 12 | } 13 | 14 | func (cmd *ReadCommand) Run() int { 15 | fs := flag.NewFlagSet("list", flag.ContinueOnError) 16 | unquote := fs.Bool("q", false, "unquote embedded JSON") 17 | 18 | fs.Usage = cmd.usage 19 | 20 | if err := fs.Parse(cmd.args); err != nil { 21 | cmd.usage() 22 | return 1 23 | } 24 | 25 | cmd.args = fs.Args() 26 | 27 | if len(cmd.args) < 2 { 28 | cmd.usage() 29 | return 1 30 | } 31 | 32 | m, err := cmd.FetchAsJSON(cmd.args[0]) 33 | 34 | if err != nil { 35 | fmt.Fprintln(cmd.stderr, err) 36 | return -1 37 | } 38 | 39 | if *unquote { 40 | // we're going to have to do this the hard way 41 | 42 | v := string(m) 43 | 44 | v = strings.Trim(v, "\"") 45 | v = strings.ReplaceAll(v, `\"`, `"`) 46 | v = strings.ReplaceAll(v, `"{`, `{`) 47 | v = strings.ReplaceAll(v, `}"`, `}`) 48 | v = strings.ReplaceAll(v, `\\n`, ``) 49 | 50 | m = []byte(v) 51 | } 52 | 53 | // it's nice to have the file (or stdout) 54 | // have a trailing newline 55 | 56 | m = append(m, '\n') 57 | 58 | if cmd.args[1] != "-" { 59 | if err := ioutil.WriteFile(cmd.args[1], m, 0600); err != nil { 60 | fmt.Fprintln(cmd.stderr, err) 61 | return -1 62 | } 63 | } else { 64 | if _, err := cmd.stdout.Write(m); err != nil { 65 | fmt.Fprintln(cmd.stderr, err) 66 | return -1 67 | } 68 | } 69 | 70 | return 0 71 | } 72 | -------------------------------------------------------------------------------- /cmd/read_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestRead(t *testing.T) { 14 | dname, err := ioutil.TempDir("", "scratch") 15 | 16 | if err != nil { 17 | t.Fatal("tempdir", err) 18 | } 19 | 20 | defer os.RemoveAll(dname) 21 | 22 | fname := path.Join(dname, "test.json") 23 | 24 | stdout := new(bytes.Buffer) 25 | stderr := new(bytes.Buffer) 26 | app := NewTestApp(t, stdout, stderr) 27 | expData := map[string]string{"a": "12", "b": "21"} 28 | 29 | if err := app.Add("test", expData); err != nil { 30 | t.Fatal("setup", err) 31 | } 32 | 33 | app.args = []string{"test", fname} 34 | 35 | cmd := ReadCommand{app} 36 | o := cmd.Run() 37 | 38 | if o != 0 { 39 | t.Errorf("errors: %s", stderr.String()) 40 | t.Fatalf("invalid return: %d", o) 41 | } 42 | 43 | raw, err := ioutil.ReadFile(fname) 44 | 45 | if err != nil { 46 | t.Fatal("read", err) 47 | } 48 | 49 | t.Log(string(raw)) 50 | 51 | var readData map[string]string 52 | 53 | if err = json.Unmarshal(raw, &readData); err != nil { 54 | t.Fatal("decode", err) 55 | } 56 | 57 | if !reflect.DeepEqual(readData, expData) { 58 | t.Errorf("invalid data: %#v", readData) 59 | } 60 | } 61 | 62 | func TestReadStdout(t *testing.T) { 63 | stdout := new(bytes.Buffer) 64 | stderr := new(bytes.Buffer) 65 | app := NewTestApp(t, stdout, stderr) 66 | expData := map[string]string{"a": "12", "b": "21"} 67 | 68 | if err := app.Add("test", expData); err != nil { 69 | t.Fatal("setup", err) 70 | } 71 | 72 | app.args = []string{"test", "-"} 73 | 74 | cmd := ReadCommand{app} 75 | o := cmd.Run() 76 | 77 | if o != 0 { 78 | t.Errorf("errors: %s", stderr.String()) 79 | t.Fatalf("invalid return: %d", o) 80 | } 81 | 82 | var readData map[string]string 83 | 84 | if err := json.NewDecoder(stdout).Decode(&readData); err != nil { 85 | t.Fatal("decode", err) 86 | } 87 | 88 | if !reflect.DeepEqual(readData, expData) { 89 | t.Errorf("invalid data: %#v", readData) 90 | } 91 | } 92 | 93 | func TestReadUnquoted(t *testing.T) { 94 | type X struct { 95 | A string `json:"a"` 96 | B string `json:"b"` 97 | } 98 | 99 | type wrap struct { 100 | One X `json:"one"` 101 | Two X `json:"two"` 102 | } 103 | 104 | expData := map[string]wrap{ 105 | "a": wrap{ 106 | One: X{"1", "2"}, 107 | Two: X{"5", "6"}, 108 | }, 109 | } 110 | 111 | stdout := new(bytes.Buffer) 112 | stderr := new(bytes.Buffer) 113 | app := NewTestApp(t, stdout, stderr) 114 | input := map[string]string{"a": `{"one":{"a":"1","b":"2"},\n "two":{"a":"5","b":"6"}}`} 115 | 116 | if err := app.Add("test", input); err != nil { 117 | t.Fatal("setup", err) 118 | } 119 | 120 | app.args = []string{"-q", "test", "-"} 121 | 122 | cmd := ReadCommand{app} 123 | o := cmd.Run() 124 | 125 | if o != 0 { 126 | t.Errorf("errors: %s", stderr.String()) 127 | t.Fatalf("invalid return: %d", o) 128 | } 129 | 130 | t.Log("output:", stdout.String()) 131 | 132 | var readData map[string]wrap 133 | 134 | if err := json.NewDecoder(stdout).Decode(&readData); err != nil { 135 | t.Fatal("decode", err) 136 | } 137 | 138 | if !reflect.DeepEqual(readData, expData) { 139 | t.Errorf("invalid data: %+v (should be %+v)", readData, expData) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /cmd/test_common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | "github.com/zalando/go-keyring" 10 | 11 | "github.com/matt4biz/envy" 12 | "github.com/matt4biz/envy/internal" 13 | ) 14 | 15 | func NewTestApp(t *testing.T, so, se *bytes.Buffer) *App { 16 | // once we've set the mock we're done in this 17 | // test process -- even for other unit tests 18 | 19 | keyring.MockInit() 20 | 21 | dname, err := ioutil.TempDir("", "envy") 22 | 23 | if err != nil { 24 | t.Fatal("tempdir", err) 25 | } 26 | 27 | defer os.RemoveAll(dname) 28 | 29 | e, err := envy.NewWithSealer(dname, internal.NewTestSealer()) 30 | 31 | if err != nil { 32 | t.Fatal("new-sealer", err) 33 | } 34 | 35 | app := App{ 36 | Envy: e, 37 | args: []string{}, 38 | stdout: so, 39 | stderr: se, 40 | } 41 | 42 | return &app 43 | } 44 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type VersionCommand struct { 8 | *App 9 | } 10 | 11 | func (a *VersionCommand) NeedsDB() bool { 12 | return false 13 | } 14 | 15 | func (cmd *VersionCommand) Run() int { 16 | fmt.Fprintln(cmd.stdout, cmd.version) 17 | return 0 18 | } 19 | -------------------------------------------------------------------------------- /cmd/write.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type WriteCommand struct { 10 | *App 11 | } 12 | 13 | func (cmd *WriteCommand) Run() int { 14 | fs := flag.NewFlagSet("list", flag.ContinueOnError) 15 | clear := fs.Bool("clear", false, "overwrite contents") 16 | 17 | fs.Usage = cmd.usage 18 | 19 | if err := fs.Parse(cmd.args); err != nil { 20 | cmd.usage() 21 | return 1 22 | } 23 | 24 | cmd.args = fs.Args() 25 | 26 | if len(cmd.args) < 2 { 27 | cmd.usage() 28 | return 1 29 | } 30 | 31 | reader := cmd.stdin 32 | 33 | if cmd.args[1] != "-" { 34 | file, err := os.Open(cmd.args[1]) 35 | 36 | if err != nil { 37 | fmt.Fprintf(cmd.stderr, "read: %s\n", err) 38 | return -1 39 | } 40 | 41 | defer file.Close() 42 | 43 | reader = file 44 | } 45 | 46 | if *clear { 47 | if err := cmd.Purge(cmd.args[0]); err != nil { 48 | fmt.Fprintf(cmd.stderr, "read: %s\n", err) 49 | return -1 50 | } 51 | } 52 | 53 | err := cmd.Read(reader, cmd.args[0]) 54 | 55 | if err != nil { 56 | fmt.Fprintf(cmd.stderr, "read: %s\n", err) 57 | return -1 58 | } 59 | 60 | return 0 61 | } 62 | -------------------------------------------------------------------------------- /cmd/write_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestWrite(t *testing.T) { 12 | dname, err := ioutil.TempDir("", "scratch") 13 | 14 | if err != nil { 15 | t.Fatal("tempdir", err) 16 | } 17 | 18 | defer os.RemoveAll(dname) 19 | 20 | file, err := ioutil.TempFile(dname, "test") 21 | 22 | if err != nil { 23 | t.Fatal("tempfile", err) 24 | } 25 | 26 | if _, err = file.WriteString(`{"x":"21", "y":"14"}`); err != nil { 27 | t.Fatal("write-temp", err) 28 | } 29 | 30 | file.Close() 31 | 32 | stdout := new(bytes.Buffer) 33 | stderr := new(bytes.Buffer) 34 | app := NewTestApp(t, stdout, stderr) 35 | 36 | app.args = []string{"test", file.Name()} 37 | 38 | cmd := WriteCommand{app} 39 | o := cmd.Run() 40 | 41 | if o != 0 { 42 | t.Errorf("errors: %s", stderr.String()) 43 | t.Fatalf("invalid return: %d", o) 44 | } 45 | 46 | m, err := cmd.Fetch("test") 47 | 48 | if err != nil { 49 | t.Fatalf("can't fetch: %s", err) 50 | } 51 | 52 | exp := map[string]string{"x": "21", "y": "14"} 53 | 54 | if !reflect.DeepEqual(m, exp) { 55 | t.Errorf("invalid values: %#v", m) 56 | } 57 | } 58 | 59 | func TestOverwrite(t *testing.T) { 60 | stdout := new(bytes.Buffer) 61 | stderr := new(bytes.Buffer) 62 | app := NewTestApp(t, stdout, stderr) 63 | data := map[string]string{"a": "xxx", "b": "yyy"} 64 | 65 | if err := app.Add("test", data); err != nil { 66 | t.Fatal("setup", err) 67 | } 68 | 69 | app.stdin = bytes.NewBufferString(`{"x":"21", "y":"14"}`) 70 | app.args = []string{"-clear", "test", "-"} 71 | 72 | cmd := WriteCommand{app} 73 | o := cmd.Run() 74 | 75 | if o != 0 { 76 | t.Errorf("errors: %s", stderr.String()) 77 | t.Fatalf("invalid return: %d", o) 78 | } 79 | 80 | m, err := cmd.Fetch("test") 81 | 82 | if err != nil { 83 | t.Fatalf("can't fetch: %s", err) 84 | } 85 | 86 | exp := map[string]string{"x": "21", "y": "14"} 87 | 88 | if !reflect.DeepEqual(m, exp) { 89 | t.Errorf("invalid values: %#v", m) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /envy.go: -------------------------------------------------------------------------------- 1 | package envy 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "math" 8 | "os" 9 | "path" 10 | "sort" 11 | 12 | "github.com/matt4biz/envy/internal" 13 | ) 14 | 15 | // Envy provides the interface to the local secure 16 | // variable store. 17 | type Envy struct { 18 | db internal.DB 19 | dir string 20 | sealer *internal.Sealer 21 | } 22 | 23 | // New returns a secure variable store whose DB 24 | // lives in the user's "config" directory. 25 | func New() (*Envy, error) { 26 | d, err := defaultDirectory() 27 | 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | s, err := internal.NewDefaultSealer() 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return NewWithSealer(d, s) 39 | } 40 | 41 | // NewWithDirectory returns a secure variable store 42 | // whose DB lives in the provided directory (mainly 43 | // for UTs that need a temporary directory). 44 | func NewWithDirectory(dir string) (*Envy, error) { 45 | s, err := internal.NewDefaultSealer() 46 | 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return NewWithSealer(dir, s) 52 | } 53 | 54 | // NewWithSealer is really a constructor for UTs, so we 55 | // can pass in a fake sealer that's deterministic. 56 | func NewWithSealer(dir string, s *internal.Sealer) (*Envy, error) { 57 | db, err := internal.NewBoltDB(path.Join(dir, "/envy.db")) 58 | 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | e := Envy{ 64 | db: db, 65 | dir: dir, 66 | sealer: s, 67 | } 68 | 69 | return &e, nil 70 | } 71 | 72 | // CurrentUser returns the user's login name. 73 | func (e *Envy) CurrentUser() string { 74 | return e.sealer.GetUsername() 75 | } 76 | 77 | // Directory returns the directory holding the DB. 78 | func (e *Envy) Directory() string { 79 | return e.dir 80 | } 81 | 82 | // Add writes a map of {variable, value} pairs to the secure 83 | // store, possibly creating it and/or overwriting variables 84 | // that are already there. 85 | func (e *Envy) Add(realm string, vars map[string]string) error { 86 | m := make(internal.Stored) 87 | 88 | for k, v := range vars { 89 | ud := internal.Unsealed{Data: v} 90 | sd, err := e.sealer.Seal(ud) 91 | 92 | if err != nil { 93 | return fmt.Errorf("sealing %s/%s: %w", realm, k, err) 94 | } 95 | 96 | m[k] = sd 97 | } 98 | 99 | return e.db.SetKeys(realm, m) 100 | } 101 | 102 | // fetchRaw does the real work of getting data from the DB. 103 | func (e *Envy) fetchRaw(realm string) (internal.Loaded, error) { 104 | m, err := e.db.GetAllKeys(realm) 105 | 106 | if err != nil { 107 | return nil, fmt.Errorf("fetching %s: %w", realm, err) 108 | } 109 | 110 | result := make(internal.Loaded, len(m)) 111 | 112 | for k, sd := range m { 113 | ud, err := e.sealer.Unseal(sd) 114 | 115 | if err != nil { 116 | return nil, fmt.Errorf("unsealing %s/%s: %w", realm, k, err) 117 | } 118 | 119 | result[k] = ud 120 | } 121 | 122 | return result, nil 123 | } 124 | 125 | // Fetch returns a map of {variable, value} pairs from the 126 | // secure store for the given realm, if present. 127 | func (e *Envy) Fetch(realm string) (map[string]string, error) { 128 | m, err := e.fetchRaw(realm) 129 | 130 | if err != nil { 131 | return nil, fmt.Errorf("fetching %s: %w", realm, err) 132 | } 133 | 134 | result := make(map[string]string, len(m)) 135 | 136 | for k, ud := range m { 137 | result[k] = ud.Data 138 | } 139 | 140 | return result, nil 141 | } 142 | 143 | // FetchAsJSON returns the variables for a given realm as a 144 | // JSON object. This is handy for other tools using Envy as a 145 | // library, e.g., to store secure login credentials / tokens. 146 | func (e *Envy) FetchAsJSON(realm string) (json.RawMessage, error) { 147 | m, err := e.Fetch(realm) 148 | 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | return json.Marshal(m) 154 | } 155 | 156 | // FetchAsVarList returns the variables in a realm as a list of 157 | // key=value expressions that can be appended to a command's 158 | // list of environment variables. 159 | func (e *Envy) FetchAsVarList(realm string) ([]string, error) { 160 | m, err := e.Fetch(realm) 161 | 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | result := make([]string, 0, len(m)) 167 | 168 | for k, v := range m { 169 | result = append(result, k+"="+v) 170 | } 171 | 172 | return result, nil 173 | } 174 | 175 | // Set adds a single key and value to the realm in the 176 | // secure store, possibly creating it and/or overwriting 177 | // and existing key. 178 | func (e *Envy) Set(realm, key, data string) error { 179 | ud := internal.Unsealed{Data: data} 180 | sd, err := e.sealer.Seal(ud) 181 | 182 | if err != nil { 183 | return fmt.Errorf("sealing %s/%s: %w", realm, key, err) 184 | } 185 | 186 | return e.db.SetKey(realm, key, sd) 187 | } 188 | 189 | // Get returns a single key's value from the realm, if it 190 | // is present. 191 | func (e *Envy) Get(realm, key string) (string, error) { 192 | sd, err := e.db.GetKey(realm, key) 193 | 194 | if err != nil { 195 | return "", fmt.Errorf("fetching %s/%s: %w", realm, key, err) 196 | } 197 | 198 | ud, err := e.sealer.Unseal(sd) 199 | 200 | if err != nil { 201 | return "", fmt.Errorf("unsealing %s/%s: %w", realm, key, err) 202 | } 203 | 204 | return ud.Data, nil 205 | } 206 | 207 | // Drop removes a single key from the realm's secure store. 208 | func (e *Envy) Drop(realm, key string) error { 209 | return e.db.DropKey(realm, key) 210 | } 211 | 212 | // Purge removes an entire realm from the secure store. 213 | // Use with caution. 214 | func (e *Envy) Purge(realm string) error { 215 | return e.db.Purge(realm) 216 | } 217 | 218 | // Realms returns a list of the realms in the secure store. 219 | func (e *Envy) Realms() ([]string, error) { 220 | realms, err := e.db.ListRealms() 221 | 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | sort.Strings(realms) 227 | return realms, nil 228 | } 229 | 230 | // List writes to its destination a single realm's variables and 231 | // their metadata, and optionally their values (use with caution). 232 | func (e *Envy) List(w io.Writer, realm, key string, decrypt bool) error { 233 | m, err := e.fetchRaw(realm) 234 | 235 | if err != nil { 236 | return err 237 | } 238 | 239 | var maxWidth int 240 | var maxSize int 241 | 242 | keys := make([]string, 0, len(m)) 243 | 244 | for k, v := range m { 245 | if key != "" && k != key { 246 | continue 247 | } 248 | 249 | keys = append(keys, k) 250 | 251 | if l := len(k); l > maxWidth { 252 | maxWidth = l 253 | } 254 | 255 | if l := v.Meta.Size; l > maxSize { 256 | maxSize = l 257 | } 258 | } 259 | 260 | maxSize = (int)(math.Log10(float64(maxSize)) + 1) 261 | 262 | sort.Strings(keys) 263 | 264 | for _, k := range keys { 265 | ud := m[k] 266 | 267 | if decrypt { 268 | fmt.Fprintf(w, "%-*s %s %s\n", maxWidth, k, ud.Meta.ToString(maxSize), ud.Data) 269 | } else { 270 | fmt.Fprintf(w, "%-*s %s\n", maxWidth, k, ud.Meta.ToString(maxSize)) 271 | } 272 | } 273 | 274 | return nil 275 | } 276 | 277 | // Read takes JSON input and writes the contents into the 278 | // realm (assumed to be an object with key-value pairs). 279 | func (e *Envy) Read(r io.Reader, realm string) error { 280 | m := make(map[string]string) 281 | 282 | err := json.NewDecoder(r).Decode(&m) 283 | 284 | if err != nil { 285 | return err 286 | } 287 | 288 | return e.Add(realm, m) 289 | } 290 | 291 | // Close closes the DB. Clients should defer this once 292 | // the Envy object has been created. 293 | func (e *Envy) Close() { 294 | _ = e.db.Close() 295 | } 296 | 297 | // defaultDirectory returns /envy. 298 | func defaultDirectory() (string, error) { 299 | d, err := os.UserConfigDir() 300 | 301 | if err != nil { 302 | return "", err 303 | } 304 | 305 | return path.Join(d, "envy"), nil 306 | } 307 | -------------------------------------------------------------------------------- /envy_test.go: -------------------------------------------------------------------------------- 1 | package envy 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/matt4biz/envy/internal" 12 | "github.com/zalando/go-keyring" 13 | ) 14 | 15 | func TestEnvy(t *testing.T) { //nolint:gocyclo 16 | // once we've set the mock we're done in this 17 | // test process -- even for other unit tests 18 | 19 | keyring.MockInit() 20 | 21 | dname, err := ioutil.TempDir("", "envy") 22 | 23 | if err != nil { 24 | t.Fatal("tempdir", err) 25 | } 26 | 27 | t.Log(dname) 28 | 29 | defer os.RemoveAll(dname) 30 | 31 | e, err := NewWithDirectory(dname) 32 | 33 | if err != nil { 34 | t.Fatal("new", err) 35 | } 36 | 37 | t.Log(e.CurrentUser()) 38 | 39 | m := map[string]string{ 40 | "name": "matt", 41 | "age": "58", 42 | } 43 | 44 | if err := e.Add("data", m); err != nil { 45 | t.Fatal("add", err) 46 | } 47 | 48 | m2, err := e.Fetch("data") 49 | 50 | if err != nil { 51 | t.Fatal("fetch", err) 52 | } else if !reflect.DeepEqual(m, m2) { 53 | t.Errorf("invalid data: %#v", m2) 54 | } 55 | 56 | if j, err := e.FetchAsJSON("data"); err != nil { 57 | t.Errorf("json: %s", err) 58 | } else { 59 | t.Logf("json: %s", j) 60 | } 61 | 62 | if err := e.Set("data", "key", "0x77e3"); err != nil { 63 | t.Errorf("set: %s", err) 64 | } 65 | 66 | m["key"] = "0x77e3" 67 | 68 | m2, err = e.Fetch("data") 69 | 70 | if err != nil { 71 | t.Fatal("fetch", err) 72 | } else if !reflect.DeepEqual(m, m2) { 73 | t.Errorf("invalid data: %#v", m2) 74 | } 75 | 76 | t.Log(m2) 77 | 78 | b := new(bytes.Buffer) 79 | 80 | if err := e.List(b, "data", "", true); err != nil { 81 | t.Errorf("list: %s", err) 82 | } else { 83 | t.Log("\n", b.String()) 84 | } 85 | 86 | if s, err := e.Get("data", "key"); err != nil || s != m["key"] { 87 | t.Errorf("get: %s", err) 88 | } 89 | 90 | if err := e.Drop("data", "key"); err != nil { 91 | t.Errorf("drop: %s", err) 92 | } 93 | 94 | if _, err := e.Get("data", "key"); !errors.Is(err, internal.ErrNotFound) { 95 | t.Errorf("after drop: %s", err) 96 | } 97 | 98 | if err := e.Purge("data"); err != nil { 99 | t.Fatal("purge", err) 100 | } 101 | 102 | if _, err = e.Fetch("data"); !errors.Is(err, internal.ErrNotFound) { 103 | t.Errorf("after purge: %s", err) 104 | } 105 | 106 | jorig := `{"x":"21","y":"14"}` 107 | jdata := bytes.NewBufferString(jorig) 108 | 109 | if err = e.Read(jdata, "json"); err != nil { 110 | t.Fatal("read", err) 111 | } 112 | 113 | if j, err := e.FetchAsJSON("json"); err != nil { 114 | t.Error("read json", err) 115 | } else if string(j) != jorig { 116 | t.Errorf("invalid json: %s (should be %s)", string(j), jorig) 117 | } 118 | } 119 | 120 | func TestDefaultDirectory(t *testing.T) { 121 | d, err := defaultDirectory() 122 | 123 | if err != nil || d == "" { 124 | t.Fatalf("invalid default: [%s]: %s", d, err) 125 | } 126 | 127 | t.Log(d) 128 | } 129 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matt4biz/envy 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/boltdb/bolt v1.3.1 7 | github.com/zalando/go-keyring v0.1.0 8 | golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= 2 | github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= 3 | github.com/danieljoos/wincred v1.0.2 h1:zf4bhty2iLuwgjgpraD2E9UbvO+fe54XXGJbOwe23fU= 4 | github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3EJbmjhLdK9U= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= 7 | github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 11 | github.com/zalando/go-keyring v0.1.0 h1:ffq972Aoa4iHNzBlUHgK5Y+k8+r/8GvcGd80/OFZb/k= 12 | github.com/zalando/go-keyring v0.1.0/go.mod h1:RaxNwUITJaHVdQ0VC7pELPZ3tOWn13nr0gZMZEhpVU0= 13 | golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM= 14 | golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 17 | -------------------------------------------------------------------------------- /hack/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | done := make(chan os.Signal, 1) 13 | 14 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 15 | 16 | a, ok := os.LookupEnv("a") 17 | 18 | if ok { 19 | fmt.Println("a:", a) 20 | } 21 | 22 | fmt.Println("child waiting ...") 23 | 24 | select { 25 | case s := <-done: 26 | fmt.Println("child got", s) 27 | case <-time.After(10 * time.Second): 28 | fmt.Println("child timed out") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/db.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path" 9 | 10 | "github.com/boltdb/bolt" 11 | ) 12 | 13 | // DB exists in case we want to mock the DB later. 14 | type DB interface { 15 | SetKey(realm, key string, data Sealed) error 16 | GetKey(realm, key string) (Sealed, error) 17 | DropKey(realm, key string) error 18 | ListKeys(realm string) ([]string, error) 19 | ListRealms() ([]string, error) 20 | Purge(realm string) error 21 | GetAllKeys(realm string) (Stored, error) 22 | SetKeys(realm string, keys Stored) error 23 | Close() error 24 | } 25 | 26 | var ErrNotFound = errors.New("not found") 27 | 28 | type BoltDB struct { 29 | db *bolt.DB 30 | } 31 | 32 | func NewBoltDB(fpath string) (*BoltDB, error) { 33 | if err := ensureDir(path.Dir(fpath)); err != nil { 34 | return nil, err 35 | } 36 | 37 | db, err := bolt.Open(fpath, 0600, nil) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &BoltDB{db}, nil 44 | } 45 | 46 | func (b *BoltDB) Close() error { 47 | return b.db.Close() 48 | } 49 | 50 | func (b *BoltDB) GetKey(realm, key string) (s Sealed, err error) { 51 | err = b.db.View(func(tx *bolt.Tx) error { 52 | bk := tx.Bucket([]byte(realm)) 53 | 54 | if bk == nil { 55 | return fmt.Errorf("realm %s: %w", realm, ErrNotFound) 56 | } 57 | 58 | v := bk.Get([]byte(key)) 59 | 60 | if v == nil { 61 | return fmt.Errorf("%s/%s: %w", realm, key, ErrNotFound) 62 | } 63 | 64 | return json.Unmarshal(v, &s) 65 | }) 66 | 67 | return 68 | } 69 | 70 | func (b *BoltDB) SetKey(realm, key string, s Sealed) error { 71 | v, err := json.Marshal(s) 72 | 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return b.db.Update(func(tx *bolt.Tx) error { 78 | bk, err := tx.CreateBucketIfNotExists([]byte(realm)) 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return bk.Put([]byte(key), v) 85 | }) 86 | } 87 | 88 | func (b *BoltDB) DropKey(realm, key string) error { 89 | return b.db.Update(func(tx *bolt.Tx) error { 90 | bk := tx.Bucket([]byte(realm)) 91 | 92 | if bk == nil { 93 | return fmt.Errorf("realm %s: %w", realm, ErrNotFound) 94 | } 95 | 96 | return bk.Delete([]byte(key)) 97 | }) 98 | } 99 | 100 | func (b *BoltDB) ListKeys(realm string) (s []string, err error) { 101 | err = b.db.Update(func(tx *bolt.Tx) error { 102 | bk := tx.Bucket([]byte(realm)) 103 | 104 | if bk == nil { 105 | return fmt.Errorf("realm %s: %w", realm, ErrNotFound) 106 | } 107 | 108 | s = make([]string, 0) 109 | 110 | // must copy any byte slice to avoid invalid 111 | // memory references later; k & v are volatile 112 | 113 | return bk.ForEach(func(k, v []byte) error { 114 | c := make([]byte, len(k)) 115 | copy(c, k) 116 | s = append(s, string(c)) 117 | return nil 118 | }) 119 | }) 120 | 121 | return 122 | } 123 | 124 | func (b *BoltDB) ListRealms() (s []string, err error) { 125 | err = b.db.Update(func(tx *bolt.Tx) error { 126 | s = make([]string, 0) 127 | 128 | // must copy any byte slice to avoid invalid 129 | // memory references later; k & v are volatile 130 | 131 | return tx.ForEach(func(k []byte, v *bolt.Bucket) error { 132 | c := make([]byte, len(k)) 133 | copy(c, k) 134 | s = append(s, string(c)) 135 | return nil 136 | }) 137 | }) 138 | 139 | return 140 | } 141 | 142 | func (b *BoltDB) Purge(realm string) error { 143 | return b.db.Update(func(tx *bolt.Tx) error { 144 | return tx.DeleteBucket([]byte(realm)) 145 | }) 146 | } 147 | 148 | func (b *BoltDB) GetAllKeys(realm string) (s Stored, err error) { 149 | err = b.db.View(func(tx *bolt.Tx) error { 150 | bk := tx.Bucket([]byte(realm)) 151 | 152 | if bk == nil { 153 | return fmt.Errorf("realm %s: %w", realm, ErrNotFound) 154 | } 155 | 156 | s = make(Stored) 157 | 158 | // must copy any byte slice to avoid invalid 159 | // memory references later; k & v are volatile 160 | 161 | return bk.ForEach(func(k, v []byte) error { 162 | c := make([]byte, len(k)) 163 | copy(c, k) 164 | 165 | var sd Sealed 166 | 167 | if err := json.Unmarshal(v, &sd); err != nil { 168 | return err 169 | } 170 | 171 | s[string(c)] = sd 172 | return nil 173 | }) 174 | }) 175 | 176 | return 177 | } 178 | 179 | func (b *BoltDB) SetKeys(realm string, s Stored) error { 180 | return b.db.Update(func(tx *bolt.Tx) error { 181 | bk, err := tx.CreateBucketIfNotExists([]byte(realm)) 182 | 183 | if err != nil { 184 | return err 185 | } 186 | 187 | for k, sd := range s { 188 | v, err := json.Marshal(sd) 189 | if err != nil { 190 | return err 191 | } 192 | if err = bk.Put([]byte(k), v); err != nil { 193 | return err 194 | } 195 | } 196 | 197 | return nil 198 | }) 199 | } 200 | 201 | func ensureDir(path string) error { 202 | fi, err := os.Stat(path) 203 | 204 | if err != nil { 205 | if os.IsNotExist(err) { 206 | if err = os.Mkdir(path, os.ModeDir|0700); err != nil { 207 | return err 208 | } 209 | 210 | return nil 211 | } 212 | 213 | return err 214 | } 215 | 216 | if !fi.IsDir() { 217 | return fmt.Errorf("%s exists, but is not a directory", path) 218 | } 219 | 220 | return nil 221 | } 222 | -------------------------------------------------------------------------------- /internal/db_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestBoltDBOps(t *testing.T) { //nolint:gocyclo 13 | dname, err := ioutil.TempDir("", "envy") 14 | 15 | if err != nil { 16 | t.Fatal("tempdir", err) 17 | } 18 | 19 | t.Log(dname) 20 | 21 | defer os.RemoveAll(dname) 22 | 23 | db, err := NewBoltDB(path.Join(dname, "/enty")) 24 | 25 | if err != nil { 26 | t.Fatal("newdb", err) 27 | } 28 | 29 | s := Sealed{Data: "data", Meta: "metadata"} 30 | 31 | if err := db.SetKey("top", "key", s); err != nil { 32 | t.Fatal("set", err) 33 | } 34 | 35 | db.Close() 36 | 37 | db2, err := NewBoltDB(path.Join(dname, "/enty")) 38 | 39 | if err != nil { 40 | t.Fatal("newdb 2", err) 41 | } 42 | 43 | if s2, err := db2.GetKey("top", "key"); err != nil { 44 | t.Fatal("get", err) 45 | } else if !reflect.DeepEqual(s, s2) { 46 | t.Errorf("bad data: %#v", s2) 47 | } 48 | 49 | if _, err := db2.GetKey("pot", "key"); err != nil { 50 | t.Log(err) 51 | if !errors.Is(err, ErrNotFound) { 52 | t.Errorf("wrong error for bad realm: %s", err) 53 | } 54 | } else { 55 | t.Errorf("no err for bad key!") 56 | } 57 | 58 | if err := db2.SetKey("top", "key2", s); err != nil { 59 | t.Fatal("set", err) 60 | } 61 | 62 | l, err := db2.ListKeys("top") 63 | 64 | t.Log("list", l) 65 | 66 | if err != nil { 67 | t.Fatal("list", err) 68 | } else if len(l) < 2 || l[0] != "key" || l[1] != "key2" { 69 | t.Errorf("invalid list: %#v", l) 70 | } 71 | 72 | x, err := db2.GetAllKeys("top") 73 | 74 | t.Log(x) 75 | 76 | if err != nil { 77 | t.Fatal("get-all", err) 78 | } else if len(x) != 2 || x["key"].Data != "data" { 79 | t.Errorf("invalid key set %#v", x) 80 | } 81 | 82 | if err := db2.SetKey("top2", "key", s); err != nil { 83 | t.Fatal("set", err) 84 | } 85 | 86 | l2, err := db2.ListRealms() 87 | 88 | t.Log("realms", l2) 89 | 90 | if err != nil { 91 | t.Fatal("realms", err) 92 | } else if len(l2) < 2 || l2[0] != "top" || l2[1] != "top2" { 93 | t.Errorf("invalid realms: %#v", l2) 94 | } 95 | 96 | if err := db2.DropKey("top", "key"); err != nil { 97 | t.Fatal("drop", err) 98 | } 99 | 100 | if _, err := db2.GetKey("top", "key"); err != nil { 101 | t.Log(err) 102 | 103 | if !errors.Is(err, ErrNotFound) { 104 | t.Errorf("wrong error for bad key: %s", err) 105 | } 106 | } else { 107 | t.Errorf("no err for bad key!") 108 | } 109 | 110 | if err := db2.DropKey("pot", "key"); err != nil { 111 | t.Log(err) 112 | 113 | if !errors.Is(err, ErrNotFound) { 114 | t.Errorf("wrong error for bad realm: %s", err) 115 | } 116 | } else { 117 | t.Errorf("no err for bad key!") 118 | } 119 | 120 | if err := db2.Purge("top2"); err != nil { 121 | t.Fatal("purge", err) 122 | } 123 | 124 | if _, err := db2.GetKey("top2", "key"); err != nil { 125 | t.Log(err) 126 | 127 | if !errors.Is(err, ErrNotFound) { 128 | t.Errorf("wrong error for bad key: %s", err) 129 | } 130 | } else { 131 | t.Errorf("no err for bad key!") 132 | } 133 | 134 | if err := db2.SetKeys("top3", x); err != nil { 135 | t.Fatal("set-all", err) 136 | } 137 | 138 | x2, err := db2.GetAllKeys("top3") 139 | 140 | t.Log(x2) 141 | 142 | if err != nil { 143 | t.Fatal("get-all", err) 144 | } else if !reflect.DeepEqual(x, x2) { 145 | t.Errorf("invalid key set %#v", x2) 146 | } 147 | 148 | l3, err := db2.ListRealms() 149 | 150 | t.Log("realms", l3) 151 | 152 | if err != nil { 153 | t.Fatal("realms", err) 154 | } else if len(l3) < 2 || l3[0] != "top" || l3[1] != "top3" { 155 | t.Errorf("invalid realms: %#v", l3) 156 | } 157 | 158 | db2.Close() 159 | } 160 | 161 | func TestEnsureDir(t *testing.T) { 162 | dname, err := ioutil.TempDir("", "scratch") 163 | 164 | if err != nil { 165 | t.Fatal("tempdir", err) 166 | } 167 | 168 | t.Log(dname) 169 | 170 | defer os.RemoveAll(dname) 171 | 172 | p1 := path.Join(dname, "t1") 173 | p2 := path.Join(dname, "t2") 174 | 175 | if err = ioutil.WriteFile(p1, []byte("a"), 0644); err != nil { 176 | t.Fatal("t1", err) 177 | } 178 | 179 | if err = ensureDir(p1); err == nil { 180 | t.Error("ensure t1 should fail") 181 | } 182 | 183 | if err = ensureDir(p2); err != nil { 184 | t.Error("ensure t2", err) 185 | } 186 | 187 | fi, err := os.Stat(p2) 188 | 189 | if err != nil || !fi.IsDir() { 190 | t.Errorf("stat returns %s: %o4", err, fi.Mode()) 191 | } 192 | 193 | t.Logf("%s perms are %o4", p2, fi.Mode()) 194 | } 195 | -------------------------------------------------------------------------------- /internal/extract.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | assignRe = regexp.MustCompile(`^(?P[\w]+)=(?P.+)$`) 12 | realmRe = regexp.MustCompile(`^[-:/\w]+$`) 13 | 14 | ErrNoArguments = errors.New("not enough arguments") 15 | ErrBadRealm = errors.New("invalid realm: non-word characters") 16 | ) 17 | 18 | type Extractor struct { 19 | args []string 20 | values map[string]string 21 | realm string 22 | index int 23 | } 24 | 25 | func NewExtractor(args []string) (*Extractor, error) { 26 | e := Extractor{args: args, values: make(map[string]string)} 27 | err := e.prepare() 28 | 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &e, nil 34 | } 35 | 36 | func (e *Extractor) Args() []string { 37 | return e.args 38 | } 39 | 40 | func (e *Extractor) Realm() string { 41 | return e.realm 42 | } 43 | 44 | func (e *Extractor) Values() map[string]string { 45 | return e.values 46 | } 47 | 48 | func (e *Extractor) prepare() error { 49 | if len(e.args) == 0 { 50 | return ErrNoArguments 51 | } 52 | 53 | e.realm = e.args[0] 54 | e.args = e.args[1:] 55 | 56 | if !realmRe.MatchString(e.realm) { 57 | return ErrBadRealm 58 | } 59 | 60 | for _, a := range e.args { 61 | // FindAllStringSubmatch is going to return [][]string in some 62 | // form like [["a=b" "a" "b"]], so the first-level slice needs 63 | // to have length 1, and the next length 3; we want m[0][1:2] 64 | 65 | m := assignRe.FindAllStringSubmatch(a, -1) 66 | 67 | if len(m) == 0 { 68 | break 69 | } 70 | 71 | if len(m) == 1 && len(m[0]) == 3 { 72 | k := strings.TrimSpace(m[0][1]) 73 | v := strings.TrimSpace(m[0][2]) 74 | 75 | e.values[k] = v 76 | e.index++ 77 | continue 78 | } 79 | 80 | return fmt.Errorf("invalid pair %s = %v", a, m) 81 | } 82 | 83 | e.args = e.args[e.index:] 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/extract_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | type extractTest struct { 9 | args []string 10 | leftover []string 11 | expected map[string]string 12 | name string 13 | realm string 14 | err error 15 | index int 16 | } 17 | 18 | func (tc extractTest) run(t *testing.T) { 19 | e, err := NewExtractor(tc.args) 20 | 21 | if err != nil { 22 | if err != tc.err { 23 | t.Fatalf("invalid err: %s", err) 24 | } 25 | 26 | return 27 | } 28 | 29 | if e == nil { 30 | t.Fatalf("no extractor, no err") 31 | } 32 | 33 | if e.realm != tc.realm { 34 | t.Errorf("invalid realm: %s", e.realm) 35 | } 36 | 37 | if e.index != tc.index { 38 | t.Errorf("invalid index: %d", e.index) 39 | } 40 | 41 | if tc.leftover != nil { 42 | if len(e.args) != len(tc.leftover) || !reflect.DeepEqual(tc.leftover, e.args) { 43 | t.Errorf("invalid leftover: %#v; wanted %#v", e.args, tc.leftover) 44 | } 45 | 46 | } else if len(e.args) != 0 { 47 | t.Errorf("invalid leftover: %#v; wanted none", e.args) 48 | } 49 | 50 | if tc.expected != nil { 51 | if len(e.values) != len(tc.expected) || !reflect.DeepEqual(tc.expected, e.values) { 52 | t.Errorf("invalid values: %#v; wanted %#v", e.values, tc.expected) 53 | } 54 | } else if len(e.values) != 0 { 55 | t.Errorf("invalid values: %#v; wanted none", e.values) 56 | } 57 | } 58 | 59 | func TestExtractor(t *testing.T) { 60 | table := []extractTest{ 61 | { 62 | name: "none", 63 | err: ErrNoArguments, 64 | }, 65 | { 66 | name: "bad realm", 67 | args: []string{","}, 68 | err: ErrBadRealm, 69 | }, 70 | { 71 | name: "only realm", 72 | args: []string{"top"}, 73 | realm: "top", 74 | }, 75 | { 76 | name: "one pair", 77 | args: []string{"top", "a=b"}, 78 | realm: "top", 79 | index: 1, 80 | expected: map[string]string{"a": "b"}, 81 | }, 82 | { 83 | name: "one pair plus", 84 | args: []string{"top", "a=b", "c", "d"}, 85 | realm: "top", 86 | index: 1, 87 | expected: map[string]string{"a": "b"}, 88 | leftover: []string{"c", "d"}, 89 | }, 90 | { 91 | name: "two pair", 92 | args: []string{"top", "a=b", "c={a:b}"}, 93 | realm: "top", 94 | index: 2, 95 | expected: map[string]string{ 96 | "a": "b", 97 | "c": "{a:b}", 98 | }, 99 | }, 100 | { 101 | name: "quoted pair", 102 | args: []string{"top", `c={"a":"b"}`}, 103 | realm: "top", 104 | index: 1, 105 | expected: map[string]string{ 106 | "c": `{"a":"b"}`, 107 | }, 108 | }, 109 | { 110 | name: "pair with space", 111 | args: []string{"top", "a =b"}, 112 | realm: "top", 113 | index: 0, 114 | leftover: []string{"a =b"}, 115 | }, 116 | { 117 | name: "pair with space after", 118 | args: []string{"top", "a= b"}, 119 | realm: "top", 120 | index: 1, 121 | expected: map[string]string{"a": "b"}, 122 | }, 123 | { 124 | name: "pair with 2 spaces", 125 | args: []string{"top", "a = b"}, 126 | realm: "top", 127 | index: 0, 128 | leftover: []string{"a = b"}, 129 | }, 130 | { 131 | name: "two pair with space after", 132 | args: []string{"top", "a=b", `c= {"a":"b"}`}, 133 | realm: "top", 134 | index: 2, 135 | expected: map[string]string{ 136 | "a": "b", 137 | "c": `{"a":"b"}`, 138 | }, 139 | }, 140 | { 141 | name: "two pair with space after plus", 142 | args: []string{"top", "a=b", `c= {"a":"b"}`, "d = e"}, 143 | realm: "top", 144 | index: 2, 145 | expected: map[string]string{ 146 | "a": "b", 147 | "c": `{"a":"b"}`, 148 | }, 149 | leftover: []string{"d = e"}, 150 | }, 151 | } 152 | 153 | for _, tc := range table { 154 | t.Run(tc.name, tc.run) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /internal/ring.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "errors" 7 | "io" 8 | "os/user" 9 | 10 | "github.com/zalando/go-keyring" 11 | ) 12 | 13 | const ( 14 | defaultService = "matt4biz-envy-secret-key" 15 | ) 16 | 17 | type Ring interface { 18 | GetSecret() ([]byte, error) 19 | GetUsername() string 20 | } 21 | 22 | type Keychain struct { 23 | service string 24 | user string 25 | keyer KeyGenerator 26 | } 27 | 28 | func NewKeychain() (*Keychain, error) { 29 | u, err := user.Current() 30 | 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | k := Keychain{ 36 | service: defaultService, 37 | user: u.Username, 38 | keyer: &realGenerator{}, 39 | } 40 | 41 | return &k, nil 42 | } 43 | 44 | func (k *Keychain) GetSecret() ([]byte, error) { 45 | secret, err := keyring.Get(k.service, k.user) 46 | 47 | if err != nil { 48 | if !errors.Is(err, keyring.ErrNotFound) { 49 | return nil, err 50 | } 51 | 52 | key, err := k.keyer.MakeKey() 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | secret = base64.StdEncoding.EncodeToString(key) 59 | 60 | if err := keyring.Set(k.service, k.user, secret); err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return base64.StdEncoding.DecodeString(secret) 66 | } 67 | 68 | func (k *Keychain) GetUsername() string { 69 | return k.user 70 | } 71 | 72 | type mockRing string 73 | 74 | func (m mockRing) GetSecret() ([]byte, error) { 75 | return base64.StdEncoding.DecodeString(string(m)) 76 | } 77 | 78 | func (m mockRing) GetUsername() string { 79 | return "test-user" 80 | } 81 | 82 | var testRing = mockRing("/k/N/2581jXuUnkflj3RXVK6oMzfTR/rBlWeWAE3ewA=") 83 | 84 | type KeyGenerator interface { 85 | MakeKey() ([]byte, error) 86 | } 87 | 88 | type realGenerator struct{} 89 | 90 | func (r realGenerator) MakeKey() ([]byte, error) { 91 | key := make([]byte, 32) 92 | 93 | if _, err := io.ReadFull(rand.Reader, key); err != nil { 94 | return nil, err 95 | } 96 | 97 | return key, nil 98 | } 99 | 100 | type mockGenerator struct{} 101 | 102 | func (m *mockGenerator) MakeKey() ([]byte, error) { 103 | return testRing.GetSecret() 104 | } 105 | -------------------------------------------------------------------------------- /internal/ring_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | 7 | "github.com/zalando/go-keyring" 8 | ) 9 | 10 | func TestMockKeyring(t *testing.T) { 11 | // once we've set the mock we're done in this 12 | // test process -- even for other unit tests 13 | 14 | keyring.MockInit() 15 | 16 | k := &Keychain{ 17 | service: "envy-test", 18 | user: "test-user", 19 | keyer: &mockGenerator{}, 20 | } 21 | 22 | s, err := k.GetSecret() 23 | 24 | if err != nil { 25 | t.Fatal("first-get", err) 26 | } else if hex.EncodeToString(s) != "fe4fcdff6e7cd635ee52791f963dd15d52baa0ccdf4d1feb06559e5801377b00" { 27 | t.Errorf("first-get wrong: %s", hex.EncodeToString(s)) 28 | } 29 | 30 | s2, err := k.GetSecret() 31 | 32 | if err != nil { 33 | t.Fatal("second-get", err) 34 | } else if string(s) != string(s2) { 35 | t.Errorf("second-get wrong: %s", hex.EncodeToString(s2)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/sealer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/md5" 7 | "crypto/rand" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "time" 14 | ) 15 | 16 | type Sealed struct { 17 | Data string `json:"data"` 18 | Meta string `json:"metadata"` 19 | } 20 | 21 | type Unsealed struct { 22 | Data string 23 | Meta metadata 24 | } 25 | 26 | type Stored map[string]Sealed 27 | type Loaded map[string]Unsealed 28 | 29 | type metadata struct { 30 | Size int `json:"size"` // size of the stored.Data before encryption 31 | Hash string `json:"hash"` // hash of the stored.Data "" "" 32 | Modified int64 `json:"timestamp"` // Unix time we make this data 33 | } 34 | 35 | func (md metadata) ToString(w int) string { 36 | if len(md.Hash) == 0 { 37 | return "unprepared" 38 | } 39 | 40 | return fmt.Sprintf("%s %*d %s", time.Unix(md.Modified, 0).Format(time.RFC3339), w, md.Size, md.Hash[:7]) 41 | } 42 | 43 | type Sealer struct { 44 | Ring 45 | 46 | key []byte 47 | noncer Noncer 48 | } 49 | 50 | func NewDefaultSealer() (*Sealer, error) { 51 | r, err := NewKeychain() 52 | 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | k, err := r.GetSecret() 58 | 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | s := Sealer{r, k, &realNonce{}} 64 | 65 | return &s, nil 66 | } 67 | 68 | func NewSealer(r Ring, n Noncer) (*Sealer, error) { 69 | k, err := r.GetSecret() 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &Sealer{r, k, n}, nil 76 | } 77 | 78 | func (u *Unsealed) prep() ([]byte, []byte, error) { 79 | b, err := json.Marshal(u.Data) 80 | 81 | if err != nil { 82 | return nil, nil, err 83 | } 84 | 85 | hash := md5.New() 86 | 87 | if _, err = hash.Write(b); err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | tag := hash.Sum(nil) 92 | 93 | u.Meta.Size = len(u.Data) 94 | u.Meta.Modified = time.Now().Unix() 95 | u.Meta.Hash = hex.EncodeToString(tag) 96 | 97 | return b, tag, nil 98 | } 99 | 100 | func (s Sealer) Seal(ud Unsealed) (Sealed, error) { 101 | var sd Sealed 102 | 103 | pt, aad, err := ud.prep() 104 | 105 | if err != nil { 106 | return sd, err 107 | } 108 | 109 | ct, err := s.encrypt(pt, aad) 110 | 111 | if err != nil { 112 | return sd, err 113 | } 114 | 115 | md, err := json.Marshal(ud.Meta) 116 | 117 | if err != nil { 118 | return sd, err 119 | } 120 | 121 | sd.Data = ct 122 | sd.Meta = base64.StdEncoding.EncodeToString(md) 123 | 124 | return sd, nil 125 | } 126 | 127 | func (s Sealer) Unseal(sd Sealed) (Unsealed, error) { 128 | var ud Unsealed 129 | 130 | md, err := base64.StdEncoding.DecodeString(sd.Meta) 131 | 132 | if err != nil { 133 | return ud, err 134 | } 135 | 136 | if err = json.Unmarshal(md, &ud.Meta); err != nil { 137 | return ud, err 138 | } 139 | 140 | pt, err := s.decrypt(sd.Data, ud.Meta.Hash) 141 | 142 | if err != nil { 143 | return ud, err 144 | } 145 | 146 | err = json.Unmarshal(pt, &ud.Data) 147 | return ud, err 148 | } 149 | 150 | func (s Sealer) encrypt(pt, aad []byte) (string, error) { 151 | nonce, err := s.noncer.GetNonce() 152 | 153 | if err != nil { 154 | return "", err 155 | } 156 | 157 | block, err := aes.NewCipher(s.key) 158 | 159 | if err != nil { 160 | return "", err 161 | } 162 | 163 | aesgcm, err := cipher.NewGCM(block) 164 | 165 | if err != nil { 166 | return "", err 167 | } 168 | 169 | ct := aesgcm.Seal(nonce, nonce, pt, aad) 170 | 171 | return base64.StdEncoding.EncodeToString(ct), nil 172 | } 173 | 174 | func (s Sealer) decrypt(data, tag string) ([]byte, error) { 175 | mixed, err := base64.StdEncoding.DecodeString(data) 176 | 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | nonce := mixed[0:12] 182 | ct := mixed[12:] 183 | block, err := aes.NewCipher(s.key) 184 | 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | aesgcm, err := cipher.NewGCM(block) 190 | 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | aad, err := hex.DecodeString(tag) 196 | 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | pt, err := aesgcm.Open(nil, nonce, ct, aad) 202 | 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | return pt, nil 208 | } 209 | 210 | func NewTestSealer() *Sealer { 211 | s, _ := NewSealer(testRing, testNonce) 212 | 213 | return s 214 | } 215 | 216 | type Noncer interface { 217 | GetNonce() ([]byte, error) 218 | } 219 | 220 | type realNonce struct{} 221 | 222 | func (r realNonce) GetNonce() ([]byte, error) { 223 | nonce := make([]byte, 12) 224 | 225 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 226 | return nil, err 227 | } 228 | 229 | return nonce, nil 230 | } 231 | 232 | type mockNonce string 233 | 234 | func (m mockNonce) GetNonce() ([]byte, error) { 235 | return base64.StdEncoding.DecodeString(string(m)) 236 | } 237 | 238 | var testNonce = mockNonce("tRKG2M0EpzwiyQfc") 239 | -------------------------------------------------------------------------------- /internal/sealer_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSealer(t *testing.T) { 8 | s := NewTestSealer() 9 | ud := Unsealed{Data: "matt"} 10 | 11 | t.Log("unsealed", ud) 12 | 13 | sd, err := s.Seal(ud) 14 | 15 | if err != nil { 16 | t.Fatal("seal", err) 17 | } 18 | 19 | t.Log("sealed", sd) 20 | 21 | ud2, err := s.Unseal(sd) 22 | 23 | if err != nil { 24 | t.Fatal("unseal", err) 25 | } else if ud2.Data != ud.Data { 26 | t.Errorf("invalid data: %#v", ud2) 27 | } 28 | 29 | t.Log("unsealed", ud2) 30 | } 31 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo $a $b 3 | --------------------------------------------------------------------------------