Files
lazysecrets/internal/provider/provider.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

246 lines
8.0 KiB
Go

// Package provider defines the read-only interface every secret backend
// implements, and the types the UI works with.
//
// All backends map onto the same hierarchy:
//
// Source Azure subscription | Databricks workspace profile
// Container Key Vault | secret scope
// Object secret, key or certificate | secret
package provider
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// Kind identifies a backend.
type Kind string
const (
KindAzure Kind = "Azure"
KindDatabricks Kind = "Databricks"
// KindRepo is a git repository's encrypted secret files (SOPS).
KindRepo Kind = "Repo"
)
// AuthError reports that the user must (re)authenticate with a backend, for
// example because their az CLI login has expired.
type AuthError struct {
Kind Kind
// Account names the identity when a provider has several, e.g.
// "Azure · admin.alice"; empty otherwise.
Account string
// Login is the command that signs the user in, e.g. ["az", "login"].
Login []string
Err error
}
func (e *AuthError) Error() string {
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
}
// Who is the account, or the kind if there is just one.
func (e *AuthError) Who() string {
if e.Account != "" {
return e.Account
}
return string(e.Kind)
}
func (e *AuthError) Unwrap() error { return e.Err }
// ErrUnsupported is returned by optional operations a backend cannot perform,
// such as listing versions of a Databricks secret.
var ErrUnsupported = errors.New("not supported by this provider")
// Source is a top-level account: an Azure subscription or a Databricks
// workspace profile.
type Source struct {
Kind Kind
// Provider is the ID of the provider instance the source came from.
// The app sets it; it routes requests when several providers share a
// Kind, e.g. one per Azure account.
Provider string
// ProviderLabel is that provider's Label, also set by the app.
ProviderLabel string
ID string
Name string
Detail string // e.g. tenant or workspace host
URL string // web page for the source, if any
// Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled.
Here bool
}
// ObjectType is the kind of object stored in a container.
type ObjectType string
const (
TypeSecret ObjectType = "Secret"
TypeKey ObjectType = "Key"
TypeCertificate ObjectType = "Certificate"
)
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
ID string
Name string
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
// Types lists the object types the container holds; empty means
// secrets only.
Types []ObjectType
URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"`
}
// ObjectTypes returns the container's object types, defaulting to secrets.
func (c Container) ObjectTypes() []ObjectType {
if len(c.Types) == 0 {
return []ObjectType{TypeSecret}
}
return c.Types
}
// Attr is a type-specific metadata field, shown in provider order.
type Attr struct {
Label string
Value string
Warn bool // worth a look (a plain .env tracked by git): shown in red
}
// Object describes a secret, key or certificate without its value.
type Object struct {
Container Container
Type ObjectType
ID string
Name string
Version string
Enabled *bool
Updated time.Time
Expires time.Time
ContentType string
Tags map[string]string
// Attrs holds type-specific metadata, e.g. key type and size, or a
// certificate's subject, issuer and thumbprint.
Attrs []Attr
// Reference is how the object is referenced from config, e.g.
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
Reference string
URL string // web page for the object (or its list), if any
}
// Provider is a read-only secret backend. Every method may block on the
// network and must honour ctx cancellation.
type Provider interface {
Kind() Kind
// ID identifies the instance, stable across runs, e.g. "Azure" or
// "Azure:user@example.com/tenant".
ID() string
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
Label() string
Sources(ctx context.Context) ([]Source, error)
Containers(ctx context.Context, src Source) ([]Container, error)
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
// metadata only; values are fetched with Value.
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
// Describe returns o with details that listing doesn't include, such as
// a key's type or a certificate's subject. It never fetches secret
// values; it may return o unchanged.
Describe(ctx context.Context, o Object) (Object, error)
// Versions may return ErrUnsupported.
Versions(ctx context.Context, o Object) ([]Object, error)
// Value returns a secret's value, or the public material of a key (PEM
// public key) or certificate (PEM certificate). Private key material is
// never returned for keys.
Value(ctx context.Context, o Object) (SecretValue, error)
}
// Current is implemented by providers that know the source lazysecrets
// was started in (the current git repo) and can find it faster than
// Sources lists everything, so it shows first.
type Current interface {
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,
// 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.
type Locator interface {
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
}
// Files is implemented by providers whose containers are files on disk
// (SOPS and .env files in repos), so they can be opened in an editor.
type Files interface {
// File is where c is, and for o (nil for the whole file) the line its
// key is on.
File(c Container, o *Object) (File, error)
}
// Contents is implemented by providers whose containers are files, so
// they can be shared (package share): the file as it is.
type Contents interface {
Contents(ctx context.Context, c Container) ([]byte, error)
}
// File is a container's file, to open.
type File struct {
Path string
Line int // 1-based; 0 for the top
Sops string // the sops command to edit it with; empty for a plain file
}
// Accounts is implemented by providers that can describe the identity
// they use, from what they have locally: for the Details pane on the
// provider's row. ok is false when there's nothing to say.
type Accounts interface {
Account() (Account, bool)
}
// Account describes a signed-in identity.
type Account struct {
Fields []Attr
// Copy is what y copies on the provider's row, named CopyLabel.
Copy, CopyLabel string
}
// Denied is a refusal with what to check, from what the service said: a
// role to activate in PIM, access to ask for, the network. Error is the
// refusal, then the checks as a list.
type Denied struct {
Err error
Checks []string
}
func (d *Denied) Error() string {
if len(d.Checks) == 0 {
return d.Err.Error()
}
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
}
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
// container than its listing does: how a file is encrypted and what git
// knows of it. ErrUnsupported means there's nothing more for c.
type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error)
}