├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Readme.md ├── cli ├── sicher.go └── sicher_test.go ├── cmd └── sicher │ └── main.go ├── config.go ├── dev.enc ├── encryption.go ├── encryption_test.go ├── example └── sample.go ├── go.mod ├── go.sum ├── helpers.go ├── helpers_test.go ├── sicher.go └── sicher_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | go-version: ["1.17"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set up workflow for ${{ matrix.go-version }} on ${{ matrix.os }} 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | *.key 4 | dev.key -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dare Samsondeen 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 | path=cmd/sicher/main.go 2 | 3 | .PHONY: build 4 | build: 5 | CGO_ENABLED=0 go build -o cmd $(path) 6 | 7 | init: 8 | go run $(path) init 9 | 10 | edit: 11 | go run $(path) edit -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Sicher 2 | 3 | Sicher is a Go implementation of the secret management system that was introduced in Ruby on Rails 6. 4 | 5 | Sicher is a go package that allows the secure storage of encrypted credentials in a version control system. The credentials can only be decrypted by a key file, and this key file is not added to the source control. The file is edited in a temp file on a local system and destroyed after each edit. 6 | 7 | Using sicher in a project creates a set of files 8 | 9 | - `environment.enc` 10 | - This is an encrypted file that stores the credentials. Since it is encrypted, it is safe to store these credentials in source control. 11 | - It it is encrypted using the [AES encryption](https://pkg.go.dev/crypto/aes) system. 12 | - `environment.key` 13 | - This is the master key used to decrypt the credentials. This must not be committed to source control. 14 | 15 | ## Installation 16 | 17 | To use sicher in your project, you need to install the go module as a library and also as a CLI tool. 18 | 19 | Installing the library, 20 | 21 | ```shell 22 | go get github.com/dsa0x/sicher 23 | ``` 24 | 25 | Installing the command line interface,: 26 | 27 | ```shell 28 | go install github.com/dsa0x/sicher/cmd/sicher 29 | ``` 30 | 31 | ## Usage 32 | 33 | **_To initialize a new sicher project_** 34 | 35 | ```shell 36 | sicher init 37 | ``` 38 | 39 | **_Optional flags:_** 40 | 41 | | flag | description | default | options | 42 | | ---------- | --------------------------------------------------------------------- | ------- | -------------- | 43 | | -env | set the environment name | dev | | 44 | | -path | set the path to the credentials file | . | | 45 | | -style | set the style of the decrypted credentials file | dotenv | dotenv or yaml | 46 | | -gitignore | path to the gitignore file. the key file will be added here, if given | | | 47 | 48 | This will create a key file `{environment}.key` and an encrypted credentials file `{environment}.enc` in the current directory. The environment name is optional and defaults to `dev`, but can be set to anything else with the `-env` flag. 49 | 50 | **_To edit the credentials:_** 51 | 52 | ```shell 53 | sicher edit 54 | ``` 55 | 56 | OR 57 | 58 | to use the key from environment variable: 59 | 60 | ```shell 61 | env SICHER_MASTER_KEY=`{YOUR_KEY_HERE}` sicher edit 62 | ``` 63 | 64 | **_Optional flags:_** 65 | 66 | | flag | description | default | options | 67 | | ------- | ----------------------------------------------- | ------- | -------------- | 68 | | -env | set the environment name | dev | | 69 | | -path | set the path to the credentials file | . | | 70 | | -editor | set the editor to use | vim | | 71 | | -style | set the style of the decrypted credentials file | dotenv | dotenv or yaml | 72 | 73 | This will create a temporary file, decrypt the credentials into it, and open it in your editor. The editor defaults to `vim`, but can be also set to other editors with the `-editor` flag. The temporary file is destroyed after each save, and the encrypted credentials file is updated with the new content. 74 | 75 | Known good editors are: 76 | 77 | - code 78 | - emacs 79 | - gvim 80 | - mvim 81 | - nano 82 | - nvim 83 | - subl 84 | - vi 85 | - vim 86 | - vimr 87 | 88 | Graphical editors require a flag to instruct the CLI to wait for the editor to exit. Additional graphical editors can be supported by adding the binary name and flag to the `waitFlagMap` in `sicher.go`. Most CLI editors should work out of the box, but your mileage may vary. 89 | 90 | Then in your app, you can use the `sicher` library to load the credentials: 91 | 92 | ```go 93 | package main 94 | import ( 95 | "fmt" 96 | 97 | "github.com/dsa0x/sicher/sicher" 98 | ) 99 | 100 | type Config struct { 101 | Port string `required:"true" env:"PORT"` 102 | MongoDbURI string `required:"true" env:"MONGO_DB_URI"` 103 | MongoDbName string `required:"true" env:"MONGO_DB_NAME"` 104 | AppUrl string `required:"false" env:"APP_URL"` 105 | } 106 | 107 | func main() { 108 | var config Config 109 | 110 | s := sicher.New("dev", ".") 111 | s.SetEnvStyle("yaml") // default is dotenv 112 | err := s.LoadEnv("", &cfg) 113 | if err != nil { 114 | fmt.Println(err) 115 | return 116 | } 117 | } 118 | ``` 119 | 120 | The `LoadEnv` function will load the credentials from the encrypted file `{environment.enc}`, decrypt it with the key file `{environment.key}` or the environment variable `SICHER_MASTER_KEY`, and then unmarshal the result into the given config object. The example above uses a `struct`, but the object can be of type `struct` or `map[string]string`. 121 | 122 | **_LoadEnv Parameters:_** 123 | 124 | | name | description | type | 125 | | ------ | --------------------------------------- | ------------- | 126 | | prefix | the prefix of the environment variables | string | 127 | | config | the config object | struct or map | 128 | 129 | The key also be loaded from the environment variable `SICHER_MASTER_KEY`. In production, storing the key in the environment variable is recommended. 130 | 131 | All env files should be in the format like the example below: 132 | 133 | For `dotenv`: 134 | 135 | ``` 136 | PORT=8080 137 | MONGO_DB_URI=mongodb://localhost:27017 138 | MONGO_DB_NAME=sicher 139 | APP_URL=http://localhost:8080 140 | ``` 141 | 142 | For `yaml`: 143 | 144 | ``` 145 | PORT:8080 146 | MONGO_DB_URI:mongodb://localhost:27017 147 | MONGO_DB_NAME:sicher 148 | APP_URL:http://localhost:8080 149 | ``` 150 | 151 | If the object is a struct, the `env` tag must be attached to each variable. The `required` tag is optional, but if set to `true`, it will be used to check if the field is set. If the field is not set, an error will be returned. 152 | An example of how the struct will look like: 153 | 154 | ```go 155 | type Config struct { 156 | Port string `required:"true" env:"PORT"` 157 | MongoDbURI string `required:"true" env:"MONGO_DB_URI"` 158 | MongoDbName string `required:"true" env:"MONGO_DB_NAME"` 159 | AppUrl string `required:"false" env:"APP_URL"` 160 | } 161 | ``` 162 | 163 | If object is a map, the keys are the environment variables and the values are the values. 164 | 165 | ### Note 166 | 167 | - Not tested with Windows. 168 | 169 | ### Todo or not todo 170 | 171 | - Add a `-force` flag to `sicher init` to overwrite the encrypted file if it already exists 172 | - Enable support for nested yaml env files 173 | - Add support for other types of encryption 174 | - Test on windows 175 | 176 | ### License 177 | 178 | MIT License 179 | -------------------------------------------------------------------------------- /cli/sicher.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/dsa0x/sicher" 11 | ) 12 | 13 | var ( 14 | pathFlag string 15 | envFlag string 16 | editorFlag string 17 | styleFlag string 18 | gitignorePathFlag string 19 | ) 20 | 21 | var writer io.Writer = os.Stderr 22 | 23 | var errHelp = ` 24 | # Initialize sicher in your project 25 | sicher init 26 | 27 | # Edit environment variables 28 | sicher edit 29 | ` 30 | 31 | func init() { 32 | flag.StringVar(&pathFlag, "path", ".", "Path to the project") 33 | flag.StringVar(&envFlag, "env", "dev", "Environment to use") 34 | flag.StringVar(&styleFlag, "style", string(sicher.DefaultEnvStyle), "Env file style. Valid values are dotenv and yaml") 35 | flag.StringVar(&editorFlag, "editor", "vim", "Select editor.") 36 | flag.StringVar(&gitignorePathFlag, "gitignore", ".", "Path to the gitignore file") 37 | 38 | flag.ErrHelp = errors.New(errHelp) 39 | flag.Usage = func() { 40 | fmt.Fprint(writer, errHelp) 41 | } 42 | } 43 | 44 | func Execute() { 45 | flag.Parse() 46 | if len(os.Args) < 2 { 47 | flag.Usage() 48 | os.Exit(1) 49 | } 50 | command := os.Args[1] 51 | flag.CommandLine.Parse(os.Args[2:]) 52 | s := sicher.New(envFlag, pathFlag) 53 | s.SetEnvStyle(styleFlag) 54 | switch command { 55 | case "init": 56 | s.Initialize(os.Stdin) 57 | case "edit": 58 | err := s.Edit(editorFlag) 59 | if err != nil { 60 | fmt.Fprintln(writer, err) 61 | os.Exit(1) 62 | } 63 | default: 64 | flag.Usage() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cli/sicher_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestInvalidCmd(t *testing.T) { 10 | oldWriter := writer 11 | defer func() { writer = oldWriter }() 12 | b := bytes.Buffer{} 13 | 14 | writer = &b 15 | os.Args = []string{"sicher", "", "testenv"} 16 | Execute() 17 | if !bytes.Equal(b.Bytes(), []byte(errHelp)) { 18 | t.Fatalf("Expected to print help text %s if command is invalid, got %s", errHelp, b.String()) 19 | } 20 | } 21 | 22 | func TestInitCmd(t *testing.T) { 23 | oldWriter := writer 24 | defer func() { writer = oldWriter }() 25 | b := bytes.Buffer{} 26 | 27 | writer = &b 28 | os.Args = []string{"sicher", "init", "testenv"} 29 | Execute() 30 | if bytes.Equal(b.Bytes(), []byte(errHelp)) { 31 | t.Fatalf("Expected to not print help text if command is valid") 32 | } 33 | 34 | t.Cleanup(func() { 35 | os.Remove("dev.enc") 36 | os.Remove("dev.key") 37 | os.Remove(".gitignore") 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/sicher/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/dsa0x/sicher/cli" 5 | ) 6 | 7 | func main() { 8 | cli.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | // configure reads the credentials file and sets the environment variables 10 | func (s *sicher) configure() { 11 | 12 | if s.Environment == "" { 13 | fmt.Println("Environment not set") 14 | return 15 | } 16 | // read the encryption key 17 | strKey, err := s.getEncryptionKey(fmt.Sprintf("%s%s.key", s.Path, s.Environment)) 18 | if err != nil { 19 | fmt.Println(err) 20 | return 21 | } 22 | 23 | // read the encrypted credentials file 24 | credFile, err := os.ReadFile(fmt.Sprintf("%s%s.enc", s.Path, s.Environment)) 25 | if err != nil { 26 | fmt.Printf("encrypted credentials file (%s.enc) is not available. Create one by running the cli with init flag.\n", s.Environment) 27 | return 28 | } 29 | 30 | encFile := string(credFile) 31 | 32 | // if file already exists, decode and decrypt it 33 | nonce, fileText, err := decodeFile(encFile) 34 | if err != nil { 35 | fmt.Printf("Error decoding encryption file: %s\n", err) 36 | return 37 | } 38 | 39 | if nonce == nil || fileText == nil { 40 | fmt.Println("Error decoding encryption file: encrypted file is invalid") 41 | return 42 | } 43 | 44 | plaintext, err := decrypt(strKey, nonce, fileText) 45 | if err != nil { 46 | fmt.Println("Error decrypting file:", err) 47 | return 48 | } 49 | 50 | err = parseConfig(plaintext, s.data, s.envStyle) 51 | if err != nil { 52 | fmt.Printf("Error parsing env file: %s\n", err) 53 | return 54 | } 55 | 56 | } 57 | 58 | func (s *sicher) setEnv() { 59 | for k, v := range s.data { 60 | err := os.Setenv(k, fmt.Sprintf("%v", v)) 61 | if err != nil { 62 | log.Fatalf("Error setting environment variable key %s: %s\n", k, err) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /dev.enc: -------------------------------------------------------------------------------- 1 | d58bd2ba9c7fea2de9e3ba15cdbd6808887ca695dab8dcdb8040767d258aa88e29ab54b8beb1d89a9f3b22==--==f1b9b603d854b48aa48cc5f2 -------------------------------------------------------------------------------- /encryption.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "io" 9 | ) 10 | 11 | // encrypt encrypts the given plaintext with the given key and returns the ciphertext 12 | func encrypt(key string, fileData []byte) (nonce []byte, ciphertext []byte, err error) { 13 | hKey, err := hex.DecodeString(key) 14 | if err != nil { 15 | return 16 | } 17 | 18 | block, err := aes.NewCipher(hKey) 19 | if err != nil { 20 | return 21 | } 22 | 23 | nonce = make([]byte, 12) 24 | if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 25 | return 26 | } 27 | 28 | aesgcm, err := cipher.NewGCM(block) 29 | if err != nil { 30 | return 31 | } 32 | 33 | ciphertext = aesgcm.Seal(nil, nonce, fileData, nil) 34 | return 35 | } 36 | 37 | func decrypt(key string, nonce, text []byte) (plaintext []byte, err error) { 38 | hKey, err := hex.DecodeString(key) 39 | if err != nil { 40 | return 41 | } 42 | 43 | block, err := aes.NewCipher(hKey) 44 | if err != nil { 45 | return 46 | } 47 | 48 | aesgcm, err := cipher.NewGCM(block) 49 | if err != nil { 50 | return 51 | } 52 | 53 | plaintext, err = aesgcm.Open(nil, nonce, text, nil) 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /encryption_test.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestEncryption(t *testing.T) { 9 | 10 | key := generateKey() 11 | fileText := []byte("mytestfiletext") 12 | nonce, cipherText, err := encrypt(key, fileText) 13 | if err != nil { 14 | t.Errorf("Unable to encrypt file; got error %v", err) 15 | } 16 | 17 | plaintext, err := decrypt(key, nonce, cipherText) 18 | if err != nil { 19 | t.Errorf("Unable to decrypt file; got error %v", err) 20 | } 21 | 22 | if !bytes.Equal(fileText, plaintext) { 23 | t.Errorf("Expected fileText to be equal to plaintext, got %s and %s", fileText, plaintext) 24 | } 25 | 26 | // decrypting with an incorrect key 27 | _, err = decrypt(generateKey(), nonce, cipherText) 28 | if err == nil { 29 | t.Errorf("Expected ciphertext not to be decryptable using an incorrect key") 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /example/sample.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dsa0x/sicher" 7 | ) 8 | 9 | type Config struct { 10 | Port string `required:"false" env:"PORT"` 11 | MongoDbURI string `required:"false" env:"MONGO_DB_URI"` 12 | MongoDbName string `required:"false" env:"MONGO_DB_NAME"` 13 | TestKey string `required:"false" env:"TESTKEY"` 14 | } 15 | 16 | // LoadConfigStruct Loads config into a struct 17 | func LoadConfigStruct() { 18 | 19 | var cfg Config 20 | 21 | s := sicher.New("dev", ".") 22 | err := s.LoadEnv("", &cfg) 23 | if err != nil { 24 | fmt.Println(err) 25 | return 26 | } 27 | fmt.Println(cfg) 28 | } 29 | 30 | // LoadConfigMap Loads config into a map 31 | func LoadConfigMap() { 32 | 33 | cfg := make(map[string]string) 34 | 35 | s := sicher.New("dev", ".") 36 | s.SetEnvStyle("yaml") // default is dotenv 37 | err := s.LoadEnv("", &cfg) 38 | if err != nil { 39 | fmt.Println(err) 40 | return 41 | } 42 | fmt.Println(cfg) 43 | } 44 | 45 | func main() { 46 | LoadConfigStruct() 47 | LoadConfigMap() 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dsa0x/sicher 2 | 3 | go 1.17 4 | 5 | require github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b 6 | 7 | require golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8= 2 | github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks= 3 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= 4 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 5 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "os" 13 | "regexp" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type EnvStyle string 19 | 20 | const ( 21 | YAML EnvStyle = "yaml" 22 | YML EnvStyle = "yml" 23 | DOTENV EnvStyle = "dotenv" 24 | ) 25 | 26 | var envStyleDelim = map[EnvStyle]string{ 27 | YAML: ":", 28 | YML: ":", 29 | DOTENV: "=", 30 | } 31 | 32 | var envStyleExt = map[EnvStyle]string{ 33 | YAML: "yml", 34 | YML: "yml", 35 | DOTENV: "env", 36 | } 37 | 38 | var envNameRegex = "^[a-zA-Z0-9_]*$" 39 | 40 | // cleanUpFile removes the given file 41 | func cleanUpFile(filePath string) { 42 | err := os.Remove(filePath) 43 | if err != nil { 44 | fmt.Printf("Error while cleaning up %v \n", err.Error()) 45 | } 46 | } 47 | 48 | // decodeFile decodes the encrypted file and returns the decoded file and nonce 49 | func decodeFile(encFile string) (nonce []byte, fileText []byte, err error) { 50 | if encFile == "" { 51 | return nil, nil, nil 52 | } 53 | 54 | resp := strings.Split(encFile, delimiter) 55 | if len(resp) < 2 { 56 | return nil, nil, errors.New("invalid credentials") 57 | } 58 | nonce, err = hex.DecodeString(resp[1]) 59 | if err != nil { 60 | return nil, nil, err 61 | } 62 | fileText, err = hex.DecodeString(resp[0]) 63 | if err != nil { 64 | return nil, nil, err 65 | } 66 | 67 | return 68 | } 69 | 70 | // generateKey generates a random key of 32 bytes and encodes as hex string 71 | func generateKey() string { 72 | timestamp := time.Now().UnixNano() 73 | key := sha256.Sum256([]byte(fmt.Sprint(timestamp))) 74 | rand.Read(key[16:]) 75 | return hex.EncodeToString(key[:]) 76 | } 77 | 78 | // parseConfig parses the environment variables into a map 79 | func parseConfig(config []byte, store map[string]string, envType EnvStyle) (err error) { 80 | 81 | delim, ok := envStyleDelim[envType] 82 | if !ok { 83 | return errors.New("invalid environment type") 84 | } 85 | 86 | var b bytes.Buffer 87 | b.Write(config) 88 | sc := bufio.NewScanner(&b) 89 | 90 | for sc.Scan() { 91 | line := strings.TrimSpace(sc.Text()) 92 | cfgLine := strings.Split(line, delim) 93 | 94 | // ignore commented lines and invalid lines 95 | if len(cfgLine) < 2 || canIgnore(line) { 96 | continue 97 | } 98 | 99 | // invalidate keys with invalid characters (only alphanumeric and _) 100 | regexpKey := regexp.MustCompile(envNameRegex) 101 | if !regexpKey.MatchString(cfgLine[0]) { 102 | continue 103 | } 104 | 105 | store[cfgLine[0]] = strings.Join(cfgLine[1:], delim) 106 | if err == io.EOF { 107 | return nil 108 | } 109 | } 110 | return nil 111 | 112 | } 113 | 114 | // canIgnore ignores commented lines and empty lines 115 | func canIgnore(line string) bool { 116 | line = strings.TrimSpace(line) 117 | return strings.HasPrefix(line, `#`) || len(line) == 0 118 | } 119 | 120 | func addToGitignore(filePath, gitignorePath string) error { 121 | f, err := os.OpenFile(fmt.Sprintf("%s.gitignore", gitignorePath), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) 122 | if err != nil { 123 | return err 124 | } 125 | defer f.Close() 126 | 127 | fr := bufio.NewReader(f) 128 | 129 | // check if the key file is already in the .gitignore file before adding it 130 | // if it is, don't add it again 131 | for err == nil { 132 | str, _, err := fr.ReadLine() 133 | if err != nil && err != io.EOF { 134 | return err 135 | } 136 | 137 | if string(str) == filePath { 138 | return nil 139 | } 140 | 141 | if err == io.EOF { 142 | break 143 | } 144 | } 145 | 146 | f.Write([]byte("\n" + filePath)) 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestCleanUpFile(t *testing.T) { 11 | f, err := os.CreateTemp("", "*tempfile.env") 12 | if err != nil { 13 | t.Errorf("Unable to create temporary test file; %v", err) 14 | } 15 | _, err = f.WriteString("Hello World") 16 | if err != nil { 17 | t.Errorf("Unable to write to temporary test file; %v", err) 18 | } 19 | 20 | // clean up test, not to be mistaken with the cleanup file function 21 | t.Cleanup(func() { 22 | f.Close() 23 | }) 24 | 25 | cleanUpFile(f.Name()) 26 | 27 | _, err = os.Open(f.Name()) 28 | if err == nil { 29 | t.Errorf("file cleanup unsuccesssful") 30 | } 31 | 32 | } 33 | 34 | func TestGenerateKey(t *testing.T) { 35 | key := generateKey() 36 | _, err := hex.DecodeString(key) 37 | if err != nil { 38 | t.Errorf("Generated key not a valid hex string") 39 | } 40 | } 41 | 42 | func TestBasicParseConfig(t *testing.T) { 43 | enMap := make(map[string]string) 44 | cfg := []byte(` 45 | PORT=8080 46 | URI=localhost 47 | #OLD_PORT=5000 48 | `) 49 | err := parseConfig(cfg, enMap, "dotenv") 50 | if err != nil { 51 | t.Errorf("Unable to parse config; %v", err) 52 | } 53 | 54 | port, ok := enMap["PORT"] 55 | if !ok { 56 | t.Errorf("Expected config to have been marshalled into map") 57 | } 58 | 59 | if port != "8080" { 60 | t.Errorf("Expected value to be %s, got %s", "8080", port) 61 | } 62 | 63 | if enMap["OLD_PORT"] != "" { 64 | t.Errorf("Expected ignored value to not be parsed") 65 | } 66 | 67 | enMap = make(map[string]string) 68 | 69 | parseConfig(cfg, enMap, "yaml") 70 | if len(enMap) != 0 { 71 | t.Errorf("Expected dotenv style env not be be parseable with yaml envType") 72 | } 73 | } 74 | func TestParseConfig(t *testing.T) { 75 | 76 | tests := []struct { 77 | text string 78 | expected map[string]string 79 | envType string 80 | }{ 81 | { 82 | text: ` 83 | PORT:8080 84 | URI:localhost 85 | #OLD_PORT:5000 86 | `, 87 | expected: map[string]string{ 88 | "PORT": "8080", 89 | "URI": "localhost", 90 | }, 91 | envType: "yaml", 92 | }, 93 | { 94 | text: ` 95 | PORT=8080 96 | URI=localhost 97 | #OLD_PORT=5000 98 | `, 99 | expected: map[string]string{ 100 | "PORT": "8080", 101 | "URI": "localhost", 102 | }, 103 | envType: "dotenv", 104 | }, 105 | { 106 | text: ` 107 | PORT=8080 108 | URI=localhost 109 | #OLD_PORT=5000 110 | KEY=value=ndsjhjdghdhg 111 | `, 112 | expected: map[string]string{ 113 | "PORT": "8080", 114 | "URI": "localhost", 115 | "KEY": "value=ndsjhjdghdhg", 116 | }, 117 | envType: "dotenv", 118 | }, 119 | { 120 | text: ` 121 | PORT:8080 122 | URI=localhost 123 | `, 124 | expected: map[string]string{ 125 | "PORT": "8080", 126 | }, 127 | envType: "yaml", 128 | }, 129 | { 130 | text: ` 131 | PORT:8080 132 | URI=localhost 133 | SOME_KEY:somevalue=jsfhjdghdhg 134 | `, 135 | expected: map[string]string{ 136 | "URI": "localhost", 137 | }, 138 | envType: "dotenv", 139 | }, 140 | } 141 | 142 | for _, val := range tests { 143 | enMap := make(map[string]string) 144 | if err := parseConfig([]byte(val.text), enMap, EnvStyle(val.envType)); err != nil { 145 | t.Errorf("Unable to parse config; %v", err) 146 | } 147 | 148 | t.Run(fmt.Sprintf("Envtype %s", val.envType), func(t *testing.T) { 149 | for key, value := range val.expected { 150 | if enMap[key] != value { 151 | t.Errorf("Expected value to be %s, got %s", value, enMap[key]) 152 | } 153 | } 154 | for key, value := range enMap { 155 | if val.expected[key] != value { 156 | t.Errorf("Expected value to be %s, got %s", value, val.expected[key]) 157 | } 158 | } 159 | }) 160 | } 161 | 162 | } 163 | 164 | func TestYamlParseConfigError(t *testing.T) { 165 | enMap := make(map[string]string) 166 | cfg := []byte(` 167 | PORT:8080 168 | URI:localhost 169 | #OLD_PORT:5000 170 | `) 171 | err := parseConfig(cfg, enMap, "wrong") 172 | if err == nil { 173 | t.Errorf("Expected error to be thrown when parsing wrong envType") 174 | } 175 | } 176 | 177 | func TestCanIgnore(t *testing.T) { 178 | data := []struct { 179 | text string 180 | expected bool 181 | }{ 182 | {text: "# url", expected: true}, 183 | {text: " # url", expected: true}, 184 | {text: "url", expected: false}, 185 | {text: " url", expected: false}, 186 | } 187 | 188 | for _, val := range data { 189 | if canIgnore(val.text) != val.expected { 190 | t.Errorf("Expected canIgnore(%s) to be %v, got %v", val.text, val.expected, canIgnore(val.text)) 191 | } 192 | } 193 | } 194 | 195 | func TestDecodeHex(t *testing.T) { 196 | _nonce, _text := generateKey(), generateKey() 197 | hexString := _text + delimiter + _nonce 198 | _, _, err := decodeFile(hexString) 199 | if err != nil { 200 | t.Errorf("Unable to decode valid hex string, got error %v", err) 201 | } 202 | nonce, text, err := decodeFile("invalidhex") 203 | if err == nil { 204 | t.Errorf("Expected invalid hex file to not decode, got values %s, %s", nonce, text) 205 | } 206 | 207 | } 208 | -------------------------------------------------------------------------------- /sicher.go: -------------------------------------------------------------------------------- 1 | /* 2 | Sicher is a go module that allows safe storage of encrypted credentials in a version control system. 3 | It is a port of the secret management system that was introduced in Ruby on Rails 6. 4 | Examples can be found in examples/ folder 5 | */ 6 | package sicher 7 | 8 | import ( 9 | "bufio" 10 | "bytes" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "os" 15 | "os/exec" 16 | "path/filepath" 17 | "reflect" 18 | 19 | "github.com/juju/fslock" 20 | ) 21 | 22 | var delimiter = "==--==" 23 | var defaultEnv = "dev" 24 | var DefaultEnvStyle = DOTENV 25 | var masterKey = "SICHER_MASTER_KEY" 26 | var ( 27 | execCmd = exec.Command 28 | stdIn io.ReadWriter = os.Stdin 29 | stdOut io.ReadWriter = os.Stdout 30 | stdErr io.ReadWriter = os.Stderr 31 | ) 32 | 33 | var waitFlagmap = map[string]string{ 34 | "code": "--wait", 35 | "gvim": "-f", 36 | "mvim": "-f", 37 | "subl": "--wait", 38 | "vimr": "--wait", 39 | } 40 | 41 | type sicher struct { 42 | // Path is the path to the project. If empty string, it defaults to the current directory 43 | Path string 44 | 45 | // Environment is the environment to use. Defaults to "dev" 46 | Environment string 47 | data map[string]string `yaml:"data"` 48 | 49 | envStyle EnvStyle 50 | 51 | // gitignorePath is the path to the .gitignore file 52 | gitignorePath string 53 | } 54 | 55 | // New creates a new sicher struct 56 | // path is the path to the project. If empty string, it defaults to the current directory 57 | // environment is the environment to use. Defaults to "dev" 58 | func New(environment string, path string) *sicher { 59 | 60 | if environment == "" { 61 | environment = defaultEnv 62 | } 63 | 64 | if path == "" { 65 | path = "." 66 | } 67 | path, _ = filepath.Abs(path) 68 | return &sicher{Path: path + "/", Environment: environment, data: make(map[string]string), envStyle: DOTENV} 69 | } 70 | 71 | // Initialize initializes the sicher project and creates the necessary files 72 | func (s *sicher) Initialize(scanReader io.Reader) error { 73 | key := generateKey() 74 | 75 | // create the key file if it doesn't exist 76 | keyFile, err := os.OpenFile(fmt.Sprintf("%s%s.key", s.Path, s.Environment), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 77 | if err != nil { 78 | return fmt.Errorf("error creating key file: %s", err) 79 | } 80 | defer keyFile.Close() 81 | 82 | keyFileStats, err := keyFile.Stat() 83 | if err != nil { 84 | return fmt.Errorf("error getting key file stats: %s", err) 85 | } 86 | 87 | // create the encrypted credentials file if it doesn't exist 88 | encFile, err := os.OpenFile(fmt.Sprintf("%s%s.enc", s.Path, s.Environment), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600) 89 | if err != nil { 90 | return fmt.Errorf("error creating encrypted credentials file: %s", err) 91 | } 92 | defer encFile.Close() 93 | 94 | encFileStats, err := encFile.Stat() 95 | if err != nil { 96 | return fmt.Errorf("error getting key file stats: %s", err) 97 | } 98 | 99 | // if keyfile is new 100 | // Absence of keyfile indicates that the project is new or keyfile is lost 101 | // if keyfile is lost, the encrypted file cannot be decrypted, 102 | // and the user needs to re-initialize or obtain the original key 103 | if keyFileStats.Size() < 1 { 104 | 105 | // if encrypted file exists 106 | // ask user if they want to overwrite the encrypted file 107 | // if yes, truncate file and continue 108 | // else cancel 109 | if encFileStats.Size() > 1 { 110 | fmt.Printf("An encrypted credentials file already exist, do you want to overwrite it? \n Enter 'yes' or 'y' to accept.\n") 111 | rd := bufio.NewScanner(scanReader) 112 | for rd.Scan() { 113 | line := rd.Text() 114 | if line == "yes" || line == "y" { 115 | encFile.Truncate(0) 116 | break 117 | } else { 118 | cleanUpFile(keyFile.Name()) 119 | fmt.Println("Exiting. Leaving credentials file unmodified") 120 | return nil 121 | } 122 | } 123 | } 124 | 125 | _, err = keyFile.WriteString(key) 126 | if err != nil { 127 | return fmt.Errorf("error saving key file: %s", err) 128 | } 129 | } 130 | 131 | // stats will have changed if the file was truncated 132 | encFileStats, err = encFile.Stat() 133 | if err != nil { 134 | return fmt.Errorf("error getting key file stats: %s", err) 135 | } 136 | 137 | // if the encrypted file is new, write some random data to it 138 | if encFileStats.Size() < 1 { 139 | initFile := []byte(fmt.Sprintf("TESTKEY%sloremipsum\n", envStyleDelim[s.envStyle])) 140 | nonce, ciphertext, err := encrypt(key, initFile) 141 | if err != nil { 142 | return fmt.Errorf("error encrypting credentials file: %s", err) 143 | } 144 | _, err = encFile.WriteString(fmt.Sprintf("%x%s%x", ciphertext, delimiter, nonce)) 145 | if err != nil { 146 | 147 | return fmt.Errorf("error writing encrypted credentials file: %s", err) 148 | } 149 | } 150 | 151 | // add the key file to gitignore 152 | if s.gitignorePath != "" { 153 | err = addToGitignore(fmt.Sprintf("%s.key", s.Environment), s.gitignorePath) 154 | if err != nil { 155 | return fmt.Errorf("error adding key file to gitignore: %s", err) 156 | } 157 | } 158 | return nil 159 | } 160 | 161 | // Edit opens the encrypted credentials in a temporary file for editing. Default editor is vim. 162 | func (s *sicher) Edit(editor ...string) error { 163 | var editorName string 164 | if len(editor) > 0 { 165 | editorName = editor[0] 166 | } else { 167 | editorName = "vim" 168 | } 169 | 170 | var cmdArgs []string 171 | 172 | // waitOpt is needed to enable vscode to wait for the editor to close before continuing 173 | waitOpt, ok := waitFlagmap[editorName] 174 | 175 | if ok { 176 | cmdArgs = append(cmdArgs, waitOpt) 177 | } 178 | 179 | // read the encryption key. if key not in file, try getting from env 180 | key, err := s.getEncryptionKey(fmt.Sprintf("%s%s.key", s.Path, s.Environment)) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | // open the encrypted credentials file 186 | credFile, err := os.OpenFile(fmt.Sprintf("%s%s.enc", s.Path, s.Environment), os.O_RDWR|os.O_APPEND|os.O_CREATE, 0644) 187 | if err != nil { 188 | return fmt.Errorf("%v", err) 189 | } 190 | defer credFile.Close() 191 | 192 | // lock file to enable only one edit at a time 193 | credFileLock := fslock.New(credFile.Name()) //newFileLock(credFile) 194 | err = credFileLock.TryLock() 195 | if err != nil { 196 | if err == fslock.ErrLocked { 197 | return fmt.Errorf("file is in use in another terminal") 198 | } 199 | return fmt.Errorf("error locking file: %s", err) 200 | } 201 | defer credFileLock.Unlock() 202 | var buf bytes.Buffer 203 | _, err = io.Copy(&buf, credFile) 204 | if err != nil { 205 | return fmt.Errorf("%v", err) 206 | } 207 | enc := buf.String() 208 | 209 | // Create a temporary file to edit the decrypted credentials 210 | f, err := os.CreateTemp("", fmt.Sprintf("*-credentials.%s", envStyleExt[s.envStyle])) 211 | if err != nil { 212 | return fmt.Errorf("error creating temp file %v", err) 213 | } 214 | defer f.Close() 215 | filePath := f.Name() 216 | defer cleanUpFile(filePath) 217 | 218 | // if file already exists, decode and decrypt it 219 | nonce, fileText, err := decodeFile(enc) 220 | if err != nil { 221 | return fmt.Errorf("error decoding encryption file: %s", err) 222 | } 223 | 224 | var plaintext []byte 225 | if nonce != nil && fileText != nil { 226 | plaintext, err = decrypt(key, nonce, fileText) 227 | if err != nil { 228 | return fmt.Errorf("error decrypting file: %s", err) 229 | } 230 | 231 | _, err = f.Write(plaintext) 232 | if err != nil { 233 | return fmt.Errorf("error saving credentials: %s", err) 234 | } 235 | } 236 | 237 | //open decrypted file with editor 238 | cmdArgs = append(cmdArgs, filePath) 239 | cmd := execCmd(editorName, cmdArgs...) 240 | cmd.Stdin = stdIn 241 | cmd.Stdout = stdOut 242 | cmd.Stderr = stdErr 243 | 244 | err = cmd.Start() 245 | if err != nil { 246 | return fmt.Errorf("error starting editor: %s", err) 247 | } 248 | 249 | err = cmd.Wait() 250 | if err != nil { 251 | return fmt.Errorf("error while editing %v", err) 252 | } 253 | 254 | file, err := os.ReadFile(filePath) 255 | if err != nil { 256 | return fmt.Errorf("error reading credentials file %v ", err) 257 | } 258 | 259 | // if no file changes, dont generate new encrypted file 260 | if bytes.Equal(file, plaintext) { 261 | fmt.Fprintf(stdOut, "No changes made.\n") 262 | return nil 263 | } 264 | 265 | //encrypt and overwrite credentials file 266 | // the encrypted file is encoded in hexadecimal format 267 | nonce, encrypted, err := encrypt(key, file) 268 | if err != nil { 269 | return fmt.Errorf("error encrypting file: %s ", err) 270 | } 271 | 272 | credFile.Truncate(0) 273 | credFile.Write([]byte(fmt.Sprintf("%x%s%x", encrypted, delimiter, nonce))) 274 | fmt.Fprintf(stdOut, "File encrypted and saved.\n") 275 | return nil 276 | } 277 | 278 | // LoadEnv loads the environment variables from the encrypted credentials file into the config gile. 279 | // configFile can be a struct or map[string]string 280 | func (s *sicher) LoadEnv(prefix string, configFile interface{}) error { 281 | s.configure() 282 | s.setEnv() 283 | 284 | d := reflect.ValueOf(configFile) 285 | if d.Kind() == reflect.Ptr { 286 | d = d.Elem() 287 | } else { 288 | return errors.New("configFile must be a pointer to a struct or map") 289 | } 290 | 291 | if !(d.Kind() == reflect.Struct || d.Kind() == reflect.Map) { 292 | return errors.New("config must be a type of struct or map") 293 | } 294 | 295 | // the configFile is a map, set the values and return 296 | if d.Kind() == reflect.Map { 297 | if d.Type() != reflect.TypeOf(map[string]string{}) { 298 | return errors.New("configFile must be a struct or map[string]string") 299 | } 300 | d.Set(reflect.ValueOf(s.data)) 301 | return nil 302 | } 303 | 304 | // if the interface is a struct, iterate over the fields and set the values 305 | for i := 0; i < d.NumField(); i++ { 306 | field := d.Field(i) 307 | fieldType := d.Type().Field(i) 308 | isRequired := fieldType.Tag.Get("required") 309 | key := fieldType.Tag.Get("env") 310 | 311 | tagName := key 312 | if prefix != "" { 313 | tagName = fmt.Sprintf("%s_%s", prefix, key) 314 | } 315 | 316 | envVar := os.Getenv(tagName) 317 | if isRequired == "true" && envVar == "" { 318 | return errors.New("required env variable " + key + " is not set") 319 | } 320 | 321 | switch field.Kind() { 322 | case reflect.String: 323 | field.SetString(envVar) 324 | case reflect.Bool: 325 | field.SetBool(envVar == "true") 326 | } 327 | 328 | } 329 | return nil 330 | } 331 | 332 | func (s *sicher) SetEnvStyle(style string) { 333 | if style != "dotenv" && style != "yaml" && style != "yml" { 334 | fmt.Println("Invalid style: Select one of dotenv, yml, or yaml") 335 | os.Exit(1) 336 | } 337 | s.envStyle = EnvStyle(style) 338 | } 339 | 340 | func (s *sicher) SetGitignorePath(path string) { 341 | path, _ = filepath.Abs(path) 342 | s.gitignorePath = path 343 | } 344 | 345 | func (s *sicher) getEncryptionKey(filePath string) (string, error) { 346 | encKey := os.Getenv(masterKey) 347 | if encKey == "" { 348 | key, err := os.ReadFile(filePath) 349 | if err != nil { 350 | return "", fmt.Errorf("encryption key(%s.key) is not available. Provide a key file or enter one through the command line", s.Environment) 351 | } 352 | encKey = string(key) 353 | } 354 | return encKey, nil 355 | } 356 | -------------------------------------------------------------------------------- /sicher_test.go: -------------------------------------------------------------------------------- 1 | package sicher 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | "testing" 12 | ) 13 | 14 | func setupTest() (*sicher, string, string) { 15 | s := New("testenv", "./example") 16 | return s, fmt.Sprintf("%s%s.enc", s.Path, s.Environment), fmt.Sprintf("%s%s.key", s.Path, s.Environment) 17 | 18 | } 19 | 20 | func TestNewWithNoEnvironment(t *testing.T) { 21 | path, _ := filepath.Abs(".") 22 | path += "/" 23 | s := New("", "") 24 | if s.Path != path { 25 | t.Errorf("Expected path to be %s, got %s", path, s.Path) 26 | } 27 | 28 | if s.Environment != defaultEnv { 29 | t.Errorf("Expected environment to be set to %s if none is given, got %s", defaultEnv, s.Environment) 30 | } 31 | 32 | } 33 | func TestNewWithEnvironment(t *testing.T) { 34 | env := "testenv" 35 | s := New("testenv", "") 36 | 37 | if s.Environment != env { 38 | t.Errorf("Expected environment to be set to %s if none is given, got %s", env, s.Environment) 39 | } 40 | 41 | } 42 | func TestEnvStyle(t *testing.T) { 43 | s := New("testenv", "") 44 | s.SetEnvStyle("dotenv") 45 | 46 | if s.envStyle != "dotenv" { 47 | t.Errorf("Expected environment style to be set to %s, got %s", "dotenv", s.envStyle) 48 | } 49 | 50 | } 51 | 52 | func TestInvalidEnvStyle(t *testing.T) { 53 | s := New("testenv", "") 54 | if os.Getenv("SICHER_ENV_STYLE") == "1" { 55 | s.SetEnvStyle("wrong") 56 | return 57 | } 58 | 59 | cmd := exec.Command(os.Args[0], "-test.run=TestInvalidEnvStyle") 60 | cmd.Env = append(os.Environ(), "SICHER_ENV_STYLE=1") 61 | err := cmd.Run() 62 | if e, ok := err.(*exec.ExitError); ok && !e.Success() { 63 | return 64 | } 65 | t.Fatalf("process ran with err %v, expected exit status 1", err) 66 | } 67 | 68 | func TestEditSuccess(t *testing.T) { 69 | oldExecCmd := execCmd 70 | defer func() { execCmd = oldExecCmd }() 71 | s, encPath, keyPath := setupTest() 72 | 73 | s.Initialize(os.Stdin) 74 | buf := bytes.Buffer{} 75 | 76 | execCmd = func(cmd string, args ...string) *exec.Cmd { 77 | stdIn, stdOut, stdErr = &buf, &buf, &buf 78 | 79 | if cmd != "vim" { 80 | t.Errorf("Expected command to be vim, got %s", cmd) 81 | } 82 | return exec.Command("cat", args...) 83 | } 84 | 85 | err := s.Edit("vim") 86 | if err != nil { 87 | t.Errorf("Expected no error, got %v", err) 88 | } 89 | 90 | // get path to the gitignore file and cleanup 91 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 92 | 93 | t.Cleanup(func() { 94 | os.Remove(encPath) 95 | os.Remove(keyPath) 96 | os.Remove(gitPath) 97 | }) 98 | } 99 | func TestEditFileLock(t *testing.T) { 100 | oldExecCmd := execCmd 101 | defer func() { execCmd = oldExecCmd }() 102 | s, encPath, keyPath := setupTest() 103 | 104 | s.Initialize(os.Stdin) 105 | buf := bytes.Buffer{} 106 | 107 | execCmd = func(cmd string, args ...string) *exec.Cmd { 108 | stdIn, stdOut, stdErr = &buf, &buf, &buf 109 | 110 | if cmd != "vim" { 111 | t.Errorf("Expected command to be vim, got %s", cmd) 112 | } 113 | return exec.Command("cat", args...) 114 | } 115 | 116 | var wg sync.WaitGroup 117 | chErr := make(chan error, 1) 118 | for i := 0; i < 2; i++ { 119 | wg.Add(1) 120 | go func() { 121 | err := s.Edit("vim") 122 | if err != nil { 123 | chErr <- err 124 | } 125 | wg.Done() 126 | }() 127 | } 128 | 129 | wg.Wait() 130 | 131 | select { 132 | case err := <-chErr: 133 | if err == nil { 134 | t.Errorf("Expected file to be locked and exit error, got nil error") 135 | } 136 | default: 137 | t.Errorf("Expected file to be locked and exit error, received no error") 138 | } 139 | 140 | // get path to the gitignore file and cleanup 141 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 142 | 143 | t.Cleanup(func() { 144 | os.Remove(encPath) 145 | os.Remove(keyPath) 146 | os.Remove(gitPath) 147 | }) 148 | } 149 | 150 | func TestEditFail(t *testing.T) { 151 | oldExecCmd := execCmd 152 | defer func() { execCmd = oldExecCmd }() 153 | s, _, _ := setupTest() 154 | 155 | buf := bytes.Buffer{} 156 | 157 | execCmd = func(cmd string, args ...string) *exec.Cmd { 158 | stdIn, stdOut, stdErr = &buf, &buf, &buf 159 | 160 | if cmd != "vim" { 161 | t.Errorf("Expected command to be vim, got %s", cmd) 162 | } 163 | return exec.Command("cat", args...) 164 | } 165 | 166 | err := s.Edit("vim") 167 | if err == nil { 168 | t.Errorf("Expected error to be returned, got %s", err) 169 | } 170 | } 171 | 172 | func TestEditAddsWaitFlag(t *testing.T) { 173 | oldExecCmd := execCmd 174 | defer func() { execCmd = oldExecCmd }() 175 | s, encPath, keyPath := setupTest() 176 | 177 | s.Initialize(os.Stdin) 178 | buf := bytes.Buffer{} 179 | 180 | editors := []string{"code", "subl", "vimr"} 181 | 182 | for _, editor := range editors { 183 | execCmd = func(cmd string, args ...string) *exec.Cmd { 184 | t.Log(cmd, args) 185 | stdIn, stdOut, stdErr = &buf, &buf, &buf 186 | 187 | if cmd != editor { 188 | t.Errorf("Expected command to be %s, got %s", editor, cmd) 189 | } 190 | 191 | if args[0] != "--wait" { 192 | t.Errorf("Expected args to include --wait") 193 | } 194 | 195 | return exec.Command("cat", args...) 196 | } 197 | 198 | s.Edit(editor) 199 | 200 | // get path to the gitignore file and cleanup 201 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 202 | 203 | t.Cleanup(func() { 204 | os.Remove(encPath) 205 | os.Remove(keyPath) 206 | os.Remove(gitPath) 207 | }) 208 | } 209 | } 210 | 211 | func TestEditAddsFFlag(t *testing.T) { 212 | oldExecCmd := execCmd 213 | defer func() { execCmd = oldExecCmd }() 214 | s, encPath, keyPath := setupTest() 215 | 216 | s.Initialize(os.Stdin) 217 | buf := bytes.Buffer{} 218 | 219 | editors := []string{"mvim", "gvim"} 220 | 221 | for _, editor := range editors { 222 | execCmd = func(cmd string, args ...string) *exec.Cmd { 223 | t.Log(cmd, args) 224 | stdIn, stdOut, stdErr = &buf, &buf, &buf 225 | 226 | if cmd != editor { 227 | t.Errorf("Expected command to be %s, got %s", editor, cmd) 228 | } 229 | 230 | if args[0] != "-f" { 231 | t.Errorf("Expected args to include -f") 232 | } 233 | 234 | return exec.Command("cat", args...) 235 | } 236 | 237 | s.Edit(editor) 238 | 239 | // get path to the gitignore file and cleanup 240 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 241 | 242 | t.Cleanup(func() { 243 | os.Remove(encPath) 244 | os.Remove(keyPath) 245 | os.Remove(gitPath) 246 | }) 247 | } 248 | } 249 | 250 | func TestSicherInitialize(t *testing.T) { 251 | 252 | s, encPath, keyPath := setupTest() 253 | 254 | s.Initialize(os.Stdin) 255 | 256 | f, err := os.Open(encPath) 257 | if err != nil { 258 | t.Errorf("Expected credential file to have been created; got error %v", err) 259 | } 260 | f, err = os.Open(keyPath) 261 | if err != nil { 262 | t.Errorf("Expected key file to have been created; got error %v", err) 263 | } 264 | 265 | // get path to the gitignore file and cleanup 266 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 267 | 268 | t.Cleanup(func() { 269 | os.Remove(encPath) 270 | os.Remove(keyPath) 271 | os.Remove(gitPath) 272 | f.Close() 273 | }) 274 | 275 | } 276 | 277 | func TestSicherInitializeExistingCredOverwrite(t *testing.T) { 278 | 279 | s, encPath, keyPath := setupTest() 280 | 281 | f, err := os.Create(encPath) 282 | if err != nil { 283 | t.Errorf("Expected credential file to be created; got error %v", err) 284 | } 285 | f.Write([]byte("test")) 286 | 287 | buf := bytes.Buffer{} 288 | buf.WriteString("yes") 289 | 290 | s.Initialize(&buf) 291 | 292 | f, err = os.Open(encPath) 293 | if err != nil { 294 | t.Errorf("Expected credential file to have been created; got error %v", err) 295 | } 296 | f, err = os.Open(keyPath) 297 | if err != nil { 298 | t.Errorf("Expected key file to have been created; got error %v", err) 299 | } 300 | 301 | // get path to the gitignore file and cleanup 302 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 303 | 304 | t.Cleanup(func() { 305 | os.Remove(encPath) 306 | os.Remove(keyPath) 307 | os.Remove(gitPath) 308 | f.Close() 309 | }) 310 | 311 | t.Logf("Expects credential file to be overwritten if user confirms with 'yes'") 312 | } 313 | 314 | func TestSicherInitializeExistingCredNoOverwrite(t *testing.T) { 315 | 316 | s, encPath, keyPath := setupTest() 317 | 318 | f, err := os.Create(encPath) 319 | if err != nil { 320 | t.Errorf("Expected credential file to be created; got error %v", err) 321 | } 322 | f.Write([]byte("test")) 323 | 324 | buf := bytes.Buffer{} 325 | buf.WriteString("n") 326 | 327 | err = s.Initialize(&buf) 328 | if err != nil { 329 | t.Errorf("Expected no error, got %v", err) 330 | } 331 | 332 | f, err = os.Open(encPath) 333 | if err != nil { 334 | t.Errorf("Expected credential file to have been created; got error %v", err) 335 | } 336 | f, err = os.Open(keyPath) 337 | if err == nil { 338 | t.Errorf("Expected key file to not have been created as user chose not to overwrite") 339 | } 340 | 341 | // get path to the gitignore file and cleanup 342 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 343 | 344 | t.Cleanup(func() { 345 | os.Remove(encPath) 346 | os.Remove(keyPath) 347 | os.Remove(gitPath) 348 | f.Close() 349 | }) 350 | 351 | t.Logf("Expects key file to not have been created as user chose not to overwrite") 352 | } 353 | 354 | func TestLoadEnv(t *testing.T) { 355 | 356 | s, encPath, keyPath := setupTest() 357 | 358 | s.Initialize(os.Stdin) 359 | 360 | f, err := os.Open(encPath) 361 | if err != nil { 362 | t.Errorf("Expected credential file to have been created; got error %v", err) 363 | } 364 | f, err = os.Open(keyPath) 365 | if err != nil { 366 | t.Errorf("Expected key file to have been created; got error %v", err) 367 | } 368 | 369 | mp := make(map[string]string) 370 | err = s.LoadEnv("", &mp) 371 | if err != nil { 372 | t.Errorf("Expected to load envirnoment variables; got error %v", err) 373 | } 374 | 375 | if len(mp) != 1 { 376 | t.Errorf("Expected config file to be been populated with env variables") 377 | } 378 | 379 | // get path to the gitignore file and cleanup 380 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 381 | 382 | t.Cleanup(func() { 383 | os.Remove(encPath) 384 | os.Remove(keyPath) 385 | os.Remove(gitPath) 386 | f.Close() 387 | }) 388 | 389 | } 390 | 391 | func TestLoadEnv_KeyInEnv(t *testing.T) { 392 | 393 | s, encPath, keyPath := setupTest() 394 | 395 | s.Initialize(os.Stdin) // created key will not be used as a file 396 | 397 | key, err := os.ReadFile(keyPath) 398 | if err != nil { 399 | t.Errorf("Expected key file to have been created; got error %v", err) 400 | } 401 | os.Remove(keyPath) // remove key file 402 | 403 | f, err := os.Open(encPath) 404 | if err != nil { 405 | t.Errorf("Expected credential file to have been created; got error %v", err) 406 | } 407 | 408 | os.Setenv(masterKey, string(key)) 409 | mp := make(map[string]string) 410 | err = s.LoadEnv("", &mp) 411 | if err != nil { 412 | t.Errorf("Expected to load envirnoment variables; got error %v", err) 413 | } 414 | 415 | if len(mp) != 1 { 416 | t.Errorf("Expected config file to be been populated with env variables") 417 | } 418 | 419 | // get path to the gitignore file and cleanup 420 | gitPath := strings.Replace(encPath, fmt.Sprintf("%s.enc", s.Environment), ".gitignore", 1) 421 | 422 | t.Cleanup(func() { 423 | os.Remove(encPath) 424 | os.Remove(keyPath) 425 | os.Remove(gitPath) 426 | f.Close() 427 | }) 428 | 429 | } 430 | 431 | func TestSetEnv(t *testing.T) { 432 | s, _, _ := setupTest() 433 | 434 | s.data["PORT"] = "8080" 435 | s.setEnv() 436 | 437 | if os.Getenv("PORT") != "8080" { 438 | t.Errorf("Expected environment variable %s to have been set to %s, got %s", "PORT", "8080", os.Getenv("PORT")) 439 | } 440 | 441 | } 442 | 443 | // func fakeExecCommand 444 | --------------------------------------------------------------------------------