Sharing refuses a key shorter than minLength characters (default 32, never under 16) or of fewer than minClasses kinds of lowercase, uppercase, digits and symbols (default 2, so random hex passes), saying so without showing it. Opening doesn't check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
357 lines
10 KiB
Go
357 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
)
|
|
|
|
// Sharing env files as an encrypted lazysecrets://share link (package
|
|
// share), and opening one: the files are encrypted with a secret's value,
|
|
// so whoever can read that secret can open the link.
|
|
|
|
// sharing is a share being made: the files read, and the key to use.
|
|
type sharing struct {
|
|
repo provider.Source
|
|
files []share.File
|
|
key ref.Ref // zero until chosen
|
|
}
|
|
|
|
type shareFilesMsg struct {
|
|
sharing
|
|
err error
|
|
}
|
|
|
|
type sealedMsg struct {
|
|
link ref.Ref
|
|
n int
|
|
err error
|
|
}
|
|
|
|
// canShareFiles reports whether src's env files can be shared: it's a
|
|
// repo whose files are on disk.
|
|
func (m Model) canShareFiles(src provider.Source) bool {
|
|
_, ok := m.providerFor(src).(provider.Files)
|
|
return src.Kind == provider.KindRepo && ok
|
|
}
|
|
|
|
// shareFiles shares env files of a repo: cs, or with cs nil all its
|
|
// plain ones.
|
|
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
|
p := m.providerFor(src)
|
|
files, ok := p.(provider.Files)
|
|
if !ok {
|
|
m.flash = warning("these files can't be shared")
|
|
return nil
|
|
}
|
|
general, timeout := m.shareKey, m.timeout
|
|
m.flash = flash{text: "reading the files…"}
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
msg := shareFilesMsg{sharing: sharing{repo: src}}
|
|
if cs == nil {
|
|
all, err := p.Containers(ctx, src)
|
|
if err != nil {
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
for _, c := range all {
|
|
if c.Detail == provider.DetailPlainEnv {
|
|
cs = append(cs, c)
|
|
}
|
|
}
|
|
if len(cs) == 0 {
|
|
msg.err = errors.New(src.Name + " has no plain env files")
|
|
return msg
|
|
}
|
|
}
|
|
for _, c := range cs {
|
|
f, err := files.File(c, nil)
|
|
if err == nil {
|
|
var b []byte
|
|
b, err = os.ReadFile(f.Path)
|
|
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
|
}
|
|
if err != nil {
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
}
|
|
key, ok, err := share.DefaultKey(src.ID, general)
|
|
if ok {
|
|
msg.key = key
|
|
}
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
|
switch {
|
|
case msg.err != nil:
|
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
|
case msg.key == (ref.Ref{}):
|
|
return m.pickShareKey(msg.sharing)
|
|
default:
|
|
m.confirmShare(msg.sharing)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// confirmShare says what the link will hold before making it.
|
|
func (m *Model) confirmShare(s sharing) {
|
|
lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")}
|
|
for _, f := range s.files {
|
|
lines = append(lines, " "+f.Path)
|
|
}
|
|
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
|
m.showMenu("Share as an encrypted link", lines, []option{
|
|
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
|
|
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
|
|
})
|
|
}
|
|
|
|
// pickShareKey asks which secret to encrypt with, in Go to.
|
|
func (m *Model) pickShareKey(s sharing) tea.Cmd {
|
|
cmd := m.openGoTo(tabSearch, true)
|
|
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
|
|
s.key = key
|
|
m.confirmShare(s)
|
|
return nil
|
|
}
|
|
m.search.pickTitle = "Encrypt with which secret?"
|
|
what := s.files[0].Path
|
|
if len(s.files) > 1 {
|
|
what = plural(len(s.files), "env file")
|
|
}
|
|
m.search.pickLines = []string{
|
|
"Sharing " + what + " from " + s.repo.Name + " as an encrypted link.",
|
|
"Pick the secret to encrypt with: whoever can read it can open the link.",
|
|
m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default."),
|
|
}
|
|
m.fillSearch(false)
|
|
return tea.Batch(cmd, m.searchLoads())
|
|
}
|
|
|
|
// pickPasted picks a pasted reference to a secret, while picking one.
|
|
func (m *Model) pickPasted(text string) tea.Cmd {
|
|
key, err := share.ParseKey(strings.TrimSpace(text))
|
|
if err != nil {
|
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
pick := m.search.pick
|
|
m.search = nil
|
|
return pick(m, key)
|
|
}
|
|
|
|
// seal reads the key and encrypts the files with it.
|
|
func (m *Model) seal(s sharing) tea.Cmd {
|
|
read, rules := m.keyReader(), m.shareRules
|
|
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
|
|
return func() tea.Msg {
|
|
v, version, err := read(s.key)
|
|
if err != nil {
|
|
return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)}
|
|
}
|
|
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
|
|
return sealedMsg{link: link, n: len(s.files), err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
m.dismissedLink = msg.link.String() // not offered back on focus
|
|
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
|
|
}
|
|
|
|
// keyReader reads a key's value with the providers, the account that
|
|
// last read it first, off the UI goroutine.
|
|
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
|
providers, st, timeout := m.providers, m.store, m.timeout
|
|
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
k := key
|
|
k.Version = ""
|
|
node := st.Node(k.String())
|
|
return ref.Read(ctx, providers, key, node.Account, node.Source)
|
|
}
|
|
}
|
|
|
|
// --- opening ---
|
|
|
|
type shareOpenedMsg struct {
|
|
sealed share.Sealed
|
|
files []share.File
|
|
dir string // the repo's clone; "" when it isn't here
|
|
err error
|
|
}
|
|
|
|
// offerShare says what a share link holds before decrypting it: links
|
|
// can come from anywhere, so nothing is read or written unasked.
|
|
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
|
s, err := share.Read(r)
|
|
if err != nil {
|
|
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
|
return nil
|
|
}
|
|
from := ""
|
|
if s.Repo != "" {
|
|
from = " from " + s.Repo
|
|
}
|
|
m.showMenu("Shared env files", []string{
|
|
m.st.Muted.Render(plural(s.Files, "env file") + from),
|
|
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
|
|
}, []option{
|
|
{key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// openShare reads the key, decrypts the files and finds the repo's clone.
|
|
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
|
read := m.keyReader()
|
|
providers, timeout := m.providers, m.timeout
|
|
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
|
return func() tea.Msg {
|
|
msg := shareOpenedMsg{sealed: s}
|
|
v, _, err := read(s.Key)
|
|
if err != nil {
|
|
msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err)
|
|
return msg
|
|
}
|
|
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
|
|
return msg
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
for _, p := range providers {
|
|
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
|
|
if root, found, _ := loc.Locate(ctx, s.Repo); found {
|
|
msg.dir = root
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
|
switch {
|
|
case msg.err != nil:
|
|
m.noteErr(msg.err)
|
|
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
|
case msg.dir == "":
|
|
return m.askShareDir(msg)
|
|
default:
|
|
m.offerWrite(msg.files, msg.dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// askShareDir asks where to save files from a repo that isn't here.
|
|
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
|
in := textinput.New()
|
|
in.Prompt = "folder: "
|
|
in.SetWidth(max(20, min(m.w-12, 64)))
|
|
dir := m.placeDir
|
|
if dir == "" {
|
|
dir, _ = os.Getwd()
|
|
}
|
|
in.SetValue(dir)
|
|
in.CursorEnd()
|
|
lines := []string{m.st.Muted.Render("Their paths are from the repo's root.")}
|
|
if msg.sealed.Repo != "" {
|
|
lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them where?")}, lines...)
|
|
}
|
|
m.prompt = &modal{
|
|
title: "Save the files", input: &in, lines: lines,
|
|
submit: func(m *Model, text string) tea.Cmd {
|
|
m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)))
|
|
return nil
|
|
},
|
|
}
|
|
return m.prompt.input.Focus()
|
|
}
|
|
|
|
// offerWrite shows where each file goes, then writes them as chosen:
|
|
// files there already are replaced only when asked.
|
|
func (m *Model) offerWrite(files []share.File, dir string) {
|
|
targets, err := share.Plan(dir, files)
|
|
if err != nil {
|
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
|
return
|
|
}
|
|
var lines []string
|
|
exist := 0
|
|
for _, t := range targets {
|
|
line := " " + t.Path + m.st.Muted.Render(" · new")
|
|
if t.Exists {
|
|
exist++
|
|
line = " " + t.Path + m.st.Muted.Render(" · replaces the one there")
|
|
}
|
|
if t.Exposed {
|
|
line += m.st.Error.Render(" · not ignored by git")
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
|
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd {
|
|
n := 0
|
|
for _, t := range targets {
|
|
if onlyNew && t.Exists {
|
|
continue
|
|
}
|
|
if err := t.Write(); err != nil {
|
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
n++
|
|
}
|
|
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
|
return nil
|
|
}
|
|
}
|
|
var opts []option
|
|
switch {
|
|
case exist == 0:
|
|
opts = append(opts, option{key: "w", label: "Save them", run: write(false)})
|
|
case exist == len(targets):
|
|
opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], run: write(false)})
|
|
default:
|
|
opts = append(opts,
|
|
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
|
|
option{key: "n", label: "Save only the new " + plural(len(targets)-exist, "file"), run: write(true)},
|
|
)
|
|
}
|
|
m.showMenu("Save the shared files", lines, opts)
|
|
}
|
|
|
|
// expandHome expands a leading ~/ to the home folder.
|
|
func expandHome(dir string) string {
|
|
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, rest)
|
|
}
|
|
}
|
|
return dir
|
|
}
|