The age commands: pub, priv, encrypt and decrypt (closes #3)
All checks were successful
check / check (push) Successful in 28s
All checks were successful
check / check (push) Successful in 28s
keyfunc age derives an age identity at the generic path the way secret's agehd does, prints the recipient or the identity, and encrypts to or decrypts with it, the derived recipient always among encrypt's recipients. Two review rounds; the second passed with no findings, the clamping step now pinned by a fixed identity test. Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #7.
This commit is contained in:
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ 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
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 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=
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
169
internal/agekey/agekey_test.go
Normal file
169
internal/agekey/agekey_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
263
internal/cli/age/age.go
Normal file
263
internal/cli/age/age.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
102
internal/cli/age_test.go
Normal file
102
internal/cli/age_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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/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"
|
||||||
@@ -30,7 +31,7 @@ func Root() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.Add(root)
|
options.Add(root)
|
||||||
root.AddCommand(ssh.Command(), mnemonic.Command())
|
root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command())
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user