Compare commits

..

1 Commits

Author SHA1 Message Date
05d67708ec The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 21s
install runs the system ssh and hands the host a short shell script to
run, with the public key line on the connection's standard input rather
than on a command line, where anyone else on the host could read it out
of the process list. The script makes ~/.ssh and authorized_keys if
they are missing, adds the line unless the same line is already there,
and says which of the two it did.

to serves the key from an agent inside the tool, on a unix socket in a
temporary directory only its owner can enter, and points ssh at it with
-o IdentityAgent. The socket and directory go when the command ends and
the private key is never written to disk. Only this command hands back
the status ssh ended with instead of ending with status 1.

The tests put a stand-in ssh on the path: for install it runs the
script the tool sends against a directory standing in for the host's
home directory, so the file, the modes and the second run that changes
nothing are all watched happening.

Model: opus-5
2026-09-07 15:52:12 +00:00
13 changed files with 10 additions and 1053 deletions

1
go.mod
View File

@@ -3,7 +3,6 @@ module git.eeqj.de/sneak/keyfunc
go 1.26 go 1.26
require ( require (
filippo.io/age v1.2.1
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
github.com/btcsuite/btcd v0.24.2 github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6

4
go.sum
View File

@@ -1,7 +1,3 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=

View File

@@ -1,203 +0,0 @@
// Package agekey turns derived bytes into an age identity and uses
// that identity to encrypt and decrypt.
package agekey
import (
"bufio"
"errors"
"fmt"
"io"
"strings"
"filippo.io/age"
"filippo.io/age/armor"
"github.com/btcsuite/btcd/btcutil/bech32"
)
const (
// Application is the number this key type occupies in the
// derivation path. It spells AGE the way BIP-85 spells RSA, as
// the ASCII codes of the letters written out.
Application = 657169
// keySize is how long an X25519 secret key is.
keySize = 32
// humanPart is what age puts in front of a secret key when it
// writes one down.
humanPart = "age-secret-key-"
)
// ErrSize is returned when the derived bytes are not the length an
// X25519 secret key has to be.
var ErrSize = errors.New("an age identity needs 32 derived bytes")
// ErrNotRecipient is returned when the file was encrypted to someone
// else, so this key cannot open it.
var ErrNotRecipient = errors.New(
"this mnemonic and index are not a recipient of the file",
)
// Key is one age identity.
type Key struct {
identity *age.X25519Identity
}
// New makes the identity whose X25519 secret key is the derived bytes,
// clamped the way that curve requires.
func New(derived []byte) (*Key, error) {
if len(derived) != keySize {
return nil, fmt.Errorf("%w, got %d", ErrSize, len(derived))
}
scalar := make([]byte, keySize)
copy(scalar, derived)
clamp(scalar)
// age offers no way to make an identity out of bytes, so the
// scalar goes in the way age writes a secret key down.
written, err := bech32.EncodeFromBase256(humanPart, scalar)
if err != nil {
return nil, fmt.Errorf("writing the secret key: %w", err)
}
identity, err := age.ParseX25519Identity(strings.ToUpper(written))
if err != nil {
return nil, fmt.Errorf("reading the secret key back: %w", err)
}
return &Key{identity: identity}, nil
}
// clamp makes the scalar one the curve accepts: the lowest three bits
// off, the highest bit off and the one below it on, as RFC 7748 says.
func clamp(scalar []byte) {
const (
lowestThreeOff = 0b1111_1000
highestOff = 0b0111_1111
secondHighestOn = 0b0100_0000
)
scalar[0] &= lowestThreeOff
scalar[len(scalar)-1] &= highestOff
scalar[len(scalar)-1] |= secondHighestOn
}
// Recipient returns the public key, the age1... line.
func (k *Key) Recipient() string {
return k.identity.Recipient().String()
}
// Identity returns the secret key, the AGE-SECRET-KEY-1... line.
func (k *Key) Identity() string {
return k.identity.String()
}
// 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.
// Armored output is the text form age also reads.
func (k *Key) Encrypt(
dst io.Writer, src io.Reader, to []string, armored bool,
) error {
all, err := recipients(k.identity.Recipient(), to)
if err != nil {
return err
}
out := dst
var text io.WriteCloser
if armored {
text = armor.NewWriter(dst)
out = text
}
err = encrypt(out, src, all)
if err != nil {
return err
}
if text == nil {
return nil
}
err = text.Close()
if err != nil {
return fmt.Errorf("finishing the text form: %w", err)
}
return nil
}
// recipients returns the key's own recipient followed by the ones
// named on the command line, so what the key encrypts it can read.
func recipients(mine age.Recipient, to []string) ([]age.Recipient, error) {
list := []age.Recipient{mine}
for _, name := range to {
parsed, err := age.ParseX25519Recipient(name)
if err != nil {
return nil, fmt.Errorf("reading recipient %q: %w", name, err)
}
list = append(list, parsed)
}
return list, nil
}
// encrypt writes src into dst for the recipients.
func encrypt(dst io.Writer, src io.Reader, to []age.Recipient) error {
sealed, err := age.Encrypt(dst, to...)
if err != nil {
return fmt.Errorf("starting the encryption: %w", err)
}
_, err = io.Copy(sealed, src)
if err != nil {
return fmt.Errorf("encrypting: %w", err)
}
err = sealed.Close()
if err != nil {
return fmt.Errorf("finishing the encryption: %w", err)
}
return nil
}
// Decrypt copies src to dst, decrypted with this key. The text form is
// recognised by the line it starts with, so it needs no flag.
func (k *Key) Decrypt(dst io.Writer, src io.Reader) error {
plain, err := age.Decrypt(unarmored(src), k.identity)
noMatch := &age.NoIdentityMatchError{}
if errors.As(err, &noMatch) {
return ErrNotRecipient
}
if err != nil {
return fmt.Errorf("decrypting: %w", err)
}
_, err = io.Copy(dst, plain)
if err != nil {
return fmt.Errorf("reading the decrypted file: %w", err)
}
return nil
}
// unarmored strips the text form when the input begins with the line
// that starts one, and leaves binary input alone.
func unarmored(src io.Reader) io.Reader {
buffered := bufio.NewReader(src)
start, err := buffered.Peek(len(armor.Header))
if err == nil && string(start) == armor.Header {
return armor.NewReader(buffered)
}
return buffered
}

View File

@@ -1,169 +0,0 @@
package agekey_test
import (
"bytes"
"strings"
"testing"
"git.eeqj.de/sneak/keyfunc/internal/agekey"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"github.com/stretchr/testify/require"
)
// The recipients the example mnemonic produces at the first two
// indexes, and the secret key behind the first of them. They are what
// makes the derivation reproducible: if the recipients change, every
// file anyone encrypted becomes unreadable, and if the secret key
// changes, the key is no longer the one other tools derive from the
// same mnemonic.
const (
recipientZero = "age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysew" +
"lj68kmx5n67e3nsg2mftq"
recipientOne = "age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfg" +
"qm7hmakqhvgyy4q5p3w7j"
identityZero = "AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD" +
"7527T70JDHGDJ7AMNF3XT44S00JFU5"
)
// 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"
}
func TestTooFewBytesAreRefused(t *testing.T) {
t.Parallel()
_, err := agekey.New([]byte("short"))
require.ErrorIs(t, err, agekey.ErrSize)
}
func TestTheSameMnemonicAlwaysGivesTheSameKey(t *testing.T) {
t.Parallel()
require.Equal(t, recipientZero, forIndex(t, 0).Recipient())
require.Equal(t, recipientOne, forIndex(t, 1).Recipient())
}
func TestTheSameMnemonicAlwaysGivesTheSameSecretKey(t *testing.T) {
t.Parallel()
require.Equal(t, identityZero, forIndex(t, 0).Identity())
}
func TestWhatWasEncryptedComesBack(t *testing.T) {
t.Parallel()
// Every byte value, so nothing assumes the input is text, and
// then text, which is what most of it will be.
payloads := map[string][]byte{
"every byte": everyByte(),
"text": []byte("the quick brown fox\nand a second line\n"),
}
forms := map[string]bool{"binary": false, "armored": true}
for name, payload := range payloads {
for form, armored := range forms {
t.Run(name+" "+form, func(t *testing.T) {
t.Parallel()
key := forIndex(t, 0)
var sealed, opened bytes.Buffer
err := key.Encrypt(
&sealed, bytes.NewReader(payload), nil, armored,
)
require.NoError(t, err)
err = key.Decrypt(&opened, &sealed)
require.NoError(t, err)
require.Equal(t, payload, opened.Bytes())
})
}
}
}
func TestTheArmoredFormIsText(t *testing.T) {
t.Parallel()
var sealed bytes.Buffer
err := forIndex(t, 0).Encrypt(
&sealed, strings.NewReader("hello"), nil, true,
)
require.NoError(t, err)
require.True(t, strings.HasPrefix(
sealed.String(), "-----BEGIN AGE ENCRYPTED FILE-----",
))
}
func TestAFileForSomebodyElseIsRefused(t *testing.T) {
t.Parallel()
var sealed, opened bytes.Buffer
err := forIndex(t, 1).Encrypt(
&sealed, strings.NewReader("hello"), nil, false,
)
require.NoError(t, err)
err = forIndex(t, 0).Decrypt(&opened, &sealed)
require.ErrorIs(t, err, agekey.ErrNotRecipient)
require.Empty(t, opened.Bytes())
}
func TestAnExtraRecipientCanReadItTooAndSoCanTheDerivedOne(t *testing.T) {
t.Parallel()
mine, theirs := forIndex(t, 0), forIndex(t, 1)
var sealed bytes.Buffer
err := mine.Encrypt(
&sealed, strings.NewReader("hello"),
[]string{theirs.Recipient()}, false,
)
require.NoError(t, err)
for _, key := range []*agekey.Key{mine, theirs} {
var opened bytes.Buffer
require.NoError(t, key.Decrypt(&opened, bytes.NewReader(sealed.Bytes())))
require.Equal(t, "hello", opened.String())
}
}
func TestARecipientThatIsNotOneIsRefused(t *testing.T) {
t.Parallel()
err := forIndex(t, 0).Encrypt(
&bytes.Buffer{}, strings.NewReader("hello"),
[]string{"not a recipient"}, false,
)
require.Error(t, err)
}
// everyByte returns a payload holding all 256 byte values.
func everyByte() []byte {
out := make([]byte, 256)
for i := range out {
out[i] = byte(i)
}
return out
}
// forIndex derives the key for one index.
func forIndex(t *testing.T, index uint32) *agekey.Key {
t.Helper()
material, err := derive.Bytes(example(), agekey.Application, index)
require.NoError(t, err)
key, err := agekey.New(material)
require.NoError(t, err)
return key
}

View File

@@ -1,63 +0,0 @@
// Package childmnemonic derives a mnemonic from another mnemonic.
package childmnemonic
import (
"errors"
"fmt"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
bip39 "github.com/tyler-smith/go-bip39"
)
// english is the number BIP-85 gives the English word list.
const english = 0
// The lengths a child mnemonic may have. BIP-85 also allows 15 and 21
// words; these three are the ones this tool offers.
const (
twelve = 12
eighteen = 18
twentyFour = 24
)
// DefaultWords is how long a child mnemonic is when the user does not
// say.
const DefaultWords = twelve
// ErrWordCount is returned for a length this tool does not offer.
var ErrWordCount = errors.New("a child mnemonic has 12, 18 or 24 words")
// 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
// application, number 39. The words come straight from the BIP-85
// entropy, cut to the length the word count needs, rather than from
// the generator the other key types read their bytes from.
func Derive(
master *hdkeychain.ExtendedKey,
words, index uint32,
) (string, error) {
switch words {
case twelve, eighteen, twentyFour:
default:
return "", fmt.Errorf("%w, not %d", ErrWordCount, words)
}
err := derive.CheckIndex(index)
if err != nil {
return "", err
}
entropy, err := bip85.DeriveBIP39Entropy(master, english, words, index)
if err != nil {
return "", fmt.Errorf("deriving entropy: %w", err)
}
child, err := bip39.NewMnemonic(entropy)
if err != nil {
return "", fmt.Errorf("turning the entropy into words: %w", err)
}
return child, nil
}

View File

@@ -1,117 +0,0 @@
package childmnemonic_test
import (
"strings"
"testing"
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/stretchr/testify/require"
bip39 "github.com/tyler-smith/go-bip39"
)
// The lengths the tool offers, and one it does not.
const (
twelve = 12
eighteen = 18
twentyFour = 24
fifteen = 15
)
// specificationKey is the master key the BIP-85 specification gives
// every one of its test vectors for.
const specificationKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBq" +
"sx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// The three English child mnemonics at key index 0 that the BIP-85
// specification gives for that master key.
const (
twelveWords = "girl mad pet galaxy egg matter matrix prison refuse " +
"sense ordinary nose"
eighteenWords = "near account window bike charge season chef number " +
"sketch tomorrow excuse sniff circle vital hockey outdoor " +
"supply token"
twentyFourWords = "puppy ocean match cereal symbol another shed " +
"magic wrap hammer bulb intact gadget divorce twin tonight " +
"reason outdoor destroy simple truth cigar social volcano"
)
// 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"
}
func TestTheSpecificationTestVectors(t *testing.T) {
t.Parallel()
require.Equal(t, twelveWords, fromSpecificationKey(t, twelve))
require.Equal(t, eighteenWords, fromSpecificationKey(t, eighteen))
require.Equal(t, twentyFourWords, fromSpecificationKey(t, twentyFour))
}
func TestTheLongestChildMnemonicPassesItsOwnChecksum(t *testing.T) {
t.Parallel()
child := fromSpecificationKey(t, twentyFour)
require.Len(t, strings.Fields(child), twentyFour)
require.True(t, bip39.IsMnemonicValid(child))
}
func TestEachKeyIndexGivesItsOwnChildMnemonic(t *testing.T) {
t.Parallel()
master, err := derive.Master(example())
require.NoError(t, err)
first, err := childmnemonic.Derive(master, twelve, 0)
require.NoError(t, err)
require.True(t, bip39.IsMnemonicValid(first))
second, err := childmnemonic.Derive(master, twelve, 1)
require.NoError(t, err)
require.True(t, bip39.IsMnemonicValid(second))
require.NotEqual(t, first, second)
}
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
t.Parallel()
master, err := hdkeychain.NewKeyFromString(specificationKey)
require.NoError(t, err)
_, err = childmnemonic.Derive(master, fifteen, 0)
require.ErrorIs(t, err, childmnemonic.ErrWordCount)
}
func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) {
t.Parallel()
master, err := hdkeychain.NewKeyFromString(specificationKey)
require.NoError(t, err)
_, err = childmnemonic.Derive(master, twelve, derive.MaxIndex+1)
require.ErrorIs(t, err, derive.ErrIndexTooLarge)
_, err = childmnemonic.Derive(master, twelve, derive.MaxIndex)
require.NoError(t, err)
}
// fromSpecificationKey derives the child mnemonic of that length at key
// index 0 from the master key the specification gives.
func fromSpecificationKey(t *testing.T, words uint32) string {
t.Helper()
master, err := hdkeychain.NewKeyFromString(specificationKey)
require.NoError(t, err)
child, err := childmnemonic.Derive(master, words, 0)
require.NoError(t, err)
return child
}

