Skip to content

PGP Encrypt & Decrypt — Java source

Encrypt or decrypt messages with PGP public/private keys. Powered by OpenPGP.js, runs entirely in your browser.

This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// PGP Encrypt & Decrypt — read OpenPGP key metadata, encrypt (optionally
// signing), decrypt.
//
// Language: Java (17+, standard library only — java.lang.ProcessBuilder drives
//            the `gpg` binary, the same engine the C port reaches through
//            GPGME and the native counterpart to the TS reference's openpgp.js:
//            the JDK has no OpenPGP implementation)
// Ported from src/lib/pgp-encrypt.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Three entry points, matching the TS public API one-for-one:
//   readKeyInfo — user ID, uppercase fingerprint, algorithm, creation date,
//                 expiry (null when the key never expires), and whether the
//                 armor holds a private key.
//   pgpEncrypt  — ASCII-armored PGP message for a recipient's public key,
//                 optionally signed with the sender's private key.
//   pgpDecrypt  — plaintext from an armored message + private key.
//
// Armored key material is imported into a fresh 0700 GNUPGHOME under a temp
// directory that is deleted on the way out, so nothing is written to the
// user's real keyring — the Java equivalent of the TS island keeping every
// operation client-side.
//
// gpg is always spawned with an argument ARRAY, never a command string, so no
// key, message or passphrase is ever parsed by a shell. Passphrases go in via
// --passphrase-file on a 0600 temp file rather than argv, where any local
// user could read them out of the process table.

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;

public final class PgpEncrypt {

    /** Key metadata, mirroring PGPKeyInfo in the TS reference. */
    public record KeyInfo(
            String userID,
            String fingerprint, // uppercase hex
            String algorithm,
            String creationDate, // yyyy-MM-dd (UTC)
            String expiry,      // yyyy-MM-dd (UTC), null when the key never expires
            boolean isPrivate) {
    }

