summaryrefslogtreecommitdiffstats
path: root/internal/cryptokit
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-23 00:26:27 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-23 00:26:27 +0200
commitbdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d (patch)
tree796d246f9874641717b5e81a72989961e65a9b9f /internal/cryptokit
parentc1decadb590c4d79d92bcc4df7772119a5c91244 (diff)
downloadaegis-main.tar.gz
aegis-main.tar.xz
aegis-main.zip
Simplify Usenet posting to signing onlyHEADmain
Diffstat (limited to 'internal/cryptokit')
-rw-r--r--internal/cryptokit/age.go91
-rw-r--r--internal/cryptokit/cryptokit_test.go109
-rw-r--r--internal/cryptokit/openpgp.go240
-rw-r--r--internal/cryptokit/yubicrypt.go75
4 files changed, 17 insertions, 498 deletions
diff --git a/internal/cryptokit/age.go b/internal/cryptokit/age.go
deleted file mode 100644
index 525b1fa..0000000
--- a/internal/cryptokit/age.go
+++ /dev/null
@@ -1,91 +0,0 @@
-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
-}
diff --git a/internal/cryptokit/cryptokit_test.go b/internal/cryptokit/cryptokit_test.go
index 928120e..cc771c2 100644
--- a/internal/cryptokit/cryptokit_test.go
+++ b/internal/cryptokit/cryptokit_test.go
@@ -1,18 +1,10 @@
package cryptokit
import (
- "bytes"
"crypto/ed25519"
"crypto/rand"
- "crypto/rsa"
"encoding/base64"
- "encoding/pem"
- "os"
- "os/exec"
"testing"
-
- "filippo.io/age"
- "golang.org/x/crypto/ssh"
)
func TestEd25519SignVerifyAcceptsSeedEncoding(t *testing.T) {
@@ -53,104 +45,3 @@ func TestEd25519PublicKeyAndFingerprint(t *testing.T) {
t.Fatalf("unexpected fingerprint %q", fingerprint)
}
}
-
-func TestAgeRoundTrip(t *testing.T) {
- identity, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- ciphertext, err := EncryptAge([]byte("age test"), identity.Recipient().String())
- if err != nil {
- t.Fatal(err)
- }
- plaintext, err := DecryptAge(ciphertext, identity.String())
- if err != nil {
- t.Fatal(err)
- }
- if string(plaintext) != "age test" {
- t.Fatalf("unexpected plaintext %q", plaintext)
- }
-}
-
-func TestAgeSSHCompatibility(t *testing.T) {
- testKey := func(private any) {
- signer, err := ssh.NewSignerFromKey(private)
- if err != nil {
- t.Fatal(err)
- }
- privatePEM, err := ssh.MarshalPrivateKey(private, "")
- if err != nil {
- t.Fatal(err)
- }
- ciphertext, err := EncryptAge([]byte("age SSH test"), string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
- if err != nil {
- t.Fatal(err)
- }
- plaintext, err := DecryptAge(ciphertext, string(pem.EncodeToMemory(privatePEM)))
- if err != nil {
- t.Fatal(err)
- }
- if string(plaintext) != "age SSH test" {
- t.Fatalf("unexpected plaintext %q", plaintext)
- }
- }
- _, edPrivate, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- testKey(edPrivate)
- rsaPrivate, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- t.Fatal(err)
- }
- testKey(rsaPrivate)
-}
-
-func TestOpenPGPRoundTripWithUserKeyMaterial(t *testing.T) {
- gpg, err := exec.LookPath("gpg")
- if err != nil {
- t.Skip("gpg is not installed")
- }
- home := t.TempDir()
- if err := os.Chmod(home, 0o700); err != nil {
- t.Fatal(err)
- }
- run := func(args []string, input []byte) ([]byte, error) {
- cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty", "--no-options", "--homedir", home}, args...)...)
- cmd.Stdin = bytes.NewReader(input)
- return cmd.Output()
- }
- if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", "Aegis Test <aegis@example.invalid>", "rsa2048", "sign", "1d"}, nil); err != nil {
- t.Skipf("gpg cannot create an ephemeral test key: %v", err)
- }
- if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-add-key", "aegis@example.invalid", "rsa2048", "encrypt", "1d"}, nil); err != nil {
- t.Skipf("gpg cannot create an ephemeral encryption subkey: %v", err)
- }
- publicKey, err := run([]string{"--armor", "--export", "aegis@example.invalid"}, nil)
- if err != nil {
- t.Fatal(err)
- }
- privateKey, err := run([]string{"--armor", "--export-secret-keys", "aegis@example.invalid"}, nil)
- if err != nil {
- t.Fatal(err)
- }
- message := []byte("OpenPGP compatibility test")
- signature, err := SignOpenPGPDetached(message, string(privateKey))
- if err != nil {
- t.Fatal(err)
- }
- if err := VerifyOpenPGPDetached(message, signature, string(publicKey)); err != nil {
- t.Fatal(err)
- }
- ciphertext, err := EncryptOpenPGP(message, string(publicKey), string(privateKey))
- if err != nil {
- t.Fatal(err)
- }
- plaintext, err := DecryptOpenPGP([]byte(ciphertext), string(privateKey))
- if err != nil {
- t.Fatal(err)
- }
- if string(plaintext) != string(message) {
- t.Fatalf("unexpected plaintext %q", plaintext)
- }
-}
diff --git a/internal/cryptokit/openpgp.go b/internal/cryptokit/openpgp.go
deleted file mode 100644
index bf766b5..0000000
--- a/internal/cryptokit/openpgp.go
+++ /dev/null
@@ -1,240 +0,0 @@
-package cryptokit
-
-import (
- "bytes"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-const maxOpenPGPMessageBytes = 64 << 20
-
-type limitedBuffer struct {
- buffer bytes.Buffer
- limit int
-}
-
-func (b *limitedBuffer) Write(value []byte) (int, error) {
- if b.buffer.Len() < b.limit {
- remaining := b.limit - b.buffer.Len()
- if len(value) > remaining {
- _, _ = b.buffer.Write(value[:remaining])
- } else {
- _, _ = b.buffer.Write(value)
- }
- }
- return len(value), nil
-}
-
-// OpenPGP support is delegated to the system GnuPG binary. This keeps Aegis
-// compatible with the user's existing OpenPGP installation, including RSA,
-// Ed25519 and X25519 keys, without embedding another keyring. Key material is
-// imported into a temporary 0700 GNUPGHOME and removed after each operation.
-
-func gpgBinary() (string, error) {
- path, err := exec.LookPath("gpg")
- if err != nil {
- return "", errors.New("gpg is required for OpenPGP operations")
- }
- return path, nil
-}
-
-func withGPG(operation func(home string) error) error {
- if _, err := gpgBinary(); err != nil {
- return err
- }
- home, err := os.MkdirTemp("", "aegis-gpg-")
- if err != nil {
- return fmt.Errorf("create temporary OpenPGP home: %w", err)
- }
- defer os.RemoveAll(home)
- if err := os.Chmod(home, 0o700); err != nil {
- return fmt.Errorf("protect temporary OpenPGP home: %w", err)
- }
- return operation(home)
-}
-
-func runGPG(home string, args []string, input []byte, outputLimit int) ([]byte, string, error) {
- binary, err := gpgBinary()
- if err != nil {
- return nil, "", err
- }
- base := []string{
- "--batch", "--no-tty", "--no-options", "--no-auto-check-trustdb",
- "--homedir", home,
- }
- cmd := exec.Command(binary, append(base, args...)...)
- cmd.Stdin = bytes.NewReader(input)
- var stdout limitedBuffer
- var stderr bytes.Buffer
- if outputLimit <= 0 {
- outputLimit = maxOpenPGPMessageBytes
- }
- stdout.limit = outputLimit + 1
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
- return stdout.buffer.Bytes(), stderr.String(), fmt.Errorf("gpg %s: %w: %s", args[0], err, cleanGPGError(stderr.String()))
- }
- if stdout.buffer.Len() > outputLimit {
- return nil, stderr.String(), fmt.Errorf("OpenPGP output exceeds the %d MiB limit", outputLimit/(1<<20))
- }
- return stdout.buffer.Bytes(), stderr.String(), nil
-}
-
-func cleanGPGError(value string) string {
- value = strings.TrimSpace(value)
- if len(value) > 1000 {
- return value[:1000] + "..."
- }
- return value
-}
-
-func importOpenPGP(home, keyMaterial string) error {
- if strings.TrimSpace(keyMaterial) == "" {
- return errors.New("OpenPGP key material is required")
- }
- _, stderr, err := runGPG(home, []string{"--import"}, []byte(keyMaterial), 1<<20)
- if err != nil {
- return fmt.Errorf("import OpenPGP key: %w", err)
- }
- if strings.Contains(stderr, "no valid OpenPGP data found") {
- return errors.New("no valid OpenPGP key found")
- }
- return nil
-}
-
-func listFingerprints(home string) ([]string, error) {
- output, _, err := runGPG(home, []string{"--with-colons", "--list-keys"}, nil, 1<<20)
- if err != nil {
- return nil, fmt.Errorf("list OpenPGP keys: %w", err)
- }
- var fingerprints []string
- for _, line := range strings.Split(string(output), "\n") {
- fields := strings.Split(line, ":")
- if len(fields) > 9 && fields[0] == "fpr" && fields[9] != "" {
- fingerprints = append(fingerprints, fields[9])
- }
- }
- if len(fingerprints) == 0 {
- return nil, errors.New("no usable OpenPGP key found")
- }
- return fingerprints, nil
-}
-
-func writeGPGFile(home, name string, content []byte) (string, error) {
- path := filepath.Join(home, name)
- file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
- if err != nil {
- return "", fmt.Errorf("create temporary OpenPGP file: %w", err)
- }
- if _, err := file.Write(content); err != nil {
- _ = file.Close()
- return "", fmt.Errorf("write temporary OpenPGP file: %w", err)
- }
- if err := file.Close(); err != nil {
- return "", fmt.Errorf("close temporary OpenPGP file: %w", err)
- }
- return path, nil
-}
-
-// SignOpenPGPDetached returns an ASCII-armored detached signature. The input
-// must contain a private signing key supplied by the user.
-func SignOpenPGPDetached(message []byte, privateKeyArmor string) (string, error) {
- var signature []byte
- err := withGPG(func(home string) error {
- if err := importOpenPGP(home, privateKeyArmor); err != nil {
- return err
- }
- output, _, err := runGPG(home, []string{"--armor", "--detach-sign", "--output", "-"}, message, 1<<20)
- if err != nil {
- return fmt.Errorf("sign OpenPGP message: %w", err)
- }
- signature = output
- return nil
- })
- return string(signature), err
-}
-
-// VerifyOpenPGPDetached verifies an ASCII-armored or binary detached
-// signature using public or private OpenPGP key material supplied by the user.
-func VerifyOpenPGPDetached(message []byte, signature, publicKeyArmor string) error {
- return withGPG(func(home string) error {
- if err := importOpenPGP(home, publicKeyArmor); err != nil {
- return err
- }
- signaturePath, err := writeGPGFile(home, "signature.asc", []byte(signature))
- if err != nil {
- return err
- }
- messagePath, err := writeGPGFile(home, "message.bin", message)
- if err != nil {
- return err
- }
- if _, _, err := runGPG(home, []string{"--verify", signaturePath, messagePath}, nil, 1<<20); err != nil {
- return fmt.Errorf("verify OpenPGP signature: %w", err)
- }
- return nil
- })
-}
-
-// EncryptOpenPGP encrypts a message to every entity in the supplied armored
-// public key ring. If privateSignerArmor is non-empty, it also signs the
-// message with the supplied private key. The result is ASCII armored.
-func EncryptOpenPGP(message []byte, recipientKeyArmor, privateSignerArmor string) (string, error) {
- var encrypted []byte
- err := withGPG(func(home string) error {
- if err := importOpenPGP(home, recipientKeyArmor); err != nil {
- return err
- }
- recipientFingerprints, err := listFingerprints(home)
- if err != nil {
- return err
- }
- args := []string{"--armor", "--trust-model", "always", "--encrypt", "--output", "-"}
- for _, fingerprint := range recipientFingerprints {
- args = append(args, "--recipient", fingerprint)
- }
- if strings.TrimSpace(privateSignerArmor) != "" {
- if err := importOpenPGP(home, privateSignerArmor); err != nil {
- return err
- }
- signerFingerprints, err := listFingerprints(home)
- if err != nil {
- return err
- }
- args = append(args, "--sign", "--local-user", signerFingerprints[0])
- }
- output, _, err := runGPG(home, args, message, maxOpenPGPMessageBytes)
- if err != nil {
- return fmt.Errorf("encrypt OpenPGP message: %w", err)
- }
- encrypted = output
- return nil
- })
- return string(encrypted), err
-}
-
-// DecryptOpenPGP decrypts an ASCII-armored or binary OpenPGP message with a
-// user-provided private key. A bad embedded signature is rejected.
-func DecryptOpenPGP(message []byte, privateKeyArmor string) ([]byte, error) {
- var plaintext []byte
- err := withGPG(func(home string) error {
- if err := importOpenPGP(home, privateKeyArmor); err != nil {
- return err
- }
- output, status, err := runGPG(home, []string{"--status-fd", "2", "--decrypt", "--output", "-"}, message, maxOpenPGPMessageBytes)
- if strings.Contains(status, "[GNUPG:] BADSIG") || strings.Contains(status, "[GNUPG:] ERRSIG") {
- return errors.New("authenticate OpenPGP message: invalid signature")
- }
- if err != nil {
- return fmt.Errorf("decrypt OpenPGP message: %w", err)
- }
- plaintext = output
- return nil
- })
- return plaintext, err
-}
diff --git a/internal/cryptokit/yubicrypt.go b/internal/cryptokit/yubicrypt.go
index 851d564..d9b641d 100644
--- a/internal/cryptokit/yubicrypt.go
+++ b/internal/cryptokit/yubicrypt.go
@@ -12,6 +12,23 @@ import (
const maxYubiCryptMessageBytes = 64 << 20
+type limitedBuffer struct {
+ buffer bytes.Buffer
+ limit int
+}
+
+func (b *limitedBuffer) Write(value []byte) (int, error) {
+ remaining := b.limit - b.buffer.Len()
+ if remaining <= 0 {
+ return len(value), nil
+ }
+ if len(value) > remaining {
+ _, _ = b.buffer.Write(value[:remaining])
+ return len(value), nil
+ }
+ return b.buffer.Write(value)
+}
+
var yubiCryptCandidates = []string{
"/home/gabriel1/bin/yubicrypt",
"/home/gabriel1/bin/yubicrpt-cli",
@@ -120,37 +137,6 @@ func checkYubiMessageSize(message []byte) error {
return nil
}
-// EncryptYubiCrypt encrypts with an RSA public certificate/key accepted by
-// yubicrypt-cli. The private decryption key remains inside the YubiKey PIV
-// slot 9d and is never read by Aegis.
-func EncryptYubiCrypt(message []byte, recipientKeyPEM string) ([]byte, error) {
- if err := checkYubiMessageSize(message); err != nil {
- return nil, err
- }
- if strings.TrimSpace(recipientKeyPEM) == "" {
- return nil, errors.New("YubiCrypt RSA recipient certificate is required")
- }
- var result []byte
- err := withYubiTemp(func(directory string) error {
- messagePath, err := writeYubiTemp(directory, "message.bin", message)
- if err != nil {
- return err
- }
- keyPath, err := writeYubiTemp(directory, "recipient.pem", []byte(recipientKeyPEM))
- if err != nil {
- return err
- }
- result, err = runYubiCrypt([]string{
- "encrypt", "--quiet", "--key", keyPath, "--input", messagePath, "--output", "-",
- }, nil)
- if err != nil {
- return fmt.Errorf("YubiCrypt encryption failed: %w", err)
- }
- return nil
- })
- return result, err
-}
-
func requireYubiPIN(pin string) ([]byte, error) {
pin = strings.TrimRight(pin, "\r\n")
if pin == "" {
@@ -186,33 +172,6 @@ func SignYubiCrypt(message []byte, pin string) ([]byte, error) {
return result, err
}
-// DecryptYubiCrypt decrypts with the YubiKey PIV slot 9d. The ciphertext is
-// kept in a temporary file while the PIN is supplied only on stdin.
-func DecryptYubiCrypt(ciphertext []byte, pin string) ([]byte, error) {
- if err := checkYubiMessageSize(ciphertext); err != nil {
- return nil, err
- }
- pinInput, err := requireYubiPIN(pin)
- if err != nil {
- return nil, err
- }
- var result []byte
- err = withYubiTemp(func(directory string) error {
- ciphertextPath, err := writeYubiTemp(directory, "ciphertext.yc", ciphertext)
- if err != nil {
- return err
- }
- result, err = runYubiCrypt([]string{
- "decrypt", "--quiet", "--pin-stdin", "--input", ciphertextPath, "--output", "-",
- }, pinInput)
- if err != nil {
- return fmt.Errorf("YubiCrypt decryption failed: %w", err)
- }
- return nil
- })
- return result, err
-}
-
// VerifyYubiCrypt verifies a yubicrypt signature block and returns the
// original message only after the CLI has authenticated it.
func VerifyYubiCrypt(signedMessage []byte) ([]byte, error) {