1 Commits
Author SHA1 Message Date
clawbot 261c817750 Check the -o path before looking for the mnemonic (closes #87)
check / check (push) Canceled after 0s
age encrypt and age decrypt looked at the -o path only after the
mnemonic had been obtained, so a path that could never be written
still prompted for the mnemonic or ran the mnemonic command. Both now
refuse, before the key is derived, an -o path whose directory is
missing or is not a directory, or a symlink that points at nothing.
The check creates nothing; output still looks at the path when it
writes, so the file beside the target is made when it was before. The
README names the -o path among what is checked first.

Model: opus-5-5
2026-10-14 19:45:32 +00:00
5 changed files with 18 additions and 130 deletions
+7 -9
View File
@@ -119,19 +119,17 @@ order; the first one found wins:
4. A prompt on the terminal with echo turned off. 4. A prompt on the terminal with echo turned off.
If none of these is available and standard input is not a terminal, the tool If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that is empty, does not have 12, 15, refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
18, 21 or 24 words, has a word that is not on the BIP-39 word list, or fails the refused with a message saying so. Keys are derived from the mnemonic's words
BIP-39 checksum is refused with a message saying which. A word that is not on joined by single spaces, whatever whitespace is around or between them, so one
the list is named by its position, never printed. Keys are derived from the word per line, tabs or extra spaces give the same keys.
mnemonic's words joined by single spaces, whatever whitespace is around or
between them, so one word per line, tabs or extra spaces give the same keys.
The key index, `--words`, the `--to` recipients, the file `age encrypt` or The key index, `--words`, the `--to` recipients, the file `age encrypt` or
`age decrypt` is given to read and its `-o` path are checked before the mnemonic `age decrypt` is given to read and its `-o` path are checked before the mnemonic
is looked for, so a run refused for one of them never prompts for the mnemonic is looked for, so a run refused for one of them never prompts for the mnemonic
or runs the mnemonic command. An `-o` path is refused at that point if it is a or runs the mnemonic command. An `-o` path is refused at that point if its
directory or a symlink to one, if its directory is missing or is not a directory is missing or is not a directory, or if it is a symlink that points at
directory, or if it is a symlink that points at nothing. nothing; anything else wrong with it shows only when the output is written.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the mnemonic command, the system `ssh` (`keyfunc ssh to`) and environment before the mnemonic command, the system `ssh` (`keyfunc ssh to`) and
+4 -12
View File
@@ -11,7 +11,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/agekey"
@@ -237,10 +236,9 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
} }
// checkOutput refuses an -o path that output could not write to, // checkOutput refuses an -o path that output could not write to,
// before anything is written: a directory or a symlink to one, one // before anything is written: one whose directory is missing or is not
// whose directory is missing or is not a directory, or a symlink that // a directory, or a symlink that points at nothing. It creates nothing;
// points at nothing. It creates nothing; output looks at the path again // output looks at the path again when it writes.
// when it writes.
func checkOutput(cmd *cobra.Command) error { func checkOutput(cmd *cobra.Command) error {
name, err := cmd.Flags().GetString("output") name, err := cmd.Flags().GetString("output")
if err != nil { if err != nil {
@@ -264,21 +262,15 @@ func checkOutput(cmd *cobra.Command) error {
if err != nil { if err != nil {
return fmt.Errorf("looking at %s: %w", directory, err) return fmt.Errorf("looking at %s: %w", directory, err)
} }
return nil
case err != nil: case err != nil:
return fmt.Errorf("looking at %s: %w", name, err) return fmt.Errorf("looking at %s: %w", name, err)
case info.Mode().Type() == fs.ModeSymlink: case info.Mode().Type() == fs.ModeSymlink:
info, err = os.Stat(name) _, err = os.Stat(name)
if err != nil { if err != nil {
return fmt.Errorf("following %s: %w", name, err) return fmt.Errorf("following %s: %w", name, err)
} }
} }
if info.IsDir() {
return fmt.Errorf("writing to %s: %w", name, syscall.EISDIR)
}
return nil return nil
} }
+2 -7
View File
@@ -119,15 +119,10 @@ func TestAnOutputPathThatCannotBeWrittenIsRefused(t *testing.T) {
dangling := filepath.Join(directory, "dangling") dangling := filepath.Join(directory, "dangling")
require.NoError(t, os.Symlink(missing, dangling)) require.NoError(t, os.Symlink(missing, dangling))
toDirectory := filepath.Join(directory, "to-directory")
require.NoError(t, os.Symlink(t.TempDir(), toDirectory))
refusals := map[string]error{ refusals := map[string]error{
filepath.Join(missing, "notes.age"): fs.ErrNotExist, filepath.Join(missing, "notes.age"): fs.ErrNotExist,
filepath.Join(plain, "notes.age"): syscall.ENOTDIR, filepath.Join(plain, "notes.age"): syscall.ENOTDIR,
dangling: fs.ErrNotExist, dangling: fs.ErrNotExist,
directory: syscall.EISDIR,
toDirectory: syscall.EISDIR,
} }
for name, refusal := range refusals { for name, refusal := range refusals {
@@ -140,10 +135,10 @@ func TestAnOutputPathThatCannotBeWrittenIsRefused(t *testing.T) {
require.Empty(t, out, name) require.Empty(t, out, name)
} }
// Nothing was made beside the two symlinks. // Nothing was made beside the symlink.
left, err := os.ReadDir(directory) left, err := os.ReadDir(directory)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, left, 2) require.Len(t, left, 1)
} }
func TestAFileForAnotherKeyIsRefused(t *testing.T) { func TestAFileForAnotherKeyIsRefused(t *testing.T) {
+5 -49
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"golang.org/x/term" "golang.org/x/term"
@@ -30,28 +29,6 @@ var ErrMissing = errors.New(
"no mnemonic given and standard input is not a terminal", "no mnemonic given and standard input is not a terminal",
) )
// The lengths a BIP-39 mnemonic may have.
const (
twelve = 12
fifteen = 15
eighteen = 18
twentyOne = 21
twentyFour = 24
)
// ErrEmpty is returned for a mnemonic with no words in it, such as the
// output of a mnemonic command that printed nothing.
var ErrEmpty = errors.New("the mnemonic is empty")
// ErrWordCount is returned for a mnemonic with a number of words that
// BIP-39 does not allow.
var ErrWordCount = errors.New("a mnemonic has 12, 15, 18, 21 or 24 words")
// ErrUnknownWord is returned for a mnemonic with a word that is not on
// the BIP-39 English word list. The error gives the word's position,
// never the word, so the mnemonic stays out of the message.
var ErrUnknownWord = errors.New("not on the BIP-39 word list")
// ErrChecksum is returned for a mnemonic that fails the BIP-39 // ErrChecksum is returned for a mnemonic that fails the BIP-39
// checksum. // checksum.
var ErrChecksum = errors.New("the mnemonic fails its BIP-39 checksum") var ErrChecksum = errors.New("the mnemonic fails its BIP-39 checksum")
@@ -149,34 +126,13 @@ func ask() (string, error) {
// checked joins the words with single spaces, whatever whitespace // checked joins the words with single spaces, whatever whitespace
// separated them, since the seed is computed over the string itself, // separated them, since the seed is computed over the string itself,
// and refuses a mnemonic that is not a valid BIP-39 mnemonic, with an // and refuses a mnemonic that does not pass the BIP-39 checksum.
// error that says what is wrong with it. func checked(words string) (string, error) {
func checked(text string) (string, error) { words = strings.Join(strings.Fields(words), " ")
words := strings.Fields(text)
switch len(words) { if !bip39.IsMnemonicValid(words) {
case 0:
return "", ErrEmpty
case twelve, fifteen, eighteen, twentyOne, twentyFour:
default:
return "", fmt.Errorf("%w, not %d", ErrWordCount, len(words))
}
wordList := bip39.English()
for i, word := range words {
if !slices.Contains(wordList, word) {
return "", fmt.Errorf(
"word %d of the mnemonic is %w", i+1, ErrUnknownWord,
)
}
}
joined := strings.Join(words, " ")
if !bip39.IsMnemonicValid(joined) {
return "", ErrChecksum return "", ErrChecksum
} }
return joined, nil return words, nil
} }
-53
View File
@@ -97,27 +97,6 @@ func TestAFailingCommandSaysWhatWentWrong(t *testing.T) {
require.Contains(t, err.Error(), "nothing here") require.Contains(t, err.Error(), "nothing here")
} }
func TestValidMnemonicsOf15To24WordsAreAccepted(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
// abandon repeated, then the last word that makes the checksum
// match: 15, 18, 21 and 24 words.
valid := []string{
strings.Repeat("abandon ", 14) + "address",
strings.Repeat("abandon ", 17) + "agent",
strings.Repeat("abandon ", 20) + "admit",
strings.Repeat("abandon ", 23) + "art",
}
for _, words := range valid {
t.Setenv(mnemonic.Variable, words)
got, err := mnemonic.Read(t.Context(), "")
require.NoError(t, err)
require.Equal(t, words, got)
}
}
func TestABadChecksumIsRefused(t *testing.T) { func TestABadChecksumIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "") t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, broken()) t.Setenv(mnemonic.Variable, broken())
@@ -126,38 +105,6 @@ func TestABadChecksumIsRefused(t *testing.T) {
require.ErrorIs(t, err, mnemonic.ErrChecksum) require.ErrorIs(t, err, mnemonic.ErrChecksum)
} }
func TestACommandThatPrintsNothingGivesAnEmptyMnemonic(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, "")
_, err := mnemonic.Read(t.Context(), "true")
require.ErrorIs(t, err, mnemonic.ErrEmpty)
require.NotContains(t, err.Error(), "checksum")
}
func TestAWrongWordCountIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, strings.Repeat("abandon ", 3))
_, err := mnemonic.Read(t.Context(), "")
require.ErrorIs(t, err, mnemonic.ErrWordCount)
require.ErrorContains(t, err, "not 3")
require.NotContains(t, err.Error(), "checksum")
}
func TestAWordNotOnTheListIsNamedByPositionOnly(t *testing.T) {
const typo = "abuot"
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, strings.Repeat("abandon ", 11)+typo)
_, err := mnemonic.Read(t.Context(), "")
require.ErrorIs(t, err, mnemonic.ErrUnknownWord)
require.ErrorContains(t, err, "word 12 ")
require.NotContains(t, err.Error(), typo)
require.NotContains(t, err.Error(), "checksum")
}
func TestNothingToReadAndNobodyToAsk(t *testing.T) { func TestNothingToReadAndNobodyToAsk(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "") t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, "") t.Setenv(mnemonic.Variable, "")