Skip to content

PGP Encrypt & Decrypt — Kotlin source

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

This is the Kotlin 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: Kotlin 1.9+ (JVM), BouncyCastle bcpg (org.bouncycastle:bcpg-jdk18on).
// The JVM has no OpenPGP in the standard library; BouncyCastle's openpgp
// package is the canonical implementation (the native counterpart to the TS
// reference's openpgp.js).
// Ported from src/lib/pgp-encrypt.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// three entry points, same ASCII-armored inputs and outputs, interoperable
// with GnuPG and openpgp.js.
//
// 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.
//
// The TS reference is async because openpgp.js is; the JVM calls are
// synchronous, so this port is too.

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
import java.security.Security
import java.util.Date
import org.bouncycastle.bcpg.ArmoredInputStream
import org.bouncycastle.bcpg.ArmoredOutputStream
import org.bouncycastle.openpgp.PGPEncryptedData
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openpgp.PGPCompressedData
import org.bouncycastle.openpgp.PGPEncryptedDataGenerator
import org.bouncycastle.openpgp.PGPEncryptedDataList
import org.bouncycastle.openpgp.PGPLiteralData
import org.bouncycastle.openpgp.PGPLiteralDataGenerator
import org.bouncycastle.openpgp.PGPObjectFactory
import org.bouncycastle.openpgp.PGPPrivateKey
import org.bouncycastle.openpgp.PGPPublicKey
import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
import org.bouncycastle.openpgp.PGPSecretKey
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
import org.bouncycastle.openpgp.PGPSignature
import org.bouncycastle.openpgp.PGPSignatureGenerator
import org.bouncycastle.openpgp.PGPSignatureList
import org.bouncycastle.openpgp.PGPUtil
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder
import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder
import org.bouncycastle.openpgp.operator.jcajce.JcePGPDataEncryptorBuilder
import org.bouncycastle.openpgp.operator.jcajce.JcePublicKeyDataDecryptorFactoryBuilder
import org.bouncycastle.util.encoders.Hex

// JcaJce operator factories need the BC provider registered once.
private val BC_READY = Security.getProvider("BC") == null &&
    Security.addProvider(BouncyCastleProvider()) != null

data class PGPKeyInfo(
    val userID: String,
    val fingerprint: String,
    val algorithm: String,
    val creationDate: String,
    val expiry: String?,
    val isPrivate: Boolean,
)

// RFC 4880 public-key algorithm numbers (kept as local constants so the
// snippet compiles against any bcpg release — constant names moved around
// between versions, the numbers never do).
private object PGAlgorithms {
    const val RSA = 1
    const val RSA_ENCRYPT = 2
    const val RSA_SIGN = 3
    const val ELGAMAL = 16
    const val DSA = 17
    const val ECDH = 18
    const val ECDSA = 19
    const val EDDSA_LEGACY = 22
    const val X25519 = 25
    const val ED25519 = 27
}

/** Human-readable algorithm name, mirroring openpgp.js's getAlgorithmInfo().algorithm. */
private fun algorithmName(algorithm: Int): String = when (algorithm) {
    PGAlgorithms.RSA, PGAlgorithms.RSA_ENCRYPT, PGAlgorithms.RSA_SIGN -> "rsa"
    PGAlgorithms.EDDSA_LEGACY, PGAlgorithms.ED25519 -> "eddsa"
    PGAlgorithms.ECDSA -> "ecdsa"
    PGAlgorithms.ECDH, PGAlgorithms.X25519 -> "ecdh"
    PGAlgorithms.DSA -> "dsa"
    PGAlgorithms.ELGAMAL -> "elgamal"
    else -> "unknown"
}

private fun decode(armored: String): ArmoredInputStream =
    ArmoredInputStream(ByteArrayInputStream(armored.trim().toByteArray(Charsets.UTF_8)))

