Skip to content

PGP Key Generator — Java source

Generate PGP key pairs (ECC or RSA) in your browser. Download your public and private keys. Powered by OpenPGP.js.

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

// PGP Key Generator — ASCII-armored OpenPGP key pairs with an optional
// passphrase and a revocation certificate.
//
// 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-keygen.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Public API, matching the TS reference one-for-one:
//   validateKeyGenIdentity — rejects an empty name, an empty email, or an
//                            email without one @ and a dotted domain.
//   generatePGPKeyPair     — armored public + private key, the v4 fingerprint
//                            (40 lowercase hex chars, no spaces), and the
//                            armored revocation certificate.
//
// Key structure mirrors openpgp.js exactly — a signing-only primary key plus
// a separate encryption subkey:
//   ecc     : Ed25519 primary + Cv25519 subkey (openpgp.js type "curve25519")
//   rsa2048 : RSA-2048 primary + RSA-2048 subkey
//   rsa4096 : RSA-4096 primary + RSA-4096 subkey
//
// Keys are generated inside a throwaway 0700 GNUPGHOME that is deleted on the
// way out, so nothing lands in the user's real keyring — the Java equivalent
// of the TS island generating keys client-side so the private key never
// leaves the machine.
//
// gpg is always spawned with an argument ARRAY, never a command string, so no
// user ID or passphrase is ever parsed by a shell. The passphrase reaches gpg
// via --passphrase-file on a 0600 temp file rather than argv, where any local
// user could read it out of the process table (an empty file leaves the key
// unprotected, like openpgp.js with no passphrase). Requires gpg >= 2.2
// (--quick-generate-key/--quick-add-key and the auto-written revocation
// certificate under openpgp-revocs.d/).

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.stream.Stream;

public final class PgpKeygen {

    /** Generation options, mirroring PGPKeyGenOptions in the TS reference. */
    public record KeyGenOptions(
            String name,
            String email,
            String passphrase, // optional; null/empty leaves the key unprotected
            String algorithm) { // "ecc" | "rsa2048" | "rsa4096"
    }

    /** Armored key pair + metadata, mirroring PGPKeyPair in the TS reference. */
    public record PGPKeyPair(
            String publicKey,            // BEGIN PGP PUBLIC KEY BLOCK
            String privateKey,           // BEGIN PGP PRIVATE KEY BLOCK
            String fingerprint,          // v4, 40 lowercase hex chars
            String revocationCertificate) {
    }

    /** Accepts `foo@bar.tld`-style addresses: one @, non-empty local + domain, a dot in the domain. */
    private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");

    private PgpKeygen() {
    }

    /** Validate the identity that goes into the key's user ID. Throws on invalid input. */
    public static void validateKeyGenIdentity(String name, String email) {
        if (name == null || name.trim().isEmpty()) throw new IllegalArgumentException("Name is required.");
        if (email == null || email.trim().isEmpty()) throw new IllegalArgumentException("Email is required.");
        if (!EMAIL_RE.matcher(email.trim()).matches()) throw new IllegalArgumentException("Invalid email address.");
    }

    /**
     * Generate an ASCII-armored PGP key pair. ECC (Curve25519) is fast;
     * RSA-4096 can take a few seconds. Throws IllegalArgumentException on an
     * invalid identity, IOException if gpg is missing or fails.
     */
    public static PGPKeyPair generatePGPKeyPair(KeyGenOptions options)
            throws IOException, InterruptedException {
        validateKeyGenIdentity(options.name(), options.email());

        // An empty-string passphrase would still encrypt the key; only a real
        // passphrase should.
        String pass = (options.passphrase() == null || options.passphrase().isEmpty())
                ? null
                : options.passphrase();

        // gpg algo names: openpgp.js "curve25519" maps to the Ed25519/Cv25519
        // pair below; the RSA branches carry their bit size.
        boolean ecc = "ecc".equals(options.algorithm());
        String algo = ecc ? "ed25519" : ("rsa4096".equals(options.algorithm()) ? "rsa4096" : "rsa2048");

        try (EphemeralHome home = new EphemeralHome()) {
            String userID = options.name().trim() + " <" + options.email().trim() + ">";

            // Primary key: signing + certification, never expires.
            List<String> genArgs = new ArrayList<>(passArgs(home, pass));
            genArgs.addAll(List.of(
                    "--quick-generate-key", userID, algo, "cert,sign", "never"));
            runGpg(home, genArgs);

            String fingerprint = primaryFingerprint(home);

            // openpgp.js always pairs the signing primary with an encryption
            // subkey (Cv25519 for ECC, same RSA size otherwise).
            List<String> subkeyArgs = new ArrayList<>(passArgs(home, pass));
            subkeyArgs.addAll(List.of(
                    "--quick-add-key", fingerprint, ecc ? "cv25519" : algo, "encr", "never"));
            runGpg(home, subkeyArgs);

            String publicKey = runGpg(home, List.of("--armor", "--export", fingerprint));
            // Exporting a protected secret key goes through the agent, which
            // needs the passphrase in loopback mode to unprotect it.
            List<String> exportArgs = new ArrayList<>(passArgs(home, pass));
            exportArgs.addAll(List.of("--armor", "--export-secret-keys", fingerprint));
            String privateKey = runGpg(home, exportArgs);

            // The revocation certificate: gpg >= 2.2 writes one automatically
            // next to the generated key (openpgp-revocs.d/<FPR>.rev). It is
            // read straight from there — the interactive `--gen-revoke` path
            // is unusable under --batch. The file prepends a plain-text
            // warning to the armor; strip to the armored block.
            Path revFile = home.path()
                    .resolve("openpgp-revocs.d")
                    .resolve(fingerprint.toUpperCase(Locale.ROOT) + ".rev");
            if (!Files.isRegularFile(revFile)) {
                throw new IOException("gpg wrote no revocation certificate (needs gpg >= 2.2).");
            }
            String revocationCertificate = armorBlock(Files.readString(revFile, StandardCharsets.UTF_8));

            return new PGPKeyPair(
                    publicKey.trim(),
                    privateKey.trim(),
                    fingerprint.toLowerCase(Locale.ROOT),
                    revocationCertificate);
        }
    }

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

