Files
keyfunc/internal/agekey/agekey.go
sneak 73c80ab173
All checks were successful
check / check (push) Successful in 3m57s
The age commands: pub, priv, encrypt and decrypt (closes #3)
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
2026-09-07 15:44:08 +00:00

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
}