Files
lazysecrets/cmd/lazysecrets/main.go
T
Karl-Einar Bergstrøm 4377eb98bf 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
2026-10-13 14:55:22 +02:00

425 lines
13 KiB
Go

// Command lazysecrets is a terminal UI for browsing secrets in Azure Key
// Vault and Databricks secret scopes.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/instance"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
"git.kebcloud.no/keb/lazysecrets/internal/provider/databricks"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1)
}
}
// looksLikeRef tells a reference given without "open" from a mistyped
// subcommand, so the latter still gets the usage text.
func looksLikeRef(s string) bool {
return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/")
}
// starredRepos are the repos the user starred something in: the repo, a
// file or a key. Their records keep the repo's folder.
func starredRepos(st *store.Store) []string {
var out []string
for key, n := range st.Nodes() {
r, err := ref.Parse(key)
if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) {
out = append(out, n.Source)
}
}
slices.Sort(out)
return out
}
// checkCommandKeys reports a custom command key that a built-in action or
// another command in an overlapping context takes.
func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
for i, c := range cmds {
if c.Key == "" {
continue
}
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil {
return err
}
for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
}
}
}
return nil
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory.
func defaultConfigPath() string {
if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" {
return env
}
dir, err := config.Dir()
if err != nil {
return "config.yaml"
}
return filepath.Join(dir, "config.yaml")
}
func run() error {
configPath := flag.String("config", defaultConfigPath(),
"config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)")
showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref>
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]
open lazysecrets:// links from the browser and
chat in a new window of this terminal
lazysecrets handler uninstall stop opening them
lazysecrets launch <ref> open a link in a new terminal window, as
the link handler does
lazysecrets update [--check] install the newest release in place of this
one (--check: just say if there is one)
Flags:
`)
flag.PrintDefaults()
}
flag.Parse()
if *showVersion {
fmt.Println("lazysecrets", versionString())
return nil
}
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":
r, err := ref.Parse(args[1])
if err != nil {
return err
}
getRef = &r
case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
if err != nil {
return err
}
open = &r
case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0])
if err != nil {
return err
}
open = &r
default:
flag.Usage()
return fmt.Errorf("unexpected arguments: %v", args)
}
cfg, err := config.LoadAll(strings.Split(*configPath, ","))
if err != nil {
return err
}
theme, defaultFg, err := styles.FromTheme(cfg.Theme)
if err != nil {
return err
}
keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil {
return err
}
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err
}
if printKeys {
fmt.Print(keymap.YAML())
return nil
}
// Precedence: flag > environment > config file > default.
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Timeout > 0 {
*timeout = cfg.Timeout
}
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
*clipClear = *cfg.ClipboardClear
}
expiryDays := 30
if cfg.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.LoadDelay != nil {
loadDelay = *cfg.LoadDelay
}
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.Icons != nil:
*icons = *cfg.Icons
}
}
// Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file.
stateDir, err := config.StateDir()
if err != nil {
return err
}
statePath := ""
if !*demo {
statePath = filepath.Join(stateDir, "state.json")
}
st, err := store.Load(statePath)
if err != nil {
return err
}
var providers []provider.Provider
if *demo {
// Two Azure accounts, like a personal and an admin login in az.
providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond),
fake.Repos(),
}
} else {
pc := cfg.Providers
// One provider per az account. Created even when disabled: AKV-
// backed Databricks scopes fall back to reading the vault through
// the default account.
accounts, err := azure.NewAccounts(azure.Settings{
HideSubscriptions: pc.Azure.HideSubscriptions,
Vaults: pc.Azure.Vaults,
})
if err != nil {
return fmt.Errorf("azure: %w", err)
}
if config.On(pc.Azure.Enabled) {
for _, a := range accounts {
providers = append(providers, a)
}
}
if config.On(pc.Databricks.Enabled) {
dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles)
if err != nil {
return fmt.Errorf("databricks: %w", err)
}
providers = append(providers, dbx)
}
if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := []string{"~/git"}
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
})
// The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like
// the other sources; repos without any aren't listed.
providers = append(providers, repos)
}
if len(providers) == 0 {
return fmt.Errorf("all providers are disabled in %s", *configPath)
}
}
// Libraries (notably the Databricks SDK) log token refreshes through
// the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard)
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
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
var srv *instance.Server
if !*demo {
srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error {
r, err := ref.Parse(link)
if err != nil {
return err
}
// Accept at once: launch waits for the answer, and opens a
// window anyway if it's slow. Coming to the front can be
// (activating the terminal app).
go func() {
p.Send(app.OpenLink(r))
instance.Raise(front)
}()
return nil
})
if err == nil {
defer srv.Close()
} // else links open in new windows: not worth failing for
}
var setup *app.HandlerSetup
if !*demo {
setup = handlerSetup(cfg, *configPath)
}
onActive := func() {}
if srv != nil {
onActive = srv.Touch
}
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs
}
}
m := app.New(providers, app.Options{
Timeout: *timeout,
Icons: *icons,
ClipboardClear: *clipClear,
FlatSources: cfg.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups),
LoadDelay: loadDelay,
NoHistory: !config.On(cfg.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup,
Updater: updater(cfg.Releases),
UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap,
Commands: cfg.CustomCommands,
Version: versionString(),
Store: st,
CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Changelog: lazysecrets.Changelog,
Dir: placeDir,
ShareKey: cfg.Share.Key,
ShareRules: shareRules(cfg),
Open: open,
Styles: &theme,
DefaultFg: defaultFg,
})
p = tea.NewProgram(m)
final, err := p.Run()
if fm, ok := final.(app.Model); ok {
_ = fm.Persist() // a cache it couldn't save is no reason to fail
}
return err
}
// configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one).
func configFiles(list string) []string {
var all, found []string
for _, f := range strings.Split(list, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
all = append(all, f)
if _, err := os.Stat(f); err == nil {
found = append(found, f)
}
}
if len(found) == 0 && len(all) > 0 {
return all[:1]
}
return found
}