1 Commits
Author SHA1 Message Date
sneak ca1faa5551 Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Successful in 2m35s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the
ssh and sftp children acted on them: the mnemonic prompt waited for
Enter, and an interrupted `age encrypt -o` went on to put the
encryption of the cut-off input in place. Now only `ssh to` and
`ssh install` catch them, from once the mnemonic is read until their
cleanup has run; everywhere else they end the tool at once. Tests cover
an interrupted `age encrypt -o` and the install working directory on a
signal.

Model: opus-5-5
2026-10-04 04:40:57 +00:00
9 changed files with 96 additions and 242 deletions
+30 -44
View File
@@ -106,8 +106,9 @@ order; the first one found wins:
1. `--mnemonic-command <command>`: a shell command, run with `sh -c`, whose
standard output is the mnemonic. Example:
`--mnemonic-command 'secret get foo'`. If the command exits with a non-zero
status, the tool prints its standard error and exits with status 1.
`--mnemonic-command 'secret get foo'`. Whitespace around the output is
dropped. If the command exits with a non-zero status, the tool prints its
standard error and exits with status 1.
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command
held in the environment.
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
@@ -115,9 +116,7 @@ order; the first one found wins:
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
refused with a message saying so. 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.
refused with a message saying so.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
@@ -163,23 +162,21 @@ Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
on the host: the file is fetched, changed here, and written back with the system
`sftp` client in batch mode.
The first connection lists `~/.ssh`, then `~/.ssh/.`, and then fetches
`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp`
reported `~/.ssh` itself as not being there, or both listings came up and the
file was not found. Any other outcome of that connection fails the run — a
`~/.ssh` that is there but cannot be read or entered, an `authorized_keys` that
is there but cannot be read, or a connection that did not come up — and the tool
prints what `sftp` said and exits with status 1 without writing anything, rather
than put a file back holding the new key alone. The listings are what tell a
missing directory from one shut to the user, which `sftp` reports on a fetch the
same way: one that cannot be read fails the first listing, and one that can be
read but not entered fails the second, after which the tool says that `~/.ssh`
cannot be entered. The wording of a missing file elsewhere does not count
either, since `ssh` writes `No such file or directory` about an `-i` it cannot
find on a session that then authenticates through the agent. If an identical
line is already in the file, the tool prints `already present` and connects no
further. Otherwise the line is added (after a newline, if the file did not end
with one) and a second connection:
The first connection lists `~/.ssh` and then fetches `~/.ssh/authorized_keys`
from it. The file reads as empty in two cases only: `sftp` reported `~/.ssh`
itself as not being there, or the listing came up and the file was not in it.
Any other outcome of that connection fails the run — a `~/.ssh` that is there
but cannot be entered, an `authorized_keys` that is there but cannot be read, or
a connection that did not come up — and the tool prints what `sftp` said and
exits with status 1 without writing anything, rather than put a file back
holding the new key alone. The listing is what tells a missing directory from
one shut to the user, which `sftp` reports on a fetch the same way; the wording
of a missing file elsewhere does not count either, since `ssh` writes
`No such file or directory` about an `-i` it cannot find on a session that then
authenticates through the agent. If an identical line is already in the file,
the tool prints `already present` and connects no further. Otherwise the line is
added (after a newline, if the file did not end with one) and a second
connection:
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection
found none; a `~/.ssh` that was already there keeps the mode it had;
@@ -202,10 +199,7 @@ error, so the tool's own standard output is only `added` or `already present`.
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
has to do it, not a typed password. Nor does it ask whether to trust a host key
it has not seen, so the host has to be in `known_hosts` already, or the run
fails with `Host key verification failed`. Connect to the host once with `ssh`
first, or pass `-o StrictHostKeyChecking=accept-new` after `--`.
has to do it, not a typed password.
### `keyfunc ssh to <host> [ssh arguments...]`
@@ -266,18 +260,10 @@ A child mnemonic is a full mnemonic in its own right: it can seed another
`keyfunc`, another wallet, or `secret`, and it never has to be written down,
since it can be derived again.
Test vector, mnemonic
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
```
index 0: prosper short ramp prepare exchange stove life snack client enough purpose fold
```
The child-mnemonic step is also checked against BIP-85's own published vectors.
Those start from the specification's master key
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`
rather than from a mnemonic, so they cannot be given to `keyfunc`; at key index
0 the 12-word English child mnemonic of that key is:
Test vector: the child-mnemonic step is checked against BIP-85's own published
vectors, which derive from the specification's master key
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`.
At key index 0 the 12-word English child mnemonic is:
```
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
@@ -290,12 +276,12 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
with the status a shell gives a program killed by that signal (130 for SIGINT).
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
the unfinished file it was writing, leaves a file already at the named path as
it was, and exits with status 1. While `ssh to` or `ssh install` has `ssh` or
`sftp` running, the signal ends that program instead, the tool removes its agent
socket or working files, and it exits with status 1, or for `ssh to` with
`ssh`'s own status if `ssh` reported one.
An interrupted `age encrypt -o` or `age decrypt -o` leaves the named file as it
was; the unfinished file it was writing stays beside it, named
`<file>.<digits>`. While `ssh to` or `ssh install` has `ssh` or `sftp` running,
the signal ends that program instead, the tool removes its agent socket or
working files, and it exits with status 1, or for `ssh to` with `ssh`'s own
status if `ssh` reported one.
## Entrypoints
+1 -25
View File
@@ -6,9 +6,7 @@ import (
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey"
@@ -187,38 +185,16 @@ func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
}, nil
}
// From before the new file is made until it is renamed or removed,
// a signal removes it and ends the tool with status 1, even while
// the work is blocked reading its input, so an interrupted run
// leaves no file.
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
// The file is made in the same directory so that putting it in
// place is a rename and never a copy, and it is readable only by
// its owner, which is the mode it keeps once renamed.
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
if err != nil {
signal.Stop(signals)
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
}
go func() {
if _, received := <-signals; received {
_ = os.Remove(file.Name())
os.Exit(1)
}
}()
return file, func(failed error) error {
finished := finish(file, name, failed)
signal.Stop(signals)
close(signals)
return finished
return finish(file, name, failed)
}, nil
}
+16 -36
View File
@@ -94,49 +94,32 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
require.Equal(t, "what was already there\n", string(kept))
}
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
func TestASignalStopsAnEncryptionAndPutsNoFileInPlace(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} {
interrupted(t, ending, "encrypt", "the start of the secret\n")
encryptionInterrupted(t, ending.String(), ending)
}
}
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
// All of an encryption but its last byte, so the tool reads the
// header and then waits for the rest.
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
cut := sealed[:len(sealed)-1]
for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} {
interrupted(t, ending, "decrypt", cut)
}
}
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
// operation says, as a subprocess writing into a directory of its own
// and reading the input from a pipe that stays open. It waits until the
// tool has begun writing the file beside the one it was named, and
// sends it the signal. The tool has to end on the signal alone, with
// status 1, and leave the directory empty. A tool that went on reading
// would not end until the input did; one that did not remove the file
// it was writing would leave it there, with what it had written so far.
func interrupted(t *testing.T, signal os.Signal, operation, input string) {
// encryptionInterrupted runs "age encrypt -o" as a subprocess reading
// from a pipe that stays open, waits until the tool has begun writing
// the file beside the one it was named, and sends it the signal. The
// tool has to end on the signal alone, with a failure, and leave
// nothing at the name it was given. A tool that went on reading would
// not end until the input did, and would then put the encryption of the
// cut-off input in place.
func encryptionInterrupted(t *testing.T, name string, signal os.Signal) {
t.Helper()
name := operation + " " + signal.String()
directory := t.TempDir()
sealed := filepath.Join(directory, "notes.age")
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], "age", operation,
"-o", filepath.Join(directory, "notes"),
t.Context(), os.Args[0], "age", "encrypt", "-o", sealed,
)
command.Env = append(os.Environ(), runAsTool+"=1")
@@ -145,11 +128,11 @@ func interrupted(t *testing.T, signal os.Signal, operation, input string) {
require.NoError(t, err)
require.NoError(t, command.Start())
_, err = io.WriteString(producer, input)
_, err = io.WriteString(producer, "the start of the secret\n")
require.NoError(t, err)
// The file beside the named one is made once the mnemonic has been
// read, before any input is.
// read, just before the encryption starts.
require.Eventually(t, func() bool {
entries, err := os.ReadDir(directory)
@@ -159,11 +142,8 @@ func interrupted(t *testing.T, signal os.Signal, operation, input string) {
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left, name)
require.False(t, command.ProcessState.Success(), name)
require.NoFileExists(t, sealed, name)
}
// written puts the contents in a file of that name in a directory of
+3 -5
View File
@@ -69,11 +69,9 @@ func Root() *cobra.Command {
//
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
// program, so a command waiting at the mnemonic prompt or reading what
// it encrypts or decrypts goes no further. The exceptions catch the
// signals to clean up first: "ssh to" and "ssh install" while they
// have ssh or sftp running, so the child ends and their own cleanup
// still runs, and "age encrypt -o" and "age decrypt -o" while they
// write, so the unfinished file is removed.
// it encrypts or decrypts goes no further. The exception is "ssh to"
// and "ssh install" while they have ssh or sftp running: they catch the
// signals there, so the child ends and their own cleanup still runs.
func Main() int {
err := Root().Execute()
if err == nil {
-27
View File
@@ -22,11 +22,6 @@ const (
"0I4FKs+eVUulTPHfk9VtXw1tMF"
)
// The child mnemonic the README says the example mnemonic gives at
// index 0.
const childZero = "prosper short ramp prepare exchange stove life " +
"snack client enough purpose fold"
// The two child mnemonic lengths the tests ask for.
const (
twelve = 12
@@ -50,28 +45,6 @@ func TestTheReadmeTestVectors(t *testing.T) {
vectorOne+" keyfunc/ssh/1",
strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")),
)
require.Equal(t,
childZero, strings.TrimSpace(run(t, "mnemonic", "-n", "0")),
)
}
func TestTheSpacingBetweenTheWordsDoesNotChangeTheKeys(t *testing.T) {
words := strings.Fields(example())
for name, spaced := range map[string]string{
"one word per line": strings.Join(words, "\n"),
"double spaces": strings.Join(words, " "),
"tabs": strings.Join(words, "\t"),
} {
t.Run(name, func(t *testing.T) {
t.Setenv(mnemonic.Variable, spaced)
require.Equal(t,
vectorZero+" keyfunc/ssh/0",
strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")),
)
})
}
}
func TestTheCommentCanBeChosen(t *testing.T) {
+21 -37
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
@@ -35,12 +34,6 @@ const (
localMode = 0o600
)
// ErrCannotEnter is the refusal of a host whose .ssh is there but
// cannot be entered, so that nothing in it can be read or written.
var ErrCannotEnter = errors.New(
"~/.ssh is there on the host but cannot be entered",
)
// install returns the command that adds the public key to a host.
func install() *cobra.Command {
cmd := &cobra.Command{
@@ -219,39 +212,30 @@ func merge(content, line string) (string, bool) {
// fetch brings the host's authorized_keys into the given path and
// returns what is in it, and whether the .ssh directory was already
// there. The one session lists .ssh, then .ssh/., and then gets the
// file, so the listings settle the state of the directory before the
// get is read.
// there. The one session lists .ssh and then gets the file, so the
// listing settles the state of the directory before the get is read.
//
// The file reads as empty in just two cases: sftp reported .ssh itself
// as not there, or both listings succeeded and the get then reported
// the file as not there. Anything else — a listing refused, the file
// as not there, or the listing succeeded and the get then reported the
// file as not there. Anything else — the listing refused, the file
// there but unreadable, the connection down — fails the run and writes
// nothing, because writing back over what was not read would leave the
// host with the new key and nothing else. sftp cannot tell a missing
// file from one in a directory it cannot enter, so the listings do: a
// directory that is there but cannot be read fails the first, and one
// that can be read but not entered fails the second, because nothing in
// it can be looked up, not even ".". The first listing of such a
// directory comes up empty, as the server leaves out every name it
// cannot look up.
// file from one in a directory it cannot enter, so the listing does:
// a directory that is there but cannot be read is a failure, not an
// empty file.
func fetch(
cmd *cobra.Command, host string, options []string, into string,
) (string, bool, error) {
said, err := session(cmd, host, options, []string{
"ls -1 " + directory,
"ls -1 " + directory + "/.",
"get " + authorized + " " + quoted(into),
})
if err != nil {
if listingNotFound(said, directory) {
if directoryAbsent(said) {
return "", false, nil
}
if listingNotFound(said, directory+"/.") {
return "", false, ErrCannotEnter
}
if absent(said) {
return "", true, nil
}
@@ -268,18 +252,18 @@ func fetch(
return string(content), true, nil
}
// listingNotFound says whether sftp reported the path it was asked to
// list as not being there. For .ssh that is the one listing failure
// read as a host that has no authorized_keys yet; for .ssh/., once .ssh
// itself has been listed, it is a .ssh that is there but cannot be
// entered. The reading is taken only from the line in which sftp
// reports on that path: any other failure of a listing, in particular a
// directory that is there but cannot be read, is left as a failure, so
// that no key is written to a host whose keys were never read.
func listingNotFound(said, path string) bool {
// directoryAbsent says whether sftp reported .ssh itself as not being
// there, which is the one listing failure read as a host that has no
// authorized_keys yet. The reading is taken only from the line in which
// sftp reports on that directory: any other failure of the listing, in
// particular a directory that is there but cannot be entered, is left
// as a failure, so that no key is written to a host whose keys were
// never read.
func directoryAbsent(said string) bool {
for line := range strings.Lines(said) {
named, is := reportedCannotList(strings.TrimSpace(line))
if is && (named == path || strings.HasSuffix(named, "/"+path)) {
if is && (named == directory ||
strings.HasSuffix(named, "/"+directory)) {
return true
}
}
@@ -288,9 +272,9 @@ func listingNotFound(said, path string) bool {
}
// reportedCannotList returns the path an sftp line reports it cannot
// list for want of it, and whether the line is such a report. The
// client writes this one wording when it cannot look up the path a
// listing names, giving the path the server expanded.
// list for want of the directory, and whether the line is such a
// report. The client writes this one wording when the directory a
// listing names is not there, giving the path the server expanded.
func reportedCannotList(line string) (string, bool) {
const (
before = `Can't ls: "`
+4 -12
View File
@@ -80,11 +80,8 @@ func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
// wordings the OpenSSH client was seen to use when a listing fails: a
// directory it cannot find is reported one way, and one it cannot read
// another, and only the first is read as a host with no .ssh yet. A
// .ssh that can be read but not entered lists as empty, and the
// listing of .ssh/. that follows reports that path, not .ssh, as not
// found.
// directory it cannot find is reported one way, and one it cannot enter
// another, and only the first is read as a host with no .ssh yet.
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
t.Parallel()
@@ -105,16 +102,11 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
`Can't ls: "/home/someone/.ssh" not found` + "\n",
want: true,
},
"the directory is there and cannot be read": {
"the directory is there and cannot be entered": {
said: listed +
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
want: false,
},
"the directory is there and cannot be entered": {
said: listed + "sftp> ls -1 .ssh/.\n" +
`Can't ls: "/home/someone/.ssh/." not found` + "\n",
want: false,
},
"some other directory is not there": {
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
want: false,
@@ -130,7 +122,7 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
if listingNotFound(listing.said, directory) != listing.want {
if directoryAbsent(listing.said) != listing.want {
t.Errorf(
"read as absent: %t, wanted %t, from:\n%s",
!listing.want, listing.want, listing.said,
+18 -52
View File
@@ -52,7 +52,7 @@ const (
)
// notADirectory is what a test puts where the .ssh directory belongs
// to make a .ssh that is listed but cannot be entered.
// to make a step of the write session fail.
const notADirectory = "a file where the directory belongs\n"
// missingIdentity is a path with no file at it, handed to sftp after
@@ -106,11 +106,9 @@ const marker = "KEYFUNC_TEST_MARKER"
// another for a file that is there and cannot be read, which is a
// failure. A directory shut to the user is stood in for by mode 000,
// which the listing reads off the mode itself so that the test does not
// turn on the user it runs as, and one the user can enter but not write
// to by mode 500, which the put reads off the same way. An -i naming a
// file that is not here draws the warning ssh writes for it, which
// carries the wording of a missing file into a session that goes on to
// authenticate.
// turn on the user it runs as. An -i naming a file that is not here
// draws the warning ssh writes for it, which carries the wording of a
// missing file into a session that goes on to authenticate.
const installer = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
previous=
@@ -162,13 +160,7 @@ while IFS= read -r line; do
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
fi
;;
put)
if [ "$(stat -c '%a' "$(dirname "$home/$3")")" = 500 ]; then
worked=no
else
cp "$2" "$home/$3" 2>/dev/null || worked=no
fi
;;
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
@@ -347,29 +339,6 @@ func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
require.Equal(t, 1, connections(t, pretend))
}
func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
// A file where .ssh belongs is listed and cannot be entered, which is
// how sftp sees a directory that can be read but not entered: the
// listing of .ssh comes up and the listing of .ssh/. finds nothing.
inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t,
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
)
printed, _, err := attempt(t, host)
require.ErrorIs(t, err, ssh.ErrCannotEnter)
require.Empty(t, printed)
// The read and nothing after it: no upload was tried, and what was
// on the host is still what is on the host.
require.Equal(t, 1, connections(t, pretend))
require.Equal(t, notADirectory, read(t, inTheWay))
}
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
@@ -433,30 +402,27 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
pretend := pretendHost(t)
// A .ssh that can be listed and entered but not written to: the
// fetch finds no file in it, and then the put has nowhere to put
// anything, so the write session ends at the put.
const unwritable = 0o500
directory := filepath.Join(pretend.home, keptUnder)
require.NoError(t, os.Mkdir(directory, unwritable))
// A file where the .ssh directory belongs: the listing shows it and
// so the directory reads as already there, but then the put has
// nowhere to put anything, so the write session ends at the put.
inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t,
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
)
printed, said, err := attempt(t, host)
require.Error(t, err)
require.Empty(t, printed)
require.Contains(t, said, "put failed")
// The put, after the three commands of the fetch, is the first and
// last command the write session got to, and the file it was
// uploading is the one the message names.
// The put is the first and last command the write session got to,
// and the file it was uploading is the one the message names.
sent := recorded(t, pretend.batch)
require.Len(t, sent, 4)
require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
require.Len(t, sent, 3)
require.Equal(t, "put", strings.Fields(sent[2])[0])
require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left)
require.Equal(t, notADirectory, read(t, inTheWay))
// The same run again, this way for the status it ends with.
given := os.Args
+3 -4
View File
@@ -104,11 +104,10 @@ func ask() (string, error) {
return checked(string(typed))
}
// checked joins the words with single spaces, whatever whitespace
// separated them, since the seed is computed over the string itself,
// and refuses a mnemonic that does not pass the BIP-39 checksum.
// checked drops the surrounding whitespace and refuses a mnemonic that
// does not pass the BIP-39 checksum.
func checked(words string) (string, error) {
words = strings.Join(strings.Fields(words), " ")
words = strings.TrimSpace(words)
if !bip39.IsMnemonicValid(words) {
return "", ErrChecksum