Files
keyfunc/internal/agekey/agekey.go
T
clawbot f1e27814cd
check / check (push) Waiting to run
Read the text form with blank lines in front, as age does (closes #77)
age decrypt took the text form only when the file began exactly with
-----BEGIN AGE ENCRYPTED FILE-----, so a blank line in front made it
read the file as the binary form and refuse it. It now looks for that
line past up to 1024 bytes of whitespace, as the age tool does, and
the text form reader skips the blank lines. The README's age decrypt
section says so.

Judgement call: that first line with spaces in front of it on the same
line is still refused, as age refuses it.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 22:35:29 +02:00

208 lines
5.1 KiB
Go

// Package agekey turns derived bytes into an age identity and uses
// that identity to encrypt and decrypt.
package agekey
import (
"bufio"
"bytes"
"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 given, 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 []age.Recipient, armored bool,
) error {
all := append([]age.Recipient{k.identity.Recipient()}, to...)
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 reads the recipients named on the command line, for
// Encrypt to add to the key's own.
func Recipients(names []string) ([]age.Recipient, error) {
list := make([]age.Recipient, 0, len(names))
for _, name := range names {
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. Like age, it looks
// past up to 1024 bytes of whitespace for that line, and the text form
// reader skips blank lines in front of it.
func unarmored(src io.Reader) io.Reader {
const maxWhitespace = 1024
buffered := bufio.NewReader(src)
// A shorter input gives an error along with all of it, which is
// enough to look at.
start, _ := buffered.Peek(maxWhitespace + len(armor.Header))
if bytes.HasPrefix(bytes.TrimSpace(start), []byte(armor.Header)) {
return armor.NewReader(buffered)
}
return buffered
}