/** The first secret key able to sign (primary first, then subkeys). */
private fun signingKey(secretRing: PGPSecretKeyRing): PGPSecretKey =
    secretRing.secretKeys.asSequence().firstOrNull { it.isSigningKey }
        ?: throw IllegalArgumentException("The signing key has no key capable of creating signatures.")

/** Unlock a secret key with its passphrase (empty array for an unprotected key). */
private fun unlockKey(key: PGPSecretKey, passphrase: String?): PGPPrivateKey {
    val decryptor = JcePBESecretKeyDecryptorBuilder(PGPUtil.SHA1)
        .setProvider("BC")
        .build(passphrase?.toCharArray() ?: CharArray(0))
    return key.extractPrivateKey(decryptor)
}

/** Wrap raw packet bytes as an ASCII-armored PGP MESSAGE block. */
private fun armorMessage(bytes: ByteArray): String {
    val out = ByteArrayOutputStream()
    ArmoredOutputStream(out).use { armored ->
        armored.setHeader("Version", "CosmoDev Kotlin")
        armored.write(bytes)
    }
    return out.toString(Charsets.UTF_8)
}

/**
 * Read a PGP key (public or private) and extract its metadata.
 * Throws on invalid or unrecognized key material.
 */
fun readKeyInfo(armoredKey: String): PGPKeyInfo {
    if (armoredKey.isBlank()) throw IllegalArgumentException("Key input is empty or invalid.")

    val trimmed = armoredKey.trim()

    // Detect key type from armor header.
    val isPrivateKey =
        trimmed.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----") ||
        trimmed.contains("-----BEGIN PGP SECRET KEY BLOCK-----")

    return try {
        val fingerprints = JcaKeyFingerprintCalculator()
        val input = decode(trimmed)

        // The first key of the first ring is the key the user pasted.
        val key: PGPPublicKey = if (isPrivateKey) {
            (PGPSecretKeyRingCollection(input, fingerprints).keyRings.next() as PGPSecretKeyRing).publicKey
        } else {
            PGPPublicKeyRingCollection(input, fingerprints).keyRings.next().publicKey
        }

        // v4 fingerprints are 20 bytes (40 hex chars); v6 keys are 32 — both render as uppercase hex.
        val utc = java.time.ZoneOffset.UTC
        val creation = key.creationTime.toInstant().atZone(utc).toLocalDate().toString()
        val validSeconds = key.validSeconds // 0 = never expires
        val expiry = if (validSeconds == 0L) null else
            Date(key.creationTime.time + validSeconds * 1000).toInstant().atZone(utc).toLocalDate().toString()

        PGPKeyInfo(
            userID = key.userIDs.asSequence().firstOrNull() ?: "unknown",
            fingerprint = Hex.toHexString(key.fingerprint).uppercase(),
            algorithm = algorithmName(key.algorithm),
            creationDate = creation,
            expiry = expiry,
            isPrivate = isPrivateKey,
        )
    } catch (e: Exception) {
        throw IllegalArgumentException("Invalid PGP key: ${e.message ?: "unrecognized key format"}")
    }
}

/**
 * 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).
 * The signed payload follows RFC 4880 packet order: one-pass signature,
 * literal data, signature.
 */
