From f8d6b0d5be17949dd01a3eaca40637ce30e74c92 Mon Sep 17 00:00:00 2001 From: James Hunt Date: Tue, 3 Apr 2018 16:42:10 -0400 Subject: [PATCH] New `safe local` command This is awesome. You're going to love this. `safe local` will spin up a Vault, for you, initialize it, unseal it, target it, authenticate to it, and then create a secret/handshake for you. All at almost no cost to you! Closes #134 --- ci/release_notes.md | 7 ++ main.go | 166 ++++++++++++++++++++++++++++++++++++++++++++ names.go | 61 ++++++++++++++++ rc/config.go | 4 ++ vault/root.go | 117 +++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+) create mode 100644 names.go create mode 100644 vault/root.go diff --git a/ci/release_notes.md b/ci/release_notes.md index f3e8ca1..af9c7c6 100644 --- a/ci/release_notes.md +++ b/ci/release_notes.md @@ -1,5 +1,12 @@ # New Features +- `safe local` will spin a Vault server for you, initialize and + unseal it, and target it seamlessly. You can opt for transient + local vaults via `safe local --memory` or more durable vaults + via `safe local --file path/to/store`. You can name your local + vaults, but `safe` took a creative writing course and it itching + to use its newfound list of adjectives and nouns! + - `safe target` and `safe targets` now support a `--json` flag, for getting target information in a script-parseable format. diff --git a/main.go b/main.go index e11b96e..6ea3608 100644 --- a/main.go +++ b/main.go @@ -6,14 +6,17 @@ import ( "encoding/json" "errors" "io/ioutil" + "net" "net/http/httputil" "os" "os/exec" + "os/signal" "reflect" "regexp" "sort" "strconv" "strings" + "syscall" "time" fmt "github.com/jhunt/go-ansi" @@ -82,6 +85,12 @@ type Options struct { Paste struct{} `cli:"paste"` Exists struct{} `cli:"exists, check"` + Local struct { + As string `cli:"--as"` + File string `cli:"-f, --file"` + Memory bool `cli:"-m, --memory"` + } `cli:"local"` + Init struct { Single bool `cli:"-s, --single"` NKeys int `cli:"--keys"` @@ -436,6 +445,163 @@ func main() { return nil }) + r.Dispatch("local", &Help{ + Summary: "Run a local vault", + Usage: "safe local (--memory|--file path/to/dir) [--as name]", + Description: ` +Spins up a new Vault instance, on an unused port between 8201 and 9999 +(inclusive). The new Vault will be initialized with a single seal key, +targeted with a catchy name, authenticated by the new root token, and +populated with a secret/handshake! + +If you just need a transient Vault for testing or experimentation, and +don't particularly care about the contents of the Vault, specify the +--memory/-m flag and get an in-memory backend. + +If, on the other hand, you want to keep the Vault around, possibly +spinning it down when not in use, specify the --file/-f flag, and give it +the path to a directory to use for the file backend. The files created +by the mechanism will be encrypted. You will be given the seal key for +subsequent activations of the Vault. +`, + Type: AdministrativeCommand, + }, func(command string, args ...string) error { + if !opt.Local.Memory && opt.Local.File == "" { + return fmt.Errorf("Please specify either --memory or --file ") + } + if opt.Local.Memory && opt.Local.File != "" { + return fmt.Errorf("Please specify either --memory or --file , but not both") + } + + var port int + for port = 8201; port < 9999; port++ { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + break + } + conn.Close() + } + + f, err := ioutil.TempFile("", "kazoo") + if err != nil { + return err + } + fmt.Fprintf(f, `# safe local config +disable_mlock = 1 +listener "tcp" { + address = "127.0.0.1:%d" + tls_disable = 1 +} +`, port) + + keys := make([]string, 0) + if opt.Local.Memory { + fmt.Fprintf(f, "storage \"inmem\" {}\n") + } else { + fmt.Fprintf(f, "storage \"file\" { path = \"%s\" }\n", opt.Local.File) + if _, err := os.Stat(opt.Local.File); err == nil || !os.IsNotExist(err) { + keys = append(keys, pr("Unseal Key", false, true)) + } + } + + echan := make(chan error) + cmd := exec.Command("vault", "server", "-config", f.Name()) + cmd.Start() + go func() { + echan <- cmd.Wait() + }() + signal.Ignore(syscall.SIGINT) + + die := func(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "@R{!! %s}\n", err) + } + fmt.Fprintf(os.Stderr, "@Y{shutting down the Vault...}\n") + if err := cmd.Process.Kill(); err != nil { + fmt.Fprintf(os.Stderr, "@R{NOTE: Unable to terminate the Vault process.}\n") + fmt.Fprintf(os.Stderr, "@R{ You may have some environmental cleanup to do.}\n") + fmt.Fprintf(os.Stderr, "@R{ Apologies.}\n") + } + os.Exit(1) + } + + cfg := rc.Apply("") + name := opt.Local.As + if name == "" { + name = RandomName() + var n int + for n = 15; n > 0; n-- { + if existing, _ := cfg.Vault(name); existing == nil { + break + } + name = RandomName() + } + if n == 0 { + die(fmt.Errorf("I was unable to come up with a cool name for your local Vault. Please try naming it with --as")) + } + } else { + if existing, _ := cfg.Vault(name); existing != nil { + die(fmt.Errorf("You already have '%s' as a Vault target", name)) + } + } + previous := cfg.Current + + cfg.SetTarget(name, fmt.Sprintf("http://127.0.0.1:%d", port), false) + cfg.Write() + + rc.Apply("") + v := connect(false) + token := "" + if len(keys) == 0 { + keys, _, err = v.Init(1, 1) + if err != nil { + die(fmt.Errorf("Unable to initialize the new (temporary) Vault: %s", err)) + } + } + + if err = v.Unseal(keys); err != nil { + die(fmt.Errorf("Unable to unseal the new (temporary) Vault: %s", err)) + } + token, err = v.NewRootToken(keys) + if err != nil { + die(fmt.Errorf("Unable to generate a new root token: %s", err)) + } + + cfg.SetToken(token) + os.Setenv("VAULT_TOKEN", token) + cfg.Write() + v = connect(true) + + s := vault.NewSecret() + s.Set("knock", "knock", false) + v.Write("secret/handshake", s) + + if !opt.Quiet { + fmt.Fprintf(os.Stderr, "Now targeting (temporary) @Y{%s} at @C{%s}\n", cfg.Current, cfg.URL()) + if opt.Local.Memory { + fmt.Fprintf(os.Stderr, "@R{This Vault is MEMORY-BACKED!}\n") + fmt.Fprintf(os.Stderr, "If you want to @Y{retain your secrets} be sure to @C{safe export}.\n") + } else { + fmt.Fprintf(os.Stderr, "Storing data (encrypted) in @G{%s}\n", opt.Local.File) + fmt.Fprintf(os.Stderr, "Your Vault Seal Key is @M{%s}\n", keys[0]) + } + fmt.Fprintf(os.Stderr, "Ctrl-C to shut down the Vault\n") + } + + err = <-echan + fmt.Fprintf(os.Stderr, "Vault terminated normally, cleaning up...\n") + cfg = rc.Apply("") + if cfg.Current == name { + if _, found, _ := cfg.Find(previous); found { + cfg.Current = previous + } + cfg.Current = "" + } + delete(cfg.Vaults, name) + cfg.Write() + return err + }) + r.Dispatch("init", &Help{ Summary: "Initialize a new vault", Usage: "safe init [--keys #] [--threshold #] [--single] [--json] [--sealed]", diff --git a/names.go b/names.go new file mode 100644 index 0000000..ff8d53c --- /dev/null +++ b/names.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + // not crypto, not significant + "math/rand" + "time" +) + +var Adjectives []string +var Nouns []string + +func init() { + Adjectives = []string{ + "hardened", + "toughened", + "annealed", + "tempered", + "fortified", + "bastioned", + "bolstered", + "reinforced", + "inviolable", + "impregnable", + "unassailable", + "impervious", + "unbreakable", + "infrangible", + "stalwart", + "sturdy", + "stouthearted", + } + + Nouns = []string{ + "garrison", + "fortress", + "castle", + "keep", + "outpost", + "coffer", + "zone", + "sanctuary", + "refuge", + "asylum", + "hold", + "oubliette", + "donjon", + "dungeon", + "gaol", + } +} + +func init() { + rand.Seed(time.Now().Unix()) +} + +func RandomName() string { + return fmt.Sprintf("%s-%s", + Adjectives[rand.Intn(len(Adjectives))], + Nouns[rand.Intn(len(Nouns))]) +} diff --git a/rc/config.go b/rc/config.go index a111049..811225d 100644 --- a/rc/config.go +++ b/rc/config.go @@ -114,6 +114,10 @@ func (c *Config) Write() error { if err != nil { return err } + if v == nil { + os.Remove(svtoken()) + return nil + } sv := struct { Vault string `yaml:"vault"` /* this is different than Vault.URL */ diff --git a/vault/root.go b/vault/root.go new file mode 100644 index 0000000..de476fa --- /dev/null +++ b/vault/root.go @@ -0,0 +1,117 @@ +package vault + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +func (v *Vault) NewRootToken(keys []string) (string, error) { + // cancel any previous generate-root attmempts (get a new nonce!) + req, err := http.NewRequest("DELETE", v.url("/v1/sys/generate-root/attempt"), nil) + if err != nil { + return "", err + } + res, err := v.request(req) + if err != nil { + return "", err + } + if res.StatusCode != 204 { + return "", fmt.Errorf("failed to cancel previous generate-root attempt: HTTP %d response", res.StatusCode) + } + + // generate a 16-byte one-time password, base64-encoded + otp := make([]byte, 16) + otp64 := make([]byte, 24) // does this need pre-alloc'd? + _, err = rand.Read(otp) + if err != nil { + return "", fmt.Errorf("unable to generate a one-time password: %s", err) + } + base64.StdEncoding.Encode(otp64, otp) + + // initiate a new generate-root attempt, with our one-time password in play + req, err = http.NewRequest("PUT", v.url("/v1/sys/generate-root/attempt"), strings.NewReader(`{"otp":"`+string(otp64)+`"}`)) + if err != nil { + return "", err + } + res, err = v.request(req) + if err != nil { + return "", err + } + if res.StatusCode != 200 { + return "", fmt.Errorf("failed to start a new generate-root attempt: HTTP %d response", res.StatusCode) + } + + // extract the nonce for this generate-root attempt go-round + var attempt struct { + Nonce string `json:"nonce"` + } + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + err = json.Unmarshal(b, &attempt) + if err != nil { + return "", err + } + + encoded := "" + for _, k := range keys { + // for each key, pass back the nonce, provide the key, and go! + payload := `{"key":"` + k + `","nonce":"` + attempt.Nonce + `"}` + req, err := http.NewRequest("PUT", v.url("/v1/sys/generate-root/update"), strings.NewReader(payload)) + if err != nil { + return "", err + } + res, err := v.request(req) + if err != nil { + return "", err + } + if res.StatusCode != 200 { + return "", fmt.Errorf("failed to provide seal key to Vault: HTTP %d response", res.StatusCode) + } + + // parse the response and save the encoded (token^otp) token + var out struct { + EncodedToken string `json:"encoded_token"` + Complete bool `json:"complete"` + } + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + err = json.Unmarshal(b, &out) + if err != nil { + return "", err + } + if out.Complete { + encoded = out.EncodedToken + } + } + + if encoded == "" { + return "", fmt.Errorf("failed to generate new root token") + } + + tok64 := []byte(encoded) + tok := make([]byte, base64.StdEncoding.DecodedLen(len(tok64))) + if len(tok64) != len(otp64) { + return "", fmt.Errorf("failed to decode new root token") + } + + base64.StdEncoding.Decode(tok, tok64) + for i := 0; i < 16; i++ { + tok[i] ^= otp[i] + } + + return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + tok[0], tok[1], tok[2], tok[3], + tok[4], tok[5], + tok[6], tok[7], + tok[8], tok[9], + tok[10], tok[11], tok[12], tok[13], tok[14], tok[15]), nil +}