The age commands: pub, priv, encrypt and decrypt (closes #3)
All checks were successful
check / check (push) Successful in 3m57s
All checks were successful
check / check (push) Successful in 3m57s
The application number is 657169, so the path is m/83696968'/657169'/<n>'. The 32 derived bytes are clamped the way X25519 requires and go through bech32 into an age identity, which is the only route age offers from raw bytes to a key; these are the steps sneak/secret takes in its agehd package. The derived recipient is always first in the recipient list, so the mnemonic that encrypted a file can always read it back. Decrypting recognises the text form by the line it starts with, so it needs no flag. A file named with -o is created readable only by its owner, since a decrypted one is as secret as what went into it. Model: opus-5
This commit is contained in:
1
go.mod
1
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
203
internal/agekey/agekey.go
Normal file
203
internal/agekey/agekey.go
Normal file
@@ -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
|
||||
}
|
||||
166
internal/agekey/agekey_test.go
Normal file
166
internal/agekey/agekey_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
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. They are what makes the derivation reproducible: if these
|
||||
// change, every file anyone encrypted becomes unreadable.
|
||||
const (
|
||||
recipientZero = "age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysew" +
|
||||
"lj68kmx5n67e3nsg2mftq"
|
||||
recipientOne = "age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfg" +
|
||||
"qm7hmakqhvgyy4q5p3w7j"
|
||||
)
|
||||
|
||||
// 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 TestTheIdentityIsWrittenTheWayAgeWritesOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t,
|
||||
strings.HasPrefix(forIndex(t, 0).Identity(), "AGE-SECRET-KEY-1"),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
238
internal/cli/age/age.go
Normal file
238
internal/cli/age/age.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Package age groups the commands that derive age identities and
|
||||
// encrypt and decrypt with them.
|
||||
package age
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ownerOnly is the mode a file the tool creates gets, since a
|
||||
// decrypted file is as secret as what went into it.
|
||||
const ownerOnly = 0o600
|
||||
|
||||
// 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 closes an output file 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, closeDst, err := output(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = work(dst, src)
|
||||
if err != nil {
|
||||
_ = closeDst()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return closeDst()
|
||||
}
|
||||
|
||||
// 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: the file --output names, or the
|
||||
// command's own output. The second result closes a file that was
|
||||
// opened, so a failure to finish writing is not lost.
|
||||
func output(cmd *cobra.Command) (io.Writer, func() 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() error { return nil }, nil
|
||||
}
|
||||
|
||||
//nolint:gosec // writing the file the user named is the point
|
||||
file, err := os.OpenFile(
|
||||
name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, ownerOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating %s: %w", name, err)
|
||||
}
|
||||
|
||||
return file, file.Close, 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)
|
||||
}
|
||||
84
internal/cli/age_test.go
Normal file
84
internal/cli/age_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -29,7 +30,7 @@ func Root() *cobra.Command {
|
||||
}
|
||||
|
||||
options.Add(root)
|
||||
root.AddCommand(ssh.Command())
|
||||
root.AddCommand(ssh.Command(), age.Command())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user