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
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
)
|
|
|
|
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
|
|
// and scopes, and the secrets, keys and certificates of every vault opened
|
|
// this session. Azure has no search across vaults, so ctrl+a in the popup
|
|
// loads the rest in the background, a few requests at a time; results
|
|
// appear as lists arrive, and the panes use them too.
|
|
|
|
const crawlWorkers = 4
|
|
|
|
// crawl loads every vault's secrets for search.
|
|
type crawl struct {
|
|
running bool
|
|
queue []crawlJob
|
|
active int
|
|
pending map[string]bool // cache keys of lists queued or loading
|
|
failed map[string]bool // cache keys of lists that couldn't be read
|
|
}
|
|
|
|
type crawlJob struct {
|
|
src *provider.Source // list its vaults or scopes
|
|
container *provider.Container // list its secrets
|
|
row string // the source's row key (containers cache key)
|
|
force bool // reload what's loaded already
|
|
}
|
|
|
|
type crawlMsg struct {
|
|
job crawlJob
|
|
containers []provider.Container
|
|
objects []provider.Object
|
|
err error
|
|
}
|
|
|
|
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), d)
|
|
}
|
|
|
|
// searchEntries lists everything loaded: the sources (with how much of
|
|
// each is loaded) and the vaults, scopes and secrets in them, each with
|
|
// its source's row key as group.
|
|
func (m *Model) searchEntries() (sources, items []option) {
|
|
type entry struct {
|
|
depth int
|
|
sort string
|
|
opt option
|
|
}
|
|
var es []entry
|
|
// key is the store key, so ctrl+f stars the entry; mark shows how
|
|
// much is loaded, reload (ctrl+r) loads it again.
|
|
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
|
|
n := m.store.Node(key)
|
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
|
es = append(es, entry{depth, sort,
|
|
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
|
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
|
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
|
return &es[len(es)-1].opt
|
|
}
|
|
for _, p := range m.providers {
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
|
row := sourceRowKey(p.ID(), src.ID)
|
|
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
|
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
|
return nil
|
|
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
|
for _, c := range m.cache.containers[row].value {
|
|
r, err := ref.ForContainer(c, provider.TypeSecret)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
|
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
|
m.startNav(r, actGo, row)
|
|
return nil
|
|
}, reload)
|
|
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
|
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
|
r, err := ref.ForObject(o, "")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
suffix := accountOf(src)
|
|
if t != provider.TypeSecret {
|
|
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
|
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
|
}
|
|
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
|
m.startNav(r, actGo, row)
|
|
return nil
|
|
}, reload)
|
|
e.plain = c.Detail == provider.DetailPlainEnv
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
|
|
for _, e := range es {
|
|
if e.depth == 0 {
|
|
sources = append(sources, e.opt)
|
|
} else {
|
|
items = append(items, e.opt)
|
|
}
|
|
}
|
|
return sources, items
|
|
}
|
|
|
|
// coverage is how much of everything search can see: sources whose
|
|
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
|
|
// that couldn't be read count as done.
|
|
type coverage struct {
|
|
sources, sourcesListed int
|
|
vaults, vaultsLoaded int
|
|
secrets, unreadable int
|
|
}
|
|
|
|
func (c coverage) complete() bool {
|
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
|
}
|
|
|
|
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
|
|
|
// providerFilter picks the providers a search or a load covers; nil is
|
|
// all of them.
|
|
type providerFilter func(provider.Provider) bool
|
|
|
|
func ofKind(k provider.Kind) providerFilter {
|
|
return func(p provider.Provider) bool { return p.Kind() == k }
|
|
}
|
|
|
|
func ofProvider(id string) providerFilter {
|
|
return func(p provider.Provider) bool { return p.ID() == id }
|
|
}
|
|
|
|
// coverageOf is searchCoverage for the providers in.
|
|
func (m *Model) coverageOf(in providerFilter) coverage {
|
|
var c coverage
|
|
for _, p := range m.providers {
|
|
if in != nil && !in(p) {
|
|
continue
|
|
}
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
row := sourceRowKey(p.ID(), src.ID)
|
|
c.sources++
|
|
e, ok := m.cache.containers[row]
|
|
if ok || m.crawl.failed[row] {
|
|
c.sourcesListed++
|
|
}
|
|
if m.crawl.failed[row] {
|
|
c.unreadable++
|
|
}
|
|
for _, v := range e.value {
|
|
c.vaults++
|
|
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
|
if objs, ok := m.cache.objects[key]; ok {
|
|
c.vaultsLoaded++
|
|
c.secrets += len(objs.value)
|
|
} else if m.crawl.failed[key] {
|
|
c.vaultsLoaded++
|
|
c.unreadable++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// searchLines says what the search covers.
|
|
func (m *Model) searchLines() []string {
|
|
c := m.searchCoverage()
|
|
switch {
|
|
case m.crawl.running:
|
|
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
|
len(m.crawl.queue)+m.crawl.active))}
|
|
case c.complete():
|
|
s := fmt.Sprintf("Searching everything: %s, %s, %s",
|
|
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
|
|
if c.unreadable > 0 {
|
|
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
|
|
}
|
|
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
|
|
}
|
|
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
|
|
if c.sourcesListed < c.sources {
|
|
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
|
|
}
|
|
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
|
|
}
|
|
|
|
func plural(n int, word string) string {
|
|
if n == 1 {
|
|
return "1 " + word
|
|
}
|
|
return fmt.Sprintf("%d %ss", n, word)
|
|
}
|
|
|
|
// startCrawl loads every unlisted subscription's vaults and every vault's
|
|
// secrets not loaded yet.
|
|
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
|
|
|
// startCrawlOf is startCrawl for the providers in.
|
|
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
|
for _, p := range m.providers {
|
|
if in != nil && !in(p) {
|
|
continue
|
|
}
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
|
m.queueSource(src, false)
|
|
}
|
|
}
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// loadSource loads (again) a subscription's vaults and their secrets.
|
|
func (m *Model) loadSource(src provider.Source) tea.Cmd {
|
|
m.queueSource(src, true)
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// loadContainer loads (again) a vault's secrets.
|
|
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
|
|
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// crawlStart runs what's queued, and shows it in the popup.
|
|
func (m *Model) crawlStart() tea.Cmd {
|
|
cmd := m.crawlNext()
|
|
m.refreshSearch()
|
|
return cmd
|
|
}
|
|
|
|
// queueSource queues listing src's vaults or, with them listed lately
|
|
// and not force, loading their secrets. Lists from an earlier run (the
|
|
// cache file) are listed again, so searching brings them up to date.
|
|
func (m *Model) queueSource(src provider.Source, force bool) {
|
|
row := sourceRowKey(src.Provider, src.ID)
|
|
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
|
|
for _, c := range e.value {
|
|
m.queueContainer(c, row, false)
|
|
}
|
|
return
|
|
}
|
|
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
|
|
}
|
|
|
|
// queueContainer queues loading c's secrets, unless loaded lately and not
|
|
// force.
|
|
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
|
return
|
|
}
|
|
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
|
|
}
|
|
|
|
func (m *Model) enqueue(job crawlJob, key string) {
|
|
if m.crawl.pending == nil {
|
|
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
|
|
}
|
|
if m.crawl.pending[key] {
|
|
return // queued or loading already
|
|
}
|
|
m.crawl.pending[key] = true
|
|
m.crawl.queue = append(m.crawl.queue, job)
|
|
}
|
|
|
|
// crawlNext starts jobs up to crawlWorkers at a time.
|
|
func (m *Model) crawlNext() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
|
|
job := m.crawl.queue[0]
|
|
m.crawl.queue = m.crawl.queue[1:]
|
|
m.crawl.active++
|
|
cmds = append(cmds, m.crawlCmd(job))
|
|
}
|
|
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|
timeout := m.timeout
|
|
if job.src != nil {
|
|
p := m.providerFor(*job.src)
|
|
src := *job.src
|
|
return func() tea.Msg {
|
|
ctx, cancel := contextWithTimeout(timeout)
|
|
defer cancel()
|
|
items, err := p.Containers(ctx, src)
|
|
for i := range items {
|
|
items[i].Source = src
|
|
}
|
|
return crawlMsg{job: job, containers: items, err: err}
|
|
}
|
|
}
|
|
c := *job.container
|
|
p := m.providerFor(c.Source)
|
|
return func() tea.Msg {
|
|
ctx, cancel := contextWithTimeout(timeout)
|
|
defer cancel()
|
|
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
|
for i := range items {
|
|
items[i].Container = c
|
|
}
|
|
return crawlMsg{job: job, objects: items, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
|
m.crawl.active--
|
|
key := msg.job.row
|
|
if msg.job.container != nil {
|
|
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
|
}
|
|
delete(m.crawl.pending, key)
|
|
switch {
|
|
case msg.err != nil:
|
|
m.crawl.failed[key] = true
|
|
case msg.job.src != nil:
|
|
delete(m.crawl.failed, key)
|
|
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
|
|
m.cacheDirty = true
|
|
for _, c := range msg.containers {
|
|
m.queueContainer(c, key, msg.job.force)
|
|
}
|
|
default:
|
|
delete(m.crawl.failed, key)
|
|
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
|
|
m.cacheDirty = true
|
|
}
|
|
cmd := m.crawlNext()
|
|
m.refreshSearch()
|
|
return cmd
|
|
}
|
|
|
|
// sourceMark shows how much of a subscription or workspace is loaded:
|
|
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
|
|
func (m *Model) sourceMark(row string) string {
|
|
if m.crawl.pending[row] {
|
|
return m.st.Muted.Render("◌")
|
|
}
|
|
if m.crawl.failed[row] {
|
|
return m.st.Error.Render("✗")
|
|
}
|
|
e, ok := m.cache.containers[row]
|
|
if !ok {
|
|
return m.st.Muted.Render("○")
|
|
}
|
|
loaded, loading := 0, false
|
|
for _, c := range e.value {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
loading = loading || m.crawl.pending[key]
|
|
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
|
loaded++
|
|
}
|
|
}
|
|
switch {
|
|
case loading:
|
|
return m.st.Muted.Render("◌")
|
|
case loaded == len(e.value):
|
|
return m.st.Success.Render("●")
|
|
case loaded > 0:
|
|
return m.st.Warning.Render("◐")
|
|
}
|
|
return m.st.Muted.Render("○")
|
|
}
|
|
|
|
// vaultMark marks a vault in search only when its secrets can't be read
|
|
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
|
// its vaults are searchable.
|
|
func (m *Model) vaultMark(c provider.Container) string {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
switch {
|
|
case m.crawl.pending[key]:
|
|
return m.st.Muted.Render("◌")
|
|
case m.crawl.failed[key]:
|
|
return m.st.Error.Render("✗")
|
|
}
|
|
return " "
|
|
}
|
|
|
|
// providerOf is the provider ID in a source row key.
|
|
func providerOf(row string) string {
|
|
p, _ := splitSourceRow(row)
|
|
return p
|
|
}
|