feat: share env files as encrypted links #54
No files matched your search
+23
-3
@@ -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 }
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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(), "")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user