From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/cryptokit/age.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 internal/cryptokit/age.go (limited to 'internal/cryptokit/age.go') diff --git a/internal/cryptokit/age.go b/internal/cryptokit/age.go new file mode 100644 index 0000000..525b1fa --- /dev/null +++ b/internal/cryptokit/age.go @@ -0,0 +1,91 @@ +package cryptokit + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + + "filippo.io/age" + "filippo.io/age/agessh" + agearmor "filippo.io/age/armor" +) + +const maxCryptoMessageBytes = 64 << 20 + +func parseAgeRecipient(value string) (age.Recipient, error) { + value = strings.TrimSpace(value) + if recipient, err := age.ParseX25519Recipient(value); err == nil { + return recipient, nil + } + if recipient, err := agessh.ParseRecipient(value); err == nil { + return recipient, nil + } + return nil, errors.New("unsupported age recipient: expected native X25519 or SSH Ed25519/RSA") +} + +func parseAgeIdentity(value string) (age.Identity, error) { + value = strings.TrimSpace(value) + if identity, err := age.ParseX25519Identity(value); err == nil { + return identity, nil + } + if identity, err := agessh.ParseIdentity([]byte(value)); err == nil { + return identity, nil + } + return nil, errors.New("unsupported age identity: expected native X25519 or SSH Ed25519/RSA private key") +} + +// EncryptAge encrypts to a user-provided native age X25519 recipient or SSH +// Ed25519/RSA recipient and returns ASCII-armored age text. +func EncryptAge(message []byte, recipientKey string) ([]byte, error) { + recipient, err := parseAgeRecipient(recipientKey) + if err != nil { + return nil, fmt.Errorf("parse age recipient: %w", err) + } + var output bytes.Buffer + armored := agearmor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + _ = armored.Close() + return nil, fmt.Errorf("create age encryption: %w", err) + } + if _, err := writer.Write(message); err != nil { + _ = writer.Close() + _ = armored.Close() + return nil, fmt.Errorf("write age message: %w", err) + } + if err := writer.Close(); err != nil { + _ = armored.Close() + return nil, fmt.Errorf("close age message: %w", err) + } + if err := armored.Close(); err != nil { + return nil, fmt.Errorf("close age armor: %w", err) + } + return output.Bytes(), nil +} + +// DecryptAge decrypts an armored or binary age message with a user-provided +// native X25519 identity or an unencrypted SSH Ed25519/RSA private key. +func DecryptAge(message []byte, identityKey string) ([]byte, error) { + identity, err := parseAgeIdentity(identityKey) + if err != nil { + return nil, fmt.Errorf("parse age identity: %w", err) + } + var input io.Reader = bytes.NewReader(message) + if bytes.HasPrefix(bytes.TrimSpace(message), []byte(agearmor.Header)) { + input = agearmor.NewReader(input) + } + reader, err := age.Decrypt(input, identity) + if err != nil { + return nil, fmt.Errorf("create age decryption: %w", err) + } + plaintext, err := io.ReadAll(io.LimitReader(reader, maxCryptoMessageBytes+1)) + if err != nil { + return nil, fmt.Errorf("read age message: %w", err) + } + if len(plaintext) > maxCryptoMessageBytes { + return nil, errors.New("decrypted age message exceeds the 64 MiB limit") + } + return plaintext, nil +} -- cgit v1.2.3