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/openpgp.go | 240 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 internal/cryptokit/openpgp.go (limited to 'internal/cryptokit/openpgp.go') 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 +} -- cgit v1.2.3