From 4b2f685921da126c0fb664430b402202f49d4acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 11:59:39 +0200 Subject: [PATCH 01/24] feat: share env files as encrypted links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 +- cmd/lazysecrets/get.go | 86 +------ cmd/lazysecrets/main.go | 20 +- cmd/lazysecrets/share.go | 87 +++++++ cmd/lazysecrets/share_test.go | 64 ++++++ docs/config.md | 5 +- docs/keys.md | 2 +- docs/links.md | 58 ++++- docs/repos.md | 10 + go.mod | 2 + go.sum | 4 + internal/config/config.go | 14 ++ internal/provider/azure/azure.go | 33 ++- internal/provider/fake/repos.go | 2 +- internal/provider/provider.go | 11 + internal/provider/repo/repo.go | 43 +++- internal/ref/read.go | 103 +++++++++ internal/ref/ref.go | 26 ++- internal/share/share.go | 271 ++++++++++++++++++++++ internal/share/share_test.go | 190 ++++++++++++++++ internal/share/write.go | 87 +++++++ internal/ui/app/app.go | 17 ++ internal/ui/app/links.go | 16 ++ internal/ui/app/sharefiles.go | 354 +++++++++++++++++++++++++++++ internal/ui/app/sharefiles_test.go | 129 +++++++++++ internal/ui/keys/keys.go | 2 +- 26 files changed, 1541 insertions(+), 98 deletions(-) create mode 100644 cmd/lazysecrets/share.go create mode 100644 cmd/lazysecrets/share_test.go create mode 100644 internal/ref/read.go create mode 100644 internal/share/share.go create mode 100644 internal/share/share_test.go create mode 100644 internal/share/write.go create mode 100644 internal/ui/app/sharefiles.go create mode 100644 internal/ui/app/sharefiles_test.go diff --git a/README.md b/README.md index f20594a..93a49da 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti fzf-style matching. - **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it with their own access, from the terminal or with a click in the browser - or chat. + or chat. `.env` files can be shared too, as a link encrypted with a + secret your team can read. - **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) - **Careful with values**: values stay hidden until you reveal them, and copied values are cleared from the clipboard after 30 seconds. diff --git a/cmd/lazysecrets/get.go b/cmd/lazysecrets/get.go index 6d33449..7eff320 100644 --- a/cmd/lazysecrets/get.go +++ b/cmd/lazysecrets/get.go @@ -1,10 +1,7 @@ package main import ( - "cmp" "context" - "errors" - "fmt" "os" "golang.org/x/term" @@ -33,82 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r return err } -// value reads what r points at without listing its vault: the vault and -// name are enough for Azure, tried with each account (the one that last -// read it first); for Databricks the workspace's profile is found by its -// host. +// value reads what r points at (ref.Read), with the account that last +// read it first. func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { - if r.IsSource() || r.IsContainer() { - return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()]) - } - if r.Kind != provider.KindAzure && r.Host == "" { - return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)") - } + v, _, err := readKey(ctx, providers, st, r) + return v, err +} + +// readKey is value, also returning the version read. +func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { key := r key.Version = "" node := st.Node(key.String()) - - type try struct { - p provider.Provider - o provider.Object - } - var tries []try - for _, p := range providers { - if p.Kind() != r.Kind { - continue - } - o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version} - if r.Kind != provider.KindAzure { // by host: workspaces, repos - loc, ok := p.(provider.Locator) - if !ok { - continue - } - profile, found, err := loc.Locate(ctx, r.Host) - if err != nil || !found { - continue - } - src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile} - o.Container = provider.Container{Source: src, Name: r.Scope} - // Listing the scopes tells a Key Vault-backed one, whose value - // can then be read from the vault if Databricks refuses. - if cs, err := p.Containers(ctx, src); err == nil { - for _, c := range cs { - if c.Name == r.Scope { - c.Source = src - o.Container = c - } - } - } - } else { - src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source} - o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()} - } - t := try{p, o} - if p.ID() == node.Account { - tries = append([]try{t}, tries...) - } else { - tries = append(tries, t) - } - } - if len(tries) == 0 { - switch r.Kind { - case provider.KindDatabricks: - return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host) - case provider.KindRepo: - return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host) - } - return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r) - } - var errs []error - for _, t := range tries { - v, err := t.p.Value(ctx, t.o) - if err == nil { - return v, nil - } - if len(tries) == 1 { - return provider.SecretValue{}, err - } - errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err)) - } - return provider.SecretValue{}, errors.Join(errs...) + return ref.Read(ctx, providers, r, node.Account, node.Source) } diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index f680c19..6de0d99 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -114,10 +114,14 @@ func run() error { fmt.Fprintf(flag.CommandLine.Output(), `Usage: lazysecrets [flags] browse secrets lazysecrets [flags] [open] - open a lazysecrets:// link, Key Vault URL, - @Microsoft.KeyVault(...) or {{secrets/scope/key}} + open a lazysecrets:// link (also a share link), + Key Vault URL, @Microsoft.KeyVault(...) or + {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) + 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 config applied), as config to copy and edit lazysecrets handler install [--terminal NAME] @@ -140,8 +144,14 @@ Flags: } var open, getRef *ref.Ref + var shareArgs []string printKeys := false switch args := flag.Args(); { + case len(args) > 0 && args[0] == "share": + shareArgs = args[1:] + if len(shareArgs) == 0 { + shareArgs = []string{} // still sharing: says how + } case len(args) == 1 && args[0] == "keybindings": printKeys = true case len(args) == 2 && args[0] == "get": @@ -304,6 +314,11 @@ Flags: defer cancel() return get(ctx, providers, st, *getRef) } + if shareArgs != nil { + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + return shareFiles(ctx, providers, st, cfg.Share.Key, shareArgs, os.Stdout) + } // Links opened elsewhere (lazysecrets launch) can come here. var p *tea.Program @@ -375,6 +390,7 @@ Flags: StatePath: statePath, Changelog: lazysecrets.Changelog, Dir: placeDir, + ShareKey: cfg.Share.Key, Open: open, Styles: &theme, DefaultFg: defaultFg, diff --git a/cmd/lazysecrets/share.go b/cmd/lazysecrets/share.go new file mode 100644 index 0000000..65cbb58 --- /dev/null +++ b/cmd/lazysecrets/share.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" + "git.kebcloud.no/keb/lazysecrets/internal/share" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// shareFiles prints a share link to env files of one repo, encrypted with +// 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 { + fs := flag.NewFlagSet("share", flag.ContinueOnError) + keyLink := fs.String("key", "", "lazysecrets:// link to the secret to encrypt with (default: share.key)") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() == 0 { + return errors.New("usage: lazysecrets share [--key ] ...") + } + + var root, id string + var files []share.File + for _, name := range fs.Args() { + abs, err := filepath.Abs(name) + if err != nil { + return err + } + r, i, err := repo.Root(filepath.Dir(abs)) + if err != nil { + return err + } + if root == "" { + root, id = r, i + } else if r != root { + return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root) + } + real, err := filepath.EvalSymlinks(abs) + if err != nil { + return err + } + rel, err := filepath.Rel(root, real) + if err != nil { + return err + } + f := share.File{Path: filepath.ToSlash(rel)} + if err := share.CheckPath(f.Path); err != nil { + return err + } + if f.Data, err = os.ReadFile(real); err != nil { + return err + } + files = append(files, f) + } + + key, ok, err := share.DefaultKey(root, general) + if *keyLink != "" { + key, err = share.ParseKey(*keyLink) + ok = err == nil + } + if err != nil { + 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") + } + v, version, err := readKey(ctx, providers, st, key) + 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) + if err != nil { + return err + } + _, err = fmt.Fprintln(out, link) + return err +} diff --git a/cmd/lazysecrets/share_test.go b/cmd/lazysecrets/share_test.go new file mode 100644 index 0000000..dbcdc24 --- /dev/null +++ b/cmd/lazysecrets/share_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/share" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +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"}} { + if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil { + t.Skip("git:", string(out)) + } + } + os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600) + os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644) + providers := []provider.Provider{fake.Azure(0)} + st, _ := store.Load("") + ctx := context.Background() + 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 { + t.Fatal(err) + } + r, err := ref.Parse(strings.TrimSpace(out.String())) + if err != nil { + t.Fatal(err) + } + s, err := share.Read(r) + if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" { + t.Fatalf("%+v, %v", s.Header, err) + } + files, err := s.Open([]byte("correct-horse-battery-staple")) + if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" { + t.Errorf("%+v, %v", files, err) + } + + for _, c := range []struct { + general string + args []string + want string + }{ + {"", []string{filepath.Join(root, ".env")}, "which secret"}, + {key, []string{filepath.Join(root, "main.go")}, "isn't an env file"}, + {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) { + t.Errorf("%v: %v, want %q", c.args, err, c.want) + } + } +} diff --git a/docs/config.md b/docs/config.md index f7a04e6..ac86426 100644 --- a/docs/config.md +++ b/docs/config.md @@ -35,9 +35,12 @@ providers: paths: [~/work/infra] 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 ``` -More, each on its own page: [repos](repos.md) (`providers.repos`), the +More, each on its own page: [repos](repos.md) (`providers.repos`), +[sharing env files](links.md#sharing-env-files) (`share`), the [link handler](links.md#from-the-browser-and-chat) (`linkHandler`), [keybindings](keys.md#rebinding-keys) (`keybinding`) and [custom commands](custom-commands.md) (`customCommands`). diff --git a/docs/keys.md b/docs/keys.md index b4c1527..b800f1c 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -54,7 +54,7 @@ match case exactly. | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | | `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | -| `s` / `S` | copy a [link](links.md) to the selected item / share menu | +| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) | | `ctrl+o` | open a link or reference (or just paste one) | | `x` | [custom commands](custom-commands.md) | | `L` | sign in again (shown when a login has expired) | diff --git a/docs/links.md b/docs/links.md index b4cc246..2ede3f4 100644 --- a/docs/links.md +++ b/docs/links.md @@ -20,10 +20,66 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password that version). `S` opens the share menu: - secrets: the link (`o`), the plain Key Vault URL (`u`), the config - reference (`y`) or the web page (`b`) + reference (`y`) or the web page (`b`); in an env file, also the file as + an [encrypted link](#sharing-env-files) (`f`) - vaults and scopes: a link (`o`), a link that opens on the current tab (`t`), the plain vault URL (`u`) or the web page (`b`) - subscriptions and workspaces: a link (`o`) or the web page (`b`) +- repos and env files: also their env files as an + [encrypted link](#sharing-env-files) (`f`) + +## Sharing env files + +`.env` files usually live only on your machine. `S` → `f` shares them as +an encrypted link, on a repo (all its plain env files), an env file, or a +key in one (its file): + +``` +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: + +```sh +az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)" +``` + +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: + +```yaml +share: + key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share +``` + +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 +secret it's encrypted with, and decrypts only when asked: lazysecrets +reads the secret with your access, then shows where each file goes in +your clone of the repo (or asks for a folder, if it isn't here), which +ones would replace a file there and which ones git doesn't ignore. It +saves them all, only the new ones, or none. New files are readable by you +only. + +A link holds the files' paths and contents encrypted ([age](https://age-encryption.org), +the secret's value as passphrase); the repo, the secret and the number of +files are in the clear. Links stay under 16 KB, which fits chat and +tickets. + +A link stays readable as long as its secret is. On Azure it's pinned to +the secret's version, so adding a new version (rotating) doesn't break +old links, and disabling the old version, or taking away someone's +access, shuts them out; Databricks secrets have no versions, so changing +the value breaks every old link. Whoever has read the secret's value +before can decrypt links made with it without asking the vault, so rotate +it now and then: new links use the newest version. ## Opening diff --git a/docs/repos.md b/docs/repos.md index c3c6878..782b922 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -55,6 +55,14 @@ providers: exclude: ["**/testdata/**"] ``` +A repo's `.lazysecrets.yaml` can also name the secret its env files are +[shared](links.md#sharing-env-files) with: + +```yaml +share: + key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share +``` + ## Decrypting Listing never decrypts: SOPS encrypts values only, so keys and metadata @@ -90,6 +98,8 @@ sops 3.8 or later. ## Links +`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files). + Links name the repo by its git remote, so they open in a colleague's clone wherever it is: diff --git a/go.mod b/go.mod index 957bee2..f137546 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,8 @@ require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + filippo.io/age v1.3.2 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect diff --git a/go.sum b/go.sum index 81e7ab7..52a4971 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= +filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= diff --git a/internal/config/config.go b/internal/config/config.go index 9226056..ac0b50a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( "go.yaml.in/yaml/v3" "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/update" ) @@ -54,6 +55,8 @@ type Config struct { Providers Providers `yaml:"providers"` // LinkHandler sets how lazysecrets:// links open; see linkhandler. LinkHandler LinkHandler `yaml:"linkHandler"` + // Share sets how env files are shared; see package share. + Share Share `yaml:"share"` // CustomCommands run shell commands on the selected item; see // package commands. CustomCommands []commands.Command `yaml:"customCommands"` @@ -82,6 +85,12 @@ type LinkHandler struct { Prompt *bool `yaml:"prompt"` } +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 string `yaml:"key"` +} + type Providers struct { Azure Azure `yaml:"azure"` Databricks Databricks `yaml:"databricks"` @@ -221,6 +230,11 @@ func (c Config) validate(path string) error { return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) } } + 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) + } + } 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/provider/azure/azure.go b/internal/provider/azure/azure.go index f88b291..522e406 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -453,11 +453,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre } switch o.Type { case provider.TypeSecret: - resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) - if err != nil { - return provider.SecretValue{}, cleanError(err) - } - return provider.NewSecretValue([]byte(deref(resp.Value))), nil + v, _, err := p.VersionedValue(ctx, o) + return v, err case provider.TypeKey: resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) if err != nil { @@ -481,7 +478,31 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre return provider.SecretValue{}, provider.ErrUnsupported } -var _ provider.Locator = (*Provider)(nil) +// VersionedValue is Value, with the version read. +func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) { + if o.Type != provider.TypeSecret { + v, err := p.Value(ctx, o) + return v, o.Version, err + } + vc, err := p.clients(o.Container.Detail) + if err != nil { + return provider.SecretValue{}, "", err + } + resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) + if err != nil { + return provider.SecretValue{}, "", cleanError(err) + } + version := o.Version + if resp.ID != nil { + version = resp.ID.Version() + } + return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil +} + +var ( + _ provider.Locator = (*Provider)(nil) + _ provider.Versioned = (*Provider)(nil) +) // validHost guards the Resource Graph query against odd input. var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`) diff --git a/internal/provider/fake/repos.go b/internal/provider/fake/repos.go index bed7936..1dc5038 100644 --- a/internal/provider/fake/repos.go +++ b/internal/provider/fake/repos.go @@ -35,7 +35,7 @@ func Repos() *Provider { // commit are what git says of it: "tracked", "ignored" or "untracked". file := func(src provider.Source, path, method, git, commit string, kv ...string) { sops := method != "" - detail := "env · plain text" + detail := provider.DetailPlainEnv if sops { detail = "sops · " + method } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e5136d4..7ad3fa8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -167,6 +167,13 @@ type Current interface { Current(ctx context.Context) (Source, bool) } +// Versioned is implemented by providers whose secrets have versions: it +// is Value, also returning the version read (the current one when +// o.Version is empty), so a link can pin it. +type Versioned interface { + VersionedValue(ctx context.Context, o Object) (SecretValue, string, error) +} + // Locator is implemented by providers that can find where a container lives, // to open links. address is a vault host (kv.vault.azure.net) or a // workspace host. It returns the ID of the source holding it. @@ -220,6 +227,10 @@ func (d *Denied) Error() string { func (d *Denied) Unwrap() error { return d.Err } +// DetailPlainEnv is the Detail of a container that is a plain (not +// encrypted) env file in a repo. +const DetailPlainEnv = "env · plain text" + // ContainerDetails is implemented by providers that can say more about a // container than its listing does: how a file is encrypted and what git // knows of it. ErrUnsupported means there's nothing more for c. diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index 424a02a..234917f 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -311,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi c.Detail = "sops · " + strings.Join(meta.methods, ", ") case isSops: return // a SOPS file where .sops.yaml says SOPS files don't go - case isEnv(f): - c.Detail = "env · plain text" + case IsEnv(f): + c.Detail = provider.DetailPlainEnv default: return } @@ -464,6 +464,16 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) return "", false, nil } +// Root is the root of the git repo dir is in, and the repo's ID: its +// remote without scheme or .git (a repo link's host), else the root. +func Root(dir string) (root, id string, err error) { + root, err = gitRoot(dir) + if err != nil { + return "", "", fmt.Errorf("%s isn't in a git repository", dir) + } + return root, info(root, "").id, nil +} + // --- git --- func gitRoot(dir string) (string, error) { @@ -568,6 +578,27 @@ func normalizeRemote(remote string) (string, string) { type lazysecretsFile struct { Include []string `yaml:"include"` Exclude []string `yaml:"exclude"` + Share struct { + // Key is the link to the secret that encrypts the repo's + // shared env files (package share). + Key string `yaml:"key"` + } `yaml:"share"` +} + +// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any. +func ShareKey(root string) (string, error) { + b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + var f lazysecretsFile + if err := yaml.Unmarshal(b, &f); err != nil { + return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) + } + return f.Share.Key, nil } // selector says which files to look at: user (the include/exclude of the @@ -618,7 +649,7 @@ func envFiles(root string, tracked []string) []string { dirs := map[string]bool{".": true} for _, f := range tracked { isTracked[f] = true - if isEnv(f) { + if IsEnv(f) { out = append(out, f) } if d := path.Dir(f); !dirs[d] && !inPackages(f) { @@ -632,7 +663,7 @@ func envFiles(root string, tracked []string) []string { } for _, e := range entries { f := path.Join(d, e.Name()) - if e.Type().IsRegular() && !isTracked[f] && isEnv(f) { + if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) { out = append(out, f) } } @@ -663,10 +694,10 @@ func inPackages(f string) bool { return false } -// isEnv reports whether a file is named like an env file with real values: +// IsEnv reports whether a file is named like an env file with real values: // .env, .env.local, prod.env; not .env.example and the like, nor one in // dependencies. -func isEnv(f string) bool { +func IsEnv(f string) bool { if inPackages(f) { return false } diff --git a/internal/ref/read.go b/internal/ref/read.go new file mode 100644 index 0000000..c40c814 --- /dev/null +++ b/internal/ref/read.go @@ -0,0 +1,103 @@ +package ref + +import ( + "cmp" + "context" + "errors" + "fmt" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Read reads what r points at without listing its vault: the vault and +// name are enough for Azure, tried with each account (account, the one +// that last read it, first, in source); for Databricks and repos the +// profile or repo is found by its host. It also returns the version read, +// when the provider tells (provider.Versioned). +func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) { + if r.IsShare() || r.IsSource() || r.IsContainer() { + what := "vault or scope" + switch { + case r.IsShare(): + what = "share link" + case r.IsSource(): + what = "subscription or workspace" + } + return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what) + } + if r.Kind != provider.KindAzure && r.Host == "" { + return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)") + } + + type try struct { + p provider.Provider + o provider.Object + } + var tries []try + for _, p := range providers { + if p.Kind() != r.Kind { + continue + } + o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version} + if r.Kind != provider.KindAzure { // by host: workspaces, repos + loc, ok := p.(provider.Locator) + if !ok { + continue + } + profile, found, err := loc.Locate(ctx, r.Host) + if err != nil || !found { + continue + } + src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile} + o.Container = provider.Container{Source: src, Name: r.Scope} + // Listing the scopes tells a Key Vault-backed one, whose value + // can then be read from the vault if Databricks refuses. + if cs, err := p.Containers(ctx, src); err == nil { + for _, c := range cs { + if c.Name == r.Scope { + c.Source = src + o.Container = c + } + } + } + } else { + src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: source} + o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()} + } + t := try{p, o} + if p.ID() == account { + tries = append([]try{t}, tries...) + } else { + tries = append(tries, t) + } + } + if len(tries) == 0 { + switch r.Kind { + case provider.KindDatabricks: + return provider.SecretValue{}, "", fmt.Errorf("no Databricks profile for workspace %s", r.Host) + case provider.KindRepo: + return provider.SecretValue{}, "", fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host) + } + return provider.SecretValue{}, "", fmt.Errorf("no %s account to read %s with", r.Kind, r) + } + var errs []error + for _, t := range tries { + v, version, err := value(ctx, t.p, t.o) + if err == nil { + return v, version, nil + } + if len(tries) == 1 || ctx.Err() != nil { + return provider.SecretValue{}, "", err + } + errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err)) + } + return provider.SecretValue{}, "", errors.Join(errs...) +} + +func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) { + if vp, ok := p.(provider.Versioned); ok { + return vp.VersionedValue(ctx, o) + } + v, err := p.Value(ctx, o) + return v, o.Version, err +} diff --git a/internal/ref/ref.go b/internal/ref/ref.go index 1074bd7..4cf181e 100644 --- a/internal/ref/ref.go +++ b/internal/ref/ref.go @@ -10,11 +10,15 @@ // lazysecrets://azure/[/[/[/]]] // lazysecrets://databricks/[/[/]] // lazysecrets://repo/[/[/]] +// lazysecrets://share/ // // A repo is its git remote without scheme or .git // (git.kebcloud.no/keb/infra), the file its path in the repo and the key // its path in the file (db.password); slashes inside them are escaped. // +// A share link carries files, encrypted (package share), instead of +// naming something. +// // Vault hosts always contain dots, so "subscriptions" can't be one. // // Every path segment is percent-encoded, so names may hold any character. @@ -49,10 +53,19 @@ type Ref struct { Type provider.ObjectType Name string // object name; empty for a container link Version string + // Share is a share link's sealed files, as in the link; the other + // fields are empty. Package share reads it. + Share string } +// IsShare reports whether r is a share link. +func (r Ref) IsShare() bool { return r.Share != "" } + // IsSource reports whether r points at a subscription or workspace. func (r Ref) IsSource() bool { + if r.IsShare() { + return false + } if r.Kind != provider.KindAzure { return r.Scope == "" } @@ -60,7 +73,7 @@ func (r Ref) IsSource() bool { } // IsContainer reports whether r points at a vault or scope. -func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() } +func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() } // ContainerName is the vault name (from its host) or the scope. func (r Ref) ContainerName() string { @@ -111,6 +124,9 @@ func (r Ref) IdentifierURL() string { // String is the lazysecrets:// link. func (r Ref) String() string { + if r.IsShare() { + return Scheme + "://share/" + r.Share + } if r.Kind == provider.KindAzure && r.Subscription != "" { return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription) } @@ -208,6 +224,8 @@ var ( portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`) // …/asset/Microsoft_Azure_KeyVault// portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`) + // a share link's sealed files: unpadded base64url + sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`) ) // Parse reads any supported reference. @@ -238,6 +256,12 @@ func parseLink(s string) (Ref, error) { if err != nil { return Ref{}, err } + if strings.EqualFold(u.Host, "share") { + if len(segs) != 1 || !sealed.MatchString(segs[0]) { + return Ref{}, errors.New("share link is cut short or garbled: copy all of it") + } + return Ref{Share: segs[0]}, nil + } var r Ref if len(segs) == 0 || segs[0] == "" { return Ref{}, errors.New("link has no vault or workspace") diff --git a/internal/share/share.go b/internal/share/share.go new file mode 100644 index 0000000..cd8428a --- /dev/null +++ b/internal/share/share.go @@ -0,0 +1,271 @@ +// Package share seals env files into a lazysecrets://share link, so they +// can be passed on in chat or a ticket. They're encrypted with the value of +// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can +// read that secret can open the link, nobody else. +// +// A link says in the clear which secret it's encrypted with, the repo the +// files are from and how many there are, which is what's needed to open +// it. The files' paths and contents are encrypted, with age +// (https://age-encryption.org), the secret's value as its passphrase. +// +// The link's payload is unpadded base64url of: +// +// version (1) | uvarint length of the header | header (JSON) | age file +// +// The age file holds the files as deflated JSON. +package share + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strings" + "unicode/utf8" + + "filippo.io/age" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +const ( + version = 1 + // 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 = 16 + // maxFiles caps what opening a link unpacks. + maxFiles = 1 << 20 +) + +// workFactor is age's scrypt work factor for sealing (its default); +// maxWorkFactor is the most opening accepts, so a link can't make it +// work for minutes. +var workFactor, maxWorkFactor = 18, 20 + +// File is a shared file. +type File struct { + Path string // relative to the repo's root, with slashes + Data []byte +} + +// Header is what a link says in the clear. +type Header struct { + // Key is the secret whose value the files are encrypted with. + Key ref.Ref + // Repo is the repo the files are from: its remote without scheme or + // .git (a repo link's host). + Repo string + // Files is how many files the link holds. + Files int +} + +// Sealed is a share link read, its files still encrypted. +type Sealed struct { + Header + box []byte +} + +type header struct { + Key string `json:"key"` + Repo string `json:"repo,omitempty"` + Files int `json:"files"` +} + +type file struct { + Path string `json:"path"` + 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) { + if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() { + return ref.Ref{}, errors.New("the key must be a secret's link") + } + 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 len(files) == 0 { + return ref.Ref{}, errors.New("no files to share") + } + plain := make([]file, len(files)) + for i, f := range files { + if err := CheckPath(f.Path); err != nil { + return ref.Ref{}, err + } + if !utf8.Valid(f.Data) { + return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path) + } + plain[i] = file{Path: f.Path, Data: string(f.Data)} + } + b, err := json.Marshal(plain) + if err != nil { + return ref.Ref{}, err + } + + rcpt, err := age.NewScryptRecipient(string(key)) + if err != nil { + return ref.Ref{}, err + } + rcpt.SetWorkFactor(workFactor) + var box bytes.Buffer + enc, err := age.Encrypt(&box, rcpt) + if err != nil { + return ref.Ref{}, err + } + z, _ := flate.NewWriter(enc, flate.BestCompression) + if _, err := z.Write(b); err != nil { + return ref.Ref{}, err + } + if err := z.Close(); err != nil { + return ref.Ref{}, err + } + if err := enc.Close(); err != nil { + return ref.Ref{}, err + } + + hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)}) + if err != nil { + return ref.Ref{}, err + } + payload := binary.AppendUvarint([]byte{version}, uint64(len(hb))) + payload = append(append(payload, hb...), box.Bytes()...) + r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)} + if n := len(r.String()); n > MaxLink { + return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10) + } + return r, nil +} + +// Read reads a share link's header. +func Read(r ref.Ref) (Sealed, error) { + bad := errors.New("share link is cut short or garbled: copy all of it") + b, err := base64.RawURLEncoding.DecodeString(r.Share) + if err != nil || len(b) < 2 { + return Sealed{}, bad + } + if b[0] != version { + return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)") + } + n, size := binary.Uvarint(b[1:]) + if size <= 0 || n > uint64(len(b)) { + return Sealed{}, bad + } + rest := b[1+size:] + if n > uint64(len(rest)) { + return Sealed{}, bad + } + var h header + if err := json.Unmarshal(rest[:n], &h); err != nil { + return Sealed{}, bad + } + key, err := ParseKey(h.Key) + if err != nil { + return Sealed{}, fmt.Errorf("share link's key: %w", err) + } + return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil +} + +// Open decrypts the files with key, the value of s.Key. +func (s Sealed) Open(key []byte) ([]File, error) { + id, err := age.NewScryptIdentity(string(key)) + if err != nil { + return nil, err + } + id.SetMaxWorkFactor(maxWorkFactor) + dec, err := age.Decrypt(bytes.NewReader(s.box), id) + if err != nil { + var wrong *age.NoIdentityMatchError + if errors.As(err, &wrong) { + return nil, errors.New("the key doesn't decrypt it: the link may have been made with another version of " + s.Key.Name) + } + return nil, fmt.Errorf("can't decrypt the link: %w", err) + } + b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxFiles+1)) + if err != nil { + return nil, fmt.Errorf("can't decrypt the link: %w", err) + } + if len(b) > maxFiles { + return nil, errors.New("the link holds more than lazysecrets unpacks") + } + var plain []file + if err := json.Unmarshal(b, &plain); err != nil { + return nil, fmt.Errorf("can't read the link's files: %w", err) + } + out := make([]File, len(plain)) + for i, f := range plain { + if err := CheckPath(f.Path); err != nil { + return nil, err + } + out[i] = File{Path: f.Path, Data: []byte(f.Data)} + } + return out, nil +} + +// CheckPath accepts a path relative to a repo's root, with slashes, of an +// env file (.env, .env.local, prod.env) outside .git: what a link may +// write. +func CheckPath(p string) error { + switch { + case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p || + p == ".." || strings.HasPrefix(p, "../"): + return fmt.Errorf("%q isn't a path inside a repo", p) + case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"): + return fmt.Errorf("%q is inside .git", p) + case !repo.IsEnv(p): + return fmt.Errorf("%q isn't an env file: only those can be shared", p) + } + return nil +} + +// DefaultKey is the key for a repo's files: share.key in the repo's +// .lazysecrets.yaml, else general (share.key in the config). ok is false +// when neither names one. +func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) { + link, err := repo.ShareKey(root) + if err != nil { + return ref.Ref{}, false, err + } + where := root + "/.lazysecrets.yaml" + if link == "" { + link, where = general, "the config" + } + if link == "" { + return ref.Ref{}, false, nil + } + key, err = ParseKey(link) + if err != nil { + return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err) + } + 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) + 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) + } + return key, nil +} + +// Pin is key at version, the version its value was read at, for Azure, +// whose old versions stay readable: a link keeps working when the key +// is rotated, until that version is disabled. +func Pin(key ref.Ref, version string) ref.Ref { + if key.Kind == provider.KindAzure && key.Version == "" { + key.Version = version + } + return key +} diff --git a/internal/share/share_test.go b/internal/share/share_test.go new file mode 100644 index 0000000..0a29603 --- /dev/null +++ b/internal/share/share_test.go @@ -0,0 +1,190 @@ +package share + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor + +var ( + key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"} + pass = []byte("0123456789abcdef0123456789abcdef") +) + +func TestSealOpen(t *testing.T) { + files := []File{ + {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) + if err != nil { + t.Fatal(err) + } + if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") { + t.Errorf("link shows what it shares: %s", link) + } + // It goes through ref.Parse like any link. + r, err := ref.Parse(link.String()) + if err != nil || !r.IsShare() { + t.Fatalf("parse: %v, %+v", err, r) + } + s, err := Read(r) + if err != nil { + t.Fatal(err) + } + if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 { + t.Errorf("header %+v", s.Header) + } + got, err := s.Open(pass) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) { + t.Errorf("opened %+v", got) + } + if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "doesn't decrypt") { + t.Errorf("wrong key: %v", err) + } +} + +func TestSealRefuses(t *testing.T) { + ok := []File{{Path: ".env", Data: []byte("A=1")}} + for name, c := range map[string]struct { + h Header + pass []byte + files []File + 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"}, + "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"}, + "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) { + t.Errorf("%s: %v, want %q", name, err, c.want) + } + } +} + +// random is text that doesn't compress. +func random(n int) string { + var b strings.Builder + x := uint32(1) + for b.Len() < n { + x ^= x << 13 + x ^= x >> 17 + x ^= x << 5 + b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36]) + } + return b.String() +} + +func TestReadGarbled(t *testing.T) { + link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}) + if err != nil { + t.Fatal(err) + } + cut := ref.Ref{Share: link.Share[:len(link.Share)/2]} + s, err := Read(cut) + if err == nil { + _, err = s.Open(pass) + } + if err == nil { + t.Error("a link cut in half opened") + } + if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") { + t.Errorf("version 2: %v", err) + } +} + +func TestCheckPath(t *testing.T) { + for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} { + if err := CheckPath(p); err != nil { + t.Errorf("%s: %v", p, err) + } + } + for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} { + if err := CheckPath(p); err == nil { + t.Errorf("%q accepted", p) + } + } +} + +func TestPlanWrite(t *testing.T) { + dir := t.TempDir() + if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + t.Skip("git:", string(out)) + } + os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644) + os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644) + targets, err := Plan(dir, []File{ + {Path: ".env", Data: []byte("NEW=1\n")}, + {Path: "apps/api/.env.local", Data: []byte("B=2\n")}, + }) + if err != nil { + t.Fatal(err) + } + if !targets[0].Exists || targets[0].Exposed { + t.Errorf(".env: %+v", targets[0]) + } + if targets[1].Exists || !targets[1].Exposed { + t.Errorf("apps/api/.env.local: %+v", targets[1]) + } + for _, tg := range targets { + if err := tg.Write(); err != nil { + t.Fatal(err) + } + } + if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" { + t.Errorf(".env is %q", b) + } + info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("new file: %v, %v", info.Mode(), err) + } +} + +func TestPlanSymlinks(t *testing.T) { + dir, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil { + t.Skip(err) + } + if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") { + t.Errorf("through a linked folder: %v", err) + } + os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env")) + if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Errorf("onto a symlink: %v", err) + } +} + +func TestDefaultKey(t *testing.T) { + dir := t.TempDir() + general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share" + k, ok, err := DefaultKey(dir, general) + if err != nil || !ok || k.Name != "env-share" { + t.Errorf("general: %+v %v %v", k, ok, err) + } + os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644) + if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" { + t.Errorf("the repo's should win: %+v", k) + } + if _, ok, _ := DefaultKey(t.TempDir(), ""); ok { + t.Error("a key with none set") + } + unpinned := key + unpinned.Version = "" + if p := Pin(unpinned, "abc"); p.Version != "abc" { + t.Errorf("pin: %+v", p) + } +} diff --git a/internal/share/write.go b/internal/share/write.go new file mode 100644 index 0000000..f7918af --- /dev/null +++ b/internal/share/write.go @@ -0,0 +1,87 @@ +package share + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Target is where an opened file goes. +type Target struct { + File + Abs string + Exists bool + // Exposed is set when dir is a git repo that doesn't ignore the file: + // its values could end up in a commit. + Exposed bool +} + +// Plan says where files go under dir, without writing anything. A path +// that leads out of dir through a symlink, or is one, is an error. +func Plan(dir string, files []File) ([]Target, error) { + root, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, err + } + out := make([]Target, len(files)) + for i, f := range files { + if err := CheckPath(f.Path); err != nil { + return nil, err + } + t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))} + if err := inside(root, filepath.Dir(t.Abs)); err != nil { + return nil, fmt.Errorf("%s: %w", f.Path, err) + } + switch info, err := os.Lstat(t.Abs); { + case err == nil && info.Mode()&fs.ModeSymlink != 0: + return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path) + case err == nil && !info.Mode().IsRegular(): + return nil, fmt.Errorf("%s isn't a file", f.Path) + case err == nil: + t.Exists = true + case !errors.Is(err, fs.ErrNotExist): + return nil, err + } + t.Exposed = exposed(root, f.Path) + out[i] = t + } + return out, nil +} + +// inside checks that dir, or the part of it that exists, is under root +// once symlinks are followed. +func inside(root, dir string) error { + for { + real, err := filepath.EvalSymlinks(dir) + if errors.Is(err, fs.ErrNotExist) && dir != root { + dir = filepath.Dir(dir) // created when writing + continue + } + if err != nil { + return err + } + if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) { + return errors.New("leads out of the folder through a symlink") + } + return nil + } +} + +// exposed reports whether root is in a git repo that doesn't ignore p. +func exposed(root, p string) bool { + err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run() + var exit *exec.ExitError + return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo +} + +// Write writes the file, readable by the user only when it's new. +func (t Target) Write() error { + if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil { + return err + } + return os.WriteFile(t.Abs, t.Data, 0o600) +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 9deebef..960a939 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -81,6 +81,9 @@ type Options struct { // CacheFile keeps what was loaded between runs; "" keeps it for this // run only. CacheFile string + // ShareKey is the config's share.key: the secret that encrypts shared + // env files, for repos whose .lazysecrets.yaml names none. + ShareKey string // 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 @@ -270,6 +273,10 @@ 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 + // A lazysecrets:// link found on the clipboard, offered in the footer. dismissedLink string // the last link offered: not offered again clipChecked time.Time @@ -455,6 +462,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.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) if m.cachePath != "" { m.loadCacheFile() @@ -570,6 +578,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case resolvedMsg: m.handleResolved(msg) + case shareFilesMsg: + cmds = append(cmds, m.handleShareFiles(msg)) + + case sealedMsg: + cmds = append(cmds, m.handleSealed(msg)) + + case shareOpenedMsg: + cmds = append(cmds, m.handleShareOpened(msg)) + case tea.PasteMsg: cmds = append(cmds, m.handlePaste(msg)) diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 7d7bbbd..195fba6 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -78,6 +78,9 @@ const ( // just open it. // With ask false, vault and scope links open straight away. func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd { + if r.IsShare() { + return m.offerShare(r) + } if r.IsContainer() || r.IsSource() { if !ask { return m.openRef(r, actGo) @@ -607,6 +610,9 @@ func (m *Model) share(menu bool) tea.Cmd { if s.URL != "" { opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")}) } + if m.canShareFiles(s) { + opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }}) + } m.showMenu("Share "+s.Name, nil, opts) case paneContainers: c, ok := m.containers.Selected() @@ -629,6 +635,11 @@ func (m *Model) share(menu bool) tea.Cmd { if c.URL != "" { opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")}) } + if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) { + opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd { + return m.shareFiles(c.Source, []provider.Container{c}) + }}) + } m.showMenu("Share "+c.Name, nil, opts) default: o, ok := m.detail.Secret() @@ -653,6 +664,11 @@ func (m *Model) share(menu bool) tea.Cmd { if o.URL != "" { opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")}) } + if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) { + opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd { + return m.shareFiles(c.Source, []provider.Container{c}) + }}) + } m.showMenu("Share "+o.Name, nil, opts) } return nil diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go new file mode 100644 index 0000000..f8e6693 --- /dev/null +++ b/internal/ui/app/sharefiles.go @@ -0,0 +1,354 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/share" +) + +// Sharing env files as an encrypted lazysecrets://share link (package +// share), and opening one: the files are encrypted with a secret's value, +// so whoever can read that secret can open the link. + +// sharing is a share being made: the files read, and the key to use. +type sharing struct { + repo provider.Source + files []share.File + key ref.Ref // zero until chosen +} + +type shareFilesMsg struct { + sharing + err error +} + +type sealedMsg struct { + link ref.Ref + n int + err error +} + +// canShareFiles reports whether src's env files can be shared: it's a +// repo whose files are on disk. +func (m Model) canShareFiles(src provider.Source) bool { + _, ok := m.providerFor(src).(provider.Files) + return src.Kind == provider.KindRepo && ok +} + +// shareFiles shares env files of a repo: cs, or with cs nil all its +// plain ones. +func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd { + p := m.providerFor(src) + files, ok := p.(provider.Files) + if !ok { + m.flash = warning("these files can't be shared") + return nil + } + general, timeout := m.shareKey, m.timeout + m.flash = flash{text: "reading the files…"} + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + msg := shareFilesMsg{sharing: sharing{repo: src}} + if cs == nil { + all, err := p.Containers(ctx, src) + if err != nil { + msg.err = err + return msg + } + for _, c := range all { + if c.Detail == provider.DetailPlainEnv { + cs = append(cs, c) + } + } + if len(cs) == 0 { + msg.err = errors.New(src.Name + " has no plain env files") + return msg + } + } + for _, c := range cs { + f, err := files.File(c, nil) + if err == nil { + var b []byte + b, err = os.ReadFile(f.Path) + msg.files = append(msg.files, share.File{Path: c.Name, Data: b}) + } + if err != nil { + msg.err = err + return msg + } + } + key, ok, err := share.DefaultKey(src.ID, general) + if ok { + msg.key = key + } + msg.err = err + return msg + } +} + +func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd { + switch { + case msg.err != nil: + m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} + case msg.key == (ref.Ref{}): + return m.askShareKey(msg.sharing) + default: + m.confirmShare(msg.sharing) + } + return nil +} + +// confirmShare says what the link will hold before making it. +func (m *Model) confirmShare(s sharing) { + lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")} + for _, f := range s.files { + lines = append(lines, " "+f.Path) + } + 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) }}, + }) +} + +// 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()) + } + 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 + }, + } + return m.prompt.input.Focus() +} + +// seal reads the key and encrypts the files with it. +func (m *Model) seal(s sharing) tea.Cmd { + read := m.keyReader() + 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) + return sealedMsg{link: link, n: len(s.files), err: err} + } +} + +func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { + if msg.err != nil { + m.noteErr(msg.err) + m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} + return nil + } + m.dismissedLink = msg.link.String() // not offered back on focus + return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false) +} + +// keyReader reads a key's value with the providers, the account that +// last read it first, off the UI goroutine. +func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) { + providers, st, timeout := m.providers, m.store, m.timeout + return func(key ref.Ref) (provider.SecretValue, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + k := key + k.Version = "" + node := st.Node(k.String()) + return ref.Read(ctx, providers, key, node.Account, node.Source) + } +} + +// --- opening --- + +type shareOpenedMsg struct { + sealed share.Sealed + files []share.File + dir string // the repo's clone; "" when it isn't here + err error +} + +// offerShare says what a share link holds before decrypting it: links +// can come from anywhere, so nothing is read or written unasked. +func (m *Model) offerShare(r ref.Ref) tea.Cmd { + s, err := share.Read(r) + if err != nil { + m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil) + return nil + } + from := "" + if s.Repo != "" { + from = " from " + s.Repo + } + m.showMenu("Shared env files", []string{ + m.st.Muted.Render(plural(s.Files, "env file") + from), + m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key), + }, []option{ + {key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }}, + }) + return nil +} + +// openShare reads the key, decrypts the files and finds the repo's clone. +func (m *Model) openShare(s share.Sealed) tea.Cmd { + read := m.keyReader() + providers, timeout := m.providers, m.timeout + m.flash = flash{text: "decrypting with " + s.Key.Name + "…"} + return func() tea.Msg { + msg := shareOpenedMsg{sealed: s} + v, _, err := read(s.Key) + if err != nil { + msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err) + return msg + } + if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil { + return msg + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + for _, p := range providers { + if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" { + if root, found, _ := loc.Locate(ctx, s.Repo); found { + msg.dir = root + break + } + } + } + return msg + } +} + +func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { + switch { + case msg.err != nil: + m.noteErr(msg.err) + m.flash = flash{text: "open: " + msg.err.Error(), isErr: true} + case msg.dir == "": + return m.askShareDir(msg) + default: + m.offerWrite(msg.files, msg.dir) + } + return nil +} + +// askShareDir asks where to save files from a repo that isn't here. +func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { + in := textinput.New() + in.Prompt = "folder: " + in.SetWidth(max(20, min(m.w-12, 64))) + dir := m.placeDir + if dir == "" { + dir, _ = os.Getwd() + } + in.SetValue(dir) + in.CursorEnd() + lines := []string{m.st.Muted.Render("Their paths are from the repo's root.")} + if msg.sealed.Repo != "" { + lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them where?")}, lines...) + } + m.prompt = &modal{ + title: "Save the files", input: &in, lines: lines, + submit: func(m *Model, text string) tea.Cmd { + m.offerWrite(msg.files, expandHome(strings.TrimSpace(text))) + return nil + }, + } + return m.prompt.input.Focus() +} + +// offerWrite shows where each file goes, then writes them as chosen: +// files there already are replaced only when asked. +func (m *Model) offerWrite(files []share.File, dir string) { + targets, err := share.Plan(dir, files) + if err != nil { + m.flash = flash{text: "save: " + err.Error(), isErr: true} + return + } + var lines []string + exist := 0 + for _, t := range targets { + line := " " + t.Path + m.st.Muted.Render(" · new") + if t.Exists { + exist++ + line = " " + t.Path + m.st.Muted.Render(" · replaces the one there") + } + if t.Exposed { + line += m.st.Error.Render(" · not ignored by git") + } + lines = append(lines, line) + } + lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...) + write := func(onlyNew bool) func(*Model) tea.Cmd { + return func(m *Model) tea.Cmd { + n := 0 + for _, t := range targets { + if onlyNew && t.Exists { + continue + } + if err := t.Write(); err != nil { + m.flash = flash{text: "save: " + err.Error(), isErr: true} + return nil + } + n++ + } + m.flash = done("saved " + plural(n, "file") + " in " + dir) + return nil + } + } + var opts []option + switch { + case exist == 0: + opts = append(opts, option{key: "w", label: "Save them", run: write(false)}) + case exist == len(targets): + opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], run: write(false)}) + default: + opts = append(opts, + option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)}, + option{key: "n", label: "Save only the new " + plural(len(targets)-exist, "file"), run: write(true)}, + ) + } + m.showMenu("Save the shared files", lines, opts) +} + +// expandHome expands a leading ~/ to the home folder. +func expandHome(dir string) string { + if rest, ok := strings.CutPrefix(dir, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, rest) + } + } + return dir +} diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go new file mode 100644 index 0000000..928cac8 --- /dev/null +++ b/internal/ui/app/sharefiles_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" +) + +// scryptWait is how long the driver waits for encrypting and decrypting, +// 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() + root := gitRepoWith(t, "https://git.example.com/team/app.git") + os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644) + for name, data := range files { + os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755) + os.WriteFile(filepath.Join(root, name), []byte(data), 0o600) + } + return root +} + +// shareFrom shares a repo's env files from lazysecrets started in it, +// returning the link copied. +func shareFrom(t *testing.T, root string) string { + t.Helper() + clip := &fakeClipboard{} + d := newDriverOpts(t, Options{Clipboard: clip}, 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) + } + 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) + } + 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()) + } + d.keys("c") + if !strings.HasPrefix(clip.content, "lazysecrets://share/") { + t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) + } + return clip.content +} + +func TestShareFilesRoundTrip(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + link := shareFrom(t, cloneWith(t, map[string]string{ + ".env": "DB_PASSWORD=hunter2\n", + "prod.env": "TOKEN=abc\n", + })) + + // A colleague's clone: .env is there already, prod.env isn't. + theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"}) + d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs})) + d.wait = scryptWait + d.send(tea.PasteMsg{Content: link}) + if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") { + t.Fatalf("not offered:\n%s", d.screen()) + } + d.keys("o") + if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" { + t.Fatalf("no save menu (%s)", d.m.flash.text) + } + if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") { + t.Errorf("save menu doesn't say what happens:\n%s", s) + } + d.keys("n") // only the new one + if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" { + t.Errorf(".env replaced: %q", b) + } + if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" { + t.Errorf("prod.env: %q", b) + } + + // Again, replacing. + d.send(tea.PasteMsg{Content: link}) + d.keys("o", "w") + if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" { + t.Errorf(".env not replaced: %q", b) + } +} + +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"})) + dir, _ := filepath.EvalSymlinks(t.TempDir()) + d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0)) + d.wait = scryptWait + d.send(tea.PasteMsg{Content: link}) + d.keys("o") + if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir { + t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen()) + } + d.keys("enter", "w") + if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" { + t.Errorf(".env: %q (%s)", b, d.m.flash.text) + } +} + +func TestShareLinkGarbled(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)}) + if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") { + t.Errorf("not reported:\n%s", d.screen()) + } +} diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index c9c3a0d..6d3a9e4 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -117,7 +117,7 @@ func (m *Map) Actions() []Action { a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), - a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."), + a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."), -- 2.54.0 From 4d57c1977220d09703a9c9c009382d03bc818806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:00:33 +0200 Subject: [PATCH 02/24] fix: staticcheck and go mod tidy Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/lazysecrets/share.go | 2 +- go.mod | 2 +- go.sum | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/lazysecrets/share.go b/cmd/lazysecrets/share.go index 65cbb58..d8d2069 100644 --- a/cmd/lazysecrets/share.go +++ b/cmd/lazysecrets/share.go @@ -26,7 +26,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St return err } if fs.NArg() == 0 { - return errors.New("usage: lazysecrets share [--key ] ...") + return errors.New("usage: lazysecrets share [--key ] ") } var root, id string diff --git a/go.mod b/go.mod index f137546..1eee0fe 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 + filippo.io/age v1.3.2 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0 @@ -29,7 +30,6 @@ require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - filippo.io/age v1.3.2 // indirect filippo.io/hpke v0.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/go.sum b/go.sum index 52a4971..4fcae07 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= -- 2.54.0 From bc691d786af3ed5bf9b5d0391d7ddd33570daa62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:09:08 +0200 Subject: [PATCH 03/24] feat: pick the share key in Go to, or by reference Sharing without share.key opens Go to as a picker of secrets instead of asking for a link: it lists only secrets, enter picks one, and a pasted reference picks it too. The key, there and in share.key or --key, can be a secret's lazysecrets:// link, Key Vault URL or @Microsoft.KeyVault(...) reference. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/lazysecrets/main.go | 2 +- cmd/lazysecrets/share.go | 6 ++-- docs/config.md | 2 +- docs/links.md | 13 ++++--- docs/repos.md | 2 +- internal/config/config.go | 7 ++-- internal/share/share.go | 18 ++++++---- internal/share/share_test.go | 25 +++++++++++++- internal/ui/app/app.go | 4 +-- internal/ui/app/links.go | 3 ++ internal/ui/app/searchview.go | 44 ++++++++++++++++++++++-- internal/ui/app/sharefiles.go | 54 +++++++++++++----------------- internal/ui/app/sharefiles_test.go | 39 +++++++++++++++------ 13 files changed, 152 insertions(+), 67 deletions(-) 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 -- 2.54.0 From 68fc76d938c6535ca992db25eb950d742bddf4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:10:17 +0200 Subject: [PATCH 04/24] docs: items can be shared as links, not have them Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/links.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/links.md b/docs/links.md index adb57ea..b8b9bfc 100644 --- a/docs/links.md +++ b/docs/links.md @@ -1,9 +1,10 @@ # Links -Every subscription, workspace, vault, scope, repo, file and secret has a -`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it -lands on that item, reading it with their own account and permissions: -links carry names, never values. +Subscriptions, workspaces, vaults, scopes, repos, files and secrets can +be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens +it lands on that item, reading it with their own account and +permissions: the link names the item, never its value. Env files can +also be [shared encrypted](#sharing-env-files), in the link itself. ``` lazysecrets://azure/kv-app-prod.vault.azure.net vault -- 2.54.0 From b46991c7c2b7a71bc20c754ef8710dbde60ca9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:13:16 +0200 Subject: [PATCH 05/24] feat: the key picker says what sharing does, and skips plain env values Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/modal.go | 1 + internal/ui/app/search.go | 6 ++++-- internal/ui/app/searchview.go | 21 +++++++++++++-------- internal/ui/app/sharefiles.go | 10 +++++++++- internal/ui/app/sharefiles_test.go | 8 ++++++++ 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/internal/ui/app/modal.go b/internal/ui/app/modal.go index 8b27d91..0dac172 100644 --- a/internal/ui/app/modal.go +++ b/internal/ui/app/modal.go @@ -40,6 +40,7 @@ type option struct { group string // the source row it's in (search) kind provider.Kind // the kind of provider it's from (search) hits []int // characters of the label the filter matched (Go to) + plain bool // a key in a plain env file: no key to encrypt with (Go to) run func(m *Model) tea.Cmd // header makes this a section title: not selectable, and hidden // while a filter narrows the list. diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index f151c17..0b4cc47 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -61,13 +61,14 @@ func (m *Model) searchEntries() (sources, items []option) { var es []entry // key is the store key, so ctrl+f stars the entry; mark shows how // much is loaded, reload (ctrl+r) loads it again. - add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) { + add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option { n := m.store.Node(key) sort := strings.ToLower(strings.Join(path, "/")) + " " + account es = append(es, entry{depth, sort, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) + return &es[len(es)-1].opt } for _, p := range m.providers { for _, src := range m.sourceResults[p.ID()].sources { @@ -98,10 +99,11 @@ func (m *Model) searchEntries() (sources, items []option) { typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" suffix = strings.Trim(typ+" · "+suffix, " ·") } - add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd { + e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd { m.startNav(r, actGo, row) return nil }, reload) + e.plain = c.Detail == provider.DetailPlainEnv } } } diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 90b790f..790542e 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -56,8 +56,9 @@ type searchView struct { // 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 + pick func(m *Model, r ref.Ref) tea.Cmd + pickTitle string + pickLines []string // what picking does, above the input // 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 @@ -749,7 +750,14 @@ func (m Model) searchViewBox() string { lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused) } - body := []string{" " + v.input.View(), ""} + var body []string + if v.pick != nil { + for _, l := range v.pickLines { + body = append(body, " "+l) + } + body = append(body, "") + } + body = append(body, " "+v.input.View(), "") for _, l := range strings.Split(lists, "\n") { body = append(body, " "+l) } @@ -761,9 +769,6 @@ 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 { @@ -808,10 +813,10 @@ func (v *searchView) selectLabel() string { } // pickable is the link of a Go to entry that a picker can pick: a -// secret (not a key or certificate). +// secret (not a key or certificate, nor a key in a plain env file). 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) { + if err != nil || o.plain || 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 6c14a8d..a25388c 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -131,7 +131,15 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { 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." + what := s.files[0].Path + if len(s.files) > 1 { + what = plural(len(s.files), "env file") + } + m.search.pickLines = []string{ + "Sharing " + what + " from " + s.repo.Name + " as an encrypted link.", + "Pick the secret to encrypt with: whoever can read it can open the link.", + m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default."), + } m.fillSearch(false) return tea.Batch(cmd, m.searchLoads()) } diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index dc0ea08..5f99097 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -47,6 +47,14 @@ func shareFrom(t *testing.T, root string, paste bool) string { if v == nil || v.pick == nil { t.Fatalf("no secret picker: %s", d.m.flash.text) } + for _, o := range v.shown { + if o.plain || strings.Contains(o.label, ".env") { + t.Errorf("a plain env value offered as the key: %s", o.label) + } + } + if !strings.Contains(d.screen(), "as an encrypted link") { + t.Errorf("doesn't say what picking does:\n%s", d.screen()) + } if paste { // A reference pasted is picked as it is. d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"}) -- 2.54.0 From 0bfa2a31415d58d276f929af2ce851ca513aa439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:16:12 +0200 Subject: [PATCH 06/24] feat: share.keyRules, how long and varied a share key must be Sharing refuses a key shorter than minLength characters (default 32, never under 16) or of fewer than minClasses kinds of lowercase, uppercase, digits and symbols (default 2, so random hex passes), saying so without showing it. Opening doesn't check. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/lazysecrets/main.go | 3 +- cmd/lazysecrets/share.go | 10 ++++- cmd/lazysecrets/share_test.go | 7 +++- docs/config.md | 3 ++ docs/links.md | 16 +++++++- internal/config/config.go | 16 ++++++++ internal/config/config_test.go | 3 ++ internal/share/rules.go | 59 ++++++++++++++++++++++++++++++ internal/share/share.go | 11 +++--- internal/share/share_test.go | 31 ++++++++++++++-- internal/ui/app/app.go | 8 +++- internal/ui/app/sharefiles.go | 4 +- internal/ui/app/sharefiles_test.go | 6 ++- 13 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 internal/share/rules.go 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) -- 2.54.0 From cd265a632ea171f35130659672459fcf30fa785e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:22:23 +0200 Subject: [PATCH 07/24] feat: share popups draw repos and files like everywhere else repoLabel draws a repo, or a file in it, with pathLabel: the repo icon and colour. The share popups use it for what's shared, where it's from and where it's saved, and are titled for what's shared ("Share .env", "Share 2 env files"), the key picker too. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/jump.go | 6 +++ internal/ui/app/sharefiles.go | 59 +++++++++++++++++++----------- internal/ui/app/sharefiles_test.go | 2 +- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index dbaa15b..86a7b94 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -138,6 +138,12 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri return label } +// repoLabel draws a repo, or a file in it, as everywhere else: the repo +// icon and colour, "app / apps/api/.env". +func (m Model) repoLabel(repo string, file ...string) string { + return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "") +} + // sourceMatches reports whether src is the subscription or workspace r // links to. func sourceMatches(r ref.Ref, src provider.Source) bool { diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 23d7089..13d3f3b 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" @@ -109,14 +110,27 @@ func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd { return nil } +// shareTitle names the action: "Share .env", "Share 2 env files". +func shareTitle(s sharing) string { + if len(s.files) == 1 { + return "Share " + path.Base(s.files[0].Path) + } + return "Share " + plural(len(s.files), "env file") +} + +// sharedFiles lists the files being shared, under what's happening. +func (m *Model) sharedFiles(s sharing) []string { + lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"} + for _, f := range s.files { + lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path)) + } + return lines +} + // confirmShare says what the link will hold before making it. func (m *Model) confirmShare(s sharing) { - lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")} - for _, f := range s.files { - lines = append(lines, " "+f.Path) - } - lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) - m.showMenu("Share as an encrypted link", lines, []option{ + lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) + m.showMenu(shareTitle(s), 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.pickShareKey(s) }}, }) @@ -130,16 +144,10 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { m.confirmShare(s) return nil } - m.search.pickTitle = "Encrypt with which secret?" - what := s.files[0].Path - if len(s.files) > 1 { - what = plural(len(s.files), "env file") - } - m.search.pickLines = []string{ - "Sharing " + what + " from " + s.repo.Name + " as an encrypted link.", - "Pick the secret to encrypt with: whoever can read it can open the link.", - m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default."), - } + m.search.pickTitle = shareTitle(s) + m.search.pickLines = append(m.sharedFiles(s), "", + "Pick the secret to encrypt them with: whoever can read it can open the link.", + m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default.")) m.fillSearch(false) return tea.Batch(cmd, m.searchLoads()) } @@ -213,10 +221,10 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd { } from := "" if s.Repo != "" { - from = " from " + s.Repo + from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo) } m.showMenu("Shared env files", []string{ - m.st.Muted.Render(plural(s.Files, "env file") + from), + m.st.Muted.Render(plural(s.Files, "env file")) + from, m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key), }, []option{ {key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }}, @@ -302,10 +310,11 @@ func (m *Model) offerWrite(files []share.File, dir string) { var lines []string exist := 0 for _, t := range targets { - line := " " + t.Path + m.st.Muted.Render(" · new") + file := " " + m.repoLabel(filepath.Base(dir), t.Path) + line := file + m.st.Muted.Render(" · new") if t.Exists { exist++ - line = " " + t.Path + m.st.Muted.Render(" · replaces the one there") + line = file + m.st.Muted.Render(" · replaces the one there") } if t.Exposed { line += m.st.Error.Render(" · not ignored by git") @@ -339,7 +348,7 @@ func (m *Model) offerWrite(files []share.File, dir string) { default: opts = append(opts, option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)}, - option{key: "n", label: "Save only the new " + plural(len(targets)-exist, "file"), run: write(true)}, + option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)}, ) } m.showMenu("Save the shared files", lines, opts) @@ -354,3 +363,11 @@ func expandHome(dir string) string { } return dir } + +// onlyNew labels saving only the n files not there yet. +func onlyNew(n int) string { + if n == 1 { + return "Save only the new one" + } + return fmt.Sprintf("Save only the %d new ones", n) +} diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 213c03c..fae4ef8 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -74,7 +74,7 @@ func shareFrom(t *testing.T, root string, paste bool) string { t.Errorf("not a secret: %s", o.label) } } - if !strings.Contains(d.screen(), "Encrypt with which secret?") { + if !strings.Contains(d.screen(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") { t.Errorf("not titled as a picker:\n%s", d.screen()) } d.keys("enter") -- 2.54.0 From 8cd0003f8cc77ef64eb17b9e923a6194a904ea90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:24:10 +0200 Subject: [PATCH 08/24] feat: the key picker's tabs go under its heading, with help text The share popup is titled for what's shared; above the search, "Pick a secret to encrypt with" says the recipient needs read access to it, with Go to's tabs under it. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/searchview.go | 37 +++++++++++++++++++++-------------- internal/ui/app/sharefiles.go | 6 ++++-- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 790542e..ff07ab0 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -750,12 +750,13 @@ func (m Model) searchViewBox() string { lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused) } + ts := panes.TitleStyle(m.st, focused) var body []string if v.pick != nil { for _, l := range v.pickLines { body = append(body, " "+l) } - body = append(body, "") + body = append(body, "", " "+m.goToTabs(ts)) } body = append(body, " "+v.input.View(), "") for _, l := range strings.Split(lists, "\n") { @@ -785,21 +786,11 @@ func (m Model) searchViewBox() string { } 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 + " ") + // Tabs in the title, the current one underlined, like the panes; a + // picker has its own title, and the tabs over the input. + head := ts.Render(goToTitle+" ") + m.goToTabs(ts) if v.pick != nil { - head = ts.Render(v.pickTitle + " ") - } - for i, name := range tabNames { - if i > 0 { - head += ts.Render(" - ") - } - if i == v.tab { - head += ts.Underline(true).Render(name) - } else { - head += m.st.Muted.Render(name) - } + head = ts.Render(v.pickTitle) } return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) } @@ -821,3 +812,19 @@ func pickable(o option) (ref.Ref, bool) { } return r, true } + +// goToTabs draws Go to's tabs, the current one underlined. +func (m Model) goToTabs(ts lipgloss.Style) string { + var out string + for i, name := range tabNames { + if i > 0 { + out += ts.Render(" - ") + } + if i == m.search.tab { + out += ts.Underline(true).Render(name) + } else { + out += m.st.Muted.Render(name) + } + } + return out +} diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 13d3f3b..10e6212 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -15,6 +15,7 @@ import ( "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/ui/panes" ) // Sharing env files as an encrypted lazysecrets://share link (package @@ -146,8 +147,9 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { } m.search.pickTitle = shareTitle(s) m.search.pickLines = append(m.sharedFiles(s), "", - "Pick the secret to encrypt them with: whoever can read it can open the link.", - m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default.")) + panes.TitleStyle(m.st, true).Render("Pick a secret to encrypt with"), + m.st.Muted.Render("The recipient needs read access to it to decrypt the files. Its value isn't"), + m.st.Muted.Render("shown or put in the link. share.key sets one for a repo, or for all.")) m.fillSearch(false) return tea.Batch(cmd, m.searchLoads()) } -- 2.54.0 From 5c97a8e249252de1e7e441f58e5d4050633ea18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:36:06 +0200 Subject: [PATCH 09/24] feat: picking the key shares, with no second popup The picker already shows what's shared. The confirmation stays for a key from share.key, which wasn't picked. The share format's version is documented as kept readable. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/share/share.go | 5 +++++ internal/ui/app/sharefiles.go | 6 +++--- internal/ui/app/sharefiles_test.go | 27 +++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/internal/share/share.go b/internal/share/share.go index 92d831d..2ce72f1 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -13,6 +13,10 @@ // version (1) | uvarint length of the header | header (JSON) | age file // // The age file holds the files as deflated JSON. +// +// A change to the format gets the next version, and Read keeps reading +// every earlier one: links stay in chat and tickets for years. A +// version it doesn't know is from a newer lazysecrets. package share import ( @@ -36,6 +40,7 @@ import ( ) const ( + // version is the format's, written first; see the package doc. version = 1 // MaxLink keeps a link short enough for chat and tickets (Teams takes // about 28 KB) and for passing it to a terminal window. diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 10e6212..b67721b 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -137,13 +137,13 @@ func (m *Model) confirmShare(s sharing) { }) } -// pickShareKey asks which secret to encrypt with, in Go to. +// pickShareKey asks which secret to encrypt with, in Go to, then shares +// with it. 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 + return m.seal(s) // the picker showed what's shared } m.search.pickTitle = shareTitle(s) m.search.pickLines = append(m.sharedFiles(s), "", diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index fae4ef8..8535f95 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -79,10 +79,7 @@ func shareFrom(t *testing.T, root string, paste bool) string { } 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()) - } - d.keys("c") + // Picking it shares: the picker said what. if !strings.HasPrefix(clip.content, "lazysecrets://share/") { t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) } @@ -129,6 +126,28 @@ func TestShareFilesRoundTrip(t *testing.T) { } } +func TestShareWithShareKeyConfirms(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + root := cloneWith(t, map[string]string{ + ".env": "A=1\n", + ".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n", + }) + clip := &fakeClipboard{} + d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) + d.wait = scryptWait + d.keys("S", "f") + // The key wasn't picked: it shows which, before sharing. + if d.m.search != nil || d.m.prompt == nil || d.m.prompt.title != "Share .env" || !strings.Contains(d.screen(), "db-password") { + t.Fatalf("no confirmation:\n%s", d.screen()) + } + d.keys("c") + if !strings.HasPrefix(clip.content, "lazysecrets://share/") { + t.Errorf("copied %q (%s)", clip.content, d.m.flash.text) + } +} + func TestShareLinkElsewhereAsksWhere(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("no git") -- 2.54.0 From 176600be545464395ad750f34bdd8c6e0f8f04cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:40:18 +0200 Subject: [PATCH 10/24] fix: opening a share link reads right for one file or several Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/sharefiles.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index b67721b..b538457 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -229,7 +229,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd { m.st.Muted.Render(plural(s.Files, "env file")) + from, m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key), }, []option{ - {key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }}, + {key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }}, }) return nil } @@ -287,9 +287,9 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { } in.SetValue(dir) in.CursorEnd() - lines := []string{m.st.Muted.Render("Their paths are from the repo's root.")} + lines := []string{m.st.Muted.Render("Paths are from the repo's root.")} if msg.sealed.Repo != "" { - lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them where?")}, lines...) + lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...) } m.prompt = &modal{ title: "Save the files", input: &in, lines: lines, @@ -344,9 +344,9 @@ func (m *Model) offerWrite(files []share.File, dir string) { var opts []option switch { case exist == 0: - opts = append(opts, option{key: "w", label: "Save them", run: write(false)}) + opts = append(opts, option{key: "w", label: "Save", run: write(false)}) case exist == len(targets): - opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], run: write(false)}) + opts = append(opts, option{key: "w", label: "Replace", run: write(false)}) default: opts = append(opts, option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)}, -- 2.54.0 From 8d65b60e692f92da0dbee44fed19578b5e88c856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:42:33 +0200 Subject: [PATCH 11/24] feat: the key picker has its own border in the share popup Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/searchview.go | 33 ++++++++++++++++++++++++++------- internal/ui/app/sharefiles.go | 12 +++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index ff07ab0..d9d6bd6 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -56,9 +56,11 @@ type searchView struct { // 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 string - pickLines []string // what picking does, above the input + pick func(m *Model, r ref.Ref) tea.Cmd + pickTitle string // the popup's + pickLines []string // what picking is for, above the picker + pickHeading string // the picker's title, on its border + pickHelp []string // inside it, above the tabs // 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 @@ -658,7 +660,15 @@ func (m Model) searchViewBox() string { v := m.search focused := !m.termBlurred w := searchWidth(m.w) + popupW := w + if v.pick != nil { + w -= 4 // inside the picker's border, in the popup + } listH := max(5, min(m.h-10, 14)) + if v.pick != nil { + // What it's for, the help, the tabs and a border more. + listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-5, 14)) + } srcW := 10 for _, o := range v.scopes { @@ -753,7 +763,7 @@ func (m Model) searchViewBox() string { ts := panes.TitleStyle(m.st, focused) var body []string if v.pick != nil { - for _, l := range v.pickLines { + for _, l := range v.pickHelp { body = append(body, " "+l) } body = append(body, "", " "+m.goToTabs(ts)) @@ -789,10 +799,19 @@ func (m Model) searchViewBox() string { // Tabs in the title, the current one underlined, like the panes; a // picker has its own title, and the tabs over the input. head := ts.Render(goToTitle+" ") + m.goToTabs(ts) - if v.pick != nil { - head = ts.Render(v.pickTitle) + if v.pick == nil { + return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) } - return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) + // A picker: in its own border, under what it's for. + var outer []string + for _, l := range v.pickLines { + outer = append(outer, " "+l) + } + outer = append(outer, "") + for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.pickHeading), "", body, w, len(body)+2, focused), "\n") { + outer = append(outer, " "+l) + } + return panes.Box(m.st, ts.Render(v.pickTitle), foot, outer, popupW, len(outer)+2, focused) } // selectLabel names what enter does with the selected entry. diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index b538457..0d247bc 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -15,7 +15,6 @@ import ( "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/ui/panes" ) // Sharing env files as an encrypted lazysecrets://share link (package @@ -146,10 +145,13 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { return m.seal(s) // the picker showed what's shared } m.search.pickTitle = shareTitle(s) - m.search.pickLines = append(m.sharedFiles(s), "", - panes.TitleStyle(m.st, true).Render("Pick a secret to encrypt with"), - m.st.Muted.Render("The recipient needs read access to it to decrypt the files. Its value isn't"), - m.st.Muted.Render("shown or put in the link. share.key sets one for a repo, or for all.")) + m.search.pickLines = m.sharedFiles(s) + m.search.pickHeading = "Pick a secret to encrypt with" + m.search.pickHelp = []string{ + m.st.Muted.Render("The recipient needs read access to it to decrypt the files. Its value"), + m.st.Muted.Render("isn't shown or put in the link. share.key sets one for a repo, or for all."), + } + m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border m.fillSearch(false) return tea.Batch(cmd, m.searchLoads()) } -- 2.54.0 From 86fb79a9157da9499f12a517ff0fd517374c7f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 12:48:05 +0200 Subject: [PATCH 12/24] fix: the key picker leaves share.key to the docs Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/sharefiles.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 0d247bc..faed1df 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -148,8 +148,8 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { m.search.pickLines = m.sharedFiles(s) m.search.pickHeading = "Pick a secret to encrypt with" m.search.pickHelp = []string{ - m.st.Muted.Render("The recipient needs read access to it to decrypt the files. Its value"), - m.st.Muted.Render("isn't shown or put in the link. share.key sets one for a repo, or for all."), + m.st.Muted.Render("The recipient needs read access to it to decrypt the files."), + m.st.Muted.Render("Its value isn't shown or put in the link."), } m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border m.fillSearch(false) -- 2.54.0 From 777c8cb532855e3687835c0725ab1d3e59a8808d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:11:59 +0200 Subject: [PATCH 13/24] feat: share env files in --demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers whose containers are files give their contents through provider.Contents, which sharing reads instead of the disk. The demo repos build their env files from their keys, and the second demo account has a key to share with (kv-shared-prod / env-share), so S → f and opening the link work in --demo; the demo repos aren't on disk, so opening asks where to save. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/development.md | 5 ++++ internal/provider/fake/fake.go | 4 +++ internal/provider/fake/repos.go | 15 +++++++++++ internal/provider/provider.go | 6 +++++ internal/provider/repo/repo.go | 9 +++++++ internal/ui/app/sharefiles.go | 14 ++++------ internal/ui/app/sharefiles_test.go | 42 ++++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/development.md b/docs/development.md index 40e1a24..9fdede4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,6 +7,11 @@ go test ./... go test ./internal/ui/app -run TestGolden -update # after intentional UI changes ``` +In `--demo`, sharing env files works end to end: `S` → `f` on a demo +repo, pick `kv-shared-prod / env-share` (the second account's) as the +key, then paste the link back. The demo repos aren't on disk, so opening +asks where to save, as for a repo you haven't cloned. + A stripped binary is about 21 MB instead of 30 MB: ```sh diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index cd86071..d7b021c 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -331,6 +331,10 @@ func Azure(delay time.Duration) *Provider { }, demoCert) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) + // A key to share env files with (S → f): random, as share.keyRules wants. + p.add(shared, provider.TypeSecret, provider.Object{ + Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), + }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") p.add(shared, provider.TypeSecret, provider.Object{ Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), }, "smtp-demo-password") diff --git a/internal/provider/fake/repos.go b/internal/provider/fake/repos.go index 1dc5038..2757c62 100644 --- a/internal/provider/fake/repos.go +++ b/internal/provider/fake/repos.go @@ -134,3 +134,18 @@ func format(path string) string { } return "dotenv" } + +var _ provider.Contents = (*Provider)(nil) + +// Contents is a plain env file as KEY=value lines, from its keys, so the +// demo repos' env files can be shared. +func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) { + if c.Detail != provider.DetailPlainEnv { + return nil, provider.ErrUnsupported + } + var b strings.Builder + for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] { + b.WriteString(o.Name + "=" + p.values[o.ID] + "\n") + } + return []byte(b.String()), ctx.Err() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7ad3fa8..a10ea66 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -189,6 +189,12 @@ type Files interface { File(c Container, o *Object) (File, error) } +// Contents is implemented by providers whose containers are files, so +// they can be shared (package share): the file as it is. +type Contents interface { + Contents(ctx context.Context, c Container) ([]byte, error) +} + // File is a container's file, to open. type File struct { Path string diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index 234917f..1f53ad7 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -412,6 +412,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre return provider.NewSecretValue(stdout.Bytes()), nil } +// Contents reads the file c is. +func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) { + f, err := p.File(c, nil) + if err != nil { + return nil, err + } + return os.ReadFile(f.Path) +} + // File is the file c is, and the line o's key is on. func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { path := c.ID diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index faed1df..3f61a7e 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -40,9 +40,9 @@ type sealedMsg struct { } // canShareFiles reports whether src's env files can be shared: it's a -// repo whose files are on disk. +// repo whose files can be read. func (m Model) canShareFiles(src provider.Source) bool { - _, ok := m.providerFor(src).(provider.Files) + _, ok := m.providerFor(src).(provider.Contents) return src.Kind == provider.KindRepo && ok } @@ -50,7 +50,7 @@ func (m Model) canShareFiles(src provider.Source) bool { // plain ones. func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd { p := m.providerFor(src) - files, ok := p.(provider.Files) + files, ok := p.(provider.Contents) if !ok { m.flash = warning("these files can't be shared") return nil @@ -78,16 +78,12 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd } } for _, c := range cs { - f, err := files.File(c, nil) - if err == nil { - var b []byte - b, err = os.ReadFile(f.Path) - msg.files = append(msg.files, share.File{Path: c.Name, Data: b}) - } + b, err := files.Contents(ctx, c) if err != nil { msg.err = err return msg } + msg.files = append(msg.files, share.File{Path: c.Name, Data: b}) } key, ok, err := share.DefaultKey(src.ID, general) if ok { diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 8535f95..526f8a7 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" + "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/share" @@ -175,3 +176,44 @@ func TestShareLinkGarbled(t *testing.T) { t.Errorf("not reported:\n%s", d.screen()) } } + +// TestShareInDemo shares a demo repo's env files with the demo key and +// opens the link, as lazysecrets --demo does: the demo repos aren't on +// disk, so it asks where to save. +func TestShareInDemo(t *testing.T) { + clip := &fakeClipboard{} + d := newDriverOpts(t, Options{Clipboard: clip}, + fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos()) + d.wait = scryptWait + var src provider.Source + for _, s := range d.m.sourceResults["Repos"].sources { + if s.Here { + src = s + } + } + if src.ID == "" { + t.Fatal("no demo repo") + } + src.Provider = "Repos" + d.run(d.m.shareFiles(src, nil)) + if d.m.search == nil || d.m.search.pick == nil { + t.Fatalf("no picker: %s", d.m.flash.text) + } + d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"}) + if !strings.HasPrefix(clip.content, "lazysecrets://share/") { + t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) + } + + dir, _ := filepath.EvalSymlinks(t.TempDir()) + d.m.placeDir = dir + d.send(tea.PasteMsg{Content: clip.content}) + d.keys("o") + if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") { + t.Fatalf("doesn't ask where:\n%s", d.screen()) + } + d.keys("enter", "w") + entries, _ := os.ReadDir(dir) + if len(entries) == 0 { + t.Fatalf("nothing saved (%s)", d.m.flash.text) + } +} -- 2.54.0 From 166790d825b3290b6ddc045059038c8fe71cd14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:17:04 +0200 Subject: [PATCH 14/24] fix: a demo repo's SOPS value can be the share key The demo provider finds repos by their remote, as the repo provider does, so a key in a demo repo's SOPS file can be read. Opening a share link uses the repo's clone only when it's on disk (the demo's aren't), else asks where to save. The demo GitHub client secret is 40 characters, like real ones, so it passes share.keyRules. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/provider/fake/fake.go | 5 +++-- internal/provider/fake/repos.go | 2 +- internal/ui/app/sharefiles.go | 9 ++++++++- internal/ui/app/sharefiles_test.go | 12 +++++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index d7b021c..4bf3a21 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -134,13 +134,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre return provider.NewSecretValue([]byte(v)), nil } -// Locate finds the source holding the vault (by host) or the workspace. +// Locate finds the source holding the vault (by host), the workspace or +// the repo (by its remote). func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { if err := p.wait(ctx); err != nil { return "", false, err } for _, src := range p.sources { - if p.kind == provider.KindDatabricks && src.Detail == host { + if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) { return src.ID, true, nil } for _, c := range p.containers[src.ID] { diff --git a/internal/provider/fake/repos.go b/internal/provider/fake/repos.go index 2757c62..ca2f141 100644 --- a/internal/provider/fake/repos.go +++ b/internal/provider/fake/repos.go @@ -94,7 +94,7 @@ func Repos() *Provider { "database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app", "stripe.secret_key", "sk_test_demo_51NxDemoKey0000") file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago", - "github.client_secret", "ghs_demo0000000000000000000000", + "github.client_secret", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f", "google.client_secret", "GOCSPX-demo-000000000000") file(api, ".env", "", "ignored", "", "DATABASE_URL", "postgres://app:local@localhost:5432/app", diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 3f61a7e..62fbdf2 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -251,7 +251,8 @@ func (m *Model) openShare(s share.Sealed) tea.Cmd { defer cancel() for _, p := range providers { if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" { - if root, found, _ := loc.Locate(ctx, s.Repo); found { + // Its clone, if it's on disk (the demo's aren't). + if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) { msg.dir = root break } @@ -354,6 +355,12 @@ func (m *Model) offerWrite(files []share.File, dir string) { m.showMenu("Save the shared files", lines, opts) } +// isDir reports whether dir is a folder on disk. +func isDir(dir string) bool { + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} + // expandHome expands a leading ~/ to the home folder. func expandHome(dir string) string { if rest, ok := strings.CutPrefix(dir, "~/"); ok { diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 526f8a7..9185d81 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -1,6 +1,7 @@ package app import ( + "net/url" "os" "os/exec" "path/filepath" @@ -203,10 +204,19 @@ func TestShareInDemo(t *testing.T) { if !strings.HasPrefix(clip.content, "lazysecrets://share/") { t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) } + link := clip.content + // A value in a demo repo's SOPS file can be the key too, found by + // the repo's remote. + clip.content = "" + d.run(d.m.shareFiles(src, nil)) + d.send(tea.PasteMsg{Content: "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/config%2Foauth.enc.json/github.client_secret"}) + if !strings.HasPrefix(clip.content, "lazysecrets://share/") { + t.Errorf("with the demo repo's key: copied %q (%s)", clip.content, d.m.flash.text) + } dir, _ := filepath.EvalSymlinks(t.TempDir()) d.m.placeDir = dir - d.send(tea.PasteMsg{Content: clip.content}) + d.send(tea.PasteMsg{Content: link}) d.keys("o") if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") { t.Fatalf("doesn't ask where:\n%s", d.screen()) -- 2.54.0 From 027e748c927cc986fd798e4c7656995dca8c9821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:24:21 +0200 Subject: [PATCH 15/24] feat: a key that can't be used is explained in the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key picker stays open until the link is made, saying "encrypting with …" meanwhile. A key that can't be read, or doesn't keep to share.keyRules, is shown in it in red with which rule it misses ("its value has 28 characters; share.keyRules needs 32 (minLength)"), to pick another. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/share/rules.go | 26 +++++++++++++++++++++++--- internal/share/share.go | 4 ++-- internal/share/share_test.go | 2 +- internal/ui/app/searchview.go | 18 +++++++++++++++--- internal/ui/app/sharefiles.go | 29 +++++++++++++++++++++++------ internal/ui/app/sharefiles_test.go | 10 +++++++++- 6 files changed, 73 insertions(+), 16 deletions(-) diff --git a/internal/share/rules.go b/internal/share/rules.go index 90abe80..6025437 100644 --- a/internal/share/rules.go +++ b/internal/share/rules.go @@ -2,6 +2,7 @@ package share import ( "fmt" + "strings" "unicode" ) @@ -40,11 +41,20 @@ func (r Rules) Check(key []byte) error { } } n := len([]rune(s)) - if n >= r.MinLength && kinds >= r.MinClasses { + var has, needs []string + if n < r.MinLength { + has = append(has, fmt.Sprintf("%d characters", n)) + needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength)) + } + if kinds < r.MinClasses { + has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1])) + needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses)) + } + if len(has) == 0 { 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) + return fmt.Errorf("its value has %s; share.keyRules needs %s. Use a random one, like openssl rand -base64 32 makes", + strings.Join(has, " and "), strings.Join(needs, " and ")) } func (r Rules) withDefaults() Rules { @@ -57,3 +67,13 @@ func (r Rules) withDefaults() Rules { r.MinLength = max(r.MinLength, MinKey) return r } + +// KeyError is a key that can't be used: it can't be read, or doesn't +// keep to the rules. +type KeyError struct { + Name string // the secret's + Err error +} + +func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() } +func (e *KeyError) Unwrap() error { return e.Err } diff --git a/internal/share/share.go b/internal/share/share.go index 2ce72f1..e9a4d22 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -91,13 +91,13 @@ type file struct { } // Seal encrypts files with key, the value of h.Key, into a share link, -// if key keeps to rules. +// if key keeps to rules (*KeyError if not). 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 err := rules.Check(key); err != nil { - return ref.Ref{}, err + return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: 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 2fc7dc7..d61f803 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -62,7 +62,7 @@ func TestSealRefuses(t *testing.T) { files []File want string }{ - "short key": {Header{Key: key}, []byte("short"), ok, "keyRules wants"}, + "short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"}, "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"}, diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index d9d6bd6..29b9fca 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -61,6 +61,8 @@ type searchView struct { pickLines []string // what picking is for, above the picker pickHeading string // the picker's title, on its border pickHelp []string // inside it, above the tabs + pickBusy string // what picking is doing, under the help + pickErr string // why the key picked can't be used // 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 @@ -438,8 +440,7 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { 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) + return v.pick(m, r) // the picker closes when it's done case v.results && v.res < len(v.shown): m.search = nil return v.shown[v.res].run(m) @@ -667,7 +668,9 @@ func (m Model) searchViewBox() string { listH := max(5, min(m.h-10, 14)) if v.pick != nil { // What it's for, the help, the tabs and a border more. - listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-5, 14)) + // What it's for, the help, a line or two on the key picked, the + // tabs and a border more. + listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-8, 14)) } srcW := 10 @@ -766,6 +769,15 @@ func (m Model) searchViewBox() string { for _, l := range v.pickHelp { body = append(body, " "+l) } + switch { + case v.pickBusy != "": + body = append(body, "", " "+m.st.Notice.Render(v.pickBusy)) + case v.pickErr != "": + body = append(body, "") + for _, l := range strings.Split(ansi.Wordwrap(v.pickErr, w-4, " "), "\n") { + body = append(body, " "+m.st.Error.Render(l)) + } + } body = append(body, "", " "+m.goToTabs(ts)) } body = append(body, " "+v.input.View(), "") diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 62fbdf2..2030599 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -137,8 +137,14 @@ func (m *Model) confirmShare(s sharing) { func (m *Model) pickShareKey(s sharing) tea.Cmd { cmd := m.openGoTo(tabSearch, true) m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { + // The picker showed what's shared: no need to ask again. It + // stays open until the link is made, to say why a key can't be + // used. s.key = key - return m.seal(s) // the picker showed what's shared + m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…" + cmd := m.seal(s) + m.flash = flash{} + return cmd } m.search.pickTitle = shareTitle(s) m.search.pickLines = m.sharedFiles(s) @@ -159,9 +165,7 @@ func (m *Model) pickPasted(text string) tea.Cmd { m.flash = flash{text: "share: " + err.Error(), isErr: true} return nil } - pick := m.search.pick - m.search = nil - return pick(m, key) + return m.search.pick(m, key) } // seal reads the key and encrypts the files with it. @@ -171,7 +175,7 @@ func (m *Model) seal(s sharing) tea.Cmd { 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)} + return sealedMsg{err: &share.KeyError{Name: s.key.Name, Err: err}} } 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} @@ -179,11 +183,24 @@ func (m *Model) seal(s sharing) tea.Cmd { } func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { - if msg.err != nil { + picking := m.search != nil && m.search.pick != nil + if picking { + m.search.pickBusy = "" + } + var bad *share.KeyError + switch { + case msg.err != nil && picking && errors.As(msg.err, &bad): + // Picked: say why in the picker, to pick another. + m.search.pickErr = "✗ " + strings.ToUpper(bad.Error()[:1]) + bad.Error()[1:] + return nil + case msg.err != nil: m.noteErr(msg.err) m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} return nil } + if picking { + m.search = nil + } m.dismissedLink = msg.link.String() // not offered back on focus return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false) } diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 9185d81..5917cee 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -200,8 +200,16 @@ func TestShareInDemo(t *testing.T) { if d.m.search == nil || d.m.search.pick == nil { t.Fatalf("no picker: %s", d.m.flash.text) } + // Too short a key: the picker stays, saying why. + d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"}) + if d.m.search == nil || !strings.Contains(d.m.search.pickErr, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { + t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen()) + } + if !strings.Contains(d.screen(), "28 characters") { + t.Errorf("not shown:\n%s", d.screen()) + } d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"}) - if !strings.HasPrefix(clip.content, "lazysecrets://share/") { + if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil { t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) } link := clip.content -- 2.54.0 From 80332680e51239ac03c4527bb097f6e42a84bfd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:29:04 +0200 Subject: [PATCH 16/24] fix: a refused share key says what failed, not how to make one Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/share/rules.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/share/rules.go b/internal/share/rules.go index 6025437..38918db 100644 --- a/internal/share/rules.go +++ b/internal/share/rules.go @@ -53,7 +53,7 @@ func (r Rules) Check(key []byte) error { if len(has) == 0 { return nil } - return fmt.Errorf("its value has %s; share.keyRules needs %s. Use a random one, like openssl rand -base64 32 makes", + return fmt.Errorf("its value has %s; share.keyRules needs %s", strings.Join(has, " and "), strings.Join(needs, " and ")) } -- 2.54.0 From 0757898d61f4d535b43a33af20e676fe7183b1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:31:34 +0200 Subject: [PATCH 17/24] perf: share links use scrypt 2^15, about 40 ms each way Keys keeping to share.keyRules are random and long, so age's 2^18 (a third of a second to share and to open) bought little. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/share/share.go | 10 ++++++---- internal/ui/app/sharefiles_test.go | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/share/share.go b/internal/share/share.go index e9a4d22..9dac614 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -51,10 +51,12 @@ const ( maxFiles = 1 << 20 ) -// workFactor is age's scrypt work factor for sealing (its default); -// maxWorkFactor is the most opening accepts, so a link can't make it -// work for minutes. -var workFactor, maxWorkFactor = 18, 20 +// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms +// each way, instead of age's 2^18 (a third of a second). scrypt makes +// guessing a weak passphrase slow; a key keeping to the rules is random +// and long enough not to need that. maxWorkFactor is the most opening +// accepts, so a link can't make it work for minutes. +var workFactor, maxWorkFactor = 15, 20 // File is a shared file. type File struct { diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 5917cee..ee3d061 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -17,8 +17,8 @@ import ( "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. +// scryptWait is how long the driver waits for encrypting and decrypting: +// tens of milliseconds, more than its usual wait, and more under load. const scryptWait = 20 * time.Second // demoRules take the demo key, made up to be remembered. -- 2.54.0 From 25dbb38842979bbe43eb33d9e40d425c68182f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:35:01 +0200 Subject: [PATCH 18/24] feat: a chosen folder saves at once, and the prompt stays until it has Choosing where to save a share link's files saves them right away, unless one would replace a file there, which still asks. The folder prompt stays open until they're written: a folder that isn't there, or a save that fails, is said under it in red, to fix the path. Its footer says "enter save", and a file git doesn't ignore is named in the status line after saving. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/modal.go | 23 ++++++-- internal/ui/app/sharefiles.go | 91 +++++++++++++++++++++--------- internal/ui/app/sharefiles_test.go | 17 +++++- 3 files changed, 98 insertions(+), 33 deletions(-) diff --git a/internal/ui/app/modal.go b/internal/ui/app/modal.go index 0dac172..777ebdc 100644 --- a/internal/ui/app/modal.go +++ b/internal/ui/app/modal.go @@ -1,9 +1,11 @@ package app import ( + "cmp" "fmt" "slices" "strings" + "unicode" "unicode/utf8" "charm.land/bubbles/v2/key" @@ -54,9 +56,13 @@ type modal struct { options []option cursor int - // A text input instead of options; submit runs on enter. - input *textinput.Model - submit func(m *Model, text string) tea.Cmd + // A text input instead of options; submit runs on enter (named + // submitLabel in the footer; "open" if empty). err says why the last + // one wasn't taken, under the input. + input *textinput.Model + submit func(m *Model, text string) tea.Cmd + submitLabel string + err string // A filter over the options: typing narrows them (all holds them // all), so moving uses arrows or ctrl+j/k. @@ -260,7 +266,10 @@ func (m Model) modalView() string { foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss")) if p.input != nil { body = append(body, " "+p.input.View()) - foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel")) + if p.err != "" { + body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err))) + } + foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel")) } // Size from all options, not the ones shown or the cursor's, so the @@ -407,3 +416,9 @@ func (m Model) overlay(base string) string { lipgloss.NewLayer(box).X(x).Y(y).Z(1), ).Render() } + +// capitalize upper-cases the first letter of a message. +func capitalize(s string) string { + r, n := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[n:] +} diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 2030599..4e44838 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -191,7 +191,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { switch { case msg.err != nil && picking && errors.As(msg.err, &bad): // Picked: say why in the picker, to pick another. - m.search.pickErr = "✗ " + strings.ToUpper(bad.Error()[:1]) + bad.Error()[1:] + m.search.pickErr = "✗ " + capitalize(bad.Error()) return nil case msg.err != nil: m.noteErr(msg.err) @@ -287,12 +287,13 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { case msg.dir == "": return m.askShareDir(msg) default: - m.offerWrite(msg.files, msg.dir) + m.offerWrite(msg.files, msg.dir, false) } return nil } -// askShareDir asks where to save files from a repo that isn't here. +// askShareDir asks where to save files from a repo that isn't here. It +// stays until they're saved, saying why when they can't be. func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { in := textinput.New() in.Prompt = "folder: " @@ -307,31 +308,51 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { if msg.sealed.Repo != "" { lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...) } - m.prompt = &modal{ - title: "Save the files", input: &in, lines: lines, - submit: func(m *Model, text string) tea.Cmd { - m.offerWrite(msg.files, expandHome(strings.TrimSpace(text))) - return nil - }, + p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"} + p.submit = func(m *Model, text string) tea.Cmd { + if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil { + p.err = err.Error() + m.prompt = p // again, to fix the folder + return p.input.Focus() + } + return nil } - return m.prompt.input.Focus() + m.prompt = p + return p.input.Focus() } // offerWrite shows where each file goes, then writes them as chosen: -// files there already are replaced only when asked. -func (m *Model) offerWrite(files []share.File, dir string) { +// files there already are replaced only when asked. With chosen (the +// user just picked dir), new files are written at once, and what goes +// wrong is returned rather than shown. +func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error { + fail := func(err error) error { + if !chosen { + m.flash = flash{text: "save: " + err.Error(), isErr: true} + } + return err + } + if !isDir(dir) { + return fail(fmt.Errorf("no folder %s", dir)) + } targets, err := share.Plan(dir, files) if err != nil { - m.flash = flash{text: "save: " + err.Error(), isErr: true} - return + return fail(err) + } + exist := 0 + for _, t := range targets { + if t.Exists { + exist++ + } + } + if chosen && exist == 0 { + return m.writeShared(targets, dir, false) // nothing to ask } var lines []string - exist := 0 for _, t := range targets { file := " " + m.repoLabel(filepath.Base(dir), t.Path) line := file + m.st.Muted.Render(" · new") if t.Exists { - exist++ line = file + m.st.Muted.Render(" · replaces the one there") } if t.Exposed { @@ -342,18 +363,9 @@ func (m *Model) offerWrite(files []share.File, dir string) { lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...) write := func(onlyNew bool) func(*Model) tea.Cmd { return func(m *Model) tea.Cmd { - n := 0 - for _, t := range targets { - if onlyNew && t.Exists { - continue - } - if err := t.Write(); err != nil { - m.flash = flash{text: "save: " + err.Error(), isErr: true} - return nil - } - n++ + if err := m.writeShared(targets, dir, onlyNew); err != nil { + m.flash = flash{text: "save: " + err.Error(), isErr: true} } - m.flash = done("saved " + plural(n, "file") + " in " + dir) return nil } } @@ -370,6 +382,31 @@ func (m *Model) offerWrite(files []share.File, dir string) { ) } m.showMenu("Save the shared files", lines, opts) + return nil +} + +// writeShared writes the files (with onlyNew, those not there yet) and +// says where, and which ones git doesn't ignore. +func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error { + n := 0 + var exposed []string + for _, t := range targets { + if onlyNew && t.Exists { + continue + } + if err := t.Write(); err != nil { + return fmt.Errorf("%s: %w", t.Path, err) + } + n++ + if t.Exposed { + exposed = append(exposed, t.Path) + } + } + m.flash = done("saved " + plural(n, "file") + " in " + dir) + if len(exposed) > 0 { + m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", ")) + } + return nil } // isDir reports whether dir is a folder on disk. diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index ee3d061..7aa3699 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -163,10 +163,23 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) { if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir { t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen()) } - d.keys("enter", "w") + // A folder that isn't there: the prompt stays, saying so. + d.m.prompt.input.SetValue(filepath.Join(dir, "nope")) + d.keys("enter") + if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") { + t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen()) + } + if !strings.Contains(d.screen(), "enter save") { + t.Errorf("the footer doesn't say enter saves:\n%s", d.screen()) + } + d.m.prompt.input.SetValue(dir) + d.keys("enter") // saved at once: nothing there to replace if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" { t.Errorf(".env: %q (%s)", b, d.m.flash.text) } + if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) { + t.Errorf("not closed with where it saved: %q", d.m.flash.text) + } } func TestShareLinkGarbled(t *testing.T) { @@ -229,7 +242,7 @@ func TestShareInDemo(t *testing.T) { if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") { t.Fatalf("doesn't ask where:\n%s", d.screen()) } - d.keys("enter", "w") + d.keys("enter") // saved at once: nothing there to replace entries, _ := os.ReadDir(dir) if len(entries) == 0 { t.Fatalf("nothing saved (%s)", d.m.flash.text) -- 2.54.0 From 61de5e616dfc21f5264eb9f7d23d122aa4cd5261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:42:47 +0200 Subject: [PATCH 19/24] refactor: tidy up sharing after the round of tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Go to's picker settings are one picker struct, set while picking. - Opening a share link plans the save, then asks or saves; no more offerWrite doing either by a flag. - Closing the picker while it encrypts cancels the share, and a second pick meanwhile is ignored. - Favourites and Recent leave out plain env values as keys too. - share.key in the config is checked with share.ParseKey, so {{secrets/…}} is refused there already. - A key that can't be read reads the same opening as sharing ("can't use …"); KeyError moved to share.go, maxFiles is maxUnpacked (bytes), and a stray comment is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/config/config.go | 6 +- internal/config/config_test.go | 1 + internal/share/rules.go | 10 --- internal/share/share.go | 18 +++- internal/ui/app/jump.go | 19 ++++ internal/ui/app/links.go | 2 +- internal/ui/app/searchview.go | 72 ++++++++------- internal/ui/app/sharefiles.go | 138 +++++++++++++++-------------- internal/ui/app/sharefiles_test.go | 70 ++++++++++++++- 9 files changed, 218 insertions(+), 118 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a1eb082..d44885c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ import ( "go.yaml.in/yaml/v3" "git.kebcloud.no/keb/lazysecrets/internal/commands" - "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/share" "git.kebcloud.no/keb/lazysecrets/internal/update" ) @@ -242,8 +242,8 @@ 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 secret's lazysecrets:// link, Key Vault URL or reference", path, k) + if _, err := share.ParseKey(k); err != nil { + return fmt.Errorf("%s: share.key: %w", path, err) } } if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d2e86c7..e5d2e94 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -73,6 +73,7 @@ func TestLoadRejectsMistakes(t *testing.T) { "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", + "workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n", "short keys": "share:\n keyRules:\n minLength: 8\n", "classes": "share:\n keyRules:\n minClasses: 5\n", } { diff --git a/internal/share/rules.go b/internal/share/rules.go index 38918db..48311c7 100644 --- a/internal/share/rules.go +++ b/internal/share/rules.go @@ -67,13 +67,3 @@ func (r Rules) withDefaults() Rules { r.MinLength = max(r.MinLength, MinKey) return r } - -// KeyError is a key that can't be used: it can't be read, or doesn't -// keep to the rules. -type KeyError struct { - Name string // the secret's - Err error -} - -func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() } -func (e *KeyError) Unwrap() error { return e.Err } diff --git a/internal/share/share.go b/internal/share/share.go index 9dac614..172235e 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -47,8 +47,8 @@ const ( MaxLink = 16 << 10 // MinKey is the shortest key the rules can ask for. MinKey = 16 - // maxFiles caps what opening a link unpacks. - maxFiles = 1 << 20 + // maxUnpacked caps the bytes opening a link unpacks. + maxUnpacked = 1 << 20 ) // workFactor is the scrypt work factor for sealing: 2^15, about 40 ms @@ -153,6 +153,16 @@ func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) { return r, nil } +// KeyError is a key that can't be used: it can't be read, or doesn't +// keep to the rules. +type KeyError struct { + Name string // the secret's + Err error +} + +func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() } +func (e *KeyError) Unwrap() error { return e.Err } + // Read reads a share link's header. func Read(r ref.Ref) (Sealed, error) { bad := errors.New("share link is cut short or garbled: copy all of it") @@ -197,11 +207,11 @@ func (s Sealed) Open(key []byte) ([]File, error) { } return nil, fmt.Errorf("can't decrypt the link: %w", err) } - b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxFiles+1)) + b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1)) if err != nil { return nil, fmt.Errorf("can't decrypt the link: %w", err) } - if len(b) > maxFiles { + if len(b) > maxUnpacked { return nil, errors.New("the link holds more than lazysecrets unpacks") } var plain []file diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 86a7b94..495ecd9 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) { out = append(out, entry{depth, sort, option{ label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), + plain: m.inPlainEnv(r), run: func(m *Model) tea.Cmd { m.used(key) return run(m) @@ -138,6 +139,24 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri return label } +// inPlainEnv reports whether r is a key in a plain env file, as far as +// the files loaded tell. +func (m Model) inPlainEnv(r ref.Ref) bool { + if r.Kind != provider.KindRepo || r.Name == "" { + return false + } + file := r + file.Name, file.Type = "", "" + for _, e := range m.cache.containers { + for _, c := range e.value { + if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() { + return true + } + } + } + return false +} + // repoLabel draws a repo, or a file in it, as everywhere else: the repo // icon and colour, "app / apps/api/.env". func (m Model) repoLabel(repo string, file ...string) string { diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index e969c0f..9899b95 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -818,7 +818,7 @@ 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 { + if m.search != nil && m.search.picker != nil { return m.pickPasted(msg.Content) // a reference to the secret } if p := m.prompt; p != nil && p.input != nil { diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 29b9fca..4a467ad 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -53,16 +53,8 @@ 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 string // the popup's - pickLines []string // what picking is for, above the picker - pickHeading string // the picker's title, on its border - pickHelp []string // inside it, above the tabs - pickBusy string // what picking is doing, under the help - pickErr string // why the key picked can't be used + // picker, if set, makes Go to pick a secret instead of going to it. + picker *picker // 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 @@ -279,7 +271,7 @@ func (v *searchView) match() []option { } } } - if v.pick != nil { + if v.picker != nil { cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok }) } q := v.input.Value() @@ -438,13 +430,13 @@ 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: + case v.results && v.res < len(v.shown) && v.picker != nil: r, _ := pickable(v.shown[v.res]) - return v.pick(m, r) // the picker closes when it's done + return v.picker.choose(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: + case !v.results && v.picker != nil: v.results = true // a picker only narrows to the scope case !v.results && v.onSource(): m.search = nil @@ -662,15 +654,14 @@ func (m Model) searchViewBox() string { focused := !m.termBlurred w := searchWidth(m.w) popupW := w - if v.pick != nil { + if v.picker != nil { w -= 4 // inside the picker's border, in the popup } listH := max(5, min(m.h-10, 14)) - if v.pick != nil { - // What it's for, the help, the tabs and a border more. + if v.picker != nil { // What it's for, the help, a line or two on the key picked, the // tabs and a border more. - listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-8, 14)) + listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14)) } srcW := 10 @@ -765,16 +756,16 @@ func (m Model) searchViewBox() string { ts := panes.TitleStyle(m.st, focused) var body []string - if v.pick != nil { - for _, l := range v.pickHelp { + if p := v.picker; p != nil { + for _, l := range p.help { body = append(body, " "+l) } switch { - case v.pickBusy != "": - body = append(body, "", " "+m.st.Notice.Render(v.pickBusy)) - case v.pickErr != "": + case p.busy != "": + body = append(body, "", " "+m.st.Notice.Render(p.busy)) + case p.err != "": body = append(body, "") - for _, l := range strings.Split(ansi.Wordwrap(v.pickErr, w-4, " "), "\n") { + for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") { body = append(body, " "+m.st.Error.Render(l)) } } @@ -810,25 +801,46 @@ func (m Model) searchViewBox() string { } // Tabs in the title, the current one underlined, like the panes; a // picker has its own title, and the tabs over the input. - head := ts.Render(goToTitle+" ") + m.goToTabs(ts) - if v.pick == nil { + if v.picker == nil { + head := ts.Render(goToTitle+" ") + m.goToTabs(ts) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) } // A picker: in its own border, under what it's for. var outer []string - for _, l := range v.pickLines { + for _, l := range v.picker.lines { outer = append(outer, " "+l) } outer = append(outer, "") - for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.pickHeading), "", body, w, len(body)+2, focused), "\n") { + for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") { outer = append(outer, " "+l) } - return panes.Box(m.st, ts.Render(v.pickTitle), foot, outer, popupW, len(outer)+2, focused) + return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused) +} + +// picker is Go to picking a secret for something (the key to share +// with): it lists secrets only, and enter hands the selected one's link +// to pick, which closes Go to when it's done with it. +type picker struct { + pick func(m *Model, r ref.Ref) tea.Cmd + title string // the popup's + lines []string // what picking is for, above the picker + heading string // the picker's title, on its border + help []string // inside it, above the tabs + busy string // what the pick is doing (picks wait meanwhile) + err string // why the secret picked can't be used +} + +// choose hands r to the picker, unless it's still busy with one. +func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd { + if p.busy != "" { + return nil + } + return p.pick(m, r) } // selectLabel names what enter does with the selected entry. func (v *searchView) selectLabel() string { - if v.pick != nil { + if v.picker != nil { return "pick" } return "go" diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 4e44838..d72a7a8 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "charm.land/bubbles/v2/textinput" @@ -36,6 +37,8 @@ type shareFilesMsg struct { type sealedMsg struct { link ref.Ref n int + // from is the picker the key was picked in; nil for share.key's. + from *picker err error } @@ -127,32 +130,34 @@ func (m *Model) sharedFiles(s sharing) []string { func (m *Model) confirmShare(s sharing) { lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) m.showMenu(shareTitle(s), lines, []option{ - {key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }}, + {key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s, nil) }}, {key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }}, }) } // pickShareKey asks which secret to encrypt with, in Go to, then shares -// with it. +// with it: the picker showed what's shared, so there's no asking again. +// It stays open until the link is made, to say why a secret can't be +// used. func (m *Model) pickShareKey(s sharing) tea.Cmd { cmd := m.openGoTo(tabSearch, true) - m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { - // The picker showed what's shared: no need to ask again. It - // stays open until the link is made, to say why a key can't be - // used. + p := &picker{ + title: shareTitle(s), + lines: m.sharedFiles(s), + heading: "Pick a secret to encrypt with", + help: []string{ + m.st.Muted.Render("The recipient needs read access to it to decrypt the files."), + m.st.Muted.Render("Its value isn't shown or put in the link."), + }, + } + p.pick = func(m *Model, key ref.Ref) tea.Cmd { s.key = key - m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…" - cmd := m.seal(s) + p.err, p.busy = "", "encrypting with "+key.Name+"…" + cmd := m.seal(s, p) m.flash = flash{} return cmd } - m.search.pickTitle = shareTitle(s) - m.search.pickLines = m.sharedFiles(s) - m.search.pickHeading = "Pick a secret to encrypt with" - m.search.pickHelp = []string{ - m.st.Muted.Render("The recipient needs read access to it to decrypt the files."), - m.st.Muted.Render("Its value isn't shown or put in the link."), - } + m.search.picker = p m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border m.fillSearch(false) return tea.Batch(cmd, m.searchLoads()) @@ -162,45 +167,45 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd { 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} + m.search.picker.err = "✗ " + capitalize(err.Error()) return nil } - return m.search.pick(m, key) + return m.search.picker.choose(m, key) } -// seal reads the key and encrypts the files with it. -func (m *Model) seal(s sharing) tea.Cmd { +// seal reads the key and encrypts the files with it, for the picker +// from (nil: the key wasn't picked). +func (m *Model) seal(s sharing, from *picker) tea.Cmd { 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: &share.KeyError{Name: s.key.Name, Err: err}} + return sealedMsg{from: from, err: &share.KeyError{Name: s.key.Name, Err: err}} } 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} + return sealedMsg{link: link, n: len(s.files), from: from, err: err} } } func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { - picking := m.search != nil && m.search.pick != nil - if picking { - m.search.pickBusy = "" + if p := msg.from; p != nil { + if m.search == nil || m.search.picker != p { + return nil // the picker was closed: cancelled + } + p.busy = "" + var bad *share.KeyError + if errors.As(msg.err, &bad) { + p.err = "✗ " + capitalize(bad.Error()) // to pick another + return nil + } + m.search = nil } - var bad *share.KeyError - switch { - case msg.err != nil && picking && errors.As(msg.err, &bad): - // Picked: say why in the picker, to pick another. - m.search.pickErr = "✗ " + capitalize(bad.Error()) - return nil - case msg.err != nil: + if msg.err != nil { m.noteErr(msg.err) m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} return nil } - if picking { - m.search = nil - } m.dismissedLink = msg.link.String() // not offered back on focus return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false) } @@ -258,7 +263,7 @@ func (m *Model) openShare(s share.Sealed) tea.Cmd { msg := shareOpenedMsg{sealed: s} v, _, err := read(s.Key) if err != nil { - msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err) + msg.err = &share.KeyError{Name: s.Key.Name, Err: err} return msg } if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil { @@ -287,7 +292,12 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { case msg.dir == "": return m.askShareDir(msg) default: - m.offerWrite(msg.files, msg.dir, false) + targets, err := planSave(msg.files, msg.dir) + if err != nil { + m.flash = flash{text: "save: " + err.Error(), isErr: true} + return nil + } + m.offerSave(targets, msg.dir) } return nil } @@ -310,49 +320,44 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { } p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"} p.submit = func(m *Model, text string) tea.Cmd { - if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil { - p.err = err.Error() - m.prompt = p // again, to fix the folder - return p.input.Focus() + dir := expandHome(strings.TrimSpace(text)) + targets, err := planSave(msg.files, dir) + switch { + case err == nil && slices.ContainsFunc(targets, func(t share.Target) bool { return t.Exists }): + m.offerSave(targets, dir) // ask before replacing + return nil + case err == nil: + // Chosen, with nothing to replace: saved at once. + if err = m.writeShared(targets, dir, false); err == nil { + return nil + } } - return nil + p.err = err.Error() + m.prompt = p // again, to fix the folder + return p.input.Focus() } m.prompt = p return p.input.Focus() } -// offerWrite shows where each file goes, then writes them as chosen: -// files there already are replaced only when asked. With chosen (the -// user just picked dir), new files are written at once, and what goes -// wrong is returned rather than shown. -func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error { - fail := func(err error) error { - if !chosen { - m.flash = flash{text: "save: " + err.Error(), isErr: true} - } - return err - } +// planSave says where each file goes in dir, which must be a folder. +func planSave(files []share.File, dir string) ([]share.Target, error) { if !isDir(dir) { - return fail(fmt.Errorf("no folder %s", dir)) - } - targets, err := share.Plan(dir, files) - if err != nil { - return fail(err) - } - exist := 0 - for _, t := range targets { - if t.Exists { - exist++ - } - } - if chosen && exist == 0 { - return m.writeShared(targets, dir, false) // nothing to ask + return nil, fmt.Errorf("no folder %s", dir) } + return share.Plan(dir, files) +} + +// offerSave shows where each file goes, then saves them as chosen: +// files there already are replaced only when asked. +func (m *Model) offerSave(targets []share.Target, dir string) { var lines []string + exist := 0 for _, t := range targets { file := " " + m.repoLabel(filepath.Base(dir), t.Path) line := file + m.st.Muted.Render(" · new") if t.Exists { + exist++ line = file + m.st.Muted.Render(" · replaces the one there") } if t.Exposed { @@ -382,7 +387,6 @@ func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error { ) } m.showMenu("Save the shared files", lines, opts) - return nil } // writeShared writes the files (with onlyNew, those not there yet) and diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 7aa3699..6e5e214 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -15,6 +15,7 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/share" + "git.kebcloud.no/keb/lazysecrets/internal/store" ) // scryptWait is how long the driver waits for encrypting and decrypting: @@ -50,7 +51,7 @@ func shareFrom(t *testing.T, root string, paste bool) string { // 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 { + if v == nil || v.picker == nil { t.Fatalf("no secret picker: %s", d.m.flash.text) } for _, o := range v.shown { @@ -210,12 +211,12 @@ func TestShareInDemo(t *testing.T) { } src.Provider = "Repos" d.run(d.m.shareFiles(src, nil)) - if d.m.search == nil || d.m.search.pick == nil { + if d.m.search == nil || d.m.search.picker == nil { t.Fatalf("no picker: %s", d.m.flash.text) } // Too short a key: the picker stays, saying why. d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"}) - if d.m.search == nil || !strings.Contains(d.m.search.pickErr, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { + if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen()) } if !strings.Contains(d.screen(), "28 characters") { @@ -248,3 +249,66 @@ func TestShareInDemo(t *testing.T) { t.Fatalf("nothing saved (%s)", d.m.flash.text) } } + +func TestPickerCancelAndBusy(t *testing.T) { + clip := &fakeClipboard{} + d := newDriverOpts(t, Options{Clipboard: clip}, + fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos()) + var src provider.Source + for _, s := range d.m.sourceResults["Repos"].sources { + if s.Here { + src = s + } + } + src.Provider = "Repos" + d.run(d.m.shareFiles(src, nil)) + p := d.m.search.picker + key, _ := share.ParseKey("https://kv-shared-prod.vault.azure.net/secrets/env-share") + + // Picking starts sealing; another pick meanwhile is ignored. + cmd := p.choose(&d.m, key) + if cmd == nil || p.busy == "" { + t.Fatal("picking didn't start") + } + if again := p.choose(&d.m, key); again != nil { + t.Error("a second pick while busy wasn't ignored") + } + + // Closed before the link is made: cancelled, nothing copied. + d.m.search = nil + d.wait = scryptWait + d.run(cmd) + if clip.content != "" { + t.Errorf("copied after the picker was closed: %q", clip.content) + } +} + +func TestPlainEnvNotPickableFromFavourites(t *testing.T) { + mem, _ := store.Load("") + d := newDriverOpts(t, Options{Store: mem}, fake.Azure(0), fake.Repos()) + var src provider.Source + for _, s := range d.m.sourceResults["Repos"].sources { + if s.Here { + src = s + } + } + // A key in the demo repo's plain .env, starred. + key := "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/.env/DATABASE_URL" + mem.Update(key, func(n *store.Node) { + n.StarredBy = []string{"Repos"} + n.Path = []string{src.Name, ".env"} + n.Source = src.ID + }) + src.Provider = "Repos" + d.run(d.m.shareFiles(src, nil)) + favs, _ := d.m.storeEntries() + for _, o := range favs { + if o.star == key { + if _, ok := pickable(o); ok { + t.Error("a plain .env value is offered as a key in Favourites") + } + return + } + } + t.Fatalf("the starred key isn't among the favourites: %d", len(favs)) +} -- 2.54.0 From 2a3379b1c104d171451efdc2d8d3ba7107857fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 13:59:43 +0200 Subject: [PATCH 20/24] feat: a share link that can't be decrypted says why in a popup Where its offer was, instead of the footer: the first line of the reason, r for the whole message (Key Vault's refusals list what to check), g to go to the key's secret at the link's version, and x to dismiss. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/sharefiles.go | 35 ++++++++++++++++++++++++- internal/ui/app/sharefiles_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index d72a7a8..940a006 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -12,6 +12,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ref" @@ -288,7 +289,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { switch { case msg.err != nil: m.noteErr(msg.err) - m.flash = flash{text: "open: " + msg.err.Error(), isErr: true} + m.openFailed(msg.sealed, msg.err, false) case msg.dir == "": return m.askShareDir(msg) default: @@ -302,6 +303,38 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { return nil } +// openFailed says why a share link couldn't be decrypted, in a popup +// where its offer was: its first line, or with whole all of it +// (Key Vault's refusals say what to check on later lines). From there, +// the key's secret can be gone to: its version, which may be disabled. +func (m *Model) openFailed(s share.Sealed, err error, whole bool) { + width := max(30, min(m.w-12, 76)) + text := capitalize(err.Error()) + first, rest, _ := strings.Cut(text, "\n") + cut := !whole && (rest != "" || ansi.StringWidth(first) > width-2) + if !whole { + text = ansi.Truncate(first, width-2, "…") + } + var lines []string + for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") { + if i == 0 { + l = "✗ " + l + } else if l != "" { + l = " " + l + } + lines = append(lines, m.st.Error.Render(l)) + } + var opts []option + if cut { + opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd { + m.openFailed(s, err, true) + return nil + }}) + } + opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }}) + m.showMenu("Can't decrypt the shared files", lines, opts) +} + // askShareDir asks where to save files from a repo that isn't here. It // stays until they're saved, saying why when they can't be. func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 6e5e214..220d4a9 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -1,6 +1,8 @@ package app import ( + "context" + "errors" "net/url" "os" "os/exec" @@ -312,3 +314,42 @@ func TestPlainEnvNotPickableFromFavourites(t *testing.T) { } t.Fatalf("the starred key isn't among the favourites: %d", len(favs)) } + +// refusing is a provider whose values can't be read, like a disabled +// version in Key Vault. +type refusing struct{ provider.Provider } + +func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) { + return provider.SecretValue{}, &provider.Denied{ + Err: errors.New("Operation get is not allowed on a disabled secret."), + Checks: []string{"the secret's version is enabled"}, + } +} + +func TestOpenFailsInAPopup(t *testing.T) { + key, _ := share.ParseKey("https://" + kv + "/secrets/db-password") + link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"}, + []byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules) + if err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)}) + d.wait = scryptWait + d.send(tea.PasteMsg{Content: link.String()}) + d.keys("o") + p := d.m.prompt + if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") { + t.Fatalf("not in a popup:\n%s", d.screen()) + } + if strings.Contains(d.screen(), "version is enabled") { + t.Error("the whole message is shown before asking") + } + d.keys("r") + if !strings.Contains(d.screen(), "version is enabled") { + t.Errorf("r doesn't show the whole message:\n%s", d.screen()) + } + d.keys("g") + if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) { + t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen()) + } +} -- 2.54.0 From 7d6a095e428f1c56066fa1d2ba6ce2068c1a8a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 14:06:16 +0200 Subject: [PATCH 21/24] feat: opening a link to a secret ends on its details As enter on it would, instead of on the vault's list with the secret selected: ctrl+o, a paste, a clicked link and the share failure popup's g. Going back to where lazysecrets was left, and Go to, stay as they were. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/links.md | 6 +++--- internal/ui/app/jump.go | 2 +- internal/ui/app/links.go | 24 +++++++++++++++++++----- internal/ui/app/links_test.go | 3 ++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/links.md b/docs/links.md index 4f7e629..d6429d5 100644 --- a/docs/links.md +++ b/docs/links.md @@ -118,9 +118,9 @@ read the clipboard over SSH. lazysecrets finds the vault in your accounts (one Resource Graph query per account) and jumps to it, asking which account to use when several can see -it. Links say what, not what to do: opening a link to a secret asks whether -to just go to it, also copy or show its value, or (`y`) copy its value -without going there. +it, ending on a secret's details as `enter` on it would. Links say what, +not what to do: opening a link to a secret asks whether to just go to it, +also copy or show its value, or (`y`) copy its value without going there. ## In scripts diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 495ecd9..905cbec 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -217,7 +217,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd { case 1: m.startNav(r, actGo, choices[0].sourceRow) default: - m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices) + m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false) } return nil } diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 9899b95..21bfae4 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -46,6 +46,9 @@ type nav struct { // quiet gives up without a word: going back to where lazysecrets // was left, which may be gone. quiet bool + // link ends on the object's details, as enter on it would: opening + // a link to it opens it, rather than just finding it in its list. + link bool } type resolvedMsg struct { @@ -401,23 +404,30 @@ func (m *Model) handleResolved(msg resolvedMsg) { for i := range msg.choices { msg.choices[i].label = "Open in " + msg.choices[i].label } - m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices) + m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true) case len(msg.others) > 0: var choices []choice for _, row := range append([]string{msg.sourceRow}, msg.others...) { choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) } - m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices) + m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true) case msg.linked != nil: m.addLinked(*msg.linked) - m.startNav(msg.ref, msg.act, linksRowID) + m.startLinkNav(msg.ref, msg.act, linksRowID) default: - m.startNav(msg.ref, msg.act, msg.sourceRow) + m.startLinkNav(msg.ref, msg.act, msg.sourceRow) } } +// startLinkNav opens a link: like startNav, ending on its object's +// details. +func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) { + m.startNav(r, act, sourceRow) + m.nav.link = true +} + // chooseSource asks where to open r. -func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) { +func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) { var opts []option for i, c := range choices { key := "" @@ -427,6 +437,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices [] row := c.sourceRow opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd { m.startNav(r, act, row) + m.nav.link = link return nil }}) } @@ -577,6 +588,9 @@ func (m *Model) advanceNav() bool { m.pendingCmd = m.toggleReveal() } } + if n.link { + m.focus = paneDetail // after: copying acts on the focused pane + } return true } return false diff --git a/internal/ui/app/links_test.go b/internal/ui/app/links_test.go index 2fc0492..2ae1846 100644 --- a/internal/ui/app/links_test.go +++ b/internal/ui/app/links_test.go @@ -40,7 +40,8 @@ func TestOpenSecretLink(t *testing.T) { if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { t.Fatalf("opened %s/%s/%s", src, c, s) } - if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden { + // Opened: on its details, as enter on it would; its value hidden. + if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden { t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State()) } } -- 2.54.0 From 8757284b0c692a6978a6a4a3460ccfcfa25bcb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 14:27:06 +0200 Subject: [PATCH 22/24] fix: o on an Azure secret opens its page in the portal Not the vault's list of secrets: the secret's own page (asset/ Microsoft_Azure_KeyVault/Secret/), and in the versions view, the version's (/), keys' and certificates' versions too. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/provider/azure/azure.go | 9 +++++++-- internal/provider/azure/azure_test.go | 16 ++++++++++++++++ internal/provider/azure/convert.go | 11 +++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index 522e406..69b8144 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -299,7 +299,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider if s.ID == nil || deref(s.Managed) { continue } - out = append(out, secretObject(c, s)) + out = append(out, secretObject(c, s, p.tenant(c.Source.ID))) } } case provider.TypeKey: @@ -392,7 +392,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider. } for _, s := range page.Value { if s.ID != nil { - out = append(out, secretObject(o.Container, s)) + out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID))) } } } @@ -425,8 +425,13 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider. default: return nil, provider.ErrUnsupported } + tenant := p.tenant(o.Container.Source.ID) for i := range out { out[i].Reference = versionedReference(out[i]) + if o.Type != provider.TypeSecret { // secretObject's is the version's already + kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type] + out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page + } } slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) return out, nil diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go index a2417f7..5fd92ad 100644 --- a/internal/provider/azure/azure_test.go +++ b/internal/provider/azure/azure_test.go @@ -30,6 +30,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -334,6 +335,21 @@ func TestPortalURLs(t *testing.T) { } } +func TestSecretPages(t *testing.T) { + c := provider.Container{Detail: "https://kv-app.vault.azure.net/"} + base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db" + for id, want := range map[string]string{ + "https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page + "https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's + } { + sid := azsecrets.ID(id) + o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com") + if o.URL != want { + t.Errorf("%s: %q, want %q", id, o.URL, want) + } + } +} + func TestVersionedReference(t *testing.T) { sec := provider.Object{Type: provider.TypeSecret, Version: "v1", Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"} diff --git a/internal/provider/azure/convert.go b/internal/provider/azure/convert.go index 32f658c..d85b8d4 100644 --- a/internal/provider/azure/convert.go +++ b/internal/provider/azure/convert.go @@ -53,8 +53,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string { return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade } -// portalAssetURL links to a Key Vault object's page; objectID must include -// the version. +// portalAssetURL links to a Key Vault object's page: a version's with +// one in objectID; a secret's own (its versions) without. func portalAssetURL(tenant, kind, objectID string) string { return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID } @@ -88,7 +88,10 @@ func objectURL(c provider.Container, collection, name string) string { return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name } -func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object { +// secretObject is a secret, or a version of one, from a listing. Its URL +// is its page in the portal: the secret's (with its versions), or with +// a version, that version's. +func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object { name := p.ID.Name() url := objectURL(c, "secrets", name) o := provider.Object{ @@ -100,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider. ContentType: deref(p.ContentType), Tags: tags(p.Tags), Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", - URL: ObjectListURL(c, provider.TypeSecret), + URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")), } if a := p.Attributes; a != nil { o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) -- 2.54.0 From 40b90df68c22502851029a2ee360f66d2afcefac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 14:30:24 +0200 Subject: [PATCH 23/24] feat: a key that no longer decrypts a share link says why By where it lives: a Databricks secret or SOPS value has changed since the link was made, an Azure secret's current version isn't the link's (unpinned), and a pinned version can't have changed, so the link may be damaged. The popup wraps the reason instead of cutting it; r is for what Key Vault adds to check. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/share/share.go | 17 ++++++++++++++++- internal/share/share_test.go | 24 +++++++++++++++++++++++- internal/ui/app/sharefiles.go | 6 +++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/share/share.go b/internal/share/share.go index 172235e..7830f24 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -203,7 +203,7 @@ func (s Sealed) Open(key []byte) ([]File, error) { if err != nil { var wrong *age.NoIdentityMatchError if errors.As(err, &wrong) { - return nil, errors.New("the key doesn't decrypt it: the link may have been made with another version of " + s.Key.Name) + return nil, s.wrongKey() } return nil, fmt.Errorf("can't decrypt the link: %w", err) } @@ -228,6 +228,21 @@ func (s Sealed) Open(key []byte) ([]File, error) { return out, nil } +// wrongKey says why the key, read fine, doesn't decrypt the link: its +// value isn't the one the link was made with. +func (s Sealed) wrongKey() error { + name := s.Key.Name + switch { + case s.Key.Kind == provider.KindAzure && s.Key.Version != "": + // A version's value never changes. + return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version) + case s.Key.Kind == provider.KindAzure: + return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name) + } + // Databricks secrets and SOPS values are changed in place. + return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name) +} + // CheckPath accepts a path relative to a repo's root, with slashes, of an // env file (.env, .env.local, prod.env) outside .git: what a link may // write. diff --git a/internal/share/share_test.go b/internal/share/share_test.go index d61f803..069c68f 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -49,11 +49,33 @@ func TestSealOpen(t *testing.T) { if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) { t.Errorf("opened %+v", got) } - if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "doesn't decrypt") { + if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") { t.Errorf("wrong key: %v", err) } } +func TestWrongKeySaysWhy(t *testing.T) { + files := []File{{Path: ".env", Data: []byte("A=1\n")}} + for link, want := range map[string]string{ + "lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made", + "lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made", + "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with", + } { + k, err := ParseKey(link) + if err != nil { + t.Fatal(err) + } + r, err := Seal(Header{Key: k}, pass, files, Rules{}) + if err != nil { + t.Fatal(err) + } + s, _ := Read(r) + if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", link, err, want) + } + } +} + func TestSealRefuses(t *testing.T) { ok := []File{{Path: ".env", Data: []byte("A=1")}} for name, c := range map[string]struct { diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 940a006..e5cf8a5 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -304,16 +304,16 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd { } // openFailed says why a share link couldn't be decrypted, in a popup -// where its offer was: its first line, or with whole all of it +// where its offer was: its first line (wrapped), or with whole all of it // (Key Vault's refusals say what to check on later lines). From there, // the key's secret can be gone to: its version, which may be disabled. func (m *Model) openFailed(s share.Sealed, err error, whole bool) { width := max(30, min(m.w-12, 76)) text := capitalize(err.Error()) first, rest, _ := strings.Cut(text, "\n") - cut := !whole && (rest != "" || ansi.StringWidth(first) > width-2) + cut := !whole && rest != "" // more than the reason: what to check if !whole { - text = ansi.Truncate(first, width-2, "…") + text = first } var lines []string for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") { -- 2.54.0 From db8ce90ee7a1e1940063b15fa5fd57c7ed7ecd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 14:50:59 +0200 Subject: [PATCH 24/24] fix: the details pane follows its secret's list when it reloads A cached list is shown at once and reloaded in the background; the selected secret's details stayed as first shown, old web page included, until another secret was selected. When the reloaded list has it changed, the details pane takes the new listing (a value shown stays shown) and describes it afresh: its cached details came from the old one. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/app.go | 2 ++ internal/ui/app/cache.go | 32 ++++++++++++++++++++++++++++++++ internal/ui/app/cache_test.go | 31 +++++++++++++++++++++++++++++++ internal/ui/panes/detail.go | 10 ++++++++++ 4 files changed, 75 insertions(+) diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index c410c57..e023152 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -679,12 +679,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.bound != nil { msg.key = m.rebind(*msg.bound) } + before := m.cache.objects[msg.key].value m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cacheDirty = true if m.versionsOf != nil { m.setVersions(msg.items) } else { m.secrets.SetItems(m.starredFirst(msg.items)) + cmds = append(cmds, m.refreshShown(before)) } } diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go index 3bfe3ab..2de8090 100644 --- a/internal/ui/app/cache.go +++ b/internal/ui/app/cache.go @@ -1,8 +1,12 @@ package app import ( + "maps" + "slices" "time" + tea "charm.land/bubbletea/v2" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) { c.cursor[parent] = child } } + +// refreshShown shows the selected secret's newer metadata when its list +// reloaded with it changed from before (a cached list refreshed in the +// background), and describes it afresh: its cached details came from +// the old listing. +func (m *Model) refreshShown(before []provider.Object) tea.Cmd { + s, ok := m.secrets.Selected() + if !ok || m.secrets.SelectedID() != m.selSecret { + return nil // a new selection is shown as usual + } + i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID }) + if i < 0 || sameListing(before[i], s) { + return nil + } + m.detail.Refresh(s) + delete(m.cache.details, detailKey(s)) + return m.describe(s) +} + +// sameListing reports whether a and b list the same: what a list +// says of an object, not what describing it adds. +func sameListing(a, b provider.Object) bool { + enabled := func(e *bool) bool { return e == nil || *e } + return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) && + a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType && + maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL && + slices.Equal(a.Attrs, b.Attrs) +} diff --git a/internal/ui/app/cache_test.go b/internal/ui/app/cache_test.go index 5047d06..f9b1840 100644 --- a/internal/ui/app/cache_test.go +++ b/internal/ui/app/cache_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "slices" "testing" "time" @@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) { t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) } } + +func TestReloadedListRefreshesShownSecret(t *testing.T) { + t.Parallel() + d, _, clock := newCachingDriver(t) + d.keys("3") // kv-app-prod's secrets, db-password selected + shown, _ := d.m.detail.Secret() + // What an older lazysecrets listed and cached for it. + const old = "https://portal.example/old" + key := d.m.selContainer + e := d.m.cache.objects[key] + items := slices.Clone(e.value) + for i := range items { + if items[i].ID == shown.ID { + items[i].URL = old + } + } + d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at} + stale := shown + stale.URL = old + d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock} + + // Away and back once the list is stale: it's shown from the cache, + // then reloaded, and the details pane follows. + d.keys("1", "j") + *clock = clock.Add(cacheTTL + time.Second) + d.keys("k", "3") + if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old { + t.Errorf("shown %s with %q after the reload", got.Name, got.URL) + } +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 4876daf..5c1e782 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) { d.container = &s.Container } +// Refresh shows s's newer metadata if s is the secret shown, keeping +// its account and any value shown or loading. +func (d *Detail) Refresh(s provider.Object) { + if d.secret == nil || d.secret.ID != s.ID { + return + } + s.Container = d.secret.Container + d.secret = &s +} + // ShowContainer shows a container summary when no secret is selected. func (d *Detail) ShowContainer(c provider.Container) { d.Clear() -- 2.54.0