summaryrefslogtreecommitdiffstats
path: root/internal/cryptokit/openpgp.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
commitc1decadb590c4d79d92bcc4df7772119a5c91244 (patch)
tree468f9fa37535dbb90cc5d4061eb20de83912131e /internal/cryptokit/openpgp.go
downloadaegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip
Initial Aegis Usenet client release
Diffstat (limited to 'internal/cryptokit/openpgp.go')
-rw-r--r--internal/cryptokit/openpgp.go240
1 files changed, 240 insertions, 0 deletions
diff --git a/internal/cryptokit/openpgp.go b/internal/cryptokit/openpgp.go
new file mode 100644
index 0000000..bf766b5
--- /dev/null
+++ b/internal/cryptokit/openpgp.go
@@ -0,0 +1,240 @@
+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
+}