Skip to content

PGP Encrypt & Decrypt — Swift source

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

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

// pgp-encrypt — read OpenPGP key metadata, encrypt (optionally signing), decrypt.
//
// Language: Swift 5.9+ (Foundation only — Foundation.Process drives the `gpg`
//           binary, the same engine the C port reaches through GPGME and the
//           native counterpart to the TS reference's openpgp.js: neither
//           Foundation nor CryptoKit implements OpenPGP)
// 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 (nil 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.
//
// Hardening, mirrored across every port of this tool:
//
//   1. Ephemeral keyring. Armored key material is imported into a fresh 0700
//      GNUPGHOME under a temp directory that is removed on the way out, so
//      nothing is written to the user's real keyring — the Swift equivalent of
//      the TS island keeping every operation client-side.
//
//   2. No shell, ever. Process gets an `arguments` ARRAY, so no key, message
//      or passphrase is parsed by /bin/sh.
//
//   3. Messages and armored keys travel over stdin/stdout; passphrases go
//      through --passphrase with --pinentry-mode loopback --batch, so no
//      secret is ever written to a file.
//
// readKeyInfo uses `--import-options show-only --import`, which parses the
// armor and prints its packets WITHOUT adding anything to the keyring — the
// closest gpg equivalent of openpgp.js `readKey`.

import Foundation

// MARK: - Types

/// Metadata extracted from one armored PGP key.
struct PGPKeyInfo {
    let userID: String
    /// v4 fingerprint, 40 uppercase hex chars (the TS reference uppercases it).
    let fingerprint: String
    let algorithm: String
    /// ISO yyyy-MM-dd.
    let creationDate: String
    /// ISO yyyy-MM-dd, nil when the key never expires.
    let expiry: String?
    let isPrivate: Bool
}

enum PGPError: Error, CustomStringConvertible {
    case emptyKeyInput
    case emptyMessage
    case emptyPublicKey
    case emptyArmoredMessage
    case emptyPrivateKey
    case invalidKey(String)
    case gpgUnavailable
    case gpgFailed(status: Int32, message: String)

    var description: String {
        switch self {
        case .emptyKeyInput: return "Key input is empty or invalid."
        case .emptyMessage: return "Message must not be empty."
        case .emptyPublicKey: return "Recipient public key must not be empty."
        case .emptyArmoredMessage: return "Armored message must not be empty."
        case .emptyPrivateKey: return "Private key must not be empty."
        case .invalidKey(let reason): return "Invalid PGP key: \(reason)"
        case .gpgUnavailable: return "Could not start gpg — is GnuPG installed and on PATH?"
        case .gpgFailed(let status, let message): return "gpg exited \(status): \(message)"
        }
    }
}

/// gpg's numeric public-key algorithm ids -> openpgp.js-style names, so the
/// `algorithm` field reads the same as `getAlgorithmInfo().algorithm` does in
/// the TS reference.
private let algorithmNames: [Int: String] = [
    1: "rsaEncryptSign",
    2: "rsaEncrypt",
    3: "rsaSign",
    16: "elgamal",
    17: "dsa",
    18: "ecdh",
    19: "ecdsa",
    22: "eddsaLegacy",
    25: "x25519",
    27: "ed25519",
]

// MARK: - Public API

/// Read a PGP key (public or private) and extract its metadata.
/// Throws on invalid or unrecognized key material.
func readKeyInfo(_ armoredKey: String) throws -> PGPKeyInfo {
    let trimmed = armoredKey.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { throw PGPError.emptyKeyInput }

    // Detect key type from the armor header, exactly as the TS reference does.
    let isPrivateKey =
        trimmed.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----")
        || trimmed.contains("-----BEGIN PGP SECRET KEY BLOCK-----")

    let home = try EphemeralGnupgHome()
    defer { home.remove() }

    let listing: String
    do {
        // show-only parses the armor and prints packets without importing.
        listing = try runGPG(
            home: home,
            arguments: ["--with-colons", "--import-options", "show-only", "--import"],
            stdin: trimmed)
    } catch PGPError.gpgFailed(_, let message) {
        throw PGPError.invalidKey(message.isEmpty ? "unrecognized key format" : message)
    }

    guard let record = parseKeyListing(listing) else {
        throw PGPError.invalidKey("unrecognized key format")
    }

    return PGPKeyInfo(
        userID: record.userID,
        fingerprint: record.fingerprint.uppercased(),
        algorithm: record.algorithm,
        creationDate: record.creationDate,
        expiry: record.expiry,
        isPrivate: isPrivateKey)
}

