Skeleton, mnemonic input, derivation, and the ssh commands (closes #1)
All checks were successful
check / check (push) Successful in 34s
All checks were successful
check / check (push) Successful in 34s
The tool derives ed25519 SSH keys from a BIP-39 mnemonic and prints them, either as an authorized_keys line or as an unencrypted OpenSSH private key. Both README test vectors are asserted in the tests. The mnemonic is looked for in the order the README gives, and refused when it fails its checksum or when there is nowhere left to look. The repository standards come with it: the vendored linter configuration and policies, the script/ entrypoints with a thin Makefile, and a Gitea workflow. Linting happens only inside the image built from Dockerfile.lint, which pins the linter by hash, so the root Dockerfile runs the formatting check, the tests and the build, and script/cibuild runs the linter before it. Model: opus-5
This commit is contained in:
117
internal/mnemonic/mnemonic.go
Normal file
117
internal/mnemonic/mnemonic.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package mnemonic finds the mnemonic the keys are derived from.
|
||||
package mnemonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
// CommandVariable holds a shell command whose output is the
|
||||
// mnemonic.
|
||||
CommandVariable = "KEYFUNC_MNEMONIC_COMMAND"
|
||||
|
||||
// Variable holds the mnemonic itself.
|
||||
Variable = "KEYFUNC_MNEMONIC"
|
||||
)
|
||||
|
||||
// ErrMissing is returned when there is nowhere left to look and
|
||||
// standard input is not a terminal, so there is nobody to ask.
|
||||
var ErrMissing = errors.New(
|
||||
"no mnemonic given and standard input is not a terminal",
|
||||
)
|
||||
|
||||
// ErrChecksum is returned for a mnemonic that fails the BIP-39
|
||||
// checksum.
|
||||
var ErrChecksum = errors.New("the mnemonic fails its BIP-39 checksum")
|
||||
|
||||
// Read returns the mnemonic. The command given on the command line is
|
||||
// used first; then the command in KEYFUNC_MNEMONIC_COMMAND; then the
|
||||
// mnemonic in KEYFUNC_MNEMONIC; then a prompt on the terminal with
|
||||
// echo turned off. The first source that has one wins.
|
||||
func Read(ctx context.Context, command string) (string, error) {
|
||||
if command == "" {
|
||||
command = os.Getenv(CommandVariable)
|
||||
}
|
||||
|
||||
if command != "" {
|
||||
words, err := run(ctx, command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return checked(words)
|
||||
}
|
||||
|
||||
if words := os.Getenv(Variable); words != "" {
|
||||
return checked(words)
|
||||
}
|
||||
|
||||
return ask()
|
||||
}
|
||||
|
||||
// run executes the command with sh and returns its standard output.
|
||||
// If the command fails, its standard error becomes part of the error.
|
||||
func run(ctx context.Context, command string) (string, error) {
|
||||
//nolint:gosec // running the user's own command is the point
|
||||
shell := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
|
||||
var complaint bytes.Buffer
|
||||
|
||||
shell.Stderr = &complaint
|
||||
|
||||
out, err := shell.Output()
|
||||
if err != nil {
|
||||
said := strings.TrimSpace(complaint.String())
|
||||
if said == "" {
|
||||
return "", fmt.Errorf("the mnemonic command failed: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"the mnemonic command failed: %w: %s", err, said,
|
||||
)
|
||||
}
|
||||
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ask prompts on the terminal with echo turned off.
|
||||
func ask() (string, error) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
return "", ErrMissing
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, "mnemonic: ")
|
||||
|
||||
typed, err := term.ReadPassword(fd)
|
||||
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return checked(string(typed))
|
||||
}
|
||||
|
||||
// checked drops the surrounding whitespace and refuses a mnemonic that
|
||||
// does not pass the BIP-39 checksum.
|
||||
func checked(words string) (string, error) {
|
||||
words = strings.TrimSpace(words)
|
||||
|
||||
if !bip39.IsMnemonicValid(words) {
|
||||
return "", ErrChecksum
|
||||
}
|
||||
|
||||
return words, nil
|
||||
}
|
||||
87
internal/mnemonic/mnemonic_test.go
Normal file
87
internal/mnemonic/mnemonic_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package mnemonic_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Two more mnemonics that pass the checksum, so a test can tell which
|
||||
// source an answer came from.
|
||||
const (
|
||||
fromCommandVariable = "legal winner thank year wave sausage worth " +
|
||||
"useful legal winner thank yellow"
|
||||
fromVariable = "letter advice cage absurd amount doctor acoustic " +
|
||||
"avoid letter advice cage above"
|
||||
)
|
||||
|
||||
// example returns the mnemonic every BIP-39 document uses to show its
|
||||
// test vectors: eleven abandons and about.
|
||||
func example() string {
|
||||
return strings.Repeat("abandon ", 11) + "about"
|
||||
}
|
||||
|
||||
// broken returns a mnemonic whose last word does not match the rest.
|
||||
func broken() string {
|
||||
return strings.TrimSpace(strings.Repeat("abandon ", 12))
|
||||
}
|
||||
|
||||
// prints builds a shell command that writes the given words padded
|
||||
// with spaces, so the test also shows that the padding is dropped.
|
||||
func prints(words string) string {
|
||||
return "printf ' %s \\n' '" + words + "'"
|
||||
}
|
||||
|
||||
func TestTheCommandOnTheCommandLineWins(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable))
|
||||
t.Setenv(mnemonic.Variable, fromVariable)
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), prints(example()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, example(), words)
|
||||
}
|
||||
|
||||
func TestTheCommandInTheEnvironmentComesNext(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable))
|
||||
t.Setenv(mnemonic.Variable, fromVariable)
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fromCommandVariable, words)
|
||||
}
|
||||
|
||||
func TestTheMnemonicInTheEnvironmentComesLast(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, " "+fromVariable+" ")
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fromVariable, words)
|
||||
}
|
||||
|
||||
func TestAFailingCommandSaysWhatWentWrong(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "echo nothing here >&2; exit 3")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nothing here")
|
||||
}
|
||||
|
||||
func TestABadChecksumIsRefused(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, broken())
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "")
|
||||
require.ErrorIs(t, err, mnemonic.ErrChecksum)
|
||||
}
|
||||
|
||||
func TestNothingToReadAndNobodyToAsk(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "")
|
||||
require.ErrorIs(t, err, mnemonic.ErrMissing)
|
||||
}
|
||||
Reference in New Issue
Block a user