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
275 lines
9.4 KiB
Go
275 lines
9.4 KiB
Go
// Package config loads the optional YAML config file.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
|
)
|
|
|
|
type Config struct {
|
|
Icons *bool `yaml:"icons"`
|
|
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
|
// RevealTimeout hides a revealed value after this long; 0s never.
|
|
// Defaults to 60s.
|
|
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
|
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
|
// within this many days; 0 marks only expired ones. Defaults to 30.
|
|
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
|
// HideOnBlur hides a revealed value when the terminal loses focus.
|
|
// Defaults to true.
|
|
HideOnBlur *bool `yaml:"hideOnBlur"`
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
// LoadDelay is how long the cursor must rest on a row before its
|
|
// contents load; 0s loads at once. Defaults to 150ms.
|
|
LoadDelay *time.Duration `yaml:"loadDelay"`
|
|
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
|
SourcesView string `yaml:"sourcesView"`
|
|
// History counts uses of items for the Recent list; defaults to true.
|
|
History *bool `yaml:"history"`
|
|
// UpdateCheck looks for a newer release once a day, in the
|
|
// background; defaults to true. U and lazysecrets update work either
|
|
// way.
|
|
UpdateCheck *bool `yaml:"updateCheck"`
|
|
// Releases are the repositories updates come from, tried in order:
|
|
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
|
|
// git.kebcloud.no (update.DefaultServers).
|
|
Releases []string `yaml:"releases"`
|
|
// Cache keeps what was loaded (never values) between runs, so lists
|
|
// show at once and refresh in the background; defaults to true.
|
|
Cache *bool `yaml:"cache"`
|
|
// CollapseGroups starts the Sources pane with its groups folded;
|
|
// defaults to true.
|
|
CollapseGroups *bool `yaml:"collapseGroups"`
|
|
Providers Providers `yaml:"providers"`
|
|
// LinkHandler sets how lazysecrets:// links open; see 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
|
|
// package commands.
|
|
CustomCommands []commands.Command `yaml:"customCommands"`
|
|
// Keybinding sets keys per context and action, replacing the
|
|
// defaults of each action given; see keys.Map.Apply.
|
|
Keybinding map[string]map[string][]string `yaml:"keybinding"`
|
|
// Theme maps roles to colours, like lazygit's gui.theme; see
|
|
// styles.Roles.
|
|
Theme map[string][]string `yaml:"theme"`
|
|
}
|
|
|
|
type LinkHandler struct {
|
|
// Terminal to open links in, overriding the one handler install
|
|
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
|
// gnome-terminal, konsole or foot.
|
|
Terminal string `yaml:"terminal"`
|
|
// Command opens any other terminal: its arguments, with {cmd} where
|
|
// the lazysecrets command goes, e.g. [foot, "{cmd}"].
|
|
Command []string `yaml:"command"`
|
|
// Reuse hands links to a running lazysecrets, the one last used that
|
|
// can be brought into view, before opening a new window. Defaults to
|
|
// true.
|
|
Reuse *bool `yaml:"reuse"`
|
|
// Prompt offers to set up the link handler at startup when links
|
|
// don't open lazysecrets. Defaults to true.
|
|
Prompt *bool `yaml:"prompt"`
|
|
}
|
|
|
|
type Share struct {
|
|
// Key is the secret whose value encrypts shared files (its
|
|
// lazysecrets:// link, Key Vault URL or reference), for repos whose
|
|
// .lazysecrets.yaml doesn't name one.
|
|
Key string `yaml:"key"`
|
|
// KeyRules are what a key's value must be to share with it.
|
|
KeyRules KeyRules `yaml:"keyRules"`
|
|
}
|
|
|
|
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
|
|
type KeyRules struct {
|
|
MinLength int `yaml:"minLength"` // at least 16
|
|
// MinClasses is how many kinds of character: lowercase, uppercase,
|
|
// digits, symbols.
|
|
MinClasses int `yaml:"minClasses"`
|
|
}
|
|
|
|
type Providers struct {
|
|
Azure Azure `yaml:"azure"`
|
|
Databricks Databricks `yaml:"databricks"`
|
|
Repos Repos `yaml:"repos"`
|
|
}
|
|
|
|
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
|
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
|
// Roots.
|
|
type Repos struct {
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
Paths []string `yaml:"paths"`
|
|
// Roots are folders of repos. Defaults to ~/git; an empty list
|
|
// turns it off.
|
|
Roots *[]string `yaml:"roots"`
|
|
// Include and Exclude are globs relative to a repo's root (** for any
|
|
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
|
// a repo's .sops.yaml creation_rules say where to look.
|
|
Include []string `yaml:"include"`
|
|
Exclude []string `yaml:"exclude"`
|
|
}
|
|
|
|
type Azure struct {
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
|
HideSubscriptions []string `yaml:"hideSubscriptions"`
|
|
// Vaults are shown under "Pinned vaults", for vaults that can't be
|
|
// discovered (e.g. data-plane access only).
|
|
Vaults []string `yaml:"vaults"`
|
|
}
|
|
|
|
type Databricks struct {
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
HideProfiles []string `yaml:"hideProfiles"`
|
|
}
|
|
|
|
// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets
|
|
// on every platform, like most terminal tools.
|
|
func Dir() (string, error) {
|
|
return xdgDir("XDG_CONFIG_HOME", ".config")
|
|
}
|
|
|
|
// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to
|
|
// ~/.cache/lazysecrets. It holds what was loaded, to show it at once.
|
|
func CacheDir() (string, error) {
|
|
return xdgDir("XDG_CACHE_HOME", ".cache")
|
|
}
|
|
|
|
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
|
|
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
|
|
func StateDir() (string, error) {
|
|
return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state"))
|
|
}
|
|
|
|
func xdgDir(env, fallback string) (string, error) {
|
|
base := os.Getenv(env)
|
|
if base == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
base = filepath.Join(home, fallback)
|
|
}
|
|
return filepath.Join(base, "lazysecrets"), nil
|
|
}
|
|
|
|
// Load reads path. A missing file is an empty config. Unknown keys are an
|
|
// error, so typos don't silently do nothing.
|
|
func Load(path string) (Config, error) {
|
|
return LoadAll([]string{path})
|
|
}
|
|
|
|
// LoadAll reads several config files in order, each on top of the ones
|
|
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
|
// settings it sets, and theme roles merge one by one. That lets an editor
|
|
// plugin add a generated theme file to the user's own config. Missing
|
|
// files are skipped.
|
|
func LoadAll(paths []string) (Config, error) {
|
|
var c Config
|
|
keybinding := map[string]map[string][]string{}
|
|
for _, path := range paths {
|
|
// Keybindings layer per action: a later file setting one action
|
|
// in a context keeps the others set before it.
|
|
c.Keybinding = nil
|
|
if err := decodeInto(&c, path); err != nil {
|
|
return c, err
|
|
}
|
|
for ctx, actions := range c.Keybinding {
|
|
if keybinding[ctx] == nil {
|
|
keybinding[ctx] = map[string][]string{}
|
|
}
|
|
for name, keys := range actions {
|
|
keybinding[ctx][name] = keys
|
|
}
|
|
}
|
|
}
|
|
c.Keybinding = keybinding
|
|
return c, c.validate(strings.Join(paths, ", "))
|
|
}
|
|
|
|
func decodeInto(c *Config, path string) error {
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dec := yaml.NewDecoder(bytes.NewReader(b))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Config) validate(path string) error {
|
|
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
|
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
|
}
|
|
for _, cmd := range c.CustomCommands {
|
|
if err := cmd.Validate(); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
}
|
|
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
|
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
|
|
}
|
|
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
|
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
|
}
|
|
for i, r := range c.Releases {
|
|
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
|
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
|
}
|
|
}
|
|
if k := c.Share.Key; k != "" {
|
|
if _, err := share.ParseKey(k); err != nil {
|
|
return fmt.Errorf("%s: share.key: %w", path, err)
|
|
}
|
|
}
|
|
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
|
|
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n)
|
|
}
|
|
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 {
|
|
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n)
|
|
}
|
|
for i, v := range c.Providers.Azure.Vaults {
|
|
if !strings.HasPrefix(v, "https://") {
|
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// On reports whether an optional enabled flag is unset or true.
|
|
func On(enabled *bool) bool { return enabled == nil || *enabled }
|
|
|
|
// Hidden reports whether name or id appears in list, ignoring case.
|
|
func Hidden(list []string, name, id string) bool {
|
|
for _, h := range list {
|
|
if strings.EqualFold(h, name) || strings.EqualFold(h, id) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|