feat: a key that no longer decrypts a share link says why
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s

By where it lives: a Databricks secret or SOPS value has changed
since the link was made, an Azure secret's current version isn't the
link's (unpinned), and a pinned version can't have changed, so the
link may be damaged. The popup wraps the reason instead of cutting
it; r is for what Key Vault adds to check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-13 14:30:24 +02:00
1 parent 8757284b0c
commit 40b90df68c
3 files changed
+42 -5

No files matched your search

+16 -1
View File
@@ -203,7 +203,7 @@ func (s Sealed) Open(key []byte) ([]File, error) {
if err != nil {
var wrong *age.NoIdentityMatchError
if errors.As(err, &wrong) {
return nil, errors.New("the key doesn't decrypt it: the link may have been made with another version of " + s.Key.Name)
return nil, s.wrongKey()
}
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
@@ -228,6 +228,21 @@ func (s Sealed) Open(key []byte) ([]File, error) {
return out, nil
}
// wrongKey says why the key, read fine, doesn't decrypt the link: its
// value isn't the one the link was made with.
func (s Sealed) wrongKey() error {
name := s.Key.Name
switch {
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
// A version's value never changes.
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
case s.Key.Kind == provider.KindAzure:
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
}
// Databricks secrets and SOPS values are changed in place.
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
}
// CheckPath accepts a path relative to a repo's root, with slashes, of an
// env file (.env, .env.local, prod.env) outside .git: what a link may
// write.
+23 -1
View File
@@ -49,11 +49,33 @@ func TestSealOpen(t *testing.T) {
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
t.Errorf("opened %+v", got)
}
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "doesn't decrypt") {
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
t.Errorf("wrong key: %v", err)
}
}
func TestWrongKeySaysWhy(t *testing.T) {
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
for link, want := range map[string]string{
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
} {
k, err := ParseKey(link)
if err != nil {
t.Fatal(err)
}
r, err := Seal(Header{Key: k}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
s, _ := Read(r)
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", link, err, want)
}
}
}
func TestSealRefuses(t *testing.T) {
ok := []File{{Path: ".env", Data: []byte("A=1")}}
for name, c := range map[string]struct {
+3 -3
View File
@@ -304,16 +304,16 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line, or with whole all of it
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
width := max(30, min(m.w-12, 76))
text := capitalize(err.Error())
first, rest, _ := strings.Cut(text, "\n")
cut := !whole && (rest != "" || ansi.StringWidth(first) > width-2)
cut := !whole && rest != "" // more than the reason: what to check
if !whole {
text = ansi.Truncate(first, width-2, "…")
text = first
}
var lines []string
for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") {