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
204 lines
4.9 KiB
Go
204 lines
4.9 KiB
Go
// 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
|
|
}
|