feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link. **Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file). - **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference. - **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels. - **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening. - **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them. - **The command line:** `lazysecrets share [--key <secret>] <env files>`. **Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`. 1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`. 2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore. 3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written. 4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged. **Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable. **Safety:** - A link can only write env files, inside the folder: never in `.git`, never through a symlink. - New files are created with mode 0600. - Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB. **Along the way:** - Opening a link to a secret ends on its details, as `enter` would. - `o` on an Azure secret opens its portal page, and in the versions view, that version's page. - The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address. - `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning. - `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there. - New dependency: `filippo.io/age`. Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
1 parent
2044341b20
commit
4377eb98bf
40 files changed
+2449
-138
No files matched your search
+9
-77
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
@@ -33,82 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
||||
return err
|
||||
}
|
||||
|
||||
// value reads what r points at without listing its vault: the vault and
|
||||
// name are enough for Azure, tried with each account (the one that last
|
||||
// read it first); for Databricks the workspace's profile is found by its
|
||||
// host.
|
||||
// value reads what r points at (ref.Read), with the account that last
|
||||
// read it first.
|
||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
||||
if r.IsSource() || r.IsContainer() {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()])
|
||||
}
|
||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
||||
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||
}
|
||||
v, _, err := readKey(ctx, providers, st, r)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// readKey is value, also returning the version read.
|
||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := st.Node(key.String())
|
||||
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
for _, p := range providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
profile, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
// Listing the scopes tells a Key Vault-backed one, whose value
|
||||
// can then be read from the vault if Databricks refuses.
|
||||
if cs, err := p.Containers(ctx, src); err == nil {
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
c.Source = src
|
||||
o.Container = c
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == node.Account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
case provider.KindRepo:
|
||||
return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r)
|
||||
}
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, err := t.p.Value(ctx, t.o)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if len(tries) == 1 {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
return provider.SecretValue{}, errors.Join(errs...)
|
||||
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
||||
}
|
||||
+19
-2
@@ -114,10 +114,14 @@ func run() error {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link, Key Vault URL,
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
open a lazysecrets:// link (also a share link),
|
||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||
{{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||
print a link to env files of a repo, encrypted
|
||||
with a secret's value (default: share.key)
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -140,8 +144,14 @@ Flags:
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
var shareArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) > 0 && args[0] == "share":
|
||||
shareArgs = args[1:]
|
||||
if len(shareArgs) == 0 {
|
||||
shareArgs = []string{} // still sharing: says how
|
||||
}
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
@@ -304,6 +314,11 @@ Flags:
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
if shareArgs != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
var p *tea.Program
|
||||
@@ -375,6 +390,8 @@ Flags:
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
ShareKey: cfg.Share.Key,
|
||||
ShareRules: shareRules(cfg),
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// shareFiles prints a share link to env files of one repo, encrypted with
|
||||
// the --key secret's value, else the repo's or the config's share.key:
|
||||
//
|
||||
// lazysecrets share .env apps/api/.env
|
||||
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
||||
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
|
||||
}
|
||||
|
||||
var root, id string
|
||||
var files []share.File
|
||||
for _, name := range fs.Args() {
|
||||
abs, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, i, err := repo.Root(filepath.Dir(abs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root == "" {
|
||||
root, id = r, i
|
||||
} else if r != root {
|
||||
return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root)
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := share.File{Path: filepath.ToSlash(rel)}
|
||||
if err := share.CheckPath(f.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Data, err = os.ReadFile(real); err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
key, ok, err := share.DefaultKey(root, general)
|
||||
if *keyLink != "" {
|
||||
key, err = share.ParseKey(*keyLink)
|
||||
ok = err == nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
|
||||
}
|
||||
v, version, err := readKey(ctx, providers, st, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the key %s: %w", key, err)
|
||||
}
|
||||
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, link)
|
||||
return err
|
||||
}
|
||||
|
||||
// shareRules are the config's share.keyRules.
|
||||
func shareRules(cfg config.Config) share.Rules {
|
||||
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// demoRules take the demo key, made up to be remembered.
|
||||
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
|
||||
|
||||
func TestShareFiles(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Skip("git:", string(out))
|
||||
}
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
|
||||
providers := []provider.Provider{fake.Azure(0)}
|
||||
st, _ := store.Load("")
|
||||
ctx := context.Background()
|
||||
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ref.Parse(strings.TrimSpace(out.String()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := share.Read(r)
|
||||
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
|
||||
t.Fatalf("%+v, %v", s.Header, err)
|
||||
}
|
||||
files, err := s.Open([]byte("correct-horse-battery-staple"))
|
||||
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
|
||||
t.Errorf("%+v, %v", files, err)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
general string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"", []string{filepath.Join(root, ".env")}, "which secret"},
|
||||
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
|
||||
{key, nil, "usage"},
|
||||
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
|
||||
} {
|
||||
if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%v: %v, want %q", c.args, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user