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
400 lines
12 KiB
Go
400 lines
12 KiB
Go
// Package ref parses and builds references to vaults, scopes and secrets:
|
|
// lazysecrets:// links, and the formats people already share (Key Vault
|
|
// identifier URLs, App Service @Microsoft.KeyVault references, portal links
|
|
// and Databricks {{secrets/scope/key}}).
|
|
//
|
|
// A link names a vault, scope or object; what to do with it is up to
|
|
// whoever opens it:
|
|
//
|
|
// lazysecrets://azure/subscriptions/<subscription-id>
|
|
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
|
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
|
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
|
// lazysecrets://share/<sealed files>
|
|
//
|
|
// A repo is its git remote without scheme or .git
|
|
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
|
|
// its path in the file (db.password); slashes inside them are escaped.
|
|
//
|
|
// A share link carries files, encrypted (package share), instead of
|
|
// naming something.
|
|
//
|
|
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
|
//
|
|
// Every path segment is percent-encoded, so names may hold any character.
|
|
// Query parameters are ignored (early links carried ?action=).
|
|
package ref
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
)
|
|
|
|
const Scheme = "lazysecrets"
|
|
|
|
// Ref points at a container, or an object in one.
|
|
type Ref struct {
|
|
Kind provider.Kind
|
|
// Host is the vault host (Azure), workspace host (Databricks) or repo
|
|
// (Repo). It is empty for a bare {{secrets/scope/key}}, whose workspace
|
|
// is unknown.
|
|
Host string
|
|
// Scope is the Databricks scope or the repo's file; empty for Azure.
|
|
Scope string
|
|
// Subscription is set for a link to an Azure subscription.
|
|
Subscription string
|
|
// Type is the object type, or for an Azure vault link the tab to show
|
|
// (empty: the default tab).
|
|
Type provider.ObjectType
|
|
Name string // object name; empty for a container link
|
|
Version string
|
|
// Share is a share link's sealed files, as in the link; the other
|
|
// fields are empty. Package share reads it.
|
|
Share string
|
|
}
|
|
|
|
// IsShare reports whether r is a share link.
|
|
func (r Ref) IsShare() bool { return r.Share != "" }
|
|
|
|
// IsSource reports whether r points at a subscription or workspace.
|
|
func (r Ref) IsSource() bool {
|
|
if r.IsShare() {
|
|
return false
|
|
}
|
|
if r.Kind != provider.KindAzure {
|
|
return r.Scope == ""
|
|
}
|
|
return r.Subscription != ""
|
|
}
|
|
|
|
// IsContainer reports whether r points at a vault or scope.
|
|
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
|
|
|
|
// ContainerName is the vault name (from its host) or the scope.
|
|
func (r Ref) ContainerName() string {
|
|
if r.Kind != provider.KindAzure {
|
|
return r.Scope
|
|
}
|
|
name, _, _ := strings.Cut(r.Host, ".")
|
|
return name
|
|
}
|
|
|
|
// VaultURL is the Azure vault's URL, as providers store it in
|
|
// Container.Detail.
|
|
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
|
|
|
|
var collections = map[provider.ObjectType]string{
|
|
provider.TypeSecret: "secrets",
|
|
provider.TypeKey: "keys",
|
|
provider.TypeCertificate: "certificates",
|
|
}
|
|
|
|
func typeOf(collection string) (provider.ObjectType, bool) {
|
|
for t, c := range collections {
|
|
if strings.EqualFold(c, collection) {
|
|
return t, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// IdentifierURL is the plain Key Vault URL of an Azure reference, e.g.
|
|
// https://kv.vault.azure.net/secrets/name[/version], usable without
|
|
// lazysecrets (az keyvault secret show --id). Empty for Databricks.
|
|
func (r Ref) IdentifierURL() string {
|
|
if r.Kind != provider.KindAzure {
|
|
return ""
|
|
}
|
|
u := "https://" + r.Host
|
|
if r.Type == "" {
|
|
return u + "/"
|
|
}
|
|
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
|
|
if seg != "" {
|
|
u += "/" + url.PathEscape(seg)
|
|
}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// String is the lazysecrets:// link.
|
|
func (r Ref) String() string {
|
|
if r.IsShare() {
|
|
return Scheme + "://share/" + r.Share
|
|
}
|
|
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
|
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
|
}
|
|
segs := []string{strings.ToLower(string(r.Kind)), r.Host}
|
|
switch r.Kind {
|
|
case provider.KindDatabricks, provider.KindRepo:
|
|
segs = append(segs, r.Scope, r.Name)
|
|
default:
|
|
if r.Type != "" {
|
|
segs = append(segs, collections[r.Type])
|
|
}
|
|
segs = append(segs, r.Name, r.Version)
|
|
}
|
|
for len(segs) > 2 && segs[len(segs)-1] == "" {
|
|
segs = segs[:len(segs)-1]
|
|
}
|
|
for i := range segs {
|
|
segs[i] = url.PathEscape(segs[i])
|
|
if r.Kind == provider.KindRepo && i > 0 {
|
|
// Repos and files have slashes: keep them in their part.
|
|
segs[i] = strings.ReplaceAll(segs[i], "/", "%2F")
|
|
}
|
|
}
|
|
return Scheme + "://" + strings.Join(segs, "/")
|
|
}
|
|
|
|
// ForSource is a link to a subscription or workspace.
|
|
func ForSource(src provider.Source) (Ref, error) {
|
|
switch src.Kind {
|
|
case provider.KindAzure:
|
|
return Ref{Kind: provider.KindAzure, Subscription: src.ID}, nil
|
|
case provider.KindDatabricks:
|
|
if src.Detail == "" {
|
|
return Ref{}, errors.New("workspace host unknown")
|
|
}
|
|
return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(src.Detail)}, nil
|
|
case provider.KindRepo:
|
|
return Ref{Kind: provider.KindRepo, Host: strings.ToLower(src.Detail)}, nil
|
|
}
|
|
return Ref{}, fmt.Errorf("can't link to %s", src.Kind)
|
|
}
|
|
|
|
// ForContainer is a link to a vault or scope.
|
|
func ForContainer(c provider.Container, tab provider.ObjectType) (Ref, error) {
|
|
switch c.Source.Kind {
|
|
case provider.KindAzure:
|
|
host, err := vaultHost(c.Detail)
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
if tab == provider.TypeSecret {
|
|
tab = "" // the default tab
|
|
}
|
|
return Ref{Kind: provider.KindAzure, Host: host, Type: tab}, nil
|
|
case provider.KindDatabricks:
|
|
if c.Source.Detail == "" {
|
|
return Ref{}, errors.New("workspace host unknown")
|
|
}
|
|
return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil
|
|
case provider.KindRepo:
|
|
return Ref{Kind: provider.KindRepo, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil
|
|
}
|
|
return Ref{}, fmt.Errorf("can't link to %s", c.Source.Kind)
|
|
}
|
|
|
|
// ForObject is a link to an object, pinned to its version if given.
|
|
func ForObject(o provider.Object, version string) (Ref, error) {
|
|
r, err := ForContainer(o.Container, o.Type)
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
r.Name, r.Type = o.Name, o.Type
|
|
if r.Kind == provider.KindAzure {
|
|
r.Version = version // Databricks secrets have no versions
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func vaultHost(vaultURL string) (string, error) {
|
|
u, err := url.Parse(vaultURL)
|
|
if err != nil || u.Host == "" {
|
|
return "", fmt.Errorf("not a vault URL: %q", vaultURL)
|
|
}
|
|
return strings.ToLower(u.Host), nil
|
|
}
|
|
|
|
var (
|
|
errUnknown = errors.New("not a lazysecrets link or a Key Vault/Databricks reference")
|
|
|
|
// @Microsoft.KeyVault(SecretUri=…) or (VaultName=…;SecretName=…[;SecretVersion=…])
|
|
appServiceRef = regexp.MustCompile(`(?i)^@Microsoft\.KeyVault\((.*)\)$`)
|
|
// {{secrets/scope/key}}
|
|
databricksRef = regexp.MustCompile(`^\{\{\s*secrets/([^/{}\s]+)/([^/{}\s]+)\s*\}\}$`)
|
|
// …/providers/Microsoft.KeyVault/vaults/<name> in a portal link
|
|
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
|
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
|
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
|
// a share link's sealed files: unpadded base64url
|
|
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
|
|
)
|
|
|
|
// Parse reads any supported reference.
|
|
func Parse(s string) (Ref, error) {
|
|
s = strings.TrimSpace(s)
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(s), Scheme+"://"):
|
|
return parseLink(s)
|
|
case appServiceRef.MatchString(s):
|
|
return parseAppService(appServiceRef.FindStringSubmatch(s)[1])
|
|
case databricksRef.MatchString(s):
|
|
m := databricksRef.FindStringSubmatch(s)
|
|
return Ref{Kind: provider.KindDatabricks, Scope: m[1], Name: m[2], Type: provider.TypeSecret}, nil
|
|
case strings.Contains(s, "portal.azure.com"):
|
|
return parsePortal(s)
|
|
case strings.HasPrefix(strings.ToLower(s), "https://"):
|
|
return parseIdentifier(s)
|
|
}
|
|
return Ref{}, errUnknown
|
|
}
|
|
|
|
func parseLink(s string) (Ref, error) {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return Ref{}, fmt.Errorf("bad link: %w", err)
|
|
}
|
|
segs, err := pathSegments(u.EscapedPath())
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
if strings.EqualFold(u.Host, "share") {
|
|
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
|
|
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
|
|
}
|
|
return Ref{Share: segs[0]}, nil
|
|
}
|
|
var r Ref
|
|
if len(segs) == 0 || segs[0] == "" {
|
|
return Ref{}, errors.New("link has no vault or workspace")
|
|
}
|
|
r.Host = strings.ToLower(segs[0])
|
|
rest := segs[1:]
|
|
|
|
switch strings.ToLower(u.Host) {
|
|
case "azure":
|
|
r.Kind = provider.KindAzure
|
|
if r.Host == "subscriptions" {
|
|
if len(rest) != 1 || rest[0] == "" {
|
|
return Ref{}, errors.New("subscription link needs exactly one subscription ID")
|
|
}
|
|
return Ref{Kind: provider.KindAzure, Subscription: rest[0]}, nil
|
|
}
|
|
if len(rest) > 3 {
|
|
return Ref{}, errors.New("azure link has too many parts")
|
|
}
|
|
if len(rest) > 0 {
|
|
t, ok := typeOf(rest[0])
|
|
if !ok {
|
|
return Ref{}, fmt.Errorf("unknown object type %q (want secrets, keys or certificates)", rest[0])
|
|
}
|
|
r.Type = t
|
|
}
|
|
if len(rest) > 1 {
|
|
r.Name = rest[1]
|
|
}
|
|
if len(rest) > 2 {
|
|
r.Version = rest[2]
|
|
}
|
|
case "databricks", "repo":
|
|
r.Kind = provider.KindDatabricks
|
|
if strings.EqualFold(u.Host, "repo") {
|
|
r.Kind = provider.KindRepo
|
|
}
|
|
if len(rest) > 2 {
|
|
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
|
|
}
|
|
if len(rest) > 0 {
|
|
r.Scope = rest[0]
|
|
}
|
|
if len(rest) > 1 {
|
|
r.Name, r.Type = rest[1], provider.TypeSecret
|
|
}
|
|
|
|
default:
|
|
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// pathSegments splits an escaped path on / and unescapes each segment, so
|
|
// an escaped slash (%2F) stays inside its segment.
|
|
func pathSegments(escaped string) ([]string, error) {
|
|
escaped = strings.Trim(escaped, "/")
|
|
if escaped == "" {
|
|
return nil, nil
|
|
}
|
|
parts := strings.Split(escaped, "/")
|
|
for i, p := range parts {
|
|
s, err := url.PathUnescape(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bad link: %w", err)
|
|
}
|
|
parts[i] = s
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseIdentifier reads a Key Vault URL: a vault, or an object identifier
|
|
// like https://kv.vault.azure.net/secrets/name[/version].
|
|
func parseIdentifier(s string) (Ref, error) {
|
|
u, err := url.Parse(s)
|
|
if err != nil || u.Host == "" {
|
|
return Ref{}, errUnknown
|
|
}
|
|
host := strings.ToLower(u.Host)
|
|
if !strings.Contains(host, ".vault.") {
|
|
return Ref{}, errUnknown
|
|
}
|
|
segs, err := pathSegments(u.EscapedPath())
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
r := Ref{Kind: provider.KindAzure, Host: host}
|
|
if len(segs) > 0 {
|
|
t, ok := typeOf(segs[0])
|
|
if !ok {
|
|
return Ref{}, fmt.Errorf("unknown Key Vault path %q", u.Path)
|
|
}
|
|
r.Type = t
|
|
}
|
|
if len(segs) > 1 {
|
|
r.Name = segs[1]
|
|
}
|
|
if len(segs) > 2 {
|
|
r.Version = segs[2]
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func parseAppService(args string) (Ref, error) {
|
|
fields := map[string]string{}
|
|
for _, kv := range strings.Split(args, ";") {
|
|
k, v, ok := strings.Cut(kv, "=")
|
|
if ok {
|
|
fields[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
if uri := fields["secreturi"]; uri != "" {
|
|
return parseIdentifier(uri)
|
|
}
|
|
vault, name := fields["vaultname"], fields["secretname"]
|
|
if vault == "" || name == "" {
|
|
return Ref{}, errors.New("@Microsoft.KeyVault reference needs SecretUri, or VaultName and SecretName")
|
|
}
|
|
return Ref{
|
|
Kind: provider.KindAzure, Host: strings.ToLower(vault) + ".vault.azure.net",
|
|
Type: provider.TypeSecret, Name: name, Version: fields["secretversion"],
|
|
}, nil
|
|
}
|
|
|
|
// parsePortal reads Azure portal links to a vault or a Key Vault object.
|
|
func parsePortal(s string) (Ref, error) {
|
|
if m := portalAsset.FindStringSubmatch(s); m != nil {
|
|
return parseIdentifier(m[1])
|
|
}
|
|
if m := portalVault.FindStringSubmatch(s); m != nil {
|
|
// Portal vault links don't carry the cloud; assume public Azure.
|
|
return Ref{Kind: provider.KindAzure, Host: strings.ToLower(m[1]) + ".vault.azure.net"}, nil
|
|
}
|
|
return Ref{}, errUnknown
|
|
}
|