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 }