Compare commits
25
Commits
v0.1.1
...
869b201695
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869b201695 | ||
|
|
db8ce90ee7
|
||
|
|
40b90df68c
|
||
|
|
8757284b0c
|
||
|
|
7d6a095e42
|
||
|
|
2a3379b1c1
|
||
|
|
61de5e616d
|
||
|
|
25dbb38842
|
||
|
|
0757898d61
|
||
|
|
80332680e5
|
||
|
|
027e748c92
|
||
|
|
166790d825
|
||
|
|
777c8cb532
|
||
|
|
86fb79a915
|
||
|
|
8d65b60e69
|
||
|
|
176600be54
|
||
|
|
5c97a8e249
|
||
|
|
8cd0003f8c
|
||
|
|
cd265a632e
|
||
|
|
0bfa2a3141
|
||
|
|
b46991c7c2
|
||
|
|
68fc76d938
|
||
|
|
bc691d786a
|
||
|
|
4d57c19772
|
||
|
|
4b2f685921
|
No files matched your search
@@ -14,7 +14,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
|||||||
fzf-style matching.
|
fzf-style matching.
|
||||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
- **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
|
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)
|
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||||
- **Careful with values**: values stay hidden until you reveal them, and
|
- **Careful with values**: values stay hidden until you reveal them, and
|
||||||
copied values are cleared from the clipboard after 30 seconds.
|
copied values are cleared from the clipboard after 30 seconds.
|
||||||
|
|||||||
+9
-77
@@ -1,10 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
@@ -33,82 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// value reads what r points at without listing its vault: the vault and
|
// value reads what r points at (ref.Read), with the account that last
|
||||||
// name are enough for Azure, tried with each account (the one that last
|
// read it first.
|
||||||
// read it first); for Databricks the workspace's profile is found by its
|
|
||||||
// host.
|
|
||||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
||||||
if r.IsSource() || r.IsContainer() {
|
v, _, err := readKey(ctx, providers, st, r)
|
||||||
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()])
|
return v, err
|
||||||
}
|
}
|
||||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
|
||||||
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
// 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 := r
|
||||||
key.Version = ""
|
key.Version = ""
|
||||||
node := st.Node(key.String())
|
node := st.Node(key.String())
|
||||||
|
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
||||||
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...)
|
|
||||||
}
|
}
|
||||||
+19
-2
@@ -114,10 +114,14 @@ func run() error {
|
|||||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||||
lazysecrets [flags] browse secrets
|
lazysecrets [flags] browse secrets
|
||||||
lazysecrets [flags] [open] <ref>
|
lazysecrets [flags] [open] <ref>
|
||||||
open a lazysecrets:// link, Key Vault URL,
|
open a lazysecrets:// link (also a share link),
|
||||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||||
|
{{secrets/scope/key}}
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
|
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||||
|
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
|
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||||
config applied), as config to copy and edit
|
config applied), as config to copy and edit
|
||||||
lazysecrets handler install [--terminal NAME]
|
lazysecrets handler install [--terminal NAME]
|
||||||
@@ -140,8 +144,14 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
var open, getRef *ref.Ref
|
||||||
|
var shareArgs []string
|
||||||
printKeys := false
|
printKeys := false
|
||||||
switch args := flag.Args(); {
|
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":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
printKeys = true
|
printKeys = true
|
||||||
case len(args) == 2 && args[0] == "get":
|
case len(args) == 2 && args[0] == "get":
|
||||||
@@ -304,6 +314,11 @@ Flags:
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
return get(ctx, providers, st, *getRef)
|
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, shareRules(cfg), shareArgs, os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||||
var p *tea.Program
|
var p *tea.Program
|
||||||
@@ -375,6 +390,8 @@ Flags:
|
|||||||
StatePath: statePath,
|
StatePath: statePath,
|
||||||
Changelog: lazysecrets.Changelog,
|
Changelog: lazysecrets.Changelog,
|
||||||
Dir: placeDir,
|
Dir: placeDir,
|
||||||
|
ShareKey: cfg.Share.Key,
|
||||||
|
ShareRules: shareRules(cfg),
|
||||||
Open: open,
|
Open: open,
|
||||||
Styles: &theme,
|
Styles: &theme,
|
||||||
DefaultFg: defaultFg,
|
DefaultFg: defaultFg,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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"
|
||||||
|
"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, 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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() == 0 {
|
||||||
|
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <secret>, 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, 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}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"}} {
|
||||||
|
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, demoRules, []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, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%v: %v, want %q", c.args, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -37,9 +37,15 @@ providers:
|
|||||||
paths: [~/work/infra]
|
paths: [~/work/infra]
|
||||||
include: ["secrets/**"]
|
include: ["secrets/**"]
|
||||||
exclude: ["**/testdata/**"]
|
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`), 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`),
|
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||||
[custom commands](custom-commands.md) (`customCommands`).
|
[custom commands](custom-commands.md) (`customCommands`).
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ go test ./...
|
|||||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
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:
|
A stripped binary is about 21 MB instead of 30 MB:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| `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) |
|
| `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)) |
|
| `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) |
|
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||||
| `x` | [custom commands](custom-commands.md) |
|
| `x` | [custom commands](custom-commands.md) |
|
||||||
| `L` | sign in again (shown when a login has expired) |
|
| `L` | sign in again (shown when a login has expired) |
|
||||||
|
|||||||
+82
-8
@@ -1,9 +1,10 @@
|
|||||||
# Links
|
# Links
|
||||||
|
|
||||||
Every subscription, workspace, vault, scope, repo, file and secret has a
|
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
||||||
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
|
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||||
lands on that item, reading it with their own account and permissions:
|
it lands on that item, reading it with their own account and
|
||||||
links carry names, never values.
|
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
|
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||||
@@ -20,10 +21,83 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
|||||||
that version). `S` opens the share menu:
|
that version). `S` opens the share menu:
|
||||||
|
|
||||||
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
|
- 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
|
- 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`)
|
(`t`), the plain vault URL (`u`) or the web page (`b`)
|
||||||
- subscriptions and workspaces: a link (`o`) 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:
|
||||||
|
|
||||||
|
```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), by its
|
||||||
|
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
|
||||||
|
reference:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
share:
|
||||||
|
key: https://kv-team.vault.azure.net/secrets/env-share
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <secret>] <env file>...`
|
||||||
|
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
|
## Opening
|
||||||
|
|
||||||
@@ -44,9 +118,9 @@ read the clipboard over SSH.
|
|||||||
|
|
||||||
lazysecrets finds the vault in your accounts (one Resource Graph query per
|
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
|
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
|
it, ending on a secret's details as `enter` on it would. Links say what,
|
||||||
to just go to it, also copy or show its value, or (`y`) copy its value
|
not what to do: opening a link to a secret asks whether to just go to it,
|
||||||
without going there.
|
also copy or show its value, or (`y`) copy its value without going there.
|
||||||
|
|
||||||
## In scripts
|
## In scripts
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ providers:
|
|||||||
exclude: ["**/testdata/**"]
|
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: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||||
|
```
|
||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||||
@@ -90,6 +98,8 @@ sops 3.8 or later.
|
|||||||
|
|
||||||
## Links
|
## 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
|
Links name the repo by its git remote, so they open in a colleague's clone
|
||||||
wherever it is:
|
wherever it is:
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
charm.land/bubbles/v2 v2.2.1
|
charm.land/bubbles/v2 v2.2.1
|
||||||
charm.land/bubbletea/v2 v2.1.0
|
charm.land/bubbletea/v2 v2.1.0
|
||||||
charm.land/lipgloss/v2 v2.0.6
|
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/azcore v1.23.3
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
|
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
|
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0
|
||||||
@@ -29,6 +30,7 @@ require (
|
|||||||
cloud.google.com/go/auth v0.18.2 // indirect
|
cloud.google.com/go/auth v0.18.2 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // 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/internal v1.13.0 // indirect
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.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
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
@@ -10,6 +12,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/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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
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 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/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ type Config struct {
|
|||||||
Providers Providers `yaml:"providers"`
|
Providers Providers `yaml:"providers"`
|
||||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||||
LinkHandler LinkHandler `yaml:"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
|
// CustomCommands run shell commands on the selected item; see
|
||||||
// package commands.
|
// package commands.
|
||||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||||
@@ -82,6 +85,23 @@ type LinkHandler struct {
|
|||||||
Prompt *bool `yaml:"prompt"`
|
Prompt *bool `yaml:"prompt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Share struct {
|
||||||
|
// 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"`
|
||||||
|
// 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 {
|
type Providers struct {
|
||||||
Azure Azure `yaml:"azure"`
|
Azure Azure `yaml:"azure"`
|
||||||
Databricks Databricks `yaml:"databricks"`
|
Databricks Databricks `yaml:"databricks"`
|
||||||
@@ -221,6 +241,17 @@ 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)
|
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 _, 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 {
|
||||||
|
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 {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"view": "sourcesView: tree\n",
|
"view": "sourcesView: tree\n",
|
||||||
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||||
"http": "releases: [http://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",
|
||||||
} {
|
} {
|
||||||
if _, err := Load(write(t, content)); err == nil {
|
if _, err := Load(write(t, content)); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
|||||||
if s.ID == nil || deref(s.Managed) {
|
if s.ID == nil || deref(s.Managed) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, secretObject(c, s))
|
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
@@ -392,7 +392,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
|||||||
}
|
}
|
||||||
for _, s := range page.Value {
|
for _, s := range page.Value {
|
||||||
if s.ID != nil {
|
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:
|
default:
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
tenant := p.tenant(o.Container.Source.ID)
|
||||||
for i := range out {
|
for i := range out {
|
||||||
out[i].Reference = versionedReference(out[i])
|
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) })
|
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -453,11 +458,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
}
|
}
|
||||||
switch o.Type {
|
switch o.Type {
|
||||||
case provider.TypeSecret:
|
case provider.TypeSecret:
|
||||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
v, _, err := p.VersionedValue(ctx, o)
|
||||||
if err != nil {
|
return v, err
|
||||||
return provider.SecretValue{}, cleanError(err)
|
|
||||||
}
|
|
||||||
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
|
||||||
case provider.TypeKey:
|
case provider.TypeKey:
|
||||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -481,7 +483,31 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.SecretValue{}, provider.ErrUnsupported
|
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.
|
// validHost guards the Resource Graph query against odd input.
|
||||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
"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/azcore/to"
|
||||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
"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"
|
"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) {
|
func TestVersionedReference(t *testing.T) {
|
||||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
|
|||||||
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||||
}
|
}
|
||||||
|
|
||||||
// portalAssetURL links to a Key Vault object's page; objectID must include
|
// portalAssetURL links to a Key Vault object's page: a version's with
|
||||||
// the version.
|
// one in objectID; a secret's own (its versions) without.
|
||||||
func portalAssetURL(tenant, kind, objectID string) string {
|
func portalAssetURL(tenant, kind, objectID string) string {
|
||||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
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
|
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()
|
name := p.ID.Name()
|
||||||
url := objectURL(c, "secrets", name)
|
url := objectURL(c, "secrets", name)
|
||||||
o := provider.Object{
|
o := provider.Object{
|
||||||
@@ -100,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.
|
|||||||
ContentType: deref(p.ContentType),
|
ContentType: deref(p.ContentType),
|
||||||
Tags: tags(p.Tags),
|
Tags: tags(p.Tags),
|
||||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
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 {
|
if a := p.Attributes; a != nil {
|
||||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||||
|
|||||||
@@ -134,13 +134,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.NewSecretValue([]byte(v)), nil
|
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) {
|
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||||
if err := p.wait(ctx); err != nil {
|
if err := p.wait(ctx); err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
for _, src := range p.sources {
|
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
|
return src.ID, true, nil
|
||||||
}
|
}
|
||||||
for _, c := range p.containers[src.ID] {
|
for _, c := range p.containers[src.ID] {
|
||||||
@@ -331,6 +332,10 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
}, demoCert)
|
}, demoCert)
|
||||||
|
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
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{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func Repos() *Provider {
|
|||||||
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
||||||
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
||||||
sops := method != ""
|
sops := method != ""
|
||||||
detail := "env · plain text"
|
detail := provider.DetailPlainEnv
|
||||||
if sops {
|
if sops {
|
||||||
detail = "sops · " + method
|
detail = "sops · " + method
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func Repos() *Provider {
|
|||||||
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
||||||
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
||||||
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
|
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")
|
"google.client_secret", "GOCSPX-demo-000000000000")
|
||||||
file(api, ".env", "", "ignored", "",
|
file(api, ".env", "", "ignored", "",
|
||||||
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
||||||
@@ -134,3 +134,18 @@ func format(path string) string {
|
|||||||
}
|
}
|
||||||
return "dotenv"
|
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()
|
||||||
|
}
|
||||||
@@ -167,6 +167,13 @@ type Current interface {
|
|||||||
Current(ctx context.Context) (Source, bool)
|
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,
|
// 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
|
// 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.
|
// workspace host. It returns the ID of the source holding it.
|
||||||
@@ -182,6 +189,12 @@ type Files interface {
|
|||||||
File(c Container, o *Object) (File, error)
|
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.
|
// File is a container's file, to open.
|
||||||
type File struct {
|
type File struct {
|
||||||
Path string
|
Path string
|
||||||
@@ -220,6 +233,10 @@ func (d *Denied) Error() string {
|
|||||||
|
|
||||||
func (d *Denied) Unwrap() error { return d.Err }
|
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
|
// ContainerDetails is implemented by providers that can say more about a
|
||||||
// container than its listing does: how a file is encrypted and what git
|
// container than its listing does: how a file is encrypted and what git
|
||||||
// knows of it. ErrUnsupported means there's nothing more for c.
|
// knows of it. ErrUnsupported means there's nothing more for c.
|
||||||
|
|||||||
@@ -311,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
|||||||
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
||||||
case isSops:
|
case isSops:
|
||||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||||
case isEnv(f):
|
case IsEnv(f):
|
||||||
c.Detail = "env · plain text"
|
c.Detail = provider.DetailPlainEnv
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -412,6 +412,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
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.
|
// 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) {
|
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||||
path := c.ID
|
path := c.ID
|
||||||
@@ -464,6 +473,16 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
|
|||||||
return "", false, nil
|
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 ---
|
// --- git ---
|
||||||
|
|
||||||
func gitRoot(dir string) (string, error) {
|
func gitRoot(dir string) (string, error) {
|
||||||
@@ -568,6 +587,27 @@ func normalizeRemote(remote string) (string, string) {
|
|||||||
type lazysecretsFile struct {
|
type lazysecretsFile struct {
|
||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
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
|
// selector says which files to look at: user (the include/exclude of the
|
||||||
@@ -618,7 +658,7 @@ func envFiles(root string, tracked []string) []string {
|
|||||||
dirs := map[string]bool{".": true}
|
dirs := map[string]bool{".": true}
|
||||||
for _, f := range tracked {
|
for _, f := range tracked {
|
||||||
isTracked[f] = true
|
isTracked[f] = true
|
||||||
if isEnv(f) {
|
if IsEnv(f) {
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||||
@@ -632,7 +672,7 @@ func envFiles(root string, tracked []string) []string {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
f := path.Join(d, e.Name())
|
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)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,10 +703,10 @@ func inPackages(f string) bool {
|
|||||||
return false
|
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
|
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||||
// dependencies.
|
// dependencies.
|
||||||
func isEnv(f string) bool {
|
func IsEnv(f string) bool {
|
||||||
if inPackages(f) {
|
if inPackages(f) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+25
-1
@@ -10,11 +10,15 @@
|
|||||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||||
|
// lazysecrets://share/<sealed files>
|
||||||
//
|
//
|
||||||
// A repo is its git remote without scheme or .git
|
// 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
|
// (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.
|
// 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.
|
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
||||||
//
|
//
|
||||||
// Every path segment is percent-encoded, so names may hold any character.
|
// Every path segment is percent-encoded, so names may hold any character.
|
||||||
@@ -49,10 +53,19 @@ type Ref struct {
|
|||||||
Type provider.ObjectType
|
Type provider.ObjectType
|
||||||
Name string // object name; empty for a container link
|
Name string // object name; empty for a container link
|
||||||
Version string
|
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.
|
// IsSource reports whether r points at a subscription or workspace.
|
||||||
func (r Ref) IsSource() bool {
|
func (r Ref) IsSource() bool {
|
||||||
|
if r.IsShare() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if r.Kind != provider.KindAzure {
|
if r.Kind != provider.KindAzure {
|
||||||
return r.Scope == ""
|
return r.Scope == ""
|
||||||
}
|
}
|
||||||
@@ -60,7 +73,7 @@ func (r Ref) IsSource() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsContainer reports whether r points at a vault or scope.
|
// 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.
|
// ContainerName is the vault name (from its host) or the scope.
|
||||||
func (r Ref) ContainerName() string {
|
func (r Ref) ContainerName() string {
|
||||||
@@ -111,6 +124,9 @@ func (r Ref) IdentifierURL() string {
|
|||||||
|
|
||||||
// String is the lazysecrets:// link.
|
// String is the lazysecrets:// link.
|
||||||
func (r Ref) String() string {
|
func (r Ref) String() string {
|
||||||
|
if r.IsShare() {
|
||||||
|
return Scheme + "://share/" + r.Share
|
||||||
|
}
|
||||||
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
||||||
return Scheme + "://azure/subscriptions/" + url.PathEscape(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-]+)`)
|
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||||
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
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.
|
// Parse reads any supported reference.
|
||||||
@@ -238,6 +256,12 @@ func parseLink(s string) (Ref, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Ref{}, err
|
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
|
var r Ref
|
||||||
if len(segs) == 0 || segs[0] == "" {
|
if len(segs) == 0 || segs[0] == "" {
|
||||||
return Ref{}, errors.New("link has no vault or workspace")
|
return Ref{}, errors.New("link has no vault or workspace")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package share
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"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))
|
||||||
|
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("its value has %s; share.keyRules needs %s",
|
||||||
|
strings.Join(has, " and "), strings.Join(needs, " and "))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 (
|
||||||
|
"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 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.
|
||||||
|
MaxLink = 16 << 10
|
||||||
|
// MinKey is the shortest key the rules can ask for.
|
||||||
|
MinKey = 16
|
||||||
|
// maxUnpacked caps the bytes opening a link unpacks.
|
||||||
|
maxUnpacked = 1 << 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 {
|
||||||
|
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,
|
||||||
|
// 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{}, &KeyError{Name: h.Key.Name, Err: err}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
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, s.wrongKey()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||||
|
}
|
||||||
|
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) > maxUnpacked {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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, Rules{})
|
||||||
|
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(), "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 {
|
||||||
|
h Header
|
||||||
|
pass []byte
|
||||||
|
files []File
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"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"},
|
||||||
|
"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, Rules{}); 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 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")}}, Rules{})
|
||||||
|
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 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"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"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/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
@@ -81,6 +82,11 @@ type Options struct {
|
|||||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||||
// run only.
|
// run only.
|
||||||
CacheFile string
|
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
|
||||||
|
// 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
|
// 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.
|
// left there, unless it was started on a link. "" doesn't.
|
||||||
Dir string
|
Dir string
|
||||||
@@ -270,6 +276,9 @@ type Model struct {
|
|||||||
linked []provider.Container // vaults opened from links, this session
|
linked []provider.Container // vaults opened from links, this session
|
||||||
open *ref.Ref // reference to open at startup
|
open *ref.Ref // reference to open at startup
|
||||||
|
|
||||||
|
shareKey string // the config's share.key
|
||||||
|
shareRules share.Rules // and share.keyRules
|
||||||
|
|
||||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||||
dismissedLink string // the last link offered: not offered again
|
dismissedLink string // the last link offered: not offered again
|
||||||
clipChecked time.Time
|
clipChecked time.Time
|
||||||
@@ -455,6 +464,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
m.spinning = true
|
m.spinning = true
|
||||||
|
|
||||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||||
|
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
|
||||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||||
if m.cachePath != "" {
|
if m.cachePath != "" {
|
||||||
m.loadCacheFile()
|
m.loadCacheFile()
|
||||||
@@ -570,6 +580,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case resolvedMsg:
|
case resolvedMsg:
|
||||||
m.handleResolved(msg)
|
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:
|
case tea.PasteMsg:
|
||||||
cmds = append(cmds, m.handlePaste(msg))
|
cmds = append(cmds, m.handlePaste(msg))
|
||||||
|
|
||||||
@@ -660,12 +679,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.bound != nil {
|
if msg.bound != nil {
|
||||||
msg.key = m.rebind(*msg.bound)
|
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.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||||
m.cacheDirty = true
|
m.cacheDirty = true
|
||||||
if m.versionsOf != nil {
|
if m.versionsOf != nil {
|
||||||
m.setVersions(msg.items)
|
m.setVersions(msg.items)
|
||||||
} else {
|
} else {
|
||||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||||
|
cmds = append(cmds, m.refreshShown(before))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) {
|
|||||||
c.cursor[parent] = child
|
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)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
|||||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-1
@@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
|||||||
out = append(out, entry{depth, sort, option{
|
out = append(out, entry{depth, sort, option{
|
||||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||||
|
plain: m.inPlainEnv(r),
|
||||||
run: func(m *Model) tea.Cmd {
|
run: func(m *Model) tea.Cmd {
|
||||||
m.used(key)
|
m.used(key)
|
||||||
return run(m)
|
return run(m)
|
||||||
@@ -138,6 +139,30 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
|
|||||||
return label
|
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 {
|
||||||
|
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
|
||||||
|
}
|
||||||
|
|
||||||
// sourceMatches reports whether src is the subscription or workspace r
|
// sourceMatches reports whether src is the subscription or workspace r
|
||||||
// links to.
|
// links to.
|
||||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||||
@@ -192,7 +217,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
|||||||
case 1:
|
case 1:
|
||||||
m.startNav(r, actGo, choices[0].sourceRow)
|
m.startNav(r, actGo, choices[0].sourceRow)
|
||||||
default:
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,9 @@ type nav struct {
|
|||||||
// quiet gives up without a word: going back to where lazysecrets
|
// quiet gives up without a word: going back to where lazysecrets
|
||||||
// was left, which may be gone.
|
// was left, which may be gone.
|
||||||
quiet bool
|
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 {
|
type resolvedMsg struct {
|
||||||
@@ -78,6 +81,9 @@ const (
|
|||||||
// just open it.
|
// just open it.
|
||||||
// With ask false, vault and scope links open straight away.
|
// With ask false, vault and scope links open straight away.
|
||||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
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 r.IsContainer() || r.IsSource() {
|
||||||
if !ask {
|
if !ask {
|
||||||
return m.openRef(r, actGo)
|
return m.openRef(r, actGo)
|
||||||
@@ -398,23 +404,30 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
|||||||
for i := range msg.choices {
|
for i := range msg.choices {
|
||||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
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:
|
case len(msg.others) > 0:
|
||||||
var choices []choice
|
var choices []choice
|
||||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
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:
|
case msg.linked != nil:
|
||||||
m.addLinked(*msg.linked)
|
m.addLinked(*msg.linked)
|
||||||
m.startNav(msg.ref, msg.act, linksRowID)
|
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
||||||
default:
|
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.
|
// 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
|
var opts []option
|
||||||
for i, c := range choices {
|
for i, c := range choices {
|
||||||
key := ""
|
key := ""
|
||||||
@@ -424,6 +437,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
|
|||||||
row := c.sourceRow
|
row := c.sourceRow
|
||||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||||
m.startNav(r, act, row)
|
m.startNav(r, act, row)
|
||||||
|
m.nav.link = link
|
||||||
return nil
|
return nil
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
@@ -574,6 +588,9 @@ func (m *Model) advanceNav() bool {
|
|||||||
m.pendingCmd = m.toggleReveal()
|
m.pendingCmd = m.toggleReveal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if n.link {
|
||||||
|
m.focus = paneDetail // after: copying acts on the focused pane
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -607,6 +624,9 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if s.URL != "" {
|
if s.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+s.Name, nil, opts)
|
||||||
case paneContainers:
|
case paneContainers:
|
||||||
c, ok := m.containers.Selected()
|
c, ok := m.containers.Selected()
|
||||||
@@ -629,6 +649,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if c.URL != "" {
|
if c.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+c.Name, nil, opts)
|
||||||
default:
|
default:
|
||||||
o, ok := m.detail.Secret()
|
o, ok := m.detail.Secret()
|
||||||
@@ -653,6 +678,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
if o.URL != "" {
|
if o.URL != "" {
|
||||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
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)
|
m.showMenu("Share "+o.Name, nil, opts)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -802,6 +832,9 @@ func (m *Model) openText(s string) tea.Cmd {
|
|||||||
|
|
||||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||||
|
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 {
|
if p := m.prompt; p != nil && p.input != nil {
|
||||||
in, cmd := p.input.Update(msg)
|
in, cmd := p.input.Update(msg)
|
||||||
p.input = &in
|
p.input = &in
|
||||||
|
|||||||
@@ -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" {
|
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)
|
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())
|
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -40,6 +42,7 @@ type option struct {
|
|||||||
group string // the source row it's in (search)
|
group string // the source row it's in (search)
|
||||||
kind provider.Kind // the kind of provider it's from (search)
|
kind provider.Kind // the kind of provider it's from (search)
|
||||||
hits []int // characters of the label the filter matched (Go to)
|
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
|
run func(m *Model) tea.Cmd
|
||||||
// header makes this a section title: not selectable, and hidden
|
// header makes this a section title: not selectable, and hidden
|
||||||
// while a filter narrows the list.
|
// while a filter narrows the list.
|
||||||
@@ -53,9 +56,13 @@ type modal struct {
|
|||||||
options []option
|
options []option
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
// A text input instead of options; submit runs on enter.
|
// A text input instead of options; submit runs on enter (named
|
||||||
input *textinput.Model
|
// submitLabel in the footer; "open" if empty). err says why the last
|
||||||
submit func(m *Model, text string) tea.Cmd
|
// 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
|
// A filter over the options: typing narrows them (all holds them
|
||||||
// all), so moving uses arrows or ctrl+j/k.
|
// all), so moving uses arrows or ctrl+j/k.
|
||||||
@@ -259,7 +266,10 @@ func (m Model) modalView() string {
|
|||||||
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
||||||
if p.input != nil {
|
if p.input != nil {
|
||||||
body = append(body, " "+p.input.View())
|
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
|
// Size from all options, not the ones shown or the cursor's, so the
|
||||||
@@ -406,3 +416,9 @@ func (m Model) overlay(base string) string {
|
|||||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||||
).Render()
|
).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:]
|
||||||
|
}
|
||||||
@@ -61,13 +61,14 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
var es []entry
|
var es []entry
|
||||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||||
// much is loaded, reload (ctrl+r) loads it again.
|
// 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)
|
n := m.store.Node(key)
|
||||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||||
es = append(es, entry{depth, sort,
|
es = append(es, entry{depth, sort,
|
||||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
||||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||||
|
return &es[len(es)-1].opt
|
||||||
}
|
}
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
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"
|
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
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)
|
m.startNav(r, actGo, row)
|
||||||
return nil
|
return nil
|
||||||
}, reload)
|
}, reload)
|
||||||
|
e.plain = c.Detail == provider.DetailPlainEnv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-11
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"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/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -52,6 +53,9 @@ type searchView struct {
|
|||||||
res int // cursor in shown
|
res int // cursor in shown
|
||||||
here string // the scope of the source Go to was opened from, if any
|
here string // the scope of the source Go to was opened from, if any
|
||||||
|
|
||||||
|
// 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:
|
// 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
|
// typing loads the scope once, so lists that fail aren't asked for
|
||||||
// again on every key.
|
// again on every key.
|
||||||
@@ -267,6 +271,9 @@ func (v *searchView) match() []option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v.picker != nil {
|
||||||
|
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
|
||||||
|
}
|
||||||
q := v.input.Value()
|
q := v.input.Value()
|
||||||
if q == "" {
|
if q == "" {
|
||||||
return slices.Clone(cand)
|
return slices.Clone(cand)
|
||||||
@@ -423,9 +430,14 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
case key.Matches(msg, k.sel):
|
case key.Matches(msg, k.sel):
|
||||||
switch {
|
switch {
|
||||||
|
case v.results && v.res < len(v.shown) && v.picker != nil:
|
||||||
|
r, _ := pickable(v.shown[v.res])
|
||||||
|
return v.picker.choose(m, r)
|
||||||
case v.results && v.res < len(v.shown):
|
case v.results && v.res < len(v.shown):
|
||||||
m.search = nil
|
m.search = nil
|
||||||
return v.shown[v.res].run(m)
|
return v.shown[v.res].run(m)
|
||||||
|
case !v.results && v.picker != nil:
|
||||||
|
v.results = true // a picker only narrows to the scope
|
||||||
case !v.results && v.onSource():
|
case !v.results && v.onSource():
|
||||||
m.search = nil
|
m.search = nil
|
||||||
return v.scope().run(m)
|
return v.scope().run(m)
|
||||||
@@ -641,7 +653,16 @@ func (m Model) searchViewBox() string {
|
|||||||
v := m.search
|
v := m.search
|
||||||
focused := !m.termBlurred
|
focused := !m.termBlurred
|
||||||
w := searchWidth(m.w)
|
w := searchWidth(m.w)
|
||||||
|
popupW := w
|
||||||
|
if v.picker != nil {
|
||||||
|
w -= 4 // inside the picker's border, in the popup
|
||||||
|
}
|
||||||
listH := max(5, min(m.h-10, 14))
|
listH := max(5, min(m.h-10, 14))
|
||||||
|
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.picker.lines)-len(v.picker.help)-8, 14))
|
||||||
|
}
|
||||||
|
|
||||||
srcW := 10
|
srcW := 10
|
||||||
for _, o := range v.scopes {
|
for _, o := range v.scopes {
|
||||||
@@ -733,7 +754,24 @@ func (m Model) searchViewBox() string {
|
|||||||
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := []string{" " + v.input.View(), ""}
|
ts := panes.TitleStyle(m.st, focused)
|
||||||
|
var body []string
|
||||||
|
if p := v.picker; p != nil {
|
||||||
|
for _, l := range p.help {
|
||||||
|
body = append(body, " "+l)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
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(p.err, w-4, " "), "\n") {
|
||||||
|
body = append(body, " "+m.st.Error.Render(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = append(body, "", " "+m.goToTabs(ts))
|
||||||
|
}
|
||||||
|
body = append(body, " "+v.input.View(), "")
|
||||||
for _, l := range strings.Split(lists, "\n") {
|
for _, l := range strings.Split(lists, "\n") {
|
||||||
body = append(body, " "+l)
|
body = append(body, " "+l)
|
||||||
}
|
}
|
||||||
@@ -753,26 +791,83 @@ func (m Model) searchViewBox() string {
|
|||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
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:
|
default:
|
||||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
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.
|
// Tabs in the title, the current one underlined, like the panes; a
|
||||||
ts := panes.TitleStyle(m.st, focused)
|
// picker has its own title, and the tabs over the input.
|
||||||
head := ts.Render(goToTitle + " ")
|
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.picker.lines {
|
||||||
|
outer = append(outer, " "+l)
|
||||||
|
}
|
||||||
|
outer = append(outer, "")
|
||||||
|
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.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.picker != 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, nor a key in a plain env file).
|
||||||
|
func pickable(o option) (ref.Ref, bool) {
|
||||||
|
r, err := ref.Parse(o.star)
|
||||||
|
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
|
||||||
|
return ref.Ref{}, false
|
||||||
|
}
|
||||||
|
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 {
|
for i, name := range tabNames {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
head += ts.Render(" - ")
|
out += ts.Render(" - ")
|
||||||
}
|
}
|
||||||
if i == v.tab {
|
if i == m.search.tab {
|
||||||
head += ts.Underline(true).Render(name)
|
out += ts.Underline(true).Render(name)
|
||||||
} else {
|
} else {
|
||||||
head += m.st.Muted.Render(name)
|
out += m.st.Muted.Render(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
return out
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"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
|
||||||
|
// from is the picker the key was picked in; nil for share.key's.
|
||||||
|
from *picker
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// canShareFiles reports whether src's env files can be shared: it's a
|
||||||
|
// repo whose files can be read.
|
||||||
|
func (m Model) canShareFiles(src provider.Source) bool {
|
||||||
|
_, ok := m.providerFor(src).(provider.Contents)
|
||||||
|
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.Contents)
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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.pickShareKey(msg.sharing)
|
||||||
|
default:
|
||||||
|
m.confirmShare(msg.sharing)
|
||||||
|
}
|
||||||
|
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 := 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, 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: 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)
|
||||||
|
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
|
||||||
|
p.err, p.busy = "", "encrypting with "+key.Name+"…"
|
||||||
|
cmd := m.seal(s, p)
|
||||||
|
m.flash = flash{}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.search.picker.err = "✗ " + capitalize(err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.search.picker.choose(m, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{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), from: from, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 = 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("encrypted with ") + m.refLabel(s.Key),
|
||||||
|
}, []option{
|
||||||
|
{key: "o", label: "Decrypt…", 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 = &share.KeyError{Name: s.Key.Name, Err: 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 != "" {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
||||||
|
switch {
|
||||||
|
case msg.err != nil:
|
||||||
|
m.noteErr(msg.err)
|
||||||
|
m.openFailed(msg.sealed, msg.err, false)
|
||||||
|
case msg.dir == "":
|
||||||
|
return m.askShareDir(msg)
|
||||||
|
default:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFailed says why a share link couldn't be decrypted, in a popup
|
||||||
|
// 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 != "" // more than the reason: what to check
|
||||||
|
if !whole {
|
||||||
|
text = first
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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("Paths are from the repo's root.")}
|
||||||
|
if msg.sealed.Repo != "" {
|
||||||
|
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
|
||||||
|
}
|
||||||
|
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
|
||||||
|
p.submit = func(m *Model, text string) tea.Cmd {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.err = err.Error()
|
||||||
|
m.prompt = p // again, to fix the folder
|
||||||
|
return p.input.Focus()
|
||||||
|
}
|
||||||
|
m.prompt = p
|
||||||
|
return p.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 {
|
||||||
|
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 {
|
||||||
|
if err := m.writeShared(targets, dir, onlyNew); err != nil {
|
||||||
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var opts []option
|
||||||
|
switch {
|
||||||
|
case exist == 0:
|
||||||
|
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
|
||||||
|
case exist == len(targets):
|
||||||
|
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)},
|
||||||
|
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
m.showMenu("Save the shared files", lines, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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()
|
||||||
|
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, paste bool) string {
|
||||||
|
t.Helper()
|
||||||
|
clip := &fakeClipboard{}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
d.keys("S", "f")
|
||||||
|
// No share.key set: it asks which secret, in Go to, which lists
|
||||||
|
// only secrets.
|
||||||
|
v := d.m.search
|
||||||
|
if v == nil || v.picker == 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"})
|
||||||
|
} 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(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") {
|
||||||
|
t.Errorf("not titled as a picker:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("enter")
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
}), false)
|
||||||
|
|
||||||
|
// 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 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")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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.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") {
|
||||||
|
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/") || d.m.search != nil {
|
||||||
|
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: 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())
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; on the status pane, your config (made if it isn't there yet). 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, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). 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, "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, "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."),
|
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
|
|||||||
d.container = &s.Container
|
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.
|
// ShowContainer shows a container summary when no secret is selected.
|
||||||
func (d *Detail) ShowContainer(c provider.Container) {
|
func (d *Detail) ShowContainer(c provider.Container) {
|
||||||
d.Clear()
|
d.Clear()
|
||||||
|
|||||||
Reference in new issue
Block a user