diff --git a/go.mod b/go.mod index 7934093..4a2f2ce 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.eeqj.de/sneak/keyfunc go 1.26 require ( + filippo.io/age v1.2.1 git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd github.com/btcsuite/btcd v0.24.2 github.com/btcsuite/btcd/btcutil v1.1.6 diff --git a/go.sum b/go.sum index 7bc30ce..4915728 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +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/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= diff --git a/internal/agekey/agekey.go b/internal/agekey/agekey.go new file mode 100644 index 0000000..7f11687 --- /dev/null +++ b/internal/agekey/agekey.go @@ -0,0 +1,203 @@ +// 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 +} diff --git a/internal/agekey/agekey_test.go b/internal/agekey/agekey_test.go new file mode 100644 index 0000000..376e3ab --- /dev/null +++ b/internal/agekey/agekey_test.go @@ -0,0 +1,169 @@ +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 +} diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go new file mode 100644 index 0000000..4de2b60 --- /dev/null +++ b/internal/cli/age/age.go @@ -0,0 +1,263 @@ +// 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) +} diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go new file mode 100644 index 0000000..3d452b9 --- /dev/null +++ b/internal/cli/age_test.go @@ -0,0 +1,102 @@ +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 +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7e95c4b..e06d916 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "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/ssh" @@ -30,7 +31,7 @@ func Root() *cobra.Command { } options.Add(root) - root.AddCommand(ssh.Command(), mnemonic.Command()) + root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command()) return root }