Skeleton, mnemonic input, derivation, and the ssh pub and priv commands (closes #1)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
The module, the script entrypoints and Makefile, Docker-only linting, the mnemonic sources in the specified order with their refusals, the BIP-85 derivation, and keyfunc ssh pub and priv with the README test vectors as tests. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #5.
This commit is contained in:
48
internal/cli/cli.go
Normal file
48
internal/cli/cli.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package cli builds the command tree and runs it.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version is what --version prints. The build sets it.
|
||||
//
|
||||
//nolint:gochecknoglobals // set at build time with -ldflags
|
||||
var Version = "dev"
|
||||
|
||||
// Root returns the whole command tree.
|
||||
func Root() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "keyfunc",
|
||||
Short: "derive key pairs from a BIP-39 mnemonic",
|
||||
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
|
||||
"be recreated from that mnemonic at any time. The same " +
|
||||
"mnemonic, key type and index always give the same key.",
|
||||
Version: Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
options.Add(root)
|
||||
root.AddCommand(ssh.Command())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// Main runs the tool and returns the status the process should exit
|
||||
// with.
|
||||
func Main() int {
|
||||
err := Root().Execute()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
110
internal/cli/cli_test.go
Normal file
110
internal/cli/cli_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// The two lines the README says the example mnemonic produces.
|
||||
const (
|
||||
vectorZero = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZOtOczrc/7CQytc" +
|
||||
"uFwt7s4r8KjkZWkwjLZWBaFKD+7"
|
||||
vectorOne = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEWY8+/gmHYVC4u0Y" +
|
||||
"0I4FKs+eVUulTPHfk9VtXw1tMF"
|
||||
)
|
||||
|
||||
// example returns the mnemonic the README gives its test vectors for:
|
||||
// eleven abandons and about.
|
||||
func example() string {
|
||||
return strings.Repeat("abandon ", 11) + "about"
|
||||
}
|
||||
|
||||
func TestTheReadmeTestVectors(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
require.Equal(t,
|
||||
vectorZero+" keyfunc/ssh/0",
|
||||
strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")),
|
||||
)
|
||||
require.Equal(t,
|
||||
vectorOne+" keyfunc/ssh/1",
|
||||
strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")),
|
||||
)
|
||||
}
|
||||
|
||||
func TestTheCommentCanBeChosen(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
line := strings.TrimSpace(run(t, "ssh", "pub", "--comment", "mine"))
|
||||
require.Equal(t, vectorZero+" mine", line)
|
||||
}
|
||||
|
||||
func TestThePrivateKeyMatchesThePublicOne(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
block := run(t, "ssh", "priv", "-n", "1")
|
||||
require.True(t,
|
||||
strings.HasPrefix(block, "-----BEGIN OPENSSH PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
parsed, err := ssh.ParsePrivateKey([]byte(block))
|
||||
require.NoError(t, err)
|
||||
|
||||
back := strings.TrimSpace(
|
||||
string(ssh.MarshalAuthorizedKey(parsed.PublicKey())),
|
||||
)
|
||||
require.Equal(t, vectorOne, back)
|
||||
}
|
||||
|
||||
func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
out, err := execute(t, "ssh", "pub", "-n", "2147483648")
|
||||
require.ErrorIs(t, err, derive.ErrIndexTooLarge)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
||||
func TestTheMnemonicCommandIsUsed(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
|
||||
line := strings.TrimSpace(run(t,
|
||||
"ssh", "pub",
|
||||
"--mnemonic-command", "printf '%s\\n' '"+example()+"'",
|
||||
))
|
||||
require.Equal(t, vectorZero+" keyfunc/ssh/0", line)
|
||||
}
|
||||
|
||||
// run executes the tool with the given arguments and returns what it
|
||||
// wrote to standard output.
|
||||
func run(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
out, err := execute(t, args...)
|
||||
require.NoError(t, err)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// execute runs the tool and returns both what it wrote and how it
|
||||
// ended.
|
||||
func execute(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
root := cli.Root()
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
|
||||
err := root.ExecuteContext(t.Context())
|
||||
|
||||
return out.String(), err
|
||||
}
|
||||
43
internal/cli/options/options.go
Normal file
43
internal/cli/options/options.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Package options holds the flags that every command has and reads
|
||||
// them back.
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Add gives a command the flags that every command has. They are
|
||||
// persistent, so every command below it has them too.
|
||||
func Add(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().String(
|
||||
"mnemonic-command", "",
|
||||
"shell command whose output is the mnemonic",
|
||||
)
|
||||
cmd.PersistentFlags().Uint32P(
|
||||
"index", "n", 0,
|
||||
"which key to derive",
|
||||
)
|
||||
}
|
||||
|
||||
// Index returns the key index the user asked for.
|
||||
func Index(cmd *cobra.Command) (uint32, error) {
|
||||
index, err := cmd.Flags().GetUint32("index")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading the index: %w", err)
|
||||
}
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// Mnemonic returns the mnemonic, from the first source that has one.
|
||||
func Mnemonic(cmd *cobra.Command) (string, error) {
|
||||
command, err := cmd.Flags().GetString("mnemonic-command")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the mnemonic command: %w", err)
|
||||
}
|
||||
|
||||
return mnemonic.Read(cmd.Context(), command)
|
||||
}
|
||||
127
internal/cli/ssh/ssh.go
Normal file
127
internal/cli/ssh/ssh.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Package ssh groups the commands that derive ed25519 SSH keys.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Command returns the ssh command and everything under it.
|
||||
func Command() *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
Use: "ssh",
|
||||
Short: "derive ed25519 SSH keys",
|
||||
}
|
||||
|
||||
group.AddCommand(public(), private())
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
// public returns the command that prints the authorized_keys line.
|
||||
func public() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "pub",
|
||||
Short: "print the public key as an authorized_keys line",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
key, comment, err := derived(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := key.Line(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return write(cmd, line+"\n")
|
||||
},
|
||||
}
|
||||
|
||||
addComment(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// private returns the command that prints the private key.
|
||||
func private() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "priv",
|
||||
Short: "print the unencrypted private key in OpenSSH format",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
key, comment, err := derived(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := key.Block(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return write(cmd, block)
|
||||
},
|
||||
}
|
||||
|
||||
addComment(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// write sends the text to wherever the command's output goes.
|
||||
func write(cmd *cobra.Command, text string) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing the key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addComment gives a command its comment flag.
|
||||
func addComment(cmd *cobra.Command) {
|
||||
cmd.Flags().String(
|
||||
"comment", "",
|
||||
"comment on the key; keyfunc/ssh/<index> when not given",
|
||||
)
|
||||
}
|
||||
|
||||
// derived returns the key for this run and the comment to put on it.
|
||||
func derived(cmd *cobra.Command) (*sshkey.Key, string, error) {
|
||||
index, err := options.Index(cmd)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
words, err := options.Mnemonic(cmd)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
material, err := derive.Bytes(words, sshkey.Application, index)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
key, err := sshkey.New(material)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
comment, err := cmd.Flags().GetString("comment")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reading the comment: %w", err)
|
||||
}
|
||||
|
||||
if comment == "" {
|
||||
comment = fmt.Sprintf("keyfunc/ssh/%d", index)
|
||||
}
|
||||
|
||||
return key, comment, nil
|
||||
}
|
||||
Reference in New Issue
Block a user