diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 1b2b63d..38afd40 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -317,7 +317,7 @@ Flags: if shareArgs != nil { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() - return shareFiles(ctx, providers, st, cfg.Share.Key, shareArgs, os.Stdout) + return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout) } // Links opened elsewhere (lazysecrets launch) can come here. @@ -391,6 +391,7 @@ Flags: Changelog: lazysecrets.Changelog, Dir: placeDir, ShareKey: cfg.Share.Key, + ShareRules: shareRules(cfg), Open: open, Styles: &theme, DefaultFg: defaultFg, diff --git a/cmd/lazysecrets/share.go b/cmd/lazysecrets/share.go index baae319..0d851f8 100644 --- a/cmd/lazysecrets/share.go +++ b/cmd/lazysecrets/share.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" + "git.kebcloud.no/keb/lazysecrets/internal/config" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/share" @@ -19,7 +20,7 @@ import ( // the --key secret's value, else the repo's or the config's share.key: // // lazysecrets share .env apps/api/.env -func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error { +func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error { fs := flag.NewFlagSet("share", flag.ContinueOnError) keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)") if err := fs.Parse(args); err != nil { @@ -78,10 +79,15 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St if err != nil { return fmt.Errorf("reading the key %s: %w", key, err) } - link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files) + link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules) if err != nil { return err } _, err = fmt.Fprintln(out, link) return err } + +// shareRules are the config's share.keyRules. +func shareRules(cfg config.Config) share.Rules { + return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses} +} diff --git a/cmd/lazysecrets/share_test.go b/cmd/lazysecrets/share_test.go index dbcdc24..7dffd0e 100644 --- a/cmd/lazysecrets/share_test.go +++ b/cmd/lazysecrets/share_test.go @@ -16,6 +16,9 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/store" ) +// demoRules take the demo key, made up to be remembered. +var demoRules = share.Rules{MinLength: 16, MinClasses: 1} + func TestShareFiles(t *testing.T) { root, _ := filepath.EvalSymlinks(t.TempDir()) for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} { @@ -31,7 +34,7 @@ func TestShareFiles(t *testing.T) { key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password" var out bytes.Buffer - if err := shareFiles(ctx, providers, st, key, []string{filepath.Join(root, ".env")}, &out); err != nil { + if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil { t.Fatal(err) } r, err := ref.Parse(strings.TrimSpace(out.String())) @@ -57,7 +60,7 @@ func TestShareFiles(t *testing.T) { {key, nil, "usage"}, {"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"}, } { - if err := shareFiles(ctx, providers, st, c.general, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) { + if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%v: %v, want %q", c.args, err, c.want) } } diff --git a/docs/config.md b/docs/config.md index 3a6cb9c..0aeb16d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -37,6 +37,9 @@ providers: exclude: ["**/testdata/**"] share: # sharing env files as encrypted links key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none + keyRules: # what a key's value must be + minLength: 32 # characters, 16 at least + minClasses: 2 # kinds: lowercase, uppercase, digits, symbols ``` More, each on its own page: [repos](repos.md) (`providers.repos`), diff --git a/docs/links.md b/docs/links.md index b8b9bfc..4f7e629 100644 --- a/docs/links.md +++ b/docs/links.md @@ -42,8 +42,7 @@ lazysecrets://share/AQ… The files are encrypted with a secret's value, so whoever can read that secret (in Key Vault, Databricks or a repo's SOPS file) can open the link, and nobody else: who may open it is up to the vault's access, as for -everything else. Make a secret for it once, random and at least 16 -characters: +everything else. Make a secret for it once, random: ```sh az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)" @@ -63,6 +62,19 @@ With neither, sharing asks which secret, in [Go to](keys.md#go-to): it lists only secrets, and `enter` picks one (pasting a reference picks it too). +Anyone with a link can try to guess the key, so sharing refuses one that +isn't long and varied enough, saying how (never showing it). By default +that's 32 characters of two kinds; your config can ask for more, or less: + +```yaml +share: + keyRules: + minLength: 32 # characters; 16 at least + minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols +``` + +Opening a link doesn't check them. + On the command line, `lazysecrets share [--key ] ...` prints the link. diff --git a/internal/config/config.go b/internal/config/config.go index fa32d82..a1eb082 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,6 +90,16 @@ type Share struct { // lazysecrets:// link, Key Vault URL or reference), for repos whose // .lazysecrets.yaml doesn't name one. Key string `yaml:"key"` + // KeyRules are what a key's value must be to share with it. + KeyRules KeyRules `yaml:"keyRules"` +} + +// KeyRules default to 32 characters of 2 kinds (share.DefaultRules). +type KeyRules struct { + MinLength int `yaml:"minLength"` // at least 16 + // MinClasses is how many kinds of character: lowercase, uppercase, + // digits, symbols. + MinClasses int `yaml:"minClasses"` } type Providers struct { @@ -236,6 +246,12 @@ func (c Config) validate(path string) error { return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k) } } + if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { + return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n) + } + if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 { + return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n) + } for i, v := range c.Providers.Azure.Vaults { if !strings.HasPrefix(v, "https://") { return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 27a285b..d2e86c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,6 +72,9 @@ func TestLoadRejectsMistakes(t *testing.T) { "view": "sourcesView: tree\n", "releases": "releases: [git.example.com/team/lazysecrets]\n", "http": "releases: [http://git.example.com/team/lazysecrets]\n", + "share key": "share:\n key: https://kv.vault.azure.net/\n", + "short keys": "share:\n keyRules:\n minLength: 8\n", + "classes": "share:\n keyRules:\n minClasses: 5\n", } { if _, err := Load(write(t, content)); err == nil { t.Errorf("%s: no error", name) diff --git a/internal/share/rules.go b/internal/share/rules.go new file mode 100644 index 0000000..90abe80 --- /dev/null +++ b/internal/share/rules.go @@ -0,0 +1,59 @@ +package share + +import ( + "fmt" + "unicode" +) + +// Rules are what a key's value must be to share with it: random enough +// that guessing it is out of reach, since anyone with a link can try. +type Rules struct { + MinLength int // characters + MinClasses int // kinds of character: lowercase, uppercase, digits, symbols +} + +// DefaultRules take a random base64 or hex value (openssl rand -base64 +// 32, or -hex 32) and refuse passwords made up to be remembered. +var DefaultRules = Rules{MinLength: 32, MinClasses: 2} + +// Check reports what key lacks, without showing it. +func (r Rules) Check(key []byte) error { + r = r.withDefaults() + s := string(key) + var lower, upper, digit, symbol bool + for _, c := range s { + switch { + case unicode.IsLower(c): + lower = true + case unicode.IsUpper(c): + upper = true + case unicode.IsDigit(c): + digit = true + default: + symbol = true + } + } + kinds := 0 + for _, has := range []bool{lower, upper, digit, symbol} { + if has { + kinds++ + } + } + n := len([]rune(s)) + if n >= r.MinLength && kinds >= r.MinClasses { + return nil + } + return fmt.Errorf("the key's value is %d characters of %d kinds, and share.keyRules wants at least %d characters of %d kinds (lowercase, uppercase, digits, symbols): use a random one, like openssl rand -base64 32 makes", + n, kinds, r.MinLength, r.MinClasses) +} + +func (r Rules) withDefaults() Rules { + if r.MinLength == 0 { + r.MinLength = DefaultRules.MinLength + } + if r.MinClasses == 0 { + r.MinClasses = DefaultRules.MinClasses + } + r.MinLength = max(r.MinLength, MinKey) + return r +} diff --git a/internal/share/share.go b/internal/share/share.go index f8f4948..92d831d 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -40,7 +40,7 @@ const ( // MaxLink keeps a link short enough for chat and tickets (Teams takes // about 28 KB) and for passing it to a terminal window. MaxLink = 16 << 10 - // MinKey is the shortest secret value accepted as a key. + // MinKey is the shortest key the rules can ask for. MinKey = 16 // maxFiles caps what opening a link unpacks. maxFiles = 1 << 20 @@ -85,13 +85,14 @@ type file struct { Data string `json:"data"` } -// Seal encrypts files with key, the value of h.Key, into a share link. -func Seal(h Header, key []byte, files []File) (ref.Ref, error) { +// Seal encrypts files with key, the value of h.Key, into a share link, +// if key keeps to rules. +func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) { if _, err := ParseKey(h.Key.String()); err != nil { return ref.Ref{}, fmt.Errorf("the key: %w", err) } - if len(key) < MinKey { - return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey) + if err := rules.Check(key); err != nil { + return ref.Ref{}, err } if len(files) == 0 { return ref.Ref{}, errors.New("no files to share") diff --git a/internal/share/share_test.go b/internal/share/share_test.go index 72044f8..2fc7dc7 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -23,7 +23,7 @@ func TestSealOpen(t *testing.T) { {Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")}, {Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")}, } - link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files) + link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{}) if err != nil { t.Fatal(err) } @@ -62,7 +62,7 @@ func TestSealRefuses(t *testing.T) { files []File want string }{ - "short key": {Header{Key: key}, []byte("short"), ok, "at least 16"}, + "short key": {Header{Key: key}, []byte("short"), ok, "keyRules wants"}, "vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"}, "no files": {Header{Key: key}, pass, nil, "no files"}, "not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"}, @@ -70,7 +70,7 @@ func TestSealRefuses(t *testing.T) { "binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"}, "too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"}, } { - if _, err := Seal(c.h, c.pass, c.files); err == nil || !strings.Contains(err.Error(), c.want) { + if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: %v, want %q", name, err, c.want) } } @@ -89,8 +89,31 @@ func random(n int) string { return b.String() } +func TestRules(t *testing.T) { + for _, c := range []struct { + r Rules + key string + pass bool + }{ + {Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32 + {Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex + {Rules{}, "correct-horse-battery-staple", false}, // 28: short + {Rules{}, strings.Repeat("a", 40), false}, // one kind + {Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true}, + {Rules{MinLength: 4}, "abc123", false}, // never under MinKey + {Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false}, + {Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true}, + } { + if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass { + t.Errorf("%+v %q: %v", c.r, c.key, err) + } else if err != nil && strings.Contains(err.Error(), c.key) { + t.Errorf("the error shows the key: %v", err) + } + } +} + func TestReadGarbled(t *testing.T) { - link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}) + link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{}) if err != nil { t.Fatal(err) } diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 2594352..c410c57 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -24,6 +24,7 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/share" "git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" @@ -84,6 +85,8 @@ type Options struct { // ShareKey is the config's share.key: the secret that encrypts shared // env files, for repos whose .lazysecrets.yaml names none. ShareKey string + // ShareRules are what a key's value must be to share with it. + ShareRules share.Rules // Dir is the folder lazysecrets runs in: it goes back to where it was // left there, unless it was started on a link. "" doesn't. Dir string @@ -273,7 +276,8 @@ type Model struct { linked []provider.Container // vaults opened from links, this session open *ref.Ref // reference to open at startup - shareKey string // the config's share.key + shareKey string // the config's share.key + shareRules share.Rules // and share.keyRules // A lazysecrets:// link found on the clipboard, offered in the footer. dismissedLink string // the last link offered: not offered again @@ -460,7 +464,7 @@ func New(providers []provider.Provider, opts Options) Model { m.spinning = true m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} - m.shareKey = opts.ShareKey + m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) if m.cachePath != "" { m.loadCacheFile() diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index a25388c..23d7089 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -158,14 +158,14 @@ func (m *Model) pickPasted(text string) tea.Cmd { // seal reads the key and encrypts the files with it. func (m *Model) seal(s sharing) tea.Cmd { - read := m.keyReader() + read, rules := m.keyReader(), m.shareRules m.flash = flash{text: "encrypting with " + s.key.Name + "…"} return func() tea.Msg { v, version, err := read(s.key) if err != nil { return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)} } - link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files) + link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules) return sealedMsg{link: link, n: len(s.files), err: err} } } diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 5f99097..213c03c 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -12,12 +12,16 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" + "git.kebcloud.no/keb/lazysecrets/internal/share" ) // scryptWait is how long the driver waits for encrypting and decrypting, // which take about a second on purpose. const scryptWait = 20 * time.Second +// demoRules take the demo key, made up to be remembered. +var demoRules = share.Rules{MinLength: 16, MinClasses: 1} + // cloneWith makes a clone of git.example.com/team/app holding files. func cloneWith(t *testing.T, files map[string]string) string { t.Helper() @@ -35,7 +39,7 @@ func cloneWith(t *testing.T, files map[string]string) string { func shareFrom(t *testing.T, root string, paste bool) string { t.Helper() clip := &fakeClipboard{} - d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) + d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) d.wait = scryptWait if src, ok := d.m.selectedSource(); !ok || src.ID != root { t.Fatalf("selected %v, want the repo", src.ID)