View File

@@ -1,263 +0,0 @@
// Package age groups the commands that derive age identities and
// encrypt and decrypt with them.
package age
import (
"fmt"
"io"
"os"
"path/filepath"
"git.eeqj.de/sneak/keyfunc/internal/agekey"
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"github.com/spf13/cobra"
)
// Command returns the age command and everything under it.
func Command() *cobra.Command {
group := &cobra.Command{
Use: "age",
Short: "derive age identities and encrypt and decrypt with them",
}
group.AddCommand(public(), private(), encrypt(), decrypt())
return group
}
// public returns the command that prints the recipient.
func public() *cobra.Command {
return &cobra.Command{
Use: "pub",
Short: "print the recipient, the age1... public key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
key, err := derived(cmd)
if err != nil {
return err
}
return write(cmd, key.Recipient())
},
}
}
// private returns the command that prints the identity.
func private() *cobra.Command {
return &cobra.Command{
Use: "priv",
Short: "print the identity, the AGE-SECRET-KEY-1... line",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
key, err := derived(cmd)
if err != nil {
return err
}
return write(cmd, key.Identity())
},
}
}
// encrypt returns the command that encrypts a file or standard input.
func encrypt() *cobra.Command {
cmd := &cobra.Command{
Use: "encrypt [file]",
Short: "encrypt to the derived recipient and any others given",
Args: cobra.MaximumNArgs(1),
RunE: runEncrypt,
}
cmd.Flags().StringArray(
"to", nil,
"another recipient to encrypt to, as well as the derived one",
)
cmd.Flags().Bool(
"armor", false,
"write the text form instead of the binary one",
)
addOutput(cmd)
return cmd
}
// decrypt returns the command that decrypts a file or standard input.
func decrypt() *cobra.Command {
cmd := &cobra.Command{
Use: "decrypt [file]",
Short: "decrypt with the derived identity",
Args: cobra.MaximumNArgs(1),
RunE: runDecrypt,
}
addOutput(cmd)
return cmd
}
// runEncrypt encrypts to the derived recipient and any others given.
func runEncrypt(cmd *cobra.Command, args []string) error {
key, err := derived(cmd)
if err != nil {
return err
}
to, err := cmd.Flags().GetStringArray("to")
if err != nil {
return fmt.Errorf("reading the recipients: %w", err)
}
armored, err := cmd.Flags().GetBool("armor")
if err != nil {
return fmt.Errorf("reading the armor flag: %w", err)
}
return through(cmd, args, func(dst io.Writer, src io.Reader) error {
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 {
return err
}
return through(cmd, args, key.Decrypt)
}
// through opens the input and the output the arguments ask for, hands
// them to the work, and finishes the output afterwards either way.
func through(
cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error,
) error {
src, closeSrc, err := input(cmd, args)
if err != nil {
return err
}
defer closeSrc()
dst, done, err := output(cmd)
if err != nil {
return err
}
err = work(dst, src)
return done(err)
}
// input returns what to read from: the named file, or the command's
// own input when no file is named. The second result closes a file
// that was opened and does nothing otherwise.
func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
if len(args) == 0 {
return cmd.InOrStdin(), func() {}, nil
}
file, err := os.Open(args[0])
if err != nil {
return nil, nil, fmt.Errorf("opening %s: %w", args[0], err)
}
return file, func() { _ = file.Close() }, nil
}
// output returns what to write to: a new file beside the one --output
// names, or the command's own output when it names none. The second
// result finishes the write, and is given whatever the work returned:
// the new file takes the named file's place only when the work
// succeeded, so a file that is already there survives a run that
// failed.
func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
name, err := cmd.Flags().GetString("output")
if err != nil {
return nil, nil, fmt.Errorf("reading the output file: %w", err)
}
if name == "" {
return cmd.OutOrStdout(), func(failed error) error {
return failed
}, nil
}
// The file is made in the same directory so that putting it in
// place is a rename and never a copy, and it is readable only by
// its owner, which is the mode it keeps once renamed.
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
if err != nil {
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
}
return file, func(failed error) error {
return finish(file, name, failed)
}, nil
}
// finish closes the new file and puts it in the named file's place, or
// throws it away when the work failed. It returns the error the caller
// should report.
func finish(file *os.File, name string, failed error) error {
closeErr := file.Close()
if failed != nil || closeErr != nil {
_ = os.Remove(file.Name())
if failed != nil {
return failed
}
return fmt.Errorf("finishing %s: %w", name, closeErr)
}
err := os.Rename(file.Name(), name)
if err != nil {
_ = os.Remove(file.Name())
return fmt.Errorf("putting %s in place: %w", name, err)
}
return nil
}
// addOutput gives a command its output file flag.
func addOutput(cmd *cobra.Command) {
cmd.Flags().StringP(
"output", "o", "",
"write to this file instead of standard output",
)
}
// write sends one line to wherever the command's output goes.
func write(cmd *cobra.Command, line string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), line)
if err != nil {
return fmt.Errorf("writing the key: %w", err)
}
return nil
}
// derived returns the age key for this run.
func derived(cmd *cobra.Command) (*agekey.Key, 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, agekey.Application, index)
if err != nil {
return nil, err
}
return agekey.New(material)
}

