10 Commits
Author SHA1 Message Date
sneak a6a9c7b18e Put the terminal back when a signal ends the mnemonic prompt (closes #91)
check / check (push) Waiting to run
A SIGINT, SIGTERM or SIGHUP at the mnemonic prompt killed the tool
with echo still off. The prompt now catches them with signals.Notify
from before echo goes off, and reads the line in a goroutine. When one
arrives it puts the terminal settings back, stops catching, and sends
the tool the same signal again, so the tool is still ended by it (130
for SIGINT in a shell). A signal the tool was started with set to be
ignored stays ignored. A test sends SIGINT at the prompt on a
pseudo-terminal.

Unchanged: the Windows prompt, which uses term.ReadPassword.

Model: opus-5-5
2026-10-14 21:47:30 +00:00
clawbot a08eb28359 End the mnemonic prompt at end of input (closes #78)
check / check (push) Waiting to run
term.ReadPassword reads the terminal with a raw read that returns 0
bytes at the end of input and takes that as nothing yet, so Ctrl-D at
the prompt did nothing. On the Unix systems the prompt now sets the
terminal as ReadPassword does and reads the line from standard input,
which reports the end of input; elsewhere, such as Windows, it still
uses ReadPassword. Ctrl-D with nothing typed gives an empty mnemonic,
refused with status 1, and the terminal settings are put back. A test
types Ctrl-D at the prompt on a pseudo-terminal.

Unverified: the prompt was run only on Linux; other systems were built.
Deviation: a Ctrl-H byte is no longer a backspace at the prompt.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 23:35:24 +02:00
clawbot 2fbc32fe75 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 that is a directory or a
symlink to one, whose directory is missing or is not a directory, or
that is a symlink pointing 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.

Left out: a directory the tool may not write to is found only when it
writes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 23:07:07 +02:00
clawbot f1e27814cd Read the text form with blank lines in front, as age does (closes #77)
check / check (push) Waiting to run
age decrypt took the text form only when the file began exactly with
-----BEGIN AGE ENCRYPTED FILE-----, so a blank line in front made it
read the file as the binary form and refuse it. It now looks for that
line past up to 1024 bytes of whitespace, as the age tool does, and
the text form reader skips the blank lines. The README's age decrypt
section says so.

Judgement call: that first line with spaces in front of it on the same
line is still refused, as age refuses it.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 22:35:29 +02:00
clawbot 5582c02edf Say why a mnemonic is refused (closes #76)
check / check (push) Waiting to run
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.

Judgement call: words are not lower-cased, so a capitalised word is
reported as not on the word list.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 22:05:37 +02:00
clawbot 44cb5215b0 Turn IdentitiesOnly off for ssh to (closes #74)
check / check (push) Canceled after 0s
ssh to now gives ssh -o IdentitiesOnly=no before the host, next to
-o IdentityAgent. With IdentitiesOnly yes in the user's ssh
configuration, ssh listed the agent's key but never offered it, and
the login failed with Permission denied. An option on the command
line wins over the configuration, and the tool's agent holds only the
derived key, so turning the option off adds no other key. A test
against the stand-in ssh requires the option before the host, and the
README's ssh to section names both options.

Judgement call: a user's own -o IdentitiesOnly=yes after the host is
now overridden.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 21:12:58 +02:00
clawbot d1ed234bc9 Check arguments before looking for the mnemonic (closes #79)
check / check (push) Canceled after 0s
The key index, --words, the --to recipients and the input file of
age encrypt and age decrypt were checked only after the mnemonic had
been obtained, so a run that could not succeed still prompted for it or
ran the mnemonic command. options.Index now refuses an index above
derive.MaxIndex, which covers every command group; the mnemonic command
calls the new childmnemonic.CheckWords first; age encrypt reads its
recipients with the new agekey.Recipients, and both age commands open
their input, before the key is derived. The README says which checks
come first.

Judgement call: the -o path is still looked at after the mnemonic.

Model: opus-5-5
2026-10-14 21:05:48 +02: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
clawbot efca0ebb39 Read --index and --words as decimal numbers (closes #75)
check / check (push) Failing after 3m40s
Both flags were uint32 flags, which the flag library reads with base-0 rules, so `-n 010` derived key 8 and `0x10` or `1_0` were accepted. Both now hold options.Decimal, a flag value whose Set parses with strconv.ParseUint(text, 10, 32): a leading zero stays decimal, and a base prefix, an underscore, a sign or an empty value is refused with an error naming the flag while the flags are parsed, before any command runs. Its type is still named uint32, so --help is unchanged and GetUint32 still reads it back. The README says the index is a decimal number.

Model: opus-5-5
2026-10-13 02:29:25 +02:00
clawbot 45463fa35b Require secret at the commit that drops go-bip39 (closes #70)
check / check (push) Successful in 5m8s
sneak/secret now carries its own copy of go-bip39
(sneak/secret#130), so requiring it at that
commit, ef0ae90, lets go mod tidy drop go-bip39 from go.sum. Earlier,
secret renamed its module to sneak.berlin/go/secret
(sneak/secret#43), so the requirement, the
two imports of its bip85 package and the README sentence naming that
package move to the new path. The bip85 functions keyfunc calls are
unchanged, and no other requirement moves.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-07 13:43:37 +02:00
23 changed files with 887 additions and 116 deletions
+41 -18
View File
@@ -100,7 +100,9 @@ m/83696968'/<app>'/<n>'
to one tool. Each key type's number is spelled the way BIP-85 spells its own to one tool. Each key type's number is spelled the way BIP-85 spells its own
RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out): RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out):
SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`). SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`).
- `n` is the key index: flag `--index` / `-n`, default `0`. - `n` is the key index, a decimal number: flag `--index` / `-n`, default `0`. A
leading zero is still decimal (`-n 010` is index 10); a base prefix, an
underscore or a sign (`0x10`, `1_0`, `+1`) is refused.
## Giving it the mnemonic ## Giving it the mnemonic
@@ -114,17 +116,31 @@ 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. 4. A prompt on the terminal with echo turned off. The mnemonic is what is typed
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 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.
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
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
directory or a symlink to one, if its directory is missing or is not a
directory, or if it is a symlink that points at nothing.
`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
@@ -217,11 +233,15 @@ first, or pass `-o StrictHostKeyChecking=accept-new` after `--`.
Derives the key, serves it from an SSH agent that runs inside the tool on a unix Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh` socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining with `-o IdentityAgent=<that socket> -o IdentitiesOnly=no` followed by the host
arguments unchanged. The tool exits with `ssh`'s exit status and removes the and all remaining arguments unchanged. Without `IdentitiesOnly=no`, an
socket and directory on the way out. The private key is never written to disk. A `IdentitiesOnly yes` in the user's `ssh` configuration keeps the derived key
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory, from being offered. The agent holds no other key, so turning it off adds nothing
and the tool then exits with status 1 unless `ssh` reported one of its own. else to what `ssh` offers. The tool exits with `ssh`'s exit status and removes
the socket and directory on the way out. The private key is never written to
disk. A SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and
directory, and the tool then exits with status 1 unless `ssh` reported one of
its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
@@ -269,7 +289,9 @@ 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. not one of the recipients, the tool says so and exits with status 1. The text
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`
@@ -306,10 +328,11 @@ 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).
While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a At the prompt the terminal's settings are put back as they were first, so echo
regular one, the signal makes it remove the unfinished file, leave a file is on again. While `age encrypt -o` or `age decrypt -o` is writing a new file or
already at the named path as it was, and exit with status 1. That holds for a replacing a regular one, the signal makes it remove the unfinished file, leave a
signal that has reached `keyfunc` when its input ends; a later one leaves the file already at the named path as it was, and exit with status 1. That holds for
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
+2 -1
View File
@@ -6,9 +6,11 @@ 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
) )
@@ -22,5 +24,4 @@ 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,6 +13,8 @@ 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=
+19 -15
View File
@@ -4,6 +4,7 @@ package agekey
import ( import (
"bufio" "bufio"
"bytes"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -94,15 +95,12 @@ func (k *Key) Identity() string {
} }
// Encrypt copies src to dst, encrypted to this key and to every extra // Encrypt copies src to dst, encrypted to this key and to every extra
// recipient named, so the same mnemonic can always read it back again. // recipient given, so the same mnemonic can always read it back again.
// Armored output is the text form age also reads. // Armored output is the text form age also reads.
func (k *Key) Encrypt( func (k *Key) Encrypt(
dst io.Writer, src io.Reader, to []string, armored bool, dst io.Writer, src io.Reader, to []age.Recipient, armored bool,
) error { ) error {
all, err := recipients(k.identity.Recipient(), to) all := append([]age.Recipient{k.identity.Recipient()}, to...)
if err != nil {
return err
}
out := dst out := dst
@@ -113,7 +111,7 @@ func (k *Key) Encrypt(
out = text out = text
} }
err = encrypt(out, src, all) err := encrypt(out, src, all)
if err != nil { if err != nil {
return err return err
} }
@@ -130,12 +128,12 @@ func (k *Key) Encrypt(
return nil return nil
} }
// recipients returns the key's own recipient followed by the ones // Recipients reads the recipients named on the command line, for
// named on the command line, so what the key encrypts it can read. // Encrypt to add to the key's own.
func recipients(mine age.Recipient, to []string) ([]age.Recipient, error) { func Recipients(names []string) ([]age.Recipient, error) {
list := []age.Recipient{mine} list := make([]age.Recipient, 0, len(names))
for _, name := range to { for _, name := range names {
parsed, err := age.ParseX25519Recipient(name) parsed, err := age.ParseX25519Recipient(name)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading recipient %q: %w", name, err) return nil, fmt.Errorf("reading recipient %q: %w", name, err)
@@ -190,12 +188,18 @@ 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. // that starts one, and leaves binary input alone. Like age, it looks
// 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)
start, err := buffered.Peek(len(armor.Header)) // A shorter input gives an error along with all of it, which is
if err == nil && string(start) == armor.Header { // enough to look at.
start, _ := buffered.Peek(maxWhitespace + len(armor.Header))
if bytes.HasPrefix(bytes.TrimSpace(start), []byte(armor.Header)) {
return armor.NewReader(buffered) return armor.NewReader(buffered)
} }
+27 -8
View File
@@ -2,6 +2,7 @@ package agekey_test
import ( import (
"bytes" "bytes"
"io"
"strings" "strings"
"testing" "testing"
@@ -99,6 +100,27 @@ 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()
@@ -119,12 +141,12 @@ func TestAnExtraRecipientCanReadItTooAndSoCanTheDerivedOne(t *testing.T) {
mine, theirs := forIndex(t, 0), forIndex(t, 1) mine, theirs := forIndex(t, 0), forIndex(t, 1)
to, err := agekey.Recipients([]string{theirs.Recipient()})
require.NoError(t, err)
var sealed bytes.Buffer var sealed bytes.Buffer
err := mine.Encrypt( err = mine.Encrypt(&sealed, strings.NewReader("hello"), to, false)
&sealed, strings.NewReader("hello"),
[]string{theirs.Recipient()}, false,
)
require.NoError(t, err) require.NoError(t, err)
for _, key := range []*agekey.Key{mine, theirs} { for _, key := range []*agekey.Key{mine, theirs} {
@@ -138,10 +160,7 @@ func TestAnExtraRecipientCanReadItTooAndSoCanTheDerivedOne(t *testing.T) {
func TestARecipientThatIsNotOneIsRefused(t *testing.T) { func TestARecipientThatIsNotOneIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
err := forIndex(t, 0).Encrypt( _, err := agekey.Recipients([]string{"not a recipient"})
&bytes.Buffer{}, strings.NewReader("hello"),
[]string{"not a recipient"}, false,
)
require.Error(t, err) require.Error(t, err)
} }
+15 -5
View File
@@ -29,6 +29,17 @@ const DefaultWords = twelve
// ErrWordCount is returned for a length this tool does not offer. // ErrWordCount is returned for a length this tool does not offer.
var ErrWordCount = errors.New("a child mnemonic has 12, 18 or 24 words") var ErrWordCount = errors.New("a child mnemonic has 12, 18 or 24 words")
// CheckWords refuses a length this tool does not offer, so that the
// command can turn it down before it looks for the mnemonic.
func CheckWords(words uint32) error {
switch words {
case twelve, eighteen, twentyFour:
return nil
default:
return fmt.Errorf("%w, not %d", ErrWordCount, words)
}
}
// Derive returns the English child mnemonic of that many words at that // Derive returns the English child mnemonic of that many words at that
// key index, taken from the master key with BIP-85's own mnemonic // key index, taken from the master key with BIP-85's own mnemonic
// application, number 39. The words come straight from the BIP-85 // application, number 39. The words come straight from the BIP-85
@@ -38,13 +49,12 @@ func Derive(
master *hdkeychain.ExtendedKey, master *hdkeychain.ExtendedKey,
words, index uint32, words, index uint32,
) (string, error) { ) (string, error) {
switch words { err := CheckWords(words)
case twelve, eighteen, twentyFour: if err != nil {
default: return "", err
return "", fmt.Errorf("%w, not %d", ErrWordCount, words)
} }
err := derive.CheckIndex(index) err = derive.CheckIndex(index)
if err != nil { if err != nil {
return "", err return "", err
} }
+100 -27
View File
@@ -11,6 +11,7 @@ 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"
@@ -108,47 +109,30 @@ func decrypt() *cobra.Command {
} }
// runEncrypt encrypts to the derived recipient and any others given. // runEncrypt encrypts to the derived recipient and any others given.
// The recipients are read, the -o path looked at and the input opened
// before the key is derived, so that a run refused for any of them
// never asks for the mnemonic.
func runEncrypt(cmd *cobra.Command, args []string) error { func runEncrypt(cmd *cobra.Command, args []string) error {
key, err := derived(cmd) names, err := cmd.Flags().GetStringArray("to")
if err != nil {
return err
}
to, err := cmd.Flags().GetStringArray("to")
if err != nil { if err != nil {
return fmt.Errorf("reading the recipients: %w", err) return fmt.Errorf("reading the recipients: %w", err)
} }
to, err := agekey.Recipients(names)
if err != nil {
return err
}
armored, err := cmd.Flags().GetBool("armor") armored, err := cmd.Flags().GetBool("armor")
if err != nil { if err != nil {
return fmt.Errorf("reading the armor flag: %w", err) return fmt.Errorf("reading the armor flag: %w", err)
} }
return through(cmd, args, func(dst io.Writer, src io.Reader) error { err = checkOutput(cmd)
return key.Encrypt(dst, src, to, armored)
})
}
// runDecrypt decrypts with the derived identity.
func runDecrypt(cmd *cobra.Command, args []string) error {
key, err := derived(cmd)
if err != nil { if err != nil {
return err return err
} }
return through(cmd, args, key.Decrypt)
}
// through opens the input the arguments ask for and hands it to the
// work, with the file --output names to write to, or the command's own
// output when it names none or names the same file as that output, and
// the command's own error output when it names the same file as that.
// Those two are the streams the tool already has, so whatever they are
// redirected to is written as the redirect says, never replaced.
func through(
cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error,
) error {
src, closeSrc, err := input(cmd, args) src, closeSrc, err := input(cmd, args)
if err != nil { if err != nil {
return err return err
@@ -156,6 +140,49 @@ func through(
defer closeSrc() defer closeSrc()
key, err := derived(cmd)
if err != nil {
return err
}
return through(cmd, src, func(dst io.Writer, plain io.Reader) error {
return key.Encrypt(dst, plain, to, armored)
})
}
// runDecrypt decrypts with the derived identity. The -o path is looked
// at and the input opened before the key is derived, as for runEncrypt.
func runDecrypt(cmd *cobra.Command, args []string) error {
err := checkOutput(cmd)
if err != nil {
return err
}
src, closeSrc, err := input(cmd, args)
if err != nil {
return err
}
defer closeSrc()
key, err := derived(cmd)
if err != nil {
return err
}
return through(cmd, src, key.Decrypt)
}
// through hands the input to the work, with the file --output names to
// write to, or the command's own output when it names none or names the
// same file as that output, and the command's own error output when it
// names the same file as that. Those two are the streams the tool
// already has, so whatever they are redirected to is written as the
// redirect says, never replaced.
func through(
cmd *cobra.Command, src io.Reader,
work func(io.Writer, io.Reader) error,
) error {
name, err := cmd.Flags().GetString("output") name, err := cmd.Flags().GetString("output")
if err != nil { if err != nil {
return fmt.Errorf("reading the output file: %w", err) return fmt.Errorf("reading the output file: %w", err)
@@ -209,6 +236,52 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
return file, func() { _ = file.Close() }, nil return file, func() { _ = file.Close() }, nil
} }
// checkOutput refuses an -o path that output could not write to,
// before anything is written: a directory or a symlink to one, one
// whose directory is missing or is not a directory, or a symlink that
// points at nothing. It creates nothing; output looks at the path again
// when it writes.
func checkOutput(cmd *cobra.Command) error {
name, err := cmd.Flags().GetString("output")
if err != nil {
return fmt.Errorf("reading the output file: %w", err)
}
if name == "" {
return nil
}
info, err := os.Lstat(name)
switch {
case errors.Is(err, fs.ErrNotExist):
// replace makes its new file in this directory. When the
// directory is a file instead, os.Lstat has already failed with
// ENOTDIR, the case below.
directory := filepath.Dir(name)
_, err = os.Stat(directory)
if err != nil {
return fmt.Errorf("looking at %s: %w", directory, err)
}
return nil
case err != nil:
return fmt.Errorf("looking at %s: %w", name, err)
case info.Mode().Type() == fs.ModeSymlink:
info, err = os.Stat(name)
if err != nil {
return fmt.Errorf("following %s: %w", name, err)
}
}
if info.IsDir() {
return fmt.Errorf("writing to %s: %w", name, syscall.EISDIR)
}
return nil
}
// output has the work write to the named path, going by what is there // output has the work write to the named path, going by what is there
// without following a final symlink: // without following a final symlink:
// //
+77
View File
@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/agekey"
"sneak.berlin/go/keyfunc/internal/cli" "sneak.berlin/go/keyfunc/internal/cli"
"sneak.berlin/go/keyfunc/internal/cli/age" "sneak.berlin/go/keyfunc/internal/cli/age"
"sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/mnemonic" "sneak.berlin/go/keyfunc/internal/mnemonic"
) )
@@ -69,6 +70,82 @@ func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
) )
} }
// The mnemonic command fails in these runs, so the refusal of the index
// shows that it was checked before the mnemonic was looked for.
func TestAnAgeIndexWithNoHardenedChildIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
for _, command := range []string{"pub", "priv", "encrypt", "decrypt"} {
out, err := execute(t, "age", command, "-n", tooLarge)
require.ErrorIs(t, err, derive.ErrIndexTooLarge, command)
require.Empty(t, out, command)
}
}
// The mnemonic command fails in this run, so the refusal of the
// recipient shows that it was read before the mnemonic was looked for.
func TestAnEncryptionToARecipientThatIsNotOneIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
plain := written(t, "notes.txt", "the secret\n")
out, err := execute(t, "age", "encrypt", "--to", "bogus", plain)
require.ErrorContains(t, err, `reading recipient "bogus"`)
require.Empty(t, out)
}
// The mnemonic command fails in these runs, so the refusal of the input
// file shows that it was opened before the mnemonic was looked for.
func TestAnInputFileThatCannotBeOpenedIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
missing := filepath.Join(t.TempDir(), "missing")
for _, operation := range []string{"encrypt", "decrypt"} {
out, err := execute(t, "age", operation, missing)
require.ErrorIs(t, err, fs.ErrNotExist, operation)
require.Empty(t, out, operation)
}
}
// The mnemonic command fails in these runs, so the refusal of the -o
// path shows that it was looked at before the mnemonic was looked for.
func TestAnOutputPathThatCannotBeWrittenIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
plain := written(t, "notes.txt", "the secret\n")
directory := t.TempDir()
missing := filepath.Join(directory, "missing")
dangling := filepath.Join(directory, "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{
filepath.Join(missing, "notes.age"): fs.ErrNotExist,
filepath.Join(plain, "notes.age"): syscall.ENOTDIR,
dangling: fs.ErrNotExist,
directory: syscall.EISDIR,
toDirectory: syscall.EISDIR,
}
for name, refusal := range refusals {
out, err := execute(t, "age", "encrypt", "-o", name, plain)
require.ErrorIs(t, err, refusal, name)
require.Empty(t, out, name)
out, err = execute(t, "age", "decrypt", "-o", name, plain)
require.ErrorIs(t, err, refusal, name)
require.Empty(t, out, name)
}
// Nothing was made beside the two symlinks.
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Len(t, left, 2)
}
func TestAFileForAnotherKeyIsRefused(t *testing.T) { func TestAFileForAnotherKeyIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
+3
View File
@@ -87,6 +87,9 @@ 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 {
+94 -4
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -33,6 +34,14 @@ const (
twentyFour = 24 twentyFour = 24
) )
// tooLarge is the smallest key index with no hardened child.
const tooLarge = "2147483648"
// failingMnemonicCommand fails loudly, so a run that looks for the
// mnemonic ends with that failure. A run that ends with the refusal of
// an argument instead shows that the argument was checked first.
const failingMnemonicCommand = "echo the mnemonic was looked for >&2; exit 1"
// example returns the mnemonic the README gives its test vectors for: // example returns the mnemonic the README gives its test vectors for:
// eleven abandons and about. // eleven abandons and about.
func example() string { func example() string {
@@ -98,12 +107,75 @@ func TestThePrivateKeyMatchesThePublicOne(t *testing.T) {
require.Equal(t, vectorOne, back) require.Equal(t, vectorOne, back)
} }
// The mnemonic command fails in these runs, so the refusal of the index
// shows that it was checked before the mnemonic was looked for. The age
// commands have a test of their own.
func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
for _, args := range [][]string{
{subcommand, "pub", "-n", tooLarge},
{subcommand, "priv", "-n", tooLarge},
{subcommand, installing, "-n", tooLarge, host},
{subcommand, "to", "-n", tooLarge, host},
{"mnemonic", "-n", tooLarge},
} {
name := strings.Join(args, " ")
out, err := execute(t, args...)
require.ErrorIs(t, err, derive.ErrIndexTooLarge, name)
require.Empty(t, out, name)
}
}
func TestAnIndexWithALeadingZeroIsDecimal(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
out, err := execute(t, "ssh", "pub", "-n", "2147483648") line := run(t, "ssh", "pub", "-n", "010")
require.ErrorIs(t, err, derive.ErrIndexTooLarge) require.Equal(t, run(t, "ssh", "pub", "-n", "10"), line)
require.Empty(t, out) require.True(t,
strings.HasSuffix(strings.TrimSpace(line), " keyfunc/ssh/10"),
)
}
// The mnemonic command in these runs fails, so a refusal naming the
// flag shows that the number was refused before the mnemonic was
// looked for.
func TestAnIndexThatIsNotDecimalDigitsIsRefused(t *testing.T) {
t.Parallel()
for _, index := range []string{"0x10", "1_0", "+1", "-1", ""} {
t.Run(index, func(t *testing.T) {
t.Parallel()
out, err := execute(t,
"ssh", "pub", "-n", index, "--mnemonic-command", "exit 1",
)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--index")
require.Empty(t, out)
})
}
}
// Neither a command group nor --version reads the index, so these
// show that it is refused while the flags are parsed.
func TestAnIndexNotInDecimalIsRefusedBeforeAnyCommandRuns(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"ssh", "-n", "0x10"},
{"-n", "0x10", "--version"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Parallel()
out, err := execute(t, args...)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--index")
require.Empty(t, out)
})
}
} }
func TestTheMnemonicCommandIsUsed(t *testing.T) { func TestTheMnemonicCommandIsUsed(t *testing.T) {
@@ -130,14 +202,32 @@ func TestAChildMnemonicIsPrinted(t *testing.T) {
require.NotEqual(t, short, next) require.NotEqual(t, short, next)
} }
// The mnemonic command fails in this run, so the refusal of the length
// shows that it was checked before the mnemonic was looked for.
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) { func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
out, err := execute(t, "mnemonic", "--words", "15") out, err := execute(t, "mnemonic", "--words", "15")
require.ErrorIs(t, err, childmnemonic.ErrWordCount) require.ErrorIs(t, err, childmnemonic.ErrWordCount)
require.Empty(t, out) require.Empty(t, out)
} }
func TestTheWordCountIsDecimal(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
words := strings.Fields(run(t, "mnemonic", "--words", "012"))
require.Len(t, words, twelve)
// As for the index, the failing mnemonic command shows that the
// number was refused first.
out, err := execute(t,
"mnemonic", "--words", "0x18", "--mnemonic-command", "exit 1",
)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--words")
require.Empty(t, out)
}
// run executes the tool with the given arguments and returns what it // run executes the tool with the given arguments and returns what it
// wrote to standard output. // wrote to standard output.
func run(t *testing.T, args ...string) string { func run(t *testing.T, args ...string) string {
+8 -2
View File
@@ -31,8 +31,9 @@ func Command() *cobra.Command {
}, },
} }
cmd.Flags().Uint32( words := options.Decimal(childmnemonic.DefaultWords)
"words", childmnemonic.DefaultWords, cmd.Flags().Var(
&words, "words",
"how long the child mnemonic is: 12, 18 or 24 words", "how long the child mnemonic is: 12, 18 or 24 words",
) )
@@ -51,6 +52,11 @@ func derived(cmd *cobra.Command) (string, error) {
return "", fmt.Errorf("reading the word count: %w", err) return "", fmt.Errorf("reading the word count: %w", err)
} }
err = childmnemonic.CheckWords(words)
if err != nil {
return "", err
}
parent, err := options.Mnemonic(cmd) parent, err := options.Mnemonic(cmd)
if err != nil { if err != nil {
return "", err return "", err
+42 -3
View File
@@ -4,8 +4,10 @@ package options
import ( import (
"fmt" "fmt"
"strconv"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/mnemonic" "sneak.berlin/go/keyfunc/internal/mnemonic"
) )
@@ -16,19 +18,26 @@ func Add(cmd *cobra.Command) {
"mnemonic-command", "", "mnemonic-command", "",
"shell command whose output is the mnemonic", "shell command whose output is the mnemonic",
) )
cmd.PersistentFlags().Uint32P( cmd.PersistentFlags().VarP(
"index", "n", 0, new(Decimal), "index", "n",
"which key to derive", "which key to derive",
) )
} }
// Index returns the key index the user asked for. // Index returns the key index the user asked for, and refuses one above
// derive.MaxIndex. Every command calls it before it looks for the
// mnemonic.
func Index(cmd *cobra.Command) (uint32, error) { func Index(cmd *cobra.Command) (uint32, error) {
index, err := cmd.Flags().GetUint32("index") index, err := cmd.Flags().GetUint32("index")
if err != nil { if err != nil {
return 0, fmt.Errorf("reading the index: %w", err) return 0, fmt.Errorf("reading the index: %w", err)
} }
err = derive.CheckIndex(index)
if err != nil {
return 0, err
}
return index, nil return index, nil
} }
@@ -41,3 +50,33 @@ func Mnemonic(cmd *cobra.Command) (string, error) {
return mnemonic.Read(cmd.Context(), command) return mnemonic.Read(cmd.Context(), command)
} }
// Decimal is a flag value for a number written in decimal digits only.
// The flag library's own uint32 flags read 010 as octal 8 and also
// take 0x10 or 1_0.
type Decimal uint32
// String returns the number in decimal digits.
func (d *Decimal) String() string {
return strconv.FormatUint(uint64(*d), 10)
}
// Set reads text as a decimal number that fits in 32 bits: a leading
// zero is still decimal, and a base prefix, an underscore or a sign is
// refused.
func (d *Decimal) Set(text string) error {
number, err := strconv.ParseUint(text, 10, 32)
if err != nil {
return err
}
*d = Decimal(number)
return nil
}
// Type is what --help shows as the flag's type, and what GetUint32
// checks before it reads the value back.
func (d *Decimal) Type() string {
return "uint32"
}
+133
View File
@@ -0,0 +1,133 @@
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
}
+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(
+7 -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.
@@ -57,8 +58,13 @@ func to() *cobra.Command {
defer served.Stop() defer served.Stop()
// IdentitiesOnly yes in the user's ssh configuration
// would keep the agent's key from being offered. The
// agent holds only the derived key, so turning it off
// adds no other key.
argv := slices.Concat([]string{ argv := slices.Concat([]string{
"-o", "IdentityAgent=" + served.Socket(), "-o", "IdentityAgent=" + served.Socket(),
"-o", "IdentitiesOnly=no",
}, args) }, args)
return connect(ctx, argv) return connect(ctx, argv)
@@ -79,7 +85,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
+21 -1
View File
@@ -587,7 +587,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, remoteCommand}, given[2:]) require.Equal(t, []string{host, remoteCommand}, given[4:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -596,6 +596,26 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
require.NoDirExists(t, filepath.Dir(socket)) require.NoDirExists(t, filepath.Dir(socket))
} }
func TestIdentitiesOnlyIsTurnedOffBeforeTheHost(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
arguments, _ := pretendCall(t)
_, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError
require.ErrorAs(t, err, &passed)
// IdentitiesOnly yes in the user's ssh configuration keeps ssh
// from offering the agent's key. An option given before the host
// wins over the configuration.
given := recorded(t, arguments)
require.Equal(t,
[]string{"-o", "IdentitiesOnly=no", host, remoteCommand}, given[2:],
)
}
func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
+71 -7
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())
@@ -93,7 +136,7 @@ func ask() (string, error) {
fmt.Fprint(os.Stderr, "mnemonic: ") fmt.Fprint(os.Stderr, "mnemonic: ")
typed, err := term.ReadPassword(fd) typed, err := readWithoutEcho()
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
@@ -101,18 +144,39 @@ func ask() (string, error) {
return "", fmt.Errorf("reading the mnemonic: %w", err) return "", fmt.Errorf("reading the mnemonic: %w", err)
} }
return checked(string(typed)) return checked(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 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
} }
+80
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, "")
@@ -70,6 +97,27 @@ 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())
@@ -78,6 +126,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, "")
+18
View File
@@ -0,0 +1,18 @@
//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
}
+101
View File
@@ -0,0 +1,101 @@
//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/cli/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 arrives 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)
}
restore := func() {
_ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings)
}
defer restore()
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)
}()
select {
case <-read:
case ending := <-received:
restore()
signal.Stop(received)
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
@@ -0,0 +1,12 @@
//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
@@ -0,0 +1,12 @@
//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
)