From c99ba53f8e68e80b993a798d31fc5feb25f7b70e Mon Sep 17 00:00:00 2001 From: Kevin Cotugno Date: Sun, 8 Nov 2020 19:59:07 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE | 21 ++++ README.md | 4 + go.mod | 3 + unseal.go | 300 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 unseal.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00d0592 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +unseal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..204fc65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Kevin Cotugno + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b99248 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Unseal + +A command line program that wraps runs another command line program with +additional environment variables which are stored in a GPG encrypted file. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3bcd879 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.cotugno.family/kevin/unseal + +go 1.15 diff --git a/unseal.go b/unseal.go new file mode 100644 index 0000000..fec4d03 --- /dev/null +++ b/unseal.go @@ -0,0 +1,300 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var help bool +var cmd string +var group string +var execargs []string + +var secretsFile string + +const mode = 0600 + +func init() { + flag.BoolVar(&help, "help", false, "Show this usage message") + flag.StringVar(&cmd, "cmd", "wrap", "Command to run\nValid commands:\n\tdecrypt\n\tedit\n\twrap\n") + flag.StringVar(&group, "group", "", "Secrets group to execute on") + flag.Parse() + + execargs = flag.Args() + secretsFile = fmt.Sprintf("%s/.secrets/%s.gpg", os.Getenv("HOME"), group) +} + +func system(command string, pipe bool, args ...string) (string, string, error) { + var err error + var stdout, stderr []byte + var stdoutPipe, stderrPipe io.ReadCloser + + c := exec.Command(command, args...) + + c.Stdin = os.Stdin + if pipe { + c.Stdout = os.Stdout + c.Stderr = os.Stderr + } else { + stdoutPipe, err = c.StdoutPipe() + if err != nil { + return "", "", err + } + defer stdoutPipe.Close() + + stderrPipe, err = c.StderrPipe() + if err != nil { + return "", "", err + } + defer stderrPipe.Close() + } + + err = c.Start() + if err != nil { + return "", "", err + } + + if !pipe { + stdout, err = ioutil.ReadAll(stdoutPipe) + if err != nil { + return "", "", err + } + + stderr, err = ioutil.ReadAll(stderrPipe) + if err != nil { + return "", "", err + } + + } + + err = c.Wait() + + return string(stdout), string(stderr), err +} + +func gpg(args ...string) (string, string, error) { + return system("gpg", false, append([]string{"--quiet", "--no-verbose"}, args...)...) +} + +func fileExists(path string) bool { + _, err := os.Lstat(path) + if err != nil { + return false + } + + return true +} + +func main() { + if help { + printHelp() + return + } + + switch cmd { + case "decrypt": + fmt.Println(decrypt()) + case "edit": + edit() + case "wrap": + wrap() + default: + fmt.Println("Unknown command: ", cmd) + printHelp() + } +} + +func ensureSecrets() { + ensureGroup() + + if !fileExists(secretsFile) { + fmt.Println("Secrets file ", group, "does not exist. Create one with the edit command") + os.Exit(1) + } +} + +func ensureGroup() { + if group == "" { + fmt.Println("Group name is required") + os.Exit(1) + } +} + +func decryptFile() string { + if !fileExists(secretsFile) { + return "" + } + + stdout, stderr, err := gpg("-d", secretsFile) + if err != nil { + fmt.Fprintln(os.Stderr, err, "\n", stderr) + os.Exit(1) + } + + return strings.TrimSpace(stdout) +} + +func decrypt() string { + ensureSecrets() + + return decryptFile() +} + +func edit() { + var contents string + ensureGroup() + + if fileExists(secretsFile) { + contents = decryptFile() + } + + file, err := writeTmpFile(contents) + if err != nil { + fmt.Println("Error opening temporary file") + os.Exit(1) + } + cleanup := func() { + file.Close() + err := os.Remove(file.Name()) + if err != nil { + fmt.Fprintln(os.Stderr, "Error cleaning up temp file. Unencrypted secrets may have leaked ", err) + } + } + + tmpEnc := fmt.Sprintf("%s.gpg", file.Name()) + + err = editFile(file.Name()) + if err != nil { + fmt.Fprintln(os.Stderr, "Error editing secrets file: ", err) + os.Exit(1) + } + + _, stderr, err := gpg("--armor", "--cipher-algo", "AES256", "-c", "-o", tmpEnc, file.Name()) + cleanup() + if err != nil { + fmt.Fprintln(os.Stderr, "Error encrypting temporary file: ", err, "\n", stderr) + os.Exit(1) + } + + err = copyFile(tmpEnc, secretsFile) + if err != nil { + fmt.Fprintln(os.Stderr, "Unable to move encrypted temp file to secrets dir: ", err) + cleanup() + os.Exit(1) + } +} + +func wrap() { + if len(execargs) < 1 { + fmt.Fprintln(os.Stderr, "Wrap requires at least an external program to run") + os.Exit(1) + } + + ensureSecrets() + + insertEnvironment(parseEnvironment(decrypt())) + + _, _, err := system(execargs[0], true, execargs[1:len(execargs)]...) + if err != nil { + fmt.Fprintln(os.Stderr, "Error executing external command: ", err) + } +} + +func writeTmpFile(contents string) (*os.File, error) { + tmpFile := filepath.Join(os.TempDir(), "unseal."+randChars()) + + f, err := os.Create(tmpFile) + if err != nil { + return nil, err + } + + err = f.Chmod(mode) + if err != nil { + f.Close() + return nil, err + } + + _, err = f.WriteString(contents) + if err != nil { + f.Close() + return nil, err + } + + return f, err +} + +func editFile(file string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + _, _, err := system(editor, true, file) + return err +} + +func copyFile(oldpath, newpath string) error { + err := os.Rename(oldpath, newpath) + if err != nil { + byteArr, err2 := ioutil.ReadFile(oldpath) + if err2 != nil { + return err2 + } + + err2 = ioutil.WriteFile(newpath, byteArr, mode) + if err2 == nil { + _ = os.Remove(oldpath) + } else { + _ = os.Remove(newpath) + } + + return err2 + } + return err +} + +func insertEnvironment(vars map[string]string) { + for key, val := range vars { + os.Setenv(key, val) + } +} + +func parseEnvironment(raw string) map[string]string { + vars := make(map[string]string) + + for _, v := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { + if v == strings.TrimSpace("") { + continue + } + + splitVar := strings.Split(v, "=") + if len(splitVar) > 1 { + vars[splitVar[0]] = splitVar[1] + } + } + + return vars +} + +func randChars() string { + buf := make([]byte, 4) + _, err := rand.Read(buf) + if err != nil { + fmt.Fprintln(os.Stderr, "Unable to create temporary file") + os.Exit(1) + } + + return hex.EncodeToString(buf) +} + +func printHelp() { + flag.PrintDefaults() +}