feat: share env files as encrypted links
S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
26 files changed
+1540
-97
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.
|
||||||
|
|||||||
+8
-76
@@ -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...)
|
|
||||||
}
|
}
|
||||||
+18
-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 <ref>] <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, 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,7 @@ Flags:
|
|||||||
StatePath: statePath,
|
StatePath: statePath,
|
||||||
Changelog: lazysecrets.Changelog,
|
Changelog: lazysecrets.Changelog,
|
||||||
Dir: placeDir,
|
Dir: placeDir,
|
||||||
|
ShareKey: cfg.Share.Key,
|
||||||
Open: open,
|
Open: open,
|
||||||
Styles: &theme,
|
Styles: &theme,
|
||||||
DefaultFg: defaultFg,
|
DefaultFg: defaultFg,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shareFiles prints a share link to env files of one repo, encrypted with
|
||||||
|
// the --key secret's value, else the repo's or the config's share.key:
|
||||||
|
//
|
||||||
|
// lazysecrets share .env apps/api/.env
|
||||||
|
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
||||||
|
keyLink := fs.String("key", "", "lazysecrets:// link to the secret to encrypt with (default: share.key)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() == 0 {
|
||||||
|
return errors.New("usage: lazysecrets share [--key <link>] <env file>...")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <link>, or share.key in the repo's .lazysecrets.yaml or the config")
|
||||||
|
}
|
||||||
|
v, version, err := readKey(ctx, providers, st, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the key %s: %w", key, err)
|
||||||
|
}
|
||||||
|
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintln(out, link)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShareFiles(t *testing.T) {
|
||||||
|
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
|
||||||
|
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||||
|
t.Skip("git:", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
|
||||||
|
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
|
||||||
|
providers := []provider.Provider{fake.Azure(0)}
|
||||||
|
st, _ := store.Load("")
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := shareFiles(ctx, providers, st, key, []string{filepath.Join(root, ".env")}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err := ref.Parse(strings.TrimSpace(out.String()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := share.Read(r)
|
||||||
|
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
|
||||||
|
t.Fatalf("%+v, %v", s.Header, err)
|
||||||
|
}
|
||||||
|
files, err := s.Open([]byte("correct-horse-battery-staple"))
|
||||||
|
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
|
||||||
|
t.Errorf("%+v, %v", files, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
general string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", []string{filepath.Join(root, ".env")}, "which secret"},
|
||||||
|
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
|
||||||
|
{key, nil, "usage"},
|
||||||
|
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
|
||||||
|
} {
|
||||||
|
if err := shareFiles(ctx, providers, st, c.general, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%v: %v, want %q", c.args, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -35,9 +35,12 @@ providers:
|
|||||||
paths: [~/work/infra]
|
paths: [~/work/infra]
|
||||||
include: ["secrets/**"]
|
include: ["secrets/**"]
|
||||||
exclude: ["**/testdata/**"]
|
exclude: ["**/testdata/**"]
|
||||||
|
share: # sharing env files as encrypted links
|
||||||
|
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share # for repos naming none
|
||||||
```
|
```
|
||||||
|
|
||||||
More, each on its own page: [repos](repos.md) (`providers.repos`), the
|
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
||||||
|
[sharing env files](links.md#sharing-env-files) (`share`), the
|
||||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
[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`).
|
||||||
|
|||||||
+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) |
|
||||||
|
|||||||
+57
-1
@@ -20,10 +20,66 @@ 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 and at least 16
|
||||||
|
characters:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
|
||||||
|
uses it) or your config (for repos that don't name one), or pick one when
|
||||||
|
sharing:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
share:
|
||||||
|
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share
|
||||||
|
```
|
||||||
|
|
||||||
|
On the command line, `lazysecrets share [--key <link>] <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
|
||||||
|
|
||||||
|
|||||||
@@ -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: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share
|
||||||
|
```
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ 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/age v1.3.2 // indirect
|
||||||
|
filippo.io/hpke v0.4.0 // indirect
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
|
github.com/Azure/azure-sdk-for-go/sdk/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
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/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/ref"
|
||||||
"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,12 @@ type LinkHandler struct {
|
|||||||
Prompt *bool `yaml:"prompt"`
|
Prompt *bool `yaml:"prompt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Share struct {
|
||||||
|
// Key is the link to the secret whose value encrypts shared files,
|
||||||
|
// for repos whose .lazysecrets.yaml doesn't name one.
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
type Providers struct {
|
type Providers struct {
|
||||||
Azure Azure `yaml:"azure"`
|
Azure Azure `yaml:"azure"`
|
||||||
Databricks Databricks `yaml:"databricks"`
|
Databricks Databricks `yaml:"databricks"`
|
||||||
@@ -221,6 +230,11 @@ func (c Config) validate(path string) error {
|
|||||||
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if k := c.Share.Key; k != "" {
|
||||||
|
if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() {
|
||||||
|
return fmt.Errorf("%s: share.key: %q is not a lazysecrets:// link to a secret", path, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, v := range c.Providers.Azure.Vaults {
|
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)
|
||||||
|
|||||||
@@ -453,11 +453,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 +478,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.-]+$`)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -220,6 +227,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
|
||||||
}
|
}
|
||||||
@@ -464,6 +464,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 +578,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 +649,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 +663,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 +694,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,271 @@
|
|||||||
|
// Package share seals env files into a lazysecrets://share link, so they
|
||||||
|
// can be passed on in chat or a ticket. They're encrypted with the value of
|
||||||
|
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
|
||||||
|
// read that secret can open the link, nobody else.
|
||||||
|
//
|
||||||
|
// A link says in the clear which secret it's encrypted with, the repo the
|
||||||
|
// files are from and how many there are, which is what's needed to open
|
||||||
|
// it. The files' paths and contents are encrypted, with age
|
||||||
|
// (https://age-encryption.org), the secret's value as its passphrase.
|
||||||
|
//
|
||||||
|
// The link's payload is unpadded base64url of:
|
||||||
|
//
|
||||||
|
// version (1) | uvarint length of the header | header (JSON) | age file
|
||||||
|
//
|
||||||
|
// The age file holds the files as deflated JSON.
|
||||||
|
package share
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
version = 1
|
||||||
|
// MaxLink keeps a link short enough for chat and tickets (Teams takes
|
||||||
|
// about 28 KB) and for passing it to a terminal window.
|
||||||
|
MaxLink = 16 << 10
|
||||||
|
// MinKey is the shortest secret value accepted as a key.
|
||||||
|
MinKey = 16
|
||||||
|
// maxFiles caps what opening a link unpacks.
|
||||||
|
maxFiles = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// workFactor is age's scrypt work factor for sealing (its default);
|
||||||
|
// maxWorkFactor is the most opening accepts, so a link can't make it
|
||||||
|
// work for minutes.
|
||||||
|
var workFactor, maxWorkFactor = 18, 20
|
||||||
|
|
||||||
|
// File is a shared file.
|
||||||
|
type File struct {
|
||||||
|
Path string // relative to the repo's root, with slashes
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header is what a link says in the clear.
|
||||||
|
type Header struct {
|
||||||
|
// Key is the secret whose value the files are encrypted with.
|
||||||
|
Key ref.Ref
|
||||||
|
// Repo is the repo the files are from: its remote without scheme or
|
||||||
|
// .git (a repo link's host).
|
||||||
|
Repo string
|
||||||
|
// Files is how many files the link holds.
|
||||||
|
Files int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sealed is a share link read, its files still encrypted.
|
||||||
|
type Sealed struct {
|
||||||
|
Header
|
||||||
|
box []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type header struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Repo string `json:"repo,omitempty"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type file struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal encrypts files with key, the value of h.Key, into a share link.
|
||||||
|
func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
|
||||||
|
if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() {
|
||||||
|
return ref.Ref{}, errors.New("the key must be a secret's link")
|
||||||
|
}
|
||||||
|
if len(key) < MinKey {
|
||||||
|
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return ref.Ref{}, errors.New("no files to share")
|
||||||
|
}
|
||||||
|
plain := make([]file, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
if err := CheckPath(f.Path); err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
if !utf8.Valid(f.Data) {
|
||||||
|
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
|
||||||
|
}
|
||||||
|
plain[i] = file{Path: f.Path, Data: string(f.Data)}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(plain)
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rcpt, err := age.NewScryptRecipient(string(key))
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
rcpt.SetWorkFactor(workFactor)
|
||||||
|
var box bytes.Buffer
|
||||||
|
enc, err := age.Encrypt(&box, rcpt)
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
z, _ := flate.NewWriter(enc, flate.BestCompression)
|
||||||
|
if _, err := z.Write(b); err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
if err := z.Close(); err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
|
||||||
|
payload = append(append(payload, hb...), box.Bytes()...)
|
||||||
|
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
|
||||||
|
if n := len(r.String()); n > MaxLink {
|
||||||
|
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads a share link's header.
|
||||||
|
func Read(r ref.Ref) (Sealed, error) {
|
||||||
|
bad := errors.New("share link is cut short or garbled: copy all of it")
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(r.Share)
|
||||||
|
if err != nil || len(b) < 2 {
|
||||||
|
return Sealed{}, bad
|
||||||
|
}
|
||||||
|
if b[0] != version {
|
||||||
|
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||||
|
}
|
||||||
|
n, size := binary.Uvarint(b[1:])
|
||||||
|
if size <= 0 || n > uint64(len(b)) {
|
||||||
|
return Sealed{}, bad
|
||||||
|
}
|
||||||
|
rest := b[1+size:]
|
||||||
|
if n > uint64(len(rest)) {
|
||||||
|
return Sealed{}, bad
|
||||||
|
}
|
||||||
|
var h header
|
||||||
|
if err := json.Unmarshal(rest[:n], &h); err != nil {
|
||||||
|
return Sealed{}, bad
|
||||||
|
}
|
||||||
|
key, err := ParseKey(h.Key)
|
||||||
|
if err != nil {
|
||||||
|
return Sealed{}, fmt.Errorf("share link's key: %w", err)
|
||||||
|
}
|
||||||
|
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open decrypts the files with key, the value of s.Key.
|
||||||
|
func (s Sealed) Open(key []byte) ([]File, error) {
|
||||||
|
id, err := age.NewScryptIdentity(string(key))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id.SetMaxWorkFactor(maxWorkFactor)
|
||||||
|
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
|
||||||
|
if err != nil {
|
||||||
|
var wrong *age.NoIdentityMatchError
|
||||||
|
if errors.As(err, &wrong) {
|
||||||
|
return nil, errors.New("the key doesn't decrypt it: the link may have been made with another version of " + s.Key.Name)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxFiles+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) > maxFiles {
|
||||||
|
return nil, errors.New("the link holds more than lazysecrets unpacks")
|
||||||
|
}
|
||||||
|
var plain []file
|
||||||
|
if err := json.Unmarshal(b, &plain); err != nil {
|
||||||
|
return nil, fmt.Errorf("can't read the link's files: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]File, len(plain))
|
||||||
|
for i, f := range plain {
|
||||||
|
if err := CheckPath(f.Path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPath accepts a path relative to a repo's root, with slashes, of an
|
||||||
|
// env file (.env, .env.local, prod.env) outside .git: what a link may
|
||||||
|
// write.
|
||||||
|
func CheckPath(p string) error {
|
||||||
|
switch {
|
||||||
|
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
|
||||||
|
p == ".." || strings.HasPrefix(p, "../"):
|
||||||
|
return fmt.Errorf("%q isn't a path inside a repo", p)
|
||||||
|
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
|
||||||
|
return fmt.Errorf("%q is inside .git", p)
|
||||||
|
case !repo.IsEnv(p):
|
||||||
|
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKey is the key for a repo's files: share.key in the repo's
|
||||||
|
// .lazysecrets.yaml, else general (share.key in the config). ok is false
|
||||||
|
// when neither names one.
|
||||||
|
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||||
|
link, err := repo.ShareKey(root)
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, false, err
|
||||||
|
}
|
||||||
|
where := root + "/.lazysecrets.yaml"
|
||||||
|
if link == "" {
|
||||||
|
link, where = general, "the config"
|
||||||
|
}
|
||||||
|
if link == "" {
|
||||||
|
return ref.Ref{}, false, nil
|
||||||
|
}
|
||||||
|
key, err = ParseKey(link)
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
|
||||||
|
}
|
||||||
|
return key, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseKey reads a key's link: a link to a secret.
|
||||||
|
func ParseKey(link string) (ref.Ref, error) {
|
||||||
|
key, err := ref.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return ref.Ref{}, err
|
||||||
|
}
|
||||||
|
if key.IsShare() || key.IsSource() || key.IsContainer() {
|
||||||
|
return ref.Ref{}, fmt.Errorf("%s isn't a secret's link", link)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin is key at version, the version its value was read at, for Azure,
|
||||||
|
// whose old versions stay readable: a link keeps working when the key
|
||||||
|
// is rotated, until that version is disabled.
|
||||||
|
func Pin(key ref.Ref, version string) ref.Ref {
|
||||||
|
if key.Kind == provider.KindAzure && key.Version == "" {
|
||||||
|
key.Version = version
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package share
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
|
||||||
|
|
||||||
|
var (
|
||||||
|
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
|
||||||
|
pass = []byte("0123456789abcdef0123456789abcdef")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSealOpen(t *testing.T) {
|
||||||
|
files := []File{
|
||||||
|
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
|
||||||
|
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
|
||||||
|
}
|
||||||
|
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
|
||||||
|
t.Errorf("link shows what it shares: %s", link)
|
||||||
|
}
|
||||||
|
// It goes through ref.Parse like any link.
|
||||||
|
r, err := ref.Parse(link.String())
|
||||||
|
if err != nil || !r.IsShare() {
|
||||||
|
t.Fatalf("parse: %v, %+v", err, r)
|
||||||
|
}
|
||||||
|
s, err := Read(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
|
||||||
|
t.Errorf("header %+v", s.Header)
|
||||||
|
}
|
||||||
|
got, err := s.Open(pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
|
||||||
|
t.Errorf("opened %+v", got)
|
||||||
|
}
|
||||||
|
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "doesn't decrypt") {
|
||||||
|
t.Errorf("wrong key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealRefuses(t *testing.T) {
|
||||||
|
ok := []File{{Path: ".env", Data: []byte("A=1")}}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
h Header
|
||||||
|
pass []byte
|
||||||
|
files []File
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
|
||||||
|
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"},
|
||||||
|
"no files": {Header{Key: key}, pass, nil, "no files"},
|
||||||
|
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||||
|
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
||||||
|
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
|
||||||
|
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
|
||||||
|
} {
|
||||||
|
if _, err := Seal(c.h, c.pass, c.files); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: %v, want %q", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// random is text that doesn't compress.
|
||||||
|
func random(n int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
x := uint32(1)
|
||||||
|
for b.Len() < n {
|
||||||
|
x ^= x << 13
|
||||||
|
x ^= x >> 17
|
||||||
|
x ^= x << 5
|
||||||
|
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadGarbled(t *testing.T) {
|
||||||
|
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
|
||||||
|
s, err := Read(cut)
|
||||||
|
if err == nil {
|
||||||
|
_, err = s.Open(pass)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Error("a link cut in half opened")
|
||||||
|
}
|
||||||
|
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
|
||||||
|
t.Errorf("version 2: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPath(t *testing.T) {
|
||||||
|
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
|
||||||
|
if err := CheckPath(p); err != nil {
|
||||||
|
t.Errorf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
|
||||||
|
if err := CheckPath(p); err == nil {
|
||||||
|
t.Errorf("%q accepted", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanWrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||||
|
t.Skip("git:", string(out))
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
|
||||||
|
targets, err := Plan(dir, []File{
|
||||||
|
{Path: ".env", Data: []byte("NEW=1\n")},
|
||||||
|
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !targets[0].Exists || targets[0].Exposed {
|
||||||
|
t.Errorf(".env: %+v", targets[0])
|
||||||
|
}
|
||||||
|
if targets[1].Exists || !targets[1].Exposed {
|
||||||
|
t.Errorf("apps/api/.env.local: %+v", targets[1])
|
||||||
|
}
|
||||||
|
for _, tg := range targets {
|
||||||
|
if err := tg.Write(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
|
||||||
|
t.Errorf(".env is %q", b)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("new file: %v, %v", info.Mode(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanSymlinks(t *testing.T) {
|
||||||
|
dir, outside := t.TempDir(), t.TempDir()
|
||||||
|
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
|
||||||
|
t.Errorf("through a linked folder: %v", err)
|
||||||
|
}
|
||||||
|
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
|
||||||
|
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||||
|
t.Errorf("onto a symlink: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
||||||
|
k, ok, err := DefaultKey(dir, general)
|
||||||
|
if err != nil || !ok || k.Name != "env-share" {
|
||||||
|
t.Errorf("general: %+v %v %v", k, ok, err)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||||
|
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
|
||||||
|
t.Errorf("the repo's should win: %+v", k)
|
||||||
|
}
|
||||||
|
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
|
||||||
|
t.Error("a key with none set")
|
||||||
|
}
|
||||||
|
unpinned := key
|
||||||
|
unpinned.Version = ""
|
||||||
|
if p := Pin(unpinned, "abc"); p.Version != "abc" {
|
||||||
|
t.Errorf("pin: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ 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
|
||||||
// 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 +273,10 @@ 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 is the config's share.key; lastShareKey the key last
|
||||||
|
// typed when sharing, offered next time.
|
||||||
|
shareKey, lastShareKey string
|
||||||
|
|
||||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
// 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 +462,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 = opts.ShareKey
|
||||||
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 +578,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))
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,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)
|
||||||
@@ -607,6 +610,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 +635,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 +664,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
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sharing env files as an encrypted lazysecrets://share link (package
|
||||||
|
// share), and opening one: the files are encrypted with a secret's value,
|
||||||
|
// so whoever can read that secret can open the link.
|
||||||
|
|
||||||
|
// sharing is a share being made: the files read, and the key to use.
|
||||||
|
type sharing struct {
|
||||||
|
repo provider.Source
|
||||||
|
files []share.File
|
||||||
|
key ref.Ref // zero until chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
type shareFilesMsg struct {
|
||||||
|
sharing
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type sealedMsg struct {
|
||||||
|
link ref.Ref
|
||||||
|
n int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// canShareFiles reports whether src's env files can be shared: it's a
|
||||||
|
// repo whose files are on disk.
|
||||||
|
func (m Model) canShareFiles(src provider.Source) bool {
|
||||||
|
_, ok := m.providerFor(src).(provider.Files)
|
||||||
|
return src.Kind == provider.KindRepo && ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareFiles shares env files of a repo: cs, or with cs nil all its
|
||||||
|
// plain ones.
|
||||||
|
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
||||||
|
p := m.providerFor(src)
|
||||||
|
files, ok := p.(provider.Files)
|
||||||
|
if !ok {
|
||||||
|
m.flash = warning("these files can't be shared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
general, timeout := m.shareKey, m.timeout
|
||||||
|
m.flash = flash{text: "reading the files…"}
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
msg := shareFilesMsg{sharing: sharing{repo: src}}
|
||||||
|
if cs == nil {
|
||||||
|
all, err := p.Containers(ctx, src)
|
||||||
|
if err != nil {
|
||||||
|
msg.err = err
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
if c.Detail == provider.DetailPlainEnv {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cs) == 0 {
|
||||||
|
msg.err = errors.New(src.Name + " has no plain env files")
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
f, err := files.File(c, nil)
|
||||||
|
if err == nil {
|
||||||
|
var b []byte
|
||||||
|
b, err = os.ReadFile(f.Path)
|
||||||
|
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
msg.err = err
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key, ok, err := share.DefaultKey(src.ID, general)
|
||||||
|
if ok {
|
||||||
|
msg.key = key
|
||||||
|
}
|
||||||
|
msg.err = err
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
||||||
|
switch {
|
||||||
|
case msg.err != nil:
|
||||||
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||||
|
case msg.key == (ref.Ref{}):
|
||||||
|
return m.askShareKey(msg.sharing)
|
||||||
|
default:
|
||||||
|
m.confirmShare(msg.sharing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmShare says what the link will hold before making it.
|
||||||
|
func (m *Model) confirmShare(s sharing) {
|
||||||
|
lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")}
|
||||||
|
for _, f := range s.files {
|
||||||
|
lines = append(lines, " "+f.Path)
|
||||||
|
}
|
||||||
|
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
||||||
|
m.showMenu("Share as an encrypted link", lines, []option{
|
||||||
|
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
|
||||||
|
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// askShareKey asks for the link to the secret to encrypt with.
|
||||||
|
func (m *Model) askShareKey(s sharing) tea.Cmd {
|
||||||
|
in := textinput.New()
|
||||||
|
in.Prompt = "key: "
|
||||||
|
in.Placeholder = "lazysecrets:// link to a secret"
|
||||||
|
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||||
|
in.SetValue(m.lastShareKey)
|
||||||
|
if s.key != (ref.Ref{}) {
|
||||||
|
in.SetValue(s.key.String())
|
||||||
|
}
|
||||||
|
in.CursorEnd()
|
||||||
|
m.prompt = &modal{
|
||||||
|
title: "Encrypt with which secret?", input: &in,
|
||||||
|
lines: []string{
|
||||||
|
m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."),
|
||||||
|
m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."),
|
||||||
|
},
|
||||||
|
submit: func(m *Model, text string) tea.Cmd {
|
||||||
|
key, err := share.ParseKey(strings.TrimSpace(text))
|
||||||
|
if err != nil {
|
||||||
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.lastShareKey = key.String()
|
||||||
|
s.key = key
|
||||||
|
m.confirmShare(s)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return m.prompt.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// seal reads the key and encrypts the files with it.
|
||||||
|
func (m *Model) seal(s sharing) tea.Cmd {
|
||||||
|
read := m.keyReader()
|
||||||
|
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
|
||||||
|
return func() tea.Msg {
|
||||||
|
v, version, err := read(s.key)
|
||||||
|
if err != nil {
|
||||||
|
return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)}
|
||||||
|
}
|
||||||
|
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files)
|
||||||
|
return sealedMsg{link: link, n: len(s.files), err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
||||||
|
if msg.err != nil {
|
||||||
|
m.noteErr(msg.err)
|
||||||
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.dismissedLink = msg.link.String() // not offered back on focus
|
||||||
|
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyReader reads a key's value with the providers, the account that
|
||||||
|
// last read it first, off the UI goroutine.
|
||||||
|
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
||||||
|
providers, st, timeout := m.providers, m.store, m.timeout
|
||||||
|
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
k := key
|
||||||
|
k.Version = ""
|
||||||
|
node := st.Node(k.String())
|
||||||
|
return ref.Read(ctx, providers, key, node.Account, node.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- opening ---
|
||||||
|
|
||||||
|
type shareOpenedMsg struct {
|
||||||
|
sealed share.Sealed
|
||||||
|
files []share.File
|
||||||
|
dir string // the repo's clone; "" when it isn't here
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerShare says what a share link holds before decrypting it: links
|
||||||
|
// can come from anywhere, so nothing is read or written unasked.
|
||||||
|
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
||||||
|
s, err := share.Read(r)
|
||||||
|
if err != nil {
|
||||||
|
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
from := ""
|
||||||
|
if s.Repo != "" {
|
||||||
|
from = " from " + s.Repo
|
||||||
|
}
|
||||||
|
m.showMenu("Shared env files", []string{
|
||||||
|
m.st.Muted.Render(plural(s.Files, "env file") + from),
|
||||||
|
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
|
||||||
|
}, []option{
|
||||||
|
{key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openShare reads the key, decrypts the files and finds the repo's clone.
|
||||||
|
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
||||||
|
read := m.keyReader()
|
||||||
|
providers, timeout := m.providers, m.timeout
|
||||||
|
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg := shareOpenedMsg{sealed: s}
|
||||||
|
v, _, err := read(s.Key)
|
||||||
|
if err != nil {
|
||||||
|
msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
for _, p := range providers {
|
||||||
|
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
|
||||||
|
if root, found, _ := loc.Locate(ctx, s.Repo); found {
|
||||||
|
msg.dir = root
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
||||||
|
switch {
|
||||||
|
case msg.err != nil:
|
||||||
|
m.noteErr(msg.err)
|
||||||
|
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
||||||
|
case msg.dir == "":
|
||||||
|
return m.askShareDir(msg)
|
||||||
|
default:
|
||||||
|
m.offerWrite(msg.files, msg.dir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// askShareDir asks where to save files from a repo that isn't here.
|
||||||
|
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
||||||
|
in := textinput.New()
|
||||||
|
in.Prompt = "folder: "
|
||||||
|
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||||
|
dir := m.placeDir
|
||||||
|
if dir == "" {
|
||||||
|
dir, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
in.SetValue(dir)
|
||||||
|
in.CursorEnd()
|
||||||
|
lines := []string{m.st.Muted.Render("Their paths are from the repo's root.")}
|
||||||
|
if msg.sealed.Repo != "" {
|
||||||
|
lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them where?")}, lines...)
|
||||||
|
}
|
||||||
|
m.prompt = &modal{
|
||||||
|
title: "Save the files", input: &in, lines: lines,
|
||||||
|
submit: func(m *Model, text string) tea.Cmd {
|
||||||
|
m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return m.prompt.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerWrite shows where each file goes, then writes them as chosen:
|
||||||
|
// files there already are replaced only when asked.
|
||||||
|
func (m *Model) offerWrite(files []share.File, dir string) {
|
||||||
|
targets, err := share.Plan(dir, files)
|
||||||
|
if err != nil {
|
||||||
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
exist := 0
|
||||||
|
for _, t := range targets {
|
||||||
|
line := " " + t.Path + m.st.Muted.Render(" · new")
|
||||||
|
if t.Exists {
|
||||||
|
exist++
|
||||||
|
line = " " + t.Path + m.st.Muted.Render(" · replaces the one there")
|
||||||
|
}
|
||||||
|
if t.Exposed {
|
||||||
|
line += m.st.Error.Render(" · not ignored by git")
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
||||||
|
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
||||||
|
return func(m *Model) tea.Cmd {
|
||||||
|
n := 0
|
||||||
|
for _, t := range targets {
|
||||||
|
if onlyNew && t.Exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := t.Write(); err != nil {
|
||||||
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var opts []option
|
||||||
|
switch {
|
||||||
|
case exist == 0:
|
||||||
|
opts = append(opts, option{key: "w", label: "Save them", run: write(false)})
|
||||||
|
case exist == len(targets):
|
||||||
|
opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], run: write(false)})
|
||||||
|
default:
|
||||||
|
opts = append(opts,
|
||||||
|
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
|
||||||
|
option{key: "n", label: "Save only the new " + plural(len(targets)-exist, "file"), run: write(true)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
m.showMenu("Save the shared files", lines, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandHome expands a leading ~/ to the home folder.
|
||||||
|
func expandHome(dir string) string {
|
||||||
|
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scryptWait is how long the driver waits for encrypting and decrypting,
|
||||||
|
// which take about a second on purpose.
|
||||||
|
const scryptWait = 20 * time.Second
|
||||||
|
|
||||||
|
// shareKeyLink is a demo secret, long enough to be a key.
|
||||||
|
const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password"
|
||||||
|
|
||||||
|
// cloneWith makes a clone of git.example.com/team/app holding files.
|
||||||
|
func cloneWith(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := gitRepoWith(t, "https://git.example.com/team/app.git")
|
||||||
|
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
|
||||||
|
for name, data := range files {
|
||||||
|
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareFrom shares a repo's env files from lazysecrets started in it,
|
||||||
|
// returning the link copied.
|
||||||
|
func shareFrom(t *testing.T, root string) string {
|
||||||
|
t.Helper()
|
||||||
|
clip := &fakeClipboard{}
|
||||||
|
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||||
|
d.wait = scryptWait
|
||||||
|
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
|
||||||
|
t.Fatalf("selected %v, want the repo", src.ID)
|
||||||
|
}
|
||||||
|
d.keys("S", "f")
|
||||||
|
// No share.key set: it asks which secret.
|
||||||
|
if d.m.prompt == nil || d.m.prompt.input == nil {
|
||||||
|
t.Fatalf("no key prompt: %s", d.m.flash.text)
|
||||||
|
}
|
||||||
|
d.send(tea.PasteMsg{Content: shareKeyLink})
|
||||||
|
d.keys("enter")
|
||||||
|
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
|
||||||
|
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("c")
|
||||||
|
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||||
|
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||||
|
}
|
||||||
|
return clip.content
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareFilesRoundTrip(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("no git")
|
||||||
|
}
|
||||||
|
link := shareFrom(t, cloneWith(t, map[string]string{
|
||||||
|
".env": "DB_PASSWORD=hunter2\n",
|
||||||
|
"prod.env": "TOKEN=abc\n",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A colleague's clone: .env is there already, prod.env isn't.
|
||||||
|
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
||||||
|
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
|
||||||
|
d.wait = scryptWait
|
||||||
|
d.send(tea.PasteMsg{Content: link})
|
||||||
|
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
|
||||||
|
t.Fatalf("not offered:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("o")
|
||||||
|
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
|
||||||
|
t.Fatalf("no save menu (%s)", d.m.flash.text)
|
||||||
|
}
|
||||||
|
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
|
||||||
|
t.Errorf("save menu doesn't say what happens:\n%s", s)
|
||||||
|
}
|
||||||
|
d.keys("n") // only the new one
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
|
||||||
|
t.Errorf(".env replaced: %q", b)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
|
||||||
|
t.Errorf("prod.env: %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Again, replacing.
|
||||||
|
d.send(tea.PasteMsg{Content: link})
|
||||||
|
d.keys("o", "w")
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
|
||||||
|
t.Errorf(".env not replaced: %q", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("no git")
|
||||||
|
}
|
||||||
|
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}))
|
||||||
|
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
||||||
|
d.wait = scryptWait
|
||||||
|
d.send(tea.PasteMsg{Content: link})
|
||||||
|
d.keys("o")
|
||||||
|
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
|
||||||
|
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("enter", "w")
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
|
||||||
|
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareLinkGarbled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriver(t)
|
||||||
|
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
|
||||||
|
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
|
||||||
|
t.Errorf("not reported:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,7 +117,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||||
|
|
||||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
a(Lists, "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."),
|
||||||
|
|||||||
Reference in new issue
Block a user