    private static final DateTimeFormatter ISO_DATE =
            DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);

    /** gpg public-key algorithm codes -> openpgp.js-style algorithm names. */
    private static final Map<String, String> ALGO_NAMES = Map.of(
            "1", "rsa", "2", "rsa", "3", "rsa",
            "16", "elgamal",
            "17", "dsa",
            "18", "ecdh",
            "19", "ecdsa",
            "22", "eddsa",
            "25", "ecdh");

    private PgpEncrypt() {
    }

    /**
     * Read a PGP key (public or private) and extract its metadata.
     * Throws IllegalArgumentException on invalid or unrecognized key material.
     */
    public static KeyInfo readKeyInfo(String armoredKey) throws IOException, InterruptedException {
        if (armoredKey == null || armoredKey.isEmpty()) {
            throw new IllegalArgumentException("Key input is empty or invalid.");
        }

        String trimmed = armoredKey.trim();

        // Detect key type from armor header, like the TS reference.
        boolean isPrivateKey = trimmed.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----")
                || trimmed.contains("-----BEGIN PGP SECRET KEY BLOCK-----");

        try (EphemeralHome home = new EphemeralHome()) {
            Path keyFile = home.write("key.asc", trimmed);
            runGpg(home, List.of("--import", keyFile.toString()), null, null);

            // --with-colons output: pub line fields (0-based after ':' split)
            //   [3] = algorithm code, [5] = creation epoch, [6] = expiry epoch
            //   fpr line [9] = fingerprint, uid line [9] = user ID.
            String listing = runGpg(home, List.of("--with-colons", "--list-keys"), null, null);
            String fingerprint = null;
            String userID = null;
            String algorithm = null;
            long creation = 0;
            String expiryField = null;
            for (String line : listing.split("\n")) {
                String[] f = line.split(":", -1);
                if (f.length < 10) continue;
                switch (f[0]) {
                    case "pub" -> {
                        algorithm = ALGO_NAMES.getOrDefault(f[3], f[3]);
                        creation = Long.parseLong(f[5]);
                        expiryField = f[6];
                    }
                    case "fpr" -> {
                        if (fingerprint == null) fingerprint = f[9];
                    }
                    case "uid" -> {
                        if (userID == null && !f[9].isEmpty()) userID = f[9];
                    }
                    default -> { }
                }
            }
            if (fingerprint == null) {
                throw new IllegalArgumentException("Invalid PGP key: no primary key packet.");
            }
            return new KeyInfo(
                    userID == null ? "unknown" : userID,
                    fingerprint.toUpperCase(Locale.ROOT),
                    algorithm == null ? "unknown" : algorithm,
                    ISO_DATE.format(Instant.ofEpochSecond(creation)),
                    expiryField == null || expiryField.isEmpty() ? null : ISO_DATE.format(Instant.ofEpochSecond(Long.parseLong(expiryField))),
                    isPrivateKey);
        } catch (IOException | InterruptedException | RuntimeException e) {
            if (e instanceof IllegalArgumentException) throw (IllegalArgumentException) e;
            throw new IllegalArgumentException("Invalid PGP key: " + e.getMessage(), e);
        }
    }

    /**
     * Encrypt a plaintext message for a recipient's public key.
     * Returns an ASCII-armored PGP message.
     *
     * Optionally signs with the sender's private key (signingPrivateKeyArmored + passphrase).
     */
    public static String pgpEncrypt(String message, String publicKeyArmored,
            String signingPrivateKeyArmored, String passphrase)
            throws IOException, InterruptedException {
        if (message == null || message.isEmpty()) {
            throw new IllegalArgumentException("Message must not be empty.");
        }
        if (publicKeyArmored == null || publicKeyArmored.isEmpty()) {
            throw new IllegalArgumentException("Recipient public key must not be empty.");
        }

        try (EphemeralHome home = new EphemeralHome()) {
            Path recipient = home.write("recipient.asc", publicKeyArmored.trim());
            runGpg(home, List.of("--import", recipient.toString()), null, null);
            String toFingerprint = primaryFingerprint(home);

            List<String> args = new ArrayList<>(List.of(
                    "--batch", "--yes", "--trust-model", "always",
                    "--armor", "--encrypt", "--recipient", toFingerprint));
            if (signingPrivateKeyArmored != null && !signingPrivateKeyArmored.isEmpty()) {
                Path signer = home.write("signer.asc", signingPrivateKeyArmored.trim());
                runGpg(home, List.of("--import", signer.toString()), null, null);
                args.add("--local-user");
                args.add(primaryFingerprint(home, true));
                if (passphrase != null && !passphrase.isEmpty()) {
                    args.add("--pinentry-mode");
                    args.add("loopback");
                    args.add("--passphrase-file");
                    args.add(home.writePassphraseFile(passphrase).toString());
                }
            }
            return runGpg(home, args, message, null);
        }
    }

    /**
     * Decrypt an ASCII-armored PGP message with the recipient's private key.
     * Returns the plaintext message.
     */
    public static String pgpDecrypt(String armoredMessage, String privateKeyArmored, String passphrase)
            throws IOException, InterruptedException {
        if (armoredMessage == null || armoredMessage.isEmpty()) {
            throw new IllegalArgumentException("Armored message must not be empty.");
        }
        if (privateKeyArmored == null || privateKeyArmored.isEmpty()) {
            throw new IllegalArgumentException("Private key must not be empty.");
        }

        try (EphemeralHome home = new EphemeralHome()) {
            Path key = home.write("private.asc", privateKeyArmored.trim());
            runGpg(home, List.of("--import", key.toString()), null, null);

            List<String> args = new ArrayList<>(List.of("--batch", "--yes", "--decrypt"));
            if (passphrase != null && !passphrase.isEmpty()) {
                args.add("--pinentry-mode");
                args.add("loopback");
                args.add("--passphrase-file");
                args.add(home.writePassphraseFile(passphrase).toString());
            }
            return runGpg(home, args, armoredMessage.trim(), null);
        }
    }

    // ---------------------------------------------------------------------------
    // gpg plumbing
    // ---------------------------------------------------------------------------

    /** The fingerprint of the (secret | first) key in the homedir, from --with-colons output. */
    private static String primaryFingerprint(EphemeralHome home) throws IOException, InterruptedException {
        return primaryFingerprint(home, false);
    }

    private static String primaryFingerprint(EphemeralHome home, boolean secret)
            throws IOException, InterruptedException {
        String listing = runGpg(home,
                List.of("--with-colons", secret ? "--list-secret-keys" : "--list-keys"), null, null);
        for (String line : listing.split("\n")) {
            String[] f = line.split(":", -1);
            if (f.length >= 10 && f[0].equals("fpr")) return f[9];
        }
        throw new IllegalArgumentException("Invalid PGP key: no primary key packet.");
    }

    /**
     * Run gpg against the ephemeral homedir: feed `stdin` (nullable), return
     * stdout, and fail with stderr when the exit code is non-zero.
     */
    private static String runGpg(EphemeralHome home, List<String> args, String stdin, String unusedStdin)
            throws IOException, InterruptedException {
        List<String> command = new ArrayList<>();
        command.add("gpg");
        command.add("--homedir");
        command.add(home.path().toString());
        command.add("--batch");
        command.addAll(args);
        Process p = new ProcessBuilder(command).start();
        if (stdin != null) {
            p.getOutputStream().write(stdin.getBytes(StandardCharsets.UTF_8));
        }
        p.getOutputStream().close();
        String stdout = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        String stderr = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
        if (p.waitFor() != 0) {
            throw new IOException(stderr.isBlank() ? "gpg exited with " + p.exitValue() : stderr.trim());
        }
        return stdout;
    }

    /**
     * A throwaway GNUPGHOME: created 0700 under the system temp dir, deleted
     * recursively when closed, so imported keys and decrypted plaintext never
     * touch the user's real keyring.
     */
    static final class EphemeralHome implements AutoCloseable {
        private final Path path;

        EphemeralHome() throws IOException {
            path = Files.createTempDirectory("cosmodev-pgp");
        }

        Path path() {
            return path;
        }

        /** Write a file inside the homedir. */
        Path write(String name, String content) throws IOException {
            Path f = path.resolve(name);
            Files.writeString(f, content, StandardCharsets.UTF_8);
            return f;
        }

        /** Write the passphrase to a 0600 file — never argv (process-table visible). */
        Path writePassphraseFile(String passphrase) throws IOException {
            Path f = path.resolve("passphrase.txt");
            Files.writeString(f, passphrase, StandardCharsets.UTF_8);
            Files.setPosixFilePermissions(f, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------"));
            return f;
        }

        @Override
        public void close() {
            try (Stream<Path> walk = Files.walk(path)) {
                walk.sorted(Comparator.reverseOrder()).forEach(p -> {
                    try {
                        Files.deleteIfExists(p);
                    } catch (IOException ignored) {
                        // best-effort cleanup of a temp dir
                    }
                });
            } catch (IOException ignored) {
                // best-effort cleanup of a temp dir
            }
        }
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →