-
Notifications
You must be signed in to change notification settings - Fork 2
/
sicher.go
355 lines (302 loc) · 9.49 KB
/
sicher.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
/*
Sicher is a go module that allows safe storage of encrypted credentials in a version control system.
It is a port of the secret management system that was introduced in Ruby on Rails 6.
Examples can be found in examples/ folder
*/
package sicher
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"github.com/juju/fslock"
)
var delimiter = "==--=="
var defaultEnv = "dev"
var DefaultEnvStyle = DOTENV
var masterKey = "SICHER_MASTER_KEY"
var (
execCmd = exec.Command
stdIn io.ReadWriter = os.Stdin
stdOut io.ReadWriter = os.Stdout
stdErr io.ReadWriter = os.Stderr
)
var waitFlagmap = map[string]string{
"code": "--wait",
"gvim": "-f",
"mvim": "-f",
"subl": "--wait",
"vimr": "--wait",
}
type sicher struct {
// Path is the path to the project. If empty string, it defaults to the current directory
Path string
// Environment is the environment to use. Defaults to "dev"
Environment string
data map[string]string `yaml:"data"`
envStyle EnvStyle
// gitignorePath is the path to the .gitignore file
gitignorePath string
}
// New creates a new sicher struct
// path is the path to the project. If empty string, it defaults to the current directory
// environment is the environment to use. Defaults to "dev"
func New(environment string, path string) *sicher {
if environment == "" {
environment = defaultEnv
}
if path == "" {
path = "."
}
path, _ = filepath.Abs(path)
return &sicher{Path: path + "/", Environment: environment, data: make(map[string]string), envStyle: DOTENV}
}
// Initialize initializes the sicher project and creates the necessary files
func (s *sicher) Initialize(scanReader io.Reader) error {
key := generateKey()
// create the key file if it doesn't exist
keyFile, err := os.OpenFile(fmt.Sprintf("%s%s.key", s.Path, s.Environment), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("error creating key file: %s", err)
}
defer keyFile.Close()
keyFileStats, err := keyFile.Stat()
if err != nil {
return fmt.Errorf("error getting key file stats: %s", err)
}
// create the encrypted credentials file if it doesn't exist
encFile, err := os.OpenFile(fmt.Sprintf("%s%s.enc", s.Path, s.Environment), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return fmt.Errorf("error creating encrypted credentials file: %s", err)
}
defer encFile.Close()
encFileStats, err := encFile.Stat()
if err != nil {
return fmt.Errorf("error getting key file stats: %s", err)
}
// if keyfile is new
// Absence of keyfile indicates that the project is new or keyfile is lost
// if keyfile is lost, the encrypted file cannot be decrypted,
// and the user needs to re-initialize or obtain the original key
if keyFileStats.Size() < 1 {
// if encrypted file exists
// ask user if they want to overwrite the encrypted file
// if yes, truncate file and continue
// else cancel
if encFileStats.Size() > 1 {
fmt.Printf("An encrypted credentials file already exist, do you want to overwrite it? \n Enter 'yes' or 'y' to accept.\n")
rd := bufio.NewScanner(scanReader)
for rd.Scan() {
line := rd.Text()
if line == "yes" || line == "y" {
encFile.Truncate(0)
break
} else {
cleanUpFile(keyFile.Name())
fmt.Println("Exiting. Leaving credentials file unmodified")
return nil
}
}
}
_, err = keyFile.WriteString(key)
if err != nil {
return fmt.Errorf("error saving key file: %s", err)
}
}
// stats will have changed if the file was truncated
encFileStats, err = encFile.Stat()
if err != nil {
return fmt.Errorf("error getting key file stats: %s", err)
}
// if the encrypted file is new, write some random data to it
if encFileStats.Size() < 1 {
initFile := []byte(fmt.Sprintf("TESTKEY%sloremipsum\n", envStyleDelim[s.envStyle]))
nonce, ciphertext, err := encrypt(key, initFile)
if err != nil {
return fmt.Errorf("error encrypting credentials file: %s", err)
}
_, err = encFile.WriteString(fmt.Sprintf("%x%s%x", ciphertext, delimiter, nonce))
if err != nil {
return fmt.Errorf("error writing encrypted credentials file: %s", err)
}
}
// add the key file to gitignore
if s.gitignorePath != "" {
err = addToGitignore(fmt.Sprintf("%s.key", s.Environment), s.gitignorePath)
if err != nil {
return fmt.Errorf("error adding key file to gitignore: %s", err)
}
}
return nil
}
// Edit opens the encrypted credentials in a temporary file for editing. Default editor is vim.
func (s *sicher) Edit(editor ...string) error {
var editorName string
if len(editor) > 0 {
editorName = editor[0]
} else {
editorName = "vim"
}
var cmdArgs []string
// waitOpt is needed to enable vscode to wait for the editor to close before continuing
waitOpt, ok := waitFlagmap[editorName]
if ok {
cmdArgs = append(cmdArgs, waitOpt)
}
// read the encryption key. if key not in file, try getting from env
key, err := s.getEncryptionKey(fmt.Sprintf("%s%s.key", s.Path, s.Environment))
if err != nil {
return err
}
// open the encrypted credentials file
credFile, err := os.OpenFile(fmt.Sprintf("%s%s.enc", s.Path, s.Environment), os.O_RDWR|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("%v", err)
}
defer credFile.Close()
// lock file to enable only one edit at a time
credFileLock := fslock.New(credFile.Name()) //newFileLock(credFile)
err = credFileLock.TryLock()
if err != nil {
if err == fslock.ErrLocked {
return fmt.Errorf("file is in use in another terminal")
}
return fmt.Errorf("error locking file: %s", err)
}
defer credFileLock.Unlock()
var buf bytes.Buffer
_, err = io.Copy(&buf, credFile)
if err != nil {
return fmt.Errorf("%v", err)
}
enc := buf.String()
// Create a temporary file to edit the decrypted credentials
f, err := os.CreateTemp("", fmt.Sprintf("*-credentials.%s", envStyleExt[s.envStyle]))
if err != nil {
return fmt.Errorf("error creating temp file %v", err)
}
defer f.Close()
filePath := f.Name()
defer cleanUpFile(filePath)
// if file already exists, decode and decrypt it
nonce, fileText, err := decodeFile(enc)
if err != nil {
return fmt.Errorf("error decoding encryption file: %s", err)
}
var plaintext []byte
if nonce != nil && fileText != nil {
plaintext, err = decrypt(key, nonce, fileText)
if err != nil {
return fmt.Errorf("error decrypting file: %s", err)
}
_, err = f.Write(plaintext)
if err != nil {
return fmt.Errorf("error saving credentials: %s", err)
}
}
//open decrypted file with editor
cmdArgs = append(cmdArgs, filePath)
cmd := execCmd(editorName, cmdArgs...)
cmd.Stdin = stdIn
cmd.Stdout = stdOut
cmd.Stderr = stdErr
err = cmd.Start()
if err != nil {
return fmt.Errorf("error starting editor: %s", err)
}
err = cmd.Wait()
if err != nil {
return fmt.Errorf("error while editing %v", err)
}
file, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("error reading credentials file %v ", err)
}
// if no file changes, dont generate new encrypted file
if bytes.Equal(file, plaintext) {
fmt.Fprintf(stdOut, "No changes made.\n")
return nil
}
//encrypt and overwrite credentials file
// the encrypted file is encoded in hexadecimal format
nonce, encrypted, err := encrypt(key, file)
if err != nil {
return fmt.Errorf("error encrypting file: %s ", err)
}
credFile.Truncate(0)
credFile.Write([]byte(fmt.Sprintf("%x%s%x", encrypted, delimiter, nonce)))
fmt.Fprintf(stdOut, "File encrypted and saved.\n")
return nil
}
// LoadEnv loads the environment variables from the encrypted credentials file into the config gile.
// configFile can be a struct or map[string]string
func (s *sicher) LoadEnv(prefix string, configFile interface{}) error {
s.configure()
s.setEnv()
d := reflect.ValueOf(configFile)
if d.Kind() == reflect.Ptr {
d = d.Elem()
} else {
return errors.New("configFile must be a pointer to a struct or map")
}
if !(d.Kind() == reflect.Struct || d.Kind() == reflect.Map) {
return errors.New("config must be a type of struct or map")
}
// the configFile is a map, set the values and return
if d.Kind() == reflect.Map {
if d.Type() != reflect.TypeOf(map[string]string{}) {
return errors.New("configFile must be a struct or map[string]string")
}
d.Set(reflect.ValueOf(s.data))
return nil
}
// if the interface is a struct, iterate over the fields and set the values
for i := 0; i < d.NumField(); i++ {
field := d.Field(i)
fieldType := d.Type().Field(i)
isRequired := fieldType.Tag.Get("required")
key := fieldType.Tag.Get("env")
tagName := key
if prefix != "" {
tagName = fmt.Sprintf("%s_%s", prefix, key)
}
envVar := os.Getenv(tagName)
if isRequired == "true" && envVar == "" {
return errors.New("required env variable " + key + " is not set")
}
switch field.Kind() {
case reflect.String:
field.SetString(envVar)
case reflect.Bool:
field.SetBool(envVar == "true")
}
}
return nil
}
func (s *sicher) SetEnvStyle(style string) {
if style != "dotenv" && style != "yaml" && style != "yml" {
fmt.Println("Invalid style: Select one of dotenv, yml, or yaml")
os.Exit(1)
}
s.envStyle = EnvStyle(style)
}
func (s *sicher) SetGitignorePath(path string) {
path, _ = filepath.Abs(path)
s.gitignorePath = path
}
func (s *sicher) getEncryptionKey(filePath string) (string, error) {
encKey := os.Getenv(masterKey)
if encKey == "" {
key, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("encryption key(%s.key) is not available. Provide a key file or enter one through the command line", s.Environment)
}
encKey = string(key)
}
return encKey, nil
}