/// 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).
func pgpEncrypt(
    message: String,
    publicKeyArmored: String,
    signingPrivateKeyArmored: String? = nil,
    passphrase: String? = nil
) throws -> String {
    guard !message.isEmpty else { throw PGPError.emptyMessage }
    guard !publicKeyArmored.isEmpty else { throw PGPError.emptyPublicKey }

    let home = try EphemeralGnupgHome()
    defer { home.remove() }

    let recipient = try importKey(
        home: home, armored: publicKeyArmored.trimmingCharacters(in: .whitespacesAndNewlines))

    var arguments = ["--armor", "--encrypt", "--recipient", recipient]
    // The freshly imported key carries no web-of-trust path in this throwaway
    // keyring, so gpg would refuse it without an explicit trust model. The
    // caller chose this recipient, which is exactly what openpgp.js assumes.
    arguments += ["--trust-model", "always"]

    var signingPassphrase: String?
    if let signingKey = signingPrivateKeyArmored, !signingKey.isEmpty {
        let signer = try importKey(
            home: home, armored: signingKey.trimmingCharacters(in: .whitespacesAndNewlines))
        arguments += ["--sign", "--local-user", signer]
        signingPassphrase = (passphrase?.isEmpty ?? true) ? nil : passphrase
    }

    return try runGPG(
        home: home, arguments: arguments, stdin: message, passphrase: signingPassphrase)
}

/// Decrypt an ASCII-armored PGP message with the recipient's private key.
/// Returns the plaintext message.
func pgpDecrypt(
    armoredMessage: String,
    privateKeyArmored: String,
    passphrase: String? = nil
) throws -> String {
    guard !armoredMessage.isEmpty else { throw PGPError.emptyArmoredMessage }
    guard !privateKeyArmored.isEmpty else { throw PGPError.emptyPrivateKey }

    let home = try EphemeralGnupgHome()
    defer { home.remove() }

    let effectivePassphrase = (passphrase?.isEmpty ?? true) ? nil : passphrase
    _ = try importKey(
        home: home,
        armored: privateKeyArmored.trimmingCharacters(in: .whitespacesAndNewlines),
        passphrase: effectivePassphrase)

    return try runGPG(
        home: home,
        arguments: ["--decrypt"],
        stdin: armoredMessage.trimmingCharacters(in: .whitespacesAndNewlines),
        passphrase: effectivePassphrase)
}

// MARK: - Colon-listing parsing

private struct KeyRecord {
    let userID: String
    let fingerprint: String
    let algorithm: String
    let creationDate: String
    let expiry: String?
}

/// Parse a `--with-colons` listing. The first pub/sec record carries the
/// algorithm (field 4), creation (field 6) and expiry (field 7) as epoch
/// seconds; the following fpr record carries the fingerprint (field 10) and
/// the first uid record the user ID (field 10).
private func parseKeyListing(_ listing: String) -> KeyRecord? {
    var algorithm: String?
    var creationDate: String?
    var expiry: String?
    var fingerprint: String?
    var userID: String?

    for line in listing.components(separatedBy: "\n") {
        let fields = line.components(separatedBy: ":")
        guard let tag = fields.first else { continue }

        switch tag {
        case "pub", "sec":
            guard algorithm == nil, fields.count > 6 else { continue }
            let algoID = Int(fields[3]) ?? 0
            algorithm = algorithmNames[algoID] ?? "unknown"
            creationDate = isoDay(from: fields[5])
            expiry = isoDay(from: fields[6])
        case "fpr":
            if fingerprint == nil, fields.count > 9, fields[9].count == 40 {
                fingerprint = fields[9]
            }
        case "uid":
            if userID == nil, fields.count > 9, !fields[9].isEmpty {
                userID = decodeColonEscapes(fields[9])
            }
        default:
            continue
        }
    }

    guard let fingerprint, let algorithm, let creationDate else { return nil }
    return KeyRecord(
        // openpgp.js `getUserIDs()[0] || 'unknown'`.
        userID: userID ?? "unknown",
        fingerprint: fingerprint,
        algorithm: algorithm,
        creationDate: creationDate,
        expiry: expiry)
}