fun pgpEncrypt(
    message: String,
    publicKeyArmored: String,
    signingPrivateKeyArmored: String? = null,
    passphrase: String? = null,
): String {
    if (message.isEmpty()) throw IllegalArgumentException("Message must not be empty.")
    if (publicKeyArmored.isBlank()) throw IllegalArgumentException("Recipient public key must not be empty.")

    val fingerprints = JcaKeyFingerprintCalculator()
    val publicKey = PGPPublicKeyRingCollection(decode(publicKeyArmored), fingerprints)
        .keyRings.next().publicKey

    // Optional signing key, unlocked up front so packet writing stays linear.
    val signing = signingPrivateKeyArmored?.takeIf { it.isNotBlank() }?.let {
        val ring = PGPSecretKeyRingCollection(decode(it), fingerprints).keyRings.next() as PGPSecretKeyRing
        val secret = signingKey(ring)
        val privateKey = unlockKey(secret, passphrase)
        PGPSignatureGenerator(
            JcaPGPContentSignerBuilder(secret.publicKey.algorithm, PGPUtil.SHA256).setProvider("BC"),
        ).apply { init(PGPSignature.BINARY_DOCUMENT, privateKey) }
    }

    // Inner (plaintext, signed) packet stream: [one-pass sig][literal][sig].
    val bytes = message.toByteArray(Charsets.UTF_8)
    val inner = ByteArrayOutputStream()
    signing?.generateOnePassVersion(false)?.encode(inner)
    PGPLiteralDataGenerator().open(inner, PGPLiteralData.BINARY, "message", Date(), bytes.size.toLong())
        .use { it.write(bytes) }
    signing?.update(bytes, 0, bytes.size)
    signing?.generate()?.encode(inner)

    // Outer: AES-256 session key wrapped for the recipient, integrity-protected.
    val encryptor = PGPEncryptedDataGenerator(
        JcePGPDataEncryptorBuilder(PGPEncryptedData.AES_256)
            .setWithIntegrityPacket(true)
            .setSecureRandom(SecureRandom()),
    )
    encryptor.addMethod(publicKey)

    val packetBytes = ByteArrayOutputStream()
    encryptor.open(packetBytes, 4096).use { it.write(inner.toByteArray()) }
    encryptor.close()

    return armorMessage(packetBytes.toByteArray())
}

/**
 * Decrypt an ASCII-armored PGP message with the recipient's private key.
 * Returns the plaintext message.
 */
fun pgpDecrypt(armoredMessage: String, privateKeyArmored: String, passphrase: String? = null): String {
    if (armoredMessage.isBlank()) throw IllegalArgumentException("Armored message must not be empty.")
    if (privateKeyArmored.isBlank()) throw IllegalArgumentException("Private key must not be empty.")

    val fingerprints = JcaKeyFingerprintCalculator()
    val secretRings = PGPSecretKeyRingCollection(decode(privateKeyArmored), fingerprints)

    // Walk the object stream until the encrypted data packet appears.
    val factory = PGPObjectFactory(decode(armoredMessage), fingerprints)
    var encryptedList: PGPEncryptedDataList? = null
    var obj = factory.nextObject()
    while (obj != null && encryptedList == null) {
        if (obj is PGPEncryptedDataList) encryptedList = obj
        obj = factory.nextObject()
    }
    val list = encryptedList
        ?: throw IllegalArgumentException("No encrypted data found in the armored message.")

    var literal: PGPLiteralData? = null
    var signatures: PGPSignatureList? = null
    var lastFailure: Exception? = null

    for (item in list) {
        val encrypted = item as? PGPPublicKeyEncryptedData ?: continue
        val secretKey = secretRings.getSecretKey(encrypted.keyID) ?: continue
        val privateKey = try {
            unlockKey(secretKey, passphrase)
        } catch (e: Exception) {
            lastFailure = e // likely a wrong passphrase — try the next session-key packet
            continue
        }

        val clear = PGPObjectFactory(
            encrypted.getDataStream(JcePublicKeyDataDecryptorFactoryBuilder().setProvider("BC").build(privateKey)),
            fingerprints,
        )
        var inner = clear.nextObject()
        while (inner != null) {
            when (inner) {
                is PGPCompressedData -> inner = PGPObjectFactory(inner.dataStream, fingerprints).nextObject()
                is PGPLiteralData -> literal = inner
                is PGPSignatureList -> signatures = inner
            }
            inner = clear.nextObject()
        }
        break
    }

    val data = literal ?: throw IllegalArgumentException(
        lastFailure?.message ?: "Decryption failed - could not decrypt with the supplied private key."
    )
    val plain = data.inputStream.readBytes().toString(Charsets.UTF_8)
    // Signature verification is available via `signatures` here; the TS
    // reference returns only the text, so this port does too.
    return plain
}

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 →