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 →