/// Epoch seconds -> "yyyy-MM-dd" in UTC, matching the TS reference's
/// `toISOString().split('T')[0]`. Empty or "0" means "never" -> nil.
private func isoDay(from field: String) -> String? {
    guard !field.isEmpty, let seconds = TimeInterval(field), seconds > 0 else { return nil }
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.locale = Locale(identifier: "en_US_POSIX")
    return formatter.string(from: Date(timeIntervalSince1970: seconds))
}

/// gpg escapes ':' and other reserved bytes in colon listings as `\xHH`.
private func decodeColonEscapes(_ value: String) -> String {
    guard value.contains("\\x") else { return value }
    var out = ""
    let chars = Array(value)
    var i = 0
    while i < chars.count {
        if chars[i] == "\\", i + 3 < chars.count, chars[i + 1] == "x",
           let byte = UInt8(String(chars[(i + 2)...(i + 3)]), radix: 16),
           let scalar = Unicode.Scalar(UInt32(byte)) {
            out.append(Character(scalar))
            i += 4
        } else {
            out.append(chars[i])
            i += 1
        }
    }
    return out
}

// MARK: - Key import

/// Import one armored key into the ephemeral home and return its fingerprint.
private func importKey(
    home: EphemeralGnupgHome, armored: String, passphrase: String? = nil
) throws -> String {
    do {
        _ = try runGPG(home: home, arguments: ["--import"], stdin: armored, passphrase: passphrase)
    } catch PGPError.gpgFailed(_, let message) {
        throw PGPError.invalidKey(message.isEmpty ? "unrecognized key format" : message)
    }

    let listing = try runGPG(home: home, arguments: ["--list-keys", "--with-colons"])
    for line in listing.components(separatedBy: "\n") where line.hasPrefix("fpr:") {
        let fields = line.components(separatedBy: ":")
        if fields.count > 9, fields[9].count == 40 { return fields[9] }
    }
    throw PGPError.invalidKey("no fingerprint after import")
}

// MARK: - Process plumbing

/// Spawn gpg with an argument ARRAY against the ephemeral home and return
/// stdout. Never touches a shell.
private func runGPG(
    home: EphemeralGnupgHome,
    arguments: [String],
    stdin: String? = nil,
    passphrase: String? = nil
) throws -> String {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/env")

    var argv = ["gpg", "--batch", "--yes", "--no-tty"]
    if let passphrase {
        argv += ["--pinentry-mode", "loopback", "--passphrase", passphrase]
    }
    argv += arguments
    process.arguments = argv

    var environment = ProcessInfo.processInfo.environment
    environment["GNUPGHOME"] = home.url.path
    process.environment = environment

    let stdinPipe = Pipe()
    let stdoutPipe = Pipe()
    let stderrPipe = Pipe()
    process.standardInput = stdinPipe
    process.standardOutput = stdoutPipe
    process.standardError = stderrPipe

    do { try process.run() } catch { throw PGPError.gpgUnavailable }

    // Drain both pipes on background queues while writing stdin, or a large
    // armored message fills the OS pipe buffer and both sides block forever.
    var outData = Data()
    var errData = Data()
    let group = DispatchGroup()
    let queue = DispatchQueue(label: "pgp-encrypt.gpg.io", attributes: .concurrent)

    group.enter()
    queue.async {
        outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
        group.leave()
    }
    group.enter()
    queue.async {
        errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
        group.leave()
    }

    if let stdin, let data = stdin.data(using: .utf8) {
        stdinPipe.fileHandleForWriting.write(data)
    }
    stdinPipe.fileHandleForWriting.closeFile()

    process.waitUntilExit()
    group.wait()

    guard process.terminationStatus == 0 else {
        let message = String(data: errData, encoding: .utf8)?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        throw PGPError.gpgFailed(status: process.terminationStatus, message: message)
    }
    return String(data: outData, encoding: .utf8) ?? ""
}

/// A fresh 0700 GNUPGHOME under a temp directory, removed (best-effort) by the
/// caller's `defer`. gpg refuses a world-readable homedir, and this way no key
/// ever touches the user's real keyring.
private final class EphemeralGnupgHome {
    let url: URL

    init() throws {
        url = FileManager.default.temporaryDirectory
            .appendingPathComponent("cosmodev-gnupg-\(UUID().uuidString)")
        try FileManager.default.createDirectory(
            at: url,
            withIntermediateDirectories: true,
            attributes: [.posixPermissions: 0o700])
    }

    func remove() {
        try? FileManager.default.removeItem(at: url)
    }
}

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 →