View File

@@ -1,102 +0,0 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.eeqj.de/sneak/keyfunc/internal/agekey"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"github.com/stretchr/testify/require"
)
func TestTheAgeCommandsPrintTheKey(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
recipient := strings.TrimSpace(run(t, "age", "pub"))
require.True(t, strings.HasPrefix(recipient, "age1"))
identity := strings.TrimSpace(run(t, "age", "priv"))
require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1"))
}
func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-o", sealed, plain)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
}
func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
armored := run(t, "age", "encrypt", "--armor", plain)
require.True(t, strings.HasPrefix(
armored, "-----BEGIN AGE ENCRYPTED FILE-----",
))
sealed := written(t, "notes.age", armored)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
}
func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7"))
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
require.Equal(t,
"the secret\n", run(t, "age", "decrypt", "-n", "7", sealed),
)
}
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
_, err := execute(t, "age", "decrypt", sealed)
require.ErrorIs(t, err, agekey.ErrNotRecipient)
}
func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
existing := written(t, "notes.out", "what was already there\n")
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
_, err := execute(t, "age", "decrypt", "-o", existing, sealed)
require.ErrorIs(t, err, agekey.ErrNotRecipient)
//nolint:gosec // the test made this path itself
kept, err := os.ReadFile(existing)
require.NoError(t, err)
require.Equal(t, "what was already there\n", string(kept))
}
// written puts the contents in a file of that name in a directory of
// this test's own and returns the path to it.
func written(t *testing.T, name, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
return path
}

