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:
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
|
||||
}
|
||||
90
internal/cli/cli_test.go
Normal file
90
internal/cli/cli_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||
"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 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()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
root := cli.Root()
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
|
||||
require.NoError(t, root.ExecuteContext(t.Context()))
|
||||
|
||||
return out.String()
|
||||
}
|
||||
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