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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user