1 Commits
Author SHA1 Message Date
clawbot 261c817750 Check the -o path before looking for the mnemonic (closes #87)
check / check (push) Waiting to run
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
18 changed files with 42 additions and 488 deletions
+16 -27
View File
@@ -65,12 +65,10 @@ calls into `internal/`. The packages there are:
and decrypts with it. and decrypts with it.
- `internal/childmnemonic` derives a child mnemonic from the main one using - `internal/childmnemonic` derives a child mnemonic from the main one using
BIP-85's own mnemonic application. BIP-85's own mnemonic application.
- `internal/signals` catches SIGINT, SIGTERM and SIGHUP for the commands that
clean up before they end, and for the mnemonic prompt, which puts the terminal
back before the signal ends the tool.
- `internal/cli` builds the cobra command tree and runs it. Under it, - `internal/cli` builds the cobra command tree and runs it. Under it,
`cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age` `cli/options` holds the flags every command shares, `cli/signals` catches
and `cli/mnemonic` are the command groups. SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and
`cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups.
- `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0, - `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0,
trimmed to what keyfunc uses. trimmed to what keyfunc uses.
@@ -118,26 +116,20 @@ order; the first one found wins:
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command 2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command
held in the environment. held in the environment.
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
4. A prompt on the terminal with echo turned off. The mnemonic is what is typed 4. A prompt on the terminal with echo turned off.
up to Enter, and echo is turned back on once it is read. Ctrl-D with nothing
typed ends the prompt at once and leaves the mnemonic empty, so the tool
refuses it and exits with status 1. After text has been typed, Ctrl-D has to
be pressed twice to end the prompt, and the text typed is the mnemonic.
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
@@ -291,9 +283,7 @@ or a device, such as `/dev/null`, is written to directly.
Decrypts the file (or standard input) with the derived identity. Output goes to Decrypts the file (or standard input) with the derived identity. Output goes to
`-o`, which is treated as for `encrypt`, or standard output. If the identity is `-o`, which is treated as for `encrypt`, or standard output. If the identity is
not one of the recipients, the tool says so and exits with status 1. The text not one of the recipients, the tool says so and exits with status 1.
form needs no flag, and up to 1024 bytes of blank lines in front of it are
skipped, as `age` skips them.
## Derived mnemonics: `keyfunc mnemonic` ## Derived mnemonics: `keyfunc mnemonic`
@@ -330,11 +320,10 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, 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). with the status a shell gives a program killed by that signal (130 for SIGINT).
At the prompt the terminal's settings are put back as they were first, so echo While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a
is on again. While `age encrypt -o` or `age decrypt -o` is writing a new file or regular one, the signal makes it remove the unfinished file, leave a file
replacing a regular one, the signal makes it remove the unfinished file, leave a already at the named path as it was, and exit with status 1. That holds for a
file already at the named path as it was, and exit with status 1. That holds for signal that has reached `keyfunc` when its input ends; a later one leaves the
a signal that has reached `keyfunc` when its input ends; a later one leaves the
whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and
on Linux `keyfunc` sees the signal first, though no system promises that. A on Linux `keyfunc` sees the signal first, though no system promises that. A
named pipe or a device at the `-o` path, or a path that is the same file as named pipe or a device at the `-o` path, or a path that is the same file as
+1 -2
View File
@@ -6,11 +6,9 @@ require (
filippo.io/age v1.3.2 filippo.io/age v1.3.2
github.com/btcsuite/btcd v0.25.0 github.com/btcsuite/btcd v0.25.0
github.com/btcsuite/btcd/btcutil v1.2.0 github.com/btcsuite/btcd/btcutil v1.2.0
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0 golang.org/x/term v0.46.0
sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8 sneak.berlin/go/secret v0.0.0-20261007105610-ef0ae90768c8
) )
@@ -24,4 +22,5 @@ require (
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.48.0 // indirect
) )
-2
View File
@@ -13,8 +13,6 @@ github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+W
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8=
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M=
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+3 -10
View File
@@ -4,7 +4,6 @@ package agekey
import ( import (
"bufio" "bufio"
"bytes"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -188,18 +187,12 @@ func (k *Key) Decrypt(dst io.Writer, src io.Reader) error {
} }
// unarmored strips the text form when the input begins with the line // unarmored strips the text form when the input begins with the line
// that starts one, and leaves binary input alone. Like age, it looks // that starts one, and leaves binary input alone.
// past up to 1024 bytes of whitespace for that line, and the text form
// reader skips blank lines in front of it.
func unarmored(src io.Reader) io.Reader { func unarmored(src io.Reader) io.Reader {
const maxWhitespace = 1024
buffered := bufio.NewReader(src) buffered := bufio.NewReader(src)
// A shorter input gives an error along with all of it, which is start, err := buffered.Peek(len(armor.Header))
// enough to look at. if err == nil && string(start) == armor.Header {
start, _ := buffered.Peek(maxWhitespace + len(armor.Header))
if bytes.HasPrefix(bytes.TrimSpace(start), []byte(armor.Header)) {
return armor.NewReader(buffered) return armor.NewReader(buffered)
} }
-22
View File
@@ -2,7 +2,6 @@ package agekey_test
import ( import (
"bytes" "bytes"
"io"
"strings" "strings"
"testing" "testing"
@@ -100,27 +99,6 @@ func TestTheArmoredFormIsText(t *testing.T) {
)) ))
} }
func TestTheTextFormIsReadWithWhitespaceInFront(t *testing.T) {
t.Parallel()
key := forIndex(t, 0)
var sealed bytes.Buffer
err := key.Encrypt(&sealed, strings.NewReader("hello"), nil, true)
require.NoError(t, err)
// A blank line and a line of two spaces, as pasting can leave. age
// refuses spaces on the first line of the text form itself, and so
// does this.
pasted := io.MultiReader(strings.NewReader("\n \n"), &sealed)
var opened bytes.Buffer
require.NoError(t, key.Decrypt(&opened, pasted))
require.Equal(t, "hello", opened.String())
}
func TestAFileForSomebodyElseIsRefused(t *testing.T) { func TestAFileForSomebodyElseIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
+5 -13
View File
@@ -11,13 +11,12 @@ 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"
"sneak.berlin/go/keyfunc/internal/cli/options" "sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/derive" "sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/signals"
) )
// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been // ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been
@@ -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) {
-3
View File
@@ -87,9 +87,6 @@ func init() {
// still runs, and "age encrypt -o" and "age decrypt -o" while they // still runs, and "age encrypt -o" and "age decrypt -o" while they
// write a new file to rename over the named one, so the unfinished file // write a new file to rename over the named one, so the unfinished file
// is removed. // is removed.
//
// At the mnemonic prompt the signal is caught only to put the terminal
// back, and then ends the tool as above.
func Main() int { func Main() int {
err := Root().Execute() err := Root().Execute()
if err == nil { if err == nil {
-133
View File
@@ -1,133 +0,0 @@
package cli_test
import (
"bufio"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"
"github.com/creack/pty"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/term"
"sneak.berlin/go/keyfunc/internal/mnemonic"
)
// TestEndOfInputAtThePromptEndsTheTool starts "age pub" and, at the
// prompt, types Ctrl-D. The tool has to end with status 1, say that the
// mnemonic is empty, and leave the terminal set as it found it, so with
// echo on again.
//
// pty.Open returns the two ends of a new terminal: tty is the end the
// tool uses as its terminal, and ptmx the end the test reads what the
// terminal shows from and types into.
func TestEndOfInputAtThePromptEndsTheTool(t *testing.T) {
t.Setenv(mnemonic.Variable, "")
t.Setenv(mnemonic.CommandVariable, "")
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
defer func() { _ = tty.Close() }()
before, err := term.GetState(int(tty.Fd()))
require.NoError(t, err)
command, terminal := prompted(t, ptmx, tty)
_, err = ptmx.WriteString("\x04") // Ctrl-D
require.NoError(t, err)
waitForTool(t, "Ctrl-D at the prompt", command)
require.Equal(t, failedStatus, command.ProcessState.ExitCode())
after, err := term.GetState(int(tty.Fd()))
require.NoError(t, err)
require.Equal(t, before, after, "the terminal settings were not restored")
// With the tool ended and this end closed too, ptmx gives what is
// left on the terminal and then an error.
require.NoError(t, tty.Close())
said, _ := io.ReadAll(terminal)
require.Contains(t, string(said), mnemonic.ErrEmpty.Error())
}
// TestASignalAtThePromptPutsTheTerminalBack starts "age pub" and, at
// the prompt, once the tool has turned echo off, sends it SIGINT. The
// tool has to be ended by SIGINT, which a shell reports as status 130,
// and leave the terminal set as it found it, so with echo on again.
func TestASignalAtThePromptPutsTheTerminalBack(t *testing.T) {
t.Setenv(mnemonic.Variable, "")
t.Setenv(mnemonic.CommandVariable, "")
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
defer func() { _ = tty.Close() }()
before, err := term.GetState(int(tty.Fd()))
require.NoError(t, err)
command, _ := prompted(t, ptmx, tty)
// The prompt is shown just before echo is turned off, and the
// signal has to come while it is off.
require.Eventually(t, func() bool {
now, err := term.GetState(int(tty.Fd()))
return err == nil && !assert.ObjectsAreEqual(before, now)
}, 5*time.Second, 5*time.Millisecond, "the tool did not turn echo off")
require.NoError(t, command.Process.Signal(syscall.SIGINT))
waitForTool(t, "SIGINT at the prompt", command)
status, ok := command.ProcessState.Sys().(syscall.WaitStatus)
require.True(t, ok)
require.True(t, status.Signaled(), "the tool was not ended by the signal")
require.Equal(t, syscall.SIGINT, status.Signal())
after, err := term.GetState(int(tty.Fd()))
require.NoError(t, err)
require.Equal(t, before, after, "the terminal settings were not restored")
}
// prompted starts "age pub" as a subprocess, for a test that has set no
// mnemonic, with the terminal's tty end on all three streams, and
// returns once the tool has shown the prompt. The reader returned reads
// what the terminal shows from its ptmx end. Reading ptmx stops at a
// deadline, so a prompt that never comes fails the test instead of
// hanging it.
func prompted(t *testing.T, ptmx, tty *os.File) (*exec.Cmd, *bufio.Reader) {
t.Helper()
require.NoError(t, ptmx.SetReadDeadline(time.Now().Add(10*time.Second)))
//nolint:gosec // this test's own binary as the tool
command := exec.CommandContext(t.Context(), os.Args[0], "age", "pub")
command.Env = append(os.Environ(), runAsTool+"=1")
command.Stdin = tty
command.Stdout = tty
command.Stderr = tty
require.NoError(t, command.Start())
var shown string
terminal := bufio.NewReader(ptmx)
for !strings.HasSuffix(shown, "mnemonic: ") {
char, err := terminal.ReadByte()
require.NoError(t, err, "the tool did not prompt: %q", shown)
shown += string(char)
}
return command, terminal
}
@@ -1,9 +1,5 @@
// Package signals catches SIGINT, SIGTERM and SIGHUP, the signals that // Package signals catches the signals that end the tool, for the
// end the tool, for the commands that clean up before they end and for // commands that clean up before they end.
// the mnemonic prompt, which puts the terminal back before the signal
// ends the tool. Go keeps an inherited ignore only for SIGHUP and
// SIGINT, so a SIGTERM the tool was started with set to be ignored is
// not left ignored.
package signals package signals
import ( import (
@@ -15,8 +11,8 @@ import (
// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the // Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the
// context it returns is cancelled when one of them arrives, and stop // context it returns is cancelled when one of them arrives, and stop
// stops catching them. It leaves out SIGHUP or SIGINT if the tool was // stops catching them. It leaves out any of the three the tool was
// started with it set to be ignored, as nohup does with SIGHUP, because // started with set to be ignored, as nohup does with SIGHUP, because
// catching a signal turns an ignored one back on and would end a run // catching a signal turns an ignored one back on and would end a run
// that was meant to survive it. // that was meant to survive it.
func Context(parent context.Context) (context.Context, context.CancelFunc) { func Context(parent context.Context) (context.Context, context.CancelFunc) {
@@ -41,8 +37,8 @@ func Notify(c chan<- os.Signal) {
} }
} }
// caught returns SIGINT, SIGTERM and SIGHUP, less any that // caught returns those of SIGINT, SIGTERM and SIGHUP that the tool was
// signal.Ignored reports as ignored. // not started with set to be ignored.
func caught() []os.Signal { func caught() []os.Signal {
endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP} endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
kept := make([]os.Signal, 0, len(endings)) kept := make([]os.Signal, 0, len(endings))
+1 -1
View File
@@ -15,8 +15,8 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/mnemonic" "sneak.berlin/go/keyfunc/internal/mnemonic"
"sneak.berlin/go/keyfunc/internal/signals"
) )
// 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
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/mnemonic" "sneak.berlin/go/keyfunc/internal/mnemonic"
"sneak.berlin/go/keyfunc/internal/signals"
) )
// StatusError says the tool should end with the status ssh ended with. // StatusError says the tool should end with the status ssh ended with.
+7 -51
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")
@@ -136,7 +113,7 @@ func ask() (string, error) {
fmt.Fprint(os.Stderr, "mnemonic: ") fmt.Fprint(os.Stderr, "mnemonic: ")
typed, err := readWithoutEcho() typed, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
@@ -144,39 +121,18 @@ func ask() (string, error) {
return "", fmt.Errorf("reading the mnemonic: %w", err) return "", fmt.Errorf("reading the mnemonic: %w", err)
} }
return checked(typed) return checked(string(typed))
} }
// 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, "")
-18
View File
@@ -1,18 +0,0 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos
package mnemonic
import (
"os"
"golang.org/x/term"
)
// readWithoutEcho reads one line from the terminal on standard input
// with echo turned off, through term.ReadPassword, on the systems
// prompt_unix.go is not built for, such as Windows.
func readWithoutEcho() (string, error) {
typed, err := term.ReadPassword(int(os.Stdin.Fd()))
return string(typed), err
}
-111
View File
@@ -1,111 +0,0 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
package mnemonic
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/signal"
"golang.org/x/sys/unix"
"sneak.berlin/go/keyfunc/internal/signals"
)
// readWithoutEcho reads one line from the terminal on standard input
// with echo turned off. The line ends at a newline or at the end of
// input (Ctrl-D). The terminal is set as term.ReadPassword sets it;
// ReadPassword itself is not used here because it never sees the end
// of input and goes on waiting.
//
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Notify
// catches them. One that has arrived by the time the line is read puts
// the terminal back as it was and then ends the tool, as it would have
// ended it if it were not caught.
func readWithoutEcho() (string, error) {
fd := int(os.Stdin.Fd())
settings, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return "", fmt.Errorf("reading the terminal settings: %w", err)
}
// Caught from before echo is turned off, so that no signal can end
// the tool with echo off.
received := make(chan os.Signal, 1)
signals.Notify(received)
defer signal.Stop(received)
hidden := *settings
hidden.Lflag &^= unix.ECHO
hidden.Lflag |= unix.ICANON | unix.ISIG
hidden.Iflag |= unix.ICRNL
err = unix.IoctlSetTermios(fd, ioctlWriteTermios, &hidden)
if err != nil {
return "", fmt.Errorf("turning echo off: %w", err)
}
var (
typed string
failed error
)
read := make(chan struct{})
go func() {
// With ICANON set, a read from the terminal returns at most one
// line, so the buffer takes nothing past the newline.
typed, failed = bufio.NewReader(os.Stdin).ReadString('\n')
close(read)
}()
var ending os.Signal
select {
case <-read:
case ending = <-received:
}
// The terminal is put back while the signals are still caught, so
// that none can end the tool with echo off. Stop returns only once
// every signal the tool has received has been handed over, so one
// that came as the line was read is in received by then.
_ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings)
signal.Stop(received)
if len(received) > 0 {
ending = <-received
}
if ending != nil {
return "", endBy(ending)
}
if errors.Is(failed, io.EOF) {
return typed, nil
}
return typed, failed
}
// endBy sends the tool the signal again, now that nothing catches it,
// so that it ends the tool, and waits for that. It returns only if the
// signal could not be sent.
func endBy(ending os.Signal) error {
tool, err := os.FindProcess(os.Getpid())
if err == nil {
err = tool.Signal(ending)
}
if err != nil {
return fmt.Errorf("ending the tool by %s: %w", ending, err)
}
select {}
}
-12
View File
@@ -1,12 +0,0 @@
//go:build darwin || dragonfly || freebsd || netbsd || openbsd
package mnemonic
import "golang.org/x/sys/unix"
// The requests that read and set the terminal settings, as
// golang.org/x/term names them.
const (
ioctlReadTermios = unix.TIOCGETA
ioctlWriteTermios = unix.TIOCSETA
)
-12
View File
@@ -1,12 +0,0 @@
//go:build aix || linux || solaris || zos
package mnemonic
import "golang.org/x/sys/unix"
// The requests that read and set the terminal settings, as
// golang.org/x/term names them.
const (
ioctlReadTermios = unix.TCGETS
ioctlWriteTermios = unix.TCSETS
)