feat: share env files as encrypted links
CI / Checks (pull_request) Failing after 10s
PR title / Conventional title (pull_request) Successful in 0s

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:
kebandClaude Opus 5.5 committed 2026-10-13 11:59:39 +02:00
1 parent 1a5e40a012
commit 4b2f685921
26 files changed
+1541 -98

No files matched your search

+9 -77
View File
@@ -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)
}
+18 -2
View File
@@ -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 <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
config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME]
@@ -140,8 +144,14 @@ Flags:
}
var open, getRef *ref.Ref
var shareArgs []string
printKeys := false
switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) == 1 && args[0] == "keybindings":
printKeys = true
case len(args) == 2 && args[0] == "get":
@@ -304,6 +314,11 @@ Flags:
defer cancel()
return get(ctx, providers, st, *getRef)
}
if shareArgs != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Share.Key, shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here.
var p *tea.Program
@@ -375,6 +390,7 @@ Flags:
StatePath: statePath,
Changelog: lazysecrets.Changelog,
Dir: placeDir,
ShareKey: cfg.Share.Key,
Open: open,
Styles: &theme,
DefaultFg: defaultFg,
+87
View File
@@ -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
}
+64
View File
@@ -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)
}
}
}