    /**
     * The passphrase flags for loopback mode, pointing at a 0600 temp file.
     * Every gpg call that touches secret-key material needs a passphrase
     * source in --batch mode (the agent cannot prompt); an EMPTY file leaves
     * the key unprotected, exactly like openpgp.js with no passphrase.
     */
    private static List<String> passArgs(EphemeralHome home, String pass) throws IOException {
        return List.of(
                "--pinentry-mode", "loopback",
                "--passphrase-file", home.writePassphraseFile(pass == null ? "" : pass).toString());
    }

    /** The fingerprint of the primary key in the homedir, from --with-colons output. */
    private static String primaryFingerprint(EphemeralHome home) throws IOException, InterruptedException {
        String listing = runGpg(home, List.of("--with-colons", "--list-keys"));
        for (String line : listing.split("\n")) {
            String[] f = line.split(":", -1);
            if (f.length >= 10 && f[0].equals("fpr")) return f[9];
        }
        throw new IOException("gpg generated a key but reported no fingerprint.");
    }

    /**
     * Run gpg against the ephemeral homedir, return stdout, and fail with
     * stderr when the exit code is non-zero. The command is an argument
     * ARRAY — nothing is ever parsed by a shell.
     */
    private static String runGpg(EphemeralHome home, List<String> args)
            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();
        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;
    }

    /** Extract the `-----BEGIN … -----END` armored block from gpg output. */
    private static String armorBlock(String output) {
        int begin = output.indexOf("-----BEGIN");
        if (begin < 0) throw new IllegalArgumentException("No armored block in gpg output.");
        int end = output.indexOf("-----END", begin);
        if (end < 0) throw new IllegalArgumentException("Truncated armored block in gpg output.");
        int endLine = output.indexOf('\n', end);
        return output.substring(begin, endLine < 0 ? output.length() : endLine).trim();
    }

    /**
     * A throwaway GNUPGHOME: created under the system temp dir, deleted
     * recursively when closed, so the generated keys and passphrase file
     * never touch the user's real keyring.
     *
     * A fresh homedir has no gpg-agent running yet, and key generation always
     * goes through the agent — so one is launched for this homedir up front
     * (`gpgconf --launch`) and killed again on close, leaving no process
     * holding sockets inside a deleted directory.
     */
    static final class EphemeralHome implements AutoCloseable {
        private final Path path;

        EphemeralHome() throws IOException, InterruptedException {
            // Prefix is kept short on purpose: the homedir path hosts the
            // agent's Unix socket, whose name must stay under the ~104-byte
            // sun_path limit — macOS's long `/var/folders/...` temp paths eat
            // most of that budget.
            path = Files.createTempDirectory("pgp-kg");
            Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));

            List<String> command = List.of(
                    "gpgconf", "--homedir", path.toString(), "--launch", "gpg-agent");
            Process p = new ProcessBuilder(command).start();
            p.getOutputStream().close();
            String stderr = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
            if (p.waitFor() != 0) {
                throw new IOException(stderr.isBlank() ? "gpgconf failed to launch gpg-agent" : stderr.trim());
            }
        }

        Path path() {
            return path;
        }

        /** 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, PosixFilePermissions.fromString("rw-------"));
            return f;
        }

        @Override
        public void close() {
            try {
                // Best-effort: stop the homedir's agent before its sockets vanish.
                new ProcessBuilder("gpgconf", "--homedir", path.toString(), "--kill", "gpg-agent")
                        .start().waitFor();
            } catch (IOException | InterruptedException ignored) {
                // best-effort agent shutdown
            }
            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 →