feat: share env files as encrypted links #54

Merged
keb merged 25 commits from share-files into main 2026-10-13 14:55:22 +02:00
6 changed files with 73 additions and 16 deletions
Showing only changes of commit 027e748c92 - Show all commits

No files matched your search

+23 -3
View File
@@ -2,6 +2,7 @@ package share
import ( import (
"fmt" "fmt"
"strings"
"unicode" "unicode"
) )
@@ -40,11 +41,20 @@ func (r Rules) Check(key []byte) error {
} }
} }
n := len([]rune(s)) n := len([]rune(s))
if n >= r.MinLength && kinds >= r.MinClasses { var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
return nil return nil
} }
return fmt.Errorf("the key's value is %d characters of %d kinds, and share.keyRules wants at least %d characters of %d kinds (lowercase, uppercase, digits, symbols): use a random one, like openssl rand -base64 32 makes", return fmt.Errorf("its value has %s; share.keyRules needs %s. Use a random one, like openssl rand -base64 32 makes",
n, kinds, r.MinLength, r.MinClasses) strings.Join(has, " and "), strings.Join(needs, " and "))
} }
func (r Rules) withDefaults() Rules { func (r Rules) withDefaults() Rules {
@@ -57,3 +67,13 @@ func (r Rules) withDefaults() Rules {
r.MinLength = max(r.MinLength, MinKey) r.MinLength = max(r.MinLength, MinKey)
return r return r
} }
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
+2 -2
View File
@@ -91,13 +91,13 @@ type file struct {
} }
// Seal encrypts files with key, the value of h.Key, into a share link, // Seal encrypts files with key, the value of h.Key, into a share link,
// if key keeps to rules. // if key keeps to rules (*KeyError if not).
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) { func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
if _, err := ParseKey(h.Key.String()); err != nil { if _, err := ParseKey(h.Key.String()); err != nil {
return ref.Ref{}, fmt.Errorf("the key: %w", err) return ref.Ref{}, fmt.Errorf("the key: %w", err)
} }
if err := rules.Check(key); err != nil { if err := rules.Check(key); err != nil {
return ref.Ref{}, err return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
} }
if len(files) == 0 { if len(files) == 0 {
return ref.Ref{}, errors.New("no files to share") return ref.Ref{}, errors.New("no files to share")
+1 -1
View File
@@ -62,7 +62,7 @@ func TestSealRefuses(t *testing.T) {
files []File files []File
want string want string
}{ }{
"short key": {Header{Key: key}, []byte("short"), ok, "keyRules wants"}, "short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"}, "vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
"no files": {Header{Key: key}, pass, nil, "no files"}, "no files": {Header{Key: key}, pass, nil, "no files"},
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"}, "not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
+15 -3
View File
@@ -61,6 +61,8 @@ type searchView struct {
pickLines []string // what picking is for, above the picker pickLines []string // what picking is for, above the picker
pickHeading string // the picker's title, on its border pickHeading string // the picker's title, on its border
pickHelp []string // inside it, above the tabs pickHelp []string // inside it, above the tabs
pickBusy string // what picking is doing, under the help
pickErr string // why the key picked can't be used
// loaded are the scopes a search has loaded since the popup opened: // loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for // typing loads the scope once, so lists that fail aren't asked for
@@ -438,8 +440,7 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
switch { switch {
case v.results && v.res < len(v.shown) && v.pick != nil: case v.results && v.res < len(v.shown) && v.pick != nil:
r, _ := pickable(v.shown[v.res]) r, _ := pickable(v.shown[v.res])
m.search = nil return v.pick(m, r) // the picker closes when it's done
return v.pick(m, r)
case v.results && v.res < len(v.shown): case v.results && v.res < len(v.shown):
m.search = nil m.search = nil
return v.shown[v.res].run(m) return v.shown[v.res].run(m)
@@ -667,7 +668,9 @@ func (m Model) searchViewBox() string {
listH := max(5, min(m.h-10, 14)) listH := max(5, min(m.h-10, 14))
if v.pick != nil { if v.pick != nil {
// What it's for, the help, the tabs and a border more. // What it's for, the help, the tabs and a border more.
listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-5, 14)) // What it's for, the help, a line or two on the key picked, the
// tabs and a border more.
listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-8, 14))
} }
srcW := 10 srcW := 10
@@ -766,6 +769,15 @@ func (m Model) searchViewBox() string {
for _, l := range v.pickHelp { for _, l := range v.pickHelp {
body = append(body, " "+l) body = append(body, " "+l)
} }
switch {
case v.pickBusy != "":
body = append(body, "", " "+m.st.Notice.Render(v.pickBusy))
case v.pickErr != "":
body = append(body, "")
for _, l := range strings.Split(ansi.Wordwrap(v.pickErr, w-4, " "), "\n") {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+m.goToTabs(ts)) body = append(body, "", " "+m.goToTabs(ts))
} }
body = append(body, " "+v.input.View(), "") body = append(body, " "+v.input.View(), "")
+23 -6
View File
@@ -137,8 +137,14 @@ func (m *Model) confirmShare(s sharing) {
func (m *Model) pickShareKey(s sharing) tea.Cmd { func (m *Model) pickShareKey(s sharing) tea.Cmd {
cmd := m.openGoTo(tabSearch, true) cmd := m.openGoTo(tabSearch, true)
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
// The picker showed what's shared: no need to ask again. It
// stays open until the link is made, to say why a key can't be
// used.
s.key = key s.key = key
return m.seal(s) // the picker showed what's shared m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…"
cmd := m.seal(s)
m.flash = flash{}
return cmd
} }
m.search.pickTitle = shareTitle(s) m.search.pickTitle = shareTitle(s)
m.search.pickLines = m.sharedFiles(s) m.search.pickLines = m.sharedFiles(s)
@@ -159,9 +165,7 @@ func (m *Model) pickPasted(text string) tea.Cmd {
m.flash = flash{text: "share: " + err.Error(), isErr: true} m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil return nil
} }
pick := m.search.pick return m.search.pick(m, key)
m.search = nil
return pick(m, key)
} }
// seal reads the key and encrypts the files with it. // seal reads the key and encrypts the files with it.
@@ -171,7 +175,7 @@ func (m *Model) seal(s sharing) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
v, version, err := read(s.key) v, version, err := read(s.key)
if err != nil { if err != nil {
return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)} return sealedMsg{err: &share.KeyError{Name: s.key.Name, Err: err}}
} }
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules) 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} return sealedMsg{link: link, n: len(s.files), err: err}
@@ -179,11 +183,24 @@ func (m *Model) seal(s sharing) tea.Cmd {
} }
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
if msg.err != nil { picking := m.search != nil && m.search.pick != nil
if picking {
m.search.pickBusy = ""
}
var bad *share.KeyError
switch {
case msg.err != nil && picking && errors.As(msg.err, &bad):
// Picked: say why in the picker, to pick another.
m.search.pickErr = "✗ " + strings.ToUpper(bad.Error()[:1]) + bad.Error()[1:]
return nil
case msg.err != nil:
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
return nil return nil
} }
if picking {
m.search = nil
}
m.dismissedLink = msg.link.String() // not offered back on focus m.dismissedLink = msg.link.String() // not offered back on focus
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false) return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
} }
+9 -1
View File
@@ -200,8 +200,16 @@ func TestShareInDemo(t *testing.T) {
if d.m.search == nil || d.m.search.pick == nil { if d.m.search == nil || d.m.search.pick == nil {
t.Fatalf("no picker: %s", d.m.flash.text) t.Fatalf("no picker: %s", d.m.flash.text)
} }
// Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.pickErr, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
}
if !strings.Contains(d.screen(), "28 characters") {
t.Errorf("not shown:\n%s", d.screen())
}
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"}) d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") { if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text) t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
} }
link := clip.content link := clip.content