2 Commits
Author SHA1 Message Date
clawbot 4aae5925af Say why a mnemonic is refused (closes #76)
check / check (push) Canceled after 0s
Every way a mnemonic could be invalid was reported as a checksum
failure, including an empty one. It is now refused as empty, as having
a word count other than 12, 15, 18, 21 or 24, as having a word that is
not on the BIP-39 word list, or as failing the checksum. A word not on
the list is named by its position, never by the word itself.

Model: opus-5-5
2026-10-14 18:57:49 +00:00
clawbot b02f502928 Keep the mnemonic variables from the mnemonic command (closes #80)
check / check (push) Canceled after 0s
The mnemonic command ran with the tool's whole environment, so a
mnemonic set in KEYFUNC_MNEMONIC reached it, although the command is
there to supply one of its own. It now gets the environment ssh and
sftp already get, with KEYFUNC_MNEMONIC and KEYFUNC_MNEMONIC_COMMAND
removed. The function that builds that environment moves from the ssh
command group into internal/mnemonic, beside the two variable names,
so all three programs use the one copy. The README says so.

A mnemonic command that runs keyfunc itself no longer finds the main
mnemonic in either variable.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 20:48:21 +02:00
6 changed files with 141 additions and 36 deletions
+9 -6
View File
@@ -119,14 +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 fails the BIP-39 checksum is refuses and exits with status 1. A mnemonic that is empty, does not have 12, 15,
refused with a message saying so. Keys are derived from the mnemonic's words 18, 21 or 24 words, has a word that is not on the BIP-39 word list, or fails the
joined by single spaces, whatever whitespace is around or between them, so one BIP-39 checksum is refused with a message saying which. A word that is not on
word per line, tabs or extra spaces give the same keys. the list is named by its position, never printed. Keys are derived from the
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.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the system `ssh` (`keyfunc ssh to`) and `sftp` environment before the mnemonic command, the system `ssh` (`keyfunc ssh to`) and
(`keyfunc ssh install`) are started, so the mnemonic is never handed on to them. `sftp` (`keyfunc ssh install`) are started, so the mnemonic is never handed on
to them.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version. `make build` stamps it; a binary `keyfunc --version` prints the version. `make build` stamps it; a binary
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals" "sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/mnemonic"
) )
// Where the key goes on the host and what the file it arrives in is // Where the key goes on the host and what the file it arrives in is
@@ -220,7 +221,7 @@ func session(
//nolint:gosec // the options are the user's own, meant for sftp //nolint:gosec // the options are the user's own, meant for sftp
command := exec.CommandContext(cmd.Context(), "sftp", argv...) command := exec.CommandContext(cmd.Context(), "sftp", argv...)
command.Env = childEnv() command.Env = mnemonic.ChildEnv()
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
command.Stdout = &said command.Stdout = &said
command.Stderr = &said command.Stderr = &said
-23
View File
@@ -3,13 +3,10 @@ package ssh
import ( import (
"fmt" "fmt"
"os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/options" "sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/derive" "sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/mnemonic"
"sneak.berlin/go/keyfunc/internal/sshkey" "sneak.berlin/go/keyfunc/internal/sshkey"
) )
@@ -87,26 +84,6 @@ func write(cmd *cobra.Command, text string) error {
return nil return nil
} }
// childEnv is the tool's environment with the mnemonic variables taken
// out, for the ssh and sftp children it starts. "ssh to" exists so the
// private key never leaves the tool; the mnemonic, from either variable,
// must not leave it either.
func childEnv() []string {
environ := os.Environ()
kept := make([]string, 0, len(environ))
for _, entry := range environ {
name, _, _ := strings.Cut(entry, "=")
if name == mnemonic.Variable || name == mnemonic.CommandVariable {
continue
}
kept = append(kept, entry)
}
return kept
}
// addComment gives a command its comment flag. // addComment gives a command its comment flag.
func addComment(cmd *cobra.Command) { func addComment(cmd *cobra.Command) {
cmd.Flags().String( cmd.Flags().String(
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals" "sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/mnemonic"
) )
// StatusError says the tool should end with the status ssh ended with. // StatusError says the tool should end with the status ssh ended with.
@@ -79,7 +80,7 @@ func to() *cobra.Command {
func connect(ctx context.Context, argv []string) error { func connect(ctx context.Context, argv []string) error {
//nolint:gosec // the arguments are the user's own, meant for ssh //nolint:gosec // the arguments are the user's own, meant for ssh
command := exec.CommandContext(ctx, "ssh", argv...) command := exec.CommandContext(ctx, "ssh", argv...)
command.Env = childEnv() command.Env = mnemonic.ChildEnv()
command.Stdin = os.Stdin command.Stdin = os.Stdin
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
+69 -5
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"golang.org/x/term" "golang.org/x/term"
@@ -29,6 +30,28 @@ 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")
@@ -63,6 +86,7 @@ func Read(ctx context.Context, command string) (string, error) {
func run(ctx context.Context, command string) (string, error) { func run(ctx context.Context, command string) (string, error) {
//nolint:gosec // running the user's own command is the point //nolint:gosec // running the user's own command is the point
shell := exec.CommandContext(ctx, "sh", "-c", command) shell := exec.CommandContext(ctx, "sh", "-c", command)
shell.Env = ChildEnv()
var complaint bytes.Buffer var complaint bytes.Buffer
@@ -83,6 +107,25 @@ func run(ctx context.Context, command string) (string, error) {
return string(out), nil return string(out), nil
} }
// ChildEnv is the tool's environment with the mnemonic variables taken
// out, for the programs the tool starts: the mnemonic command, ssh and
// sftp. The mnemonic, from either variable, never leaves the tool.
func ChildEnv() []string {
environ := os.Environ()
kept := make([]string, 0, len(environ))
for _, entry := range environ {
name, _, _ := strings.Cut(entry, "=")
if name == Variable || name == CommandVariable {
continue
}
kept = append(kept, entry)
}
return kept
}
// ask prompts on the terminal with echo turned off. // ask prompts on the terminal with echo turned off.
func ask() (string, error) { func ask() (string, error) {
fd := int(os.Stdin.Fd()) fd := int(os.Stdin.Fd())
@@ -106,13 +149,34 @@ 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 does not pass the BIP-39 checksum. // and refuses a mnemonic that is not a valid BIP-39 mnemonic, with an
func checked(words string) (string, error) { // error that says what is wrong with it.
words = strings.Join(strings.Fields(words), " ") func checked(text string) (string, error) {
words := strings.Fields(text)
if !bip39.IsMnemonicValid(words) { switch len(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 words, nil return joined, nil
} }
+59
View File
@@ -1,6 +1,8 @@
package mnemonic_test package mnemonic_test
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -61,6 +63,31 @@ func TestTheMnemonicInTheEnvironmentComesLast(t *testing.T) {
require.Equal(t, fromVariable, words) require.Equal(t, fromVariable, words)
} }
func TestTheMnemonicIsNotHandedToTheCommand(t *testing.T) {
const marker = "KEYFUNC_TEST_MARKER"
t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable))
t.Setenv(mnemonic.Variable, fromVariable)
t.Setenv(marker, "reaches the command")
path := filepath.Join(t.TempDir(), "environment")
words, err := mnemonic.Read(
t.Context(), "env > '"+path+"'; "+prints(example()),
)
require.NoError(t, err)
require.Equal(t, example(), words)
//nolint:gosec // the path is a temporary file of the test's own
content, err := os.ReadFile(path)
require.NoError(t, err)
environment := string(content)
require.NotContains(t, environment, mnemonic.Variable+"=")
require.NotContains(t, environment, mnemonic.CommandVariable+"=")
require.Contains(t, environment, marker+"=")
}
func TestAFailingCommandSaysWhatWentWrong(t *testing.T) { func TestAFailingCommandSaysWhatWentWrong(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "") t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, "") t.Setenv(mnemonic.Variable, "")
@@ -78,6 +105,38 @@ 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, "")