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 →