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
414 lines
15 KiB
Go
414 lines
15 KiB
Go
// Package fake is an in-memory provider used by tests and --demo mode.
|
|
package fake
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
)
|
|
|
|
// Provider serves a fixed tree of sources, containers and objects,
|
|
// optionally sleeping before every call to simulate network latency.
|
|
type Provider struct {
|
|
kind provider.Kind
|
|
id, label string
|
|
delay time.Duration
|
|
sources []provider.Source
|
|
containers map[string][]provider.Container // by source ID
|
|
objects map[listKey][]provider.Object
|
|
errs map[listKey]error
|
|
values map[string]string // by object ID
|
|
versions map[string][]provider.Object // by object ID
|
|
details map[string][]provider.Attr // by container ID
|
|
}
|
|
|
|
type listKey struct {
|
|
container string
|
|
typ provider.ObjectType
|
|
}
|
|
|
|
var _ provider.Provider = (*Provider)(nil)
|
|
|
|
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
|
return &Provider{
|
|
kind: kind,
|
|
delay: delay,
|
|
containers: map[string][]provider.Container{},
|
|
objects: map[listKey][]provider.Object{},
|
|
errs: map[listKey]error{},
|
|
values: map[string]string{},
|
|
versions: map[string][]provider.Object{},
|
|
}
|
|
}
|
|
|
|
func (p *Provider) Kind() provider.Kind { return p.kind }
|
|
|
|
func (p *Provider) ID() string {
|
|
if p.id != "" {
|
|
return p.id
|
|
}
|
|
return string(p.kind)
|
|
}
|
|
|
|
func (p *Provider) Label() string {
|
|
if p.label != "" {
|
|
return p.label
|
|
}
|
|
return string(p.kind)
|
|
}
|
|
|
|
// As renames the provider, to simulate several accounts of one kind.
|
|
func (p *Provider) As(id, label string) *Provider {
|
|
p.id, p.label = id, label
|
|
return p
|
|
}
|
|
|
|
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return p.sources, nil
|
|
}
|
|
|
|
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return p.containers[src.ID], nil
|
|
}
|
|
|
|
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
k := listKey{p.containerID(c), t}
|
|
if err := p.errs[k]; err != nil {
|
|
return nil, err
|
|
}
|
|
return p.objects[k], nil
|
|
}
|
|
|
|
// Describe returns o unchanged; the fixtures already carry their details.
|
|
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
|
return o, ctx.Err()
|
|
}
|
|
|
|
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
|
return nil, provider.ErrUnsupported
|
|
}
|
|
vs := p.versions[o.ID]
|
|
if len(vs) == 0 { // every object has at least its current version
|
|
return []provider.Object{o}, nil
|
|
}
|
|
out := make([]provider.Object, len(vs))
|
|
for i, v := range vs {
|
|
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return provider.SecretValue{}, err
|
|
}
|
|
if o.ID == "" { // built from a link, like the real providers read it
|
|
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
|
|
}
|
|
v, ok := p.values[o.ID+"@"+o.Version]
|
|
if !ok {
|
|
v, ok = p.values[o.ID]
|
|
}
|
|
if !ok {
|
|
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
|
|
}
|
|
return provider.NewSecretValue([]byte(v)), nil
|
|
}
|
|
|
|
// Locate finds the source holding the vault (by host), the workspace or
|
|
// the repo (by its remote).
|
|
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
|
if err := p.wait(ctx); err != nil {
|
|
return "", false, err
|
|
}
|
|
for _, src := range p.sources {
|
|
if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
|
|
return src.ID, true, nil
|
|
}
|
|
for _, c := range p.containers[src.ID] {
|
|
if c.Detail == "https://"+host {
|
|
return src.ID, true, nil
|
|
}
|
|
}
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func (p *Provider) wait(ctx context.Context) error {
|
|
if p.delay <= 0 {
|
|
return ctx.Err()
|
|
}
|
|
t := time.NewTimer(p.delay)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
const portal = "https://portal.azure.com/#resource"
|
|
|
|
// containerID finds a container by ID, or by vault URL like the real Azure
|
|
// provider, which only needs the URL (vaults opened from links).
|
|
func (p *Provider) containerID(c provider.Container) string {
|
|
for _, cs := range p.containers {
|
|
for _, x := range cs {
|
|
if x.ID == c.ID {
|
|
return x.ID
|
|
}
|
|
}
|
|
}
|
|
// Not one listed here (built from a link): by vault URL, or a scope
|
|
// by name in its workspace.
|
|
for _, cs := range p.containers {
|
|
for _, x := range cs {
|
|
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
|
|
return x.ID
|
|
}
|
|
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
|
|
return x.ID
|
|
}
|
|
}
|
|
}
|
|
return c.ID
|
|
}
|
|
|
|
func (p *Provider) addSource(id, name, detail string) provider.Source {
|
|
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
|
|
if p.kind == provider.KindAzure {
|
|
s.URL = portal + "/subscriptions/" + id + "/overview"
|
|
} else {
|
|
s.URL = "https://" + detail
|
|
}
|
|
p.sources = append(p.sources, s)
|
|
return s
|
|
}
|
|
|
|
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
|
|
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
|
|
vault := name
|
|
if p.kind == provider.KindDatabricks {
|
|
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
|
}
|
|
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
|
|
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
|
}
|
|
p.containers[src.ID] = append(p.containers[src.ID], c)
|
|
return c
|
|
}
|
|
|
|
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
|
o.Container = c
|
|
o.Type = t
|
|
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
|
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
|
o.URL = base + map[provider.ObjectType]string{
|
|
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
|
}[t]
|
|
}
|
|
k := listKey{c.ID, t}
|
|
p.objects[k] = append(p.objects[k], o)
|
|
p.values[o.ID] = value
|
|
return o
|
|
}
|
|
|
|
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
|
|
for _, t := range types {
|
|
p.errs[listKey{c.ID, t}] = err
|
|
}
|
|
}
|
|
|
|
var (
|
|
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
|
yes = true
|
|
no = false
|
|
)
|
|
|
|
// ErrForbidden mimics a data-plane permission error from a backend.
|
|
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
|
|
|
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
|
|
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
|
|
|
|
const (
|
|
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
|
|
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
|
|
)
|
|
|
|
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
|
|
|
// Azure returns a fake Azure provider with two subscriptions.
|
|
func Azure(delay time.Duration) *Provider {
|
|
p := newProvider(provider.KindAzure, delay)
|
|
secretRef := func(vault, name string) string {
|
|
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
|
}
|
|
objectURL := func(vault, kind, name string) string {
|
|
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
|
}
|
|
|
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
|
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
|
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
|
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
|
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
|
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
|
}, "correct-horse-battery-staple")
|
|
p.versions[dbPassword.ID] = []provider.Object{
|
|
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
|
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
|
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
|
}
|
|
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
|
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
|
p.add(kv, provider.TypeSecret, provider.Object{
|
|
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
|
Reference: secretRef("kv-app-prod", "api-key"),
|
|
}, "sk_live_51HxDEMO0000000000000000")
|
|
p.add(kv, provider.TypeSecret, provider.Object{
|
|
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
|
Reference: secretRef("kv-app-prod", "legacy-token"),
|
|
}, "deprecated")
|
|
|
|
p.add(kv, provider.TypeKey, provider.Object{
|
|
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
|
Attrs: []provider.Attr{
|
|
{Label: "key type", Value: "RSA 2048"},
|
|
{Label: "ops", Value: "sign, verify"},
|
|
},
|
|
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
|
}, demoPublicKey)
|
|
p.add(kv, provider.TypeKey, provider.Object{
|
|
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
|
Attrs: []provider.Attr{
|
|
{Label: "key type", Value: "EC P-256"},
|
|
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
|
},
|
|
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
|
|
}, demoPublicKey)
|
|
|
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
|
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
|
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
|
Attrs: []provider.Attr{
|
|
{Label: "subject", Value: "CN=app.example.com"},
|
|
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
|
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
|
|
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
|
|
{Label: "valid from", Value: "2026-08-01 09:30"},
|
|
},
|
|
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
|
}, demoCert)
|
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
|
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
|
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
|
Attrs: []provider.Attr{
|
|
{Label: "subject", Value: "CN=old.example.com"},
|
|
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
|
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
|
|
},
|
|
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
|
|
}, demoCert)
|
|
|
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
|
// A key to share env files with (S → f): random, as share.keyRules wants.
|
|
p.add(shared, provider.TypeSecret, provider.Object{
|
|
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
|
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
|
p.add(shared, provider.TypeSecret, provider.Object{
|
|
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
|
}, "smtp-demo-password")
|
|
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
|
|
|
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
|
|
p.fail(locked, ErrForbidden, allTypes...)
|
|
|
|
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
|
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
|
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
|
}, "dev-password")
|
|
|
|
return p
|
|
}
|
|
|
|
// AzureOps returns a second fake Azure account. It shares platform-prod
|
|
// with Azure but sees a different vault there, and has a subscription of
|
|
// its own, like an admin account next to a personal one.
|
|
func AzureOps(delay time.Duration) *Provider {
|
|
p := newProvider(provider.KindAzure, delay)
|
|
secretRef := func(vault, name string) string {
|
|
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
|
}
|
|
|
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
|
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
|
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
|
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
|
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
|
}, "break-glass-demo-password")
|
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
|
p.add(shared, provider.TypeSecret, provider.Object{
|
|
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
|
}, "smtp-demo-password")
|
|
// Unlike the first account, ops may read keys here.
|
|
p.add(shared, provider.TypeKey, provider.Object{
|
|
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
|
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
|
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
|
}, demoPublicKey)
|
|
|
|
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
|
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
|
p.add(kv, provider.TypeSecret, provider.Object{
|
|
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
|
}, "pd-demo-token")
|
|
|
|
return p
|
|
}
|
|
|
|
// Databricks returns a fake Databricks provider with two workspace profiles,
|
|
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
|
|
func Databricks(delay time.Duration) *Provider {
|
|
p := newProvider(provider.KindDatabricks, delay)
|
|
secret := func(c provider.Container, key, value string) {
|
|
p.add(c, provider.TypeSecret, provider.Object{
|
|
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
|
}, value)
|
|
}
|
|
|
|
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
|
|
app := p.addContainer(prod, "app", "Databricks-backed")
|
|
secret(app, "storage-key", "storage-demo-key")
|
|
secret(app, "sp-client-secret", "sp-demo-secret")
|
|
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
|
|
secret(akv, "db-password", "correct-horse-battery-staple")
|
|
|
|
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
|
|
devApp := p.addContainer(dev, "app", "Databricks-backed")
|
|
secret(devApp, "storage-key", "dev-storage-key")
|
|
|
|
return p
|
|
}
|