diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 6de0d99..1b2b63d 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -119,7 +119,7 @@ func run() error { {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) - lazysecrets [flags] share [--key ] ... + lazysecrets [flags] share [--key ] ... print a link to env files of a repo, encrypted with a secret's value (default: share.key) lazysecrets [flags] keybindings print every action and its keys (with your diff --git a/cmd/lazysecrets/share.go b/cmd/lazysecrets/share.go index d8d2069..baae319 100644 --- a/cmd/lazysecrets/share.go +++ b/cmd/lazysecrets/share.go @@ -21,12 +21,12 @@ import ( // 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 { fs := flag.NewFlagSet("share", flag.ContinueOnError) - keyLink := fs.String("key", "", "lazysecrets:// link to the secret to encrypt with (default: share.key)") + 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 { return err } if fs.NArg() == 0 { - return errors.New("usage: lazysecrets share [--key ] ") + return errors.New("usage: lazysecrets share [--key ] ") } var root, id string @@ -72,7 +72,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St return err } if !ok { - return errors.New("which secret to encrypt with? --key , or share.key in the repo's .lazysecrets.yaml or the config") + return errors.New("which secret to encrypt with? --key , or share.key in the repo's .lazysecrets.yaml or the config") } v, version, err := readKey(ctx, providers, st, key) if err != nil { diff --git a/docs/config.md b/docs/config.md index ac86426..3a6cb9c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,7 +36,7 @@ providers: include: ["secrets/**"] exclude: ["**/testdata/**"] share: # sharing env files as encrypted links - key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share # for repos naming none + key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none ``` More, each on its own page: [repos](repos.md) (`providers.repos`), diff --git a/docs/links.md b/docs/links.md index 2ede3f4..adb57ea 100644 --- a/docs/links.md +++ b/docs/links.md @@ -49,15 +49,20 @@ az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl ``` and name it in the repo's `.lazysecrets.yaml` (committed: the whole team -uses it) or your config (for repos that don't name one), or pick one when -sharing: +uses it) or your config (for repos that don't name one), by its +`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)` +reference: ```yaml share: - key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share + key: https://kv-team.vault.azure.net/secrets/env-share ``` -On the command line, `lazysecrets share [--key ] ...` +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). + +On the command line, `lazysecrets share [--key ] ...` prints the link. Opening a share link says which repo it's from, how many files and which diff --git a/docs/repos.md b/docs/repos.md index 782b922..d8ec9d0 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -60,7 +60,7 @@ A repo's `.lazysecrets.yaml` can also name the secret its env files are ```yaml share: - key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share + key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link ``` ## Decrypting diff --git a/internal/config/config.go b/internal/config/config.go index ac0b50a..fa32d82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,8 +86,9 @@ type LinkHandler struct { } type Share struct { - // Key is the link to the secret whose value encrypts shared files, - // for repos whose .lazysecrets.yaml doesn't name one. + // Key is the secret whose value encrypts shared files (its + // lazysecrets:// link, Key Vault URL or reference), for repos whose + // .lazysecrets.yaml doesn't name one. Key string `yaml:"key"` } @@ -232,7 +233,7 @@ func (c Config) validate(path string) error { } if k := c.Share.Key; k != "" { if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() { - return fmt.Errorf("%s: share.key: %q is not a lazysecrets:// link to a secret", path, k) + return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k) } } for i, v := range c.Providers.Azure.Vaults { diff --git a/internal/share/share.go b/internal/share/share.go index cd8428a..f8f4948 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -87,8 +87,8 @@ type file struct { // 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) { - if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() { - return ref.Ref{}, errors.New("the key must be a secret's link") + 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) @@ -248,14 +248,18 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) { return key, true, nil } -// ParseKey reads a key's link: a link to a secret. -func ParseKey(link string) (ref.Ref, error) { - key, err := ref.Parse(link) +// ParseKey reads which secret is the key: its lazysecrets:// link, Key +// Vault URL or @Microsoft.KeyVault(...) reference. +func ParseKey(s string) (ref.Ref, error) { + key, err := ref.Parse(s) if err != nil { return ref.Ref{}, err } - if key.IsShare() || key.IsSource() || key.IsContainer() { - return ref.Ref{}, fmt.Errorf("%s isn't a secret's link", link) + switch { + case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret): + return ref.Ref{}, fmt.Errorf("%s isn't a secret", s) + case key.Kind == provider.KindDatabricks && key.Host == "": + return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s) } return key, nil } diff --git a/internal/share/share_test.go b/internal/share/share_test.go index 0a29603..72044f8 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -63,7 +63,7 @@ func TestSealRefuses(t *testing.T) { want string }{ "short key": {Header{Key: key}, []byte("short"), ok, "at least 16"}, - "vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"}, + "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"}, "escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"}, @@ -168,6 +168,29 @@ func TestPlanSymlinks(t *testing.T) { } } +func TestParseKey(t *testing.T) { + for _, s := range []string{ + "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share", + "https://kv-team.vault.azure.net/secrets/env-share", + "@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)", + "lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share", + } { + if k, err := ParseKey(s); err != nil || k.Name != "env-share" { + t.Errorf("%s: %+v, %v", s, k, err) + } + } + for s, want := range map[string]string{ + "{{secrets/team/env-share}}": "names no workspace", + "https://kv-team.vault.azure.net/keys/sops": "isn't a secret", + "https://kv-team.vault.azure.net/": "isn't a secret", + "lazysecrets://azure/subscriptions/0000-1111": "isn't a secret", + } { + if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", s, err, want) + } + } +} + func TestDefaultKey(t *testing.T) { dir := t.TempDir() general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share" diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 960a939..2594352 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -273,9 +273,7 @@ type Model struct { linked []provider.Container // vaults opened from links, this session open *ref.Ref // reference to open at startup - // shareKey is the config's share.key; lastShareKey the key last - // typed when sharing, offered next time. - shareKey, lastShareKey string + shareKey string // the config's share.key // A lazysecrets:// link found on the clipboard, offered in the footer. dismissedLink string // the last link offered: not offered again diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 195fba6..e969c0f 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -818,6 +818,9 @@ func (m *Model) openText(s string) tea.Cmd { // handlePaste opens a pasted reference, unless a text input has focus. func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { + if m.search != nil && m.search.pick != nil { + return m.pickPasted(msg.Content) // a reference to the secret + } if p := m.prompt; p != nil && p.input != nil { in, cmd := p.input.Update(msg) p.input = &in diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 7fda0b2..90b790f 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -14,6 +14,7 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" @@ -52,6 +53,12 @@ type searchView struct { res int // cursor in shown here string // the scope of the source Go to was opened from, if any + // pick, if set, makes Go to a picker of secrets, for title: it lists + // secrets only, and enter gives the selected one's link to pick + // instead of going there. + pick func(m *Model, r ref.Ref) tea.Cmd + pickTitle, pickNote string + // loaded are the scopes a search has loaded since the popup opened: // typing loads the scope once, so lists that fail aren't asked for // again on every key. @@ -267,6 +274,9 @@ func (v *searchView) match() []option { } } } + if v.pick != nil { + cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok }) + } q := v.input.Value() if q == "" { return slices.Clone(cand) @@ -423,9 +433,15 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { return nil case key.Matches(msg, k.sel): switch { + case v.results && v.res < len(v.shown) && v.pick != nil: + r, _ := pickable(v.shown[v.res]) + m.search = nil + return v.pick(m, r) case v.results && v.res < len(v.shown): m.search = nil return v.shown[v.res].run(m) + case !v.results && v.pick != nil: + v.results = true // a picker only narrows to the scope case !v.results && v.onSource(): m.search = nil return v.scope().run(m) @@ -745,6 +761,9 @@ func (m Model) searchViewBox() string { } else { body = append(body, "") // the same height on every tab } + if v.pick != nil && v.pickNote != "" { + body = append(body, " "+m.st.Muted.Render(v.pickNote)) + } km := m.keys var foot string switch { @@ -753,17 +772,20 @@ func (m Model) searchViewBox() string { if v.tab == tabSearch { hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load")) } - foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...) + foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...) default: hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}} if v.tab == tabSearch { hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all")) } - foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...) + foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...) } // Tabs in the title, the current one underlined, like the panes. ts := panes.TitleStyle(m.st, focused) head := ts.Render(goToTitle + " ") + if v.pick != nil { + head = ts.Render(v.pickTitle + " ") + } for i, name := range tabNames { if i > 0 { head += ts.Render(" - ") @@ -776,3 +798,21 @@ func (m Model) searchViewBox() string { } return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) } + +// selectLabel names what enter does with the selected entry. +func (v *searchView) selectLabel() string { + if v.pick != nil { + return "pick" + } + return "go" +} + +// pickable is the link of a Go to entry that a picker can pick: a +// secret (not a key or certificate). +func pickable(o option) (ref.Ref, bool) { + r, err := ref.Parse(o.star) + if err != nil || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) { + return ref.Ref{}, false + } + return r, true +} diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index f8e6693..6c14a8d 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -102,7 +102,7 @@ func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd { case msg.err != nil: m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} case msg.key == (ref.Ref{}): - return m.askShareKey(msg.sharing) + return m.pickShareKey(msg.sharing) default: m.confirmShare(msg.sharing) } @@ -118,40 +118,34 @@ func (m *Model) confirmShare(s sharing) { lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) m.showMenu("Share as an encrypted link", lines, []option{ {key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }}, - {key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }}, + {key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }}, }) } -// askShareKey asks for the link to the secret to encrypt with. -func (m *Model) askShareKey(s sharing) tea.Cmd { - in := textinput.New() - in.Prompt = "key: " - in.Placeholder = "lazysecrets:// link to a secret" - in.SetWidth(max(20, min(m.w-12, 64))) - in.SetValue(m.lastShareKey) - if s.key != (ref.Ref{}) { - in.SetValue(s.key.String()) +// pickShareKey asks which secret to encrypt with, in Go to. +func (m *Model) pickShareKey(s sharing) tea.Cmd { + cmd := m.openGoTo(tabSearch, true) + m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { + s.key = key + m.confirmShare(s) + return nil } - in.CursorEnd() - m.prompt = &modal{ - title: "Encrypt with which secret?", input: &in, - lines: []string{ - m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."), - m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."), - }, - submit: func(m *Model, text string) tea.Cmd { - key, err := share.ParseKey(strings.TrimSpace(text)) - if err != nil { - m.flash = flash{text: "share: " + err.Error(), isErr: true} - return nil - } - m.lastShareKey = key.String() - s.key = key - m.confirmShare(s) - return nil - }, + m.search.pickTitle = "Encrypt with which secret?" + m.search.pickNote = "Whoever can read it can open the link. share.key sets a default." + m.fillSearch(false) + return tea.Batch(cmd, m.searchLoads()) +} + +// pickPasted picks a pasted reference to a secret, while picking one. +func (m *Model) pickPasted(text string) tea.Cmd { + key, err := share.ParseKey(strings.TrimSpace(text)) + if err != nil { + m.flash = flash{text: "share: " + err.Error(), isErr: true} + return nil } - return m.prompt.input.Focus() + pick := m.search.pick + m.search = nil + return pick(m, key) } // seal reads the key and encrypts the files with it. diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 928cac8..dc0ea08 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -18,9 +18,6 @@ import ( // which take about a second on purpose. const scryptWait = 20 * time.Second -// shareKeyLink is a demo secret, long enough to be a key. -const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password" - // 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 +32,7 @@ func cloneWith(t *testing.T, files map[string]string) string { // shareFrom shares a repo's env files from lazysecrets started in it, // returning the link copied. -func shareFrom(t *testing.T, root 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})) @@ -44,12 +41,32 @@ func shareFrom(t *testing.T, root string) string { t.Fatalf("selected %v, want the repo", src.ID) } d.keys("S", "f") - // No share.key set: it asks which secret. - if d.m.prompt == nil || d.m.prompt.input == nil { - t.Fatalf("no key prompt: %s", d.m.flash.text) + // No share.key set: it asks which secret, in Go to, which lists + // only secrets. + v := d.m.search + if v == nil || v.pick == nil { + t.Fatalf("no secret picker: %s", d.m.flash.text) + } + if paste { + // A reference pasted is picked as it is. + d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"}) + } else { + for _, r := range "app-prod db-password" { + d.keys(string(r)) + } + if len(v.shown) == 0 { + t.Fatalf("no secrets to pick:\n%s", d.screen()) + } + for _, o := range v.shown { + if _, ok := pickable(o); !ok { + t.Errorf("not a secret: %s", o.label) + } + } + if !strings.Contains(d.screen(), "Encrypt with which secret?") { + t.Errorf("not titled as a picker:\n%s", d.screen()) + } + d.keys("enter") } - d.send(tea.PasteMsg{Content: shareKeyLink}) - d.keys("enter") if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") { t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen()) } @@ -67,7 +84,7 @@ func TestShareFilesRoundTrip(t *testing.T) { link := shareFrom(t, cloneWith(t, map[string]string{ ".env": "DB_PASSWORD=hunter2\n", "prod.env": "TOKEN=abc\n", - })) + }), false) // A colleague's clone: .env is there already, prod.env isn't. theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"}) @@ -104,7 +121,7 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("no git") } - link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"})) + link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true) dir, _ := filepath.EvalSymlinks(t.TempDir()) d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0)) d.wait = scryptWait