View File

@@ -6,8 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
"git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -32,7 +30,7 @@ func Root() *cobra.Command {
} }
options.Add(root) options.Add(root)
root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command()) root.AddCommand(ssh.Command())
return root return root
} }

View File

@@ -5,12 +5,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/keyfunc/internal/derive"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
bip39 "github.com/tyler-smith/go-bip39"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -22,12 +20,6 @@ const (
"0I4FKs+eVUulTPHfk9VtXw1tMF" "0I4FKs+eVUulTPHfk9VtXw1tMF"
) )
// The two child mnemonic lengths the tests ask for.
const (
twelve = 12
twentyFour = 24
)
// 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 {
@@ -89,28 +81,6 @@ func TestTheMnemonicCommandIsUsed(t *testing.T) {
require.Equal(t, vectorZero+" keyfunc/ssh/0", line) require.Equal(t, vectorZero+" keyfunc/ssh/0", line)
} }
func TestAChildMnemonicIsPrinted(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
short := strings.Fields(run(t, "mnemonic"))
require.Len(t, short, twelve)
require.True(t, bip39.IsMnemonicValid(strings.Join(short, " ")))
long := strings.Fields(run(t, "mnemonic", "--words", "24"))
require.Len(t, long, twentyFour)
next := strings.Fields(run(t, "mnemonic", "-n", "1"))
require.NotEqual(t, short, next)
}
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
out, err := execute(t, "mnemonic", "--words", "15")
require.ErrorIs(t, err, childmnemonic.ErrWordCount)
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 {

View File

@@ -1,65 +0,0 @@
// Package mnemonic is the command that prints a child mnemonic.
package mnemonic
import (
"fmt"
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"github.com/spf13/cobra"
)
// Command returns the mnemonic command.
func Command() *cobra.Command {
cmd := &cobra.Command{
Use: "mnemonic",
Short: "print a child mnemonic derived from the main one",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
child, err := derived(cmd)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), child)
if err != nil {
return fmt.Errorf("writing the mnemonic: %w", err)
}
return nil
},
}
cmd.Flags().Uint32(
"words", childmnemonic.DefaultWords,
"how long the child mnemonic is: 12, 18 or 24 words",
)
return cmd
}
// derived returns the child mnemonic this run asks for.
func derived(cmd *cobra.Command) (string, error) {
index, err := options.Index(cmd)
if err != nil {
return "", err
}
words, err := cmd.Flags().GetUint32("words")
if err != nil {
return "", fmt.Errorf("reading the word count: %w", err)
}
parent, err := options.Mnemonic(cmd)
if err != nil {
return "", err
}
master, err := derive.Master(parent)
if err != nil {
return "", err
}
return childmnemonic.Derive(master, words, index)
}

View File

@@ -35,47 +35,24 @@ func Path(application, index uint32) string {
return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index) return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index)
} }
// CheckIndex refuses a key index above MaxIndex, so that every key
// type turns such an index down before deriving anything.
func CheckIndex(index uint32) error {
if index > MaxIndex {
return fmt.Errorf(
"%w: %d is above %d",
ErrIndexTooLarge, index, MaxIndex,
)
}
return nil
}
// Master returns the BIP-32 master key a mnemonic stands for: the
// mnemonic becomes a seed with an empty passphrase, and the seed
// becomes the key.
func Master(words string) (*hdkeychain.ExtendedKey, error) {
seed := bip39.NewSeed(words, "")
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil {
return nil, fmt.Errorf("making the master key: %w", err)
}
return master, nil
}
// Bytes returns the bytes for an application number and a key index. // Bytes returns the bytes for an application number and a key index.
// The mnemonic becomes a seed with an empty passphrase, the seed // The mnemonic becomes a seed with an empty passphrase, the seed
// becomes a master key, the master key gives BIP-85 entropy at the // becomes a master key, the master key gives BIP-85 entropy at the
// path, and the entropy seeds the generator the bytes are read from. // path, and the entropy seeds the generator the bytes are read from.
// An index above MaxIndex is refused before any of that happens. // An index above MaxIndex is refused before any of that happens.
func Bytes(words string, application, index uint32) ([]byte, error) { func Bytes(words string, application, index uint32) ([]byte, error) {
err := CheckIndex(index) if index > MaxIndex {
if err != nil { return nil, fmt.Errorf(
return nil, err "%w: %d is above %d",
ErrIndexTooLarge, index, MaxIndex,
)
} }
master, err := Master(words) seed := bip39.NewSeed(words, "")
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("making the master key: %w", err)
} }
entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index)) entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index))

View File

@@ -72,7 +72,6 @@ func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) {
served, err := key.Serve(t.Context(), "a comment") served, err := key.Serve(t.Context(), "a comment")
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(served.Stop)
directory, err := os.Stat(filepath.Dir(served.Socket())) directory, err := os.Stat(filepath.Dir(served.Socket()))
require.NoError(t, err) require.NoError(t, err)