Skip to content

PGP Key Generator — Swift source

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

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

// pgp-keygen — ASCII-armored OpenPGP key pairs with an optional passphrase
// and a revocation certificate.
//
// 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-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), and the revocation
//                            certificate.
//
// Three hardening choices, mirrored from the Ruby/Java/C# ports:
//
//   1. Ephemeral keyring. Every call gets a fresh 0700 GNUPGHOME under a temp
//      directory that is removed on the way out, so a generated private key
//      never lands in the user's real keyring — the Swift equivalent of the TS
//      island keeping the whole operation client-side.
//
//   2. No shell, ever. Process is given `executableURL` + an `arguments`
//      ARRAY, so no name, email or passphrase is parsed by /bin/sh.
//
//   3. The batch parameter file travels over stdin, not disk. gpg's
//      `--batch --gen-key` reads its parameter file from stdin when no
//      filename is given, keeping `Passphrase:` out of the filesystem. That
//      file is line-oriented, so a newline in the name or email would inject
//      arbitrary directives — assertSafeUserIDField rejects control
//      characters before anything is written.

import Foundation

// MARK: - Types

/// Algorithm choices: ECC Curve25519 (default), RSA-2048, or RSA-4096.
enum PGPKeyGenAlgorithm {
    /// Ed25519 signing key + Curve25519 encryption subkey. Fast.
    case ecc
    case rsa2048
    case rsa4096
}

/// The identity and algorithm that define the key to generate.
struct PGPKeyGenOptions {
    /// User's real name (goes into the key's user ID packet).
    let name: String
    /// User's email (goes into the key's user ID packet).
    let email: String
    /// Optional passphrase. If given, the private key is encrypted with it.
    let passphrase: String?
    let algorithm: PGPKeyGenAlgorithm

    init(name: String, email: String, passphrase: String? = nil, algorithm: PGPKeyGenAlgorithm = .ecc) {
        self.name = name
        self.email = email
        self.passphrase = passphrase
        self.algorithm = algorithm
    }
}

/// The four armored outputs of a generation run.
struct PGPKeyPair {
    /// ASCII-armored public key (BEGIN PGP PUBLIC KEY BLOCK).
    let publicKey: String
    /// ASCII-armored private key (BEGIN PGP PRIVATE KEY BLOCK).
    let privateKey: String
    /// v4 fingerprint, 40 lowercase hex chars, no spaces.
    let fingerprint: String
    /// ASCII-armored revocation certificate.
    let revocationCertificate: String
}

enum PGPKeyGenError: Error, CustomStringConvertible {
    case nameRequired
    case emailRequired
    case invalidEmail
    case unsafeField(String)
    case gpgUnavailable
    case gpgFailed(status: Int32, message: String)
    case noFingerprint

    var description: String {
        switch self {
        case .nameRequired: return "Name is required."
        case .emailRequired: return "Email is required."
        case .invalidEmail: return "Invalid email address."
        case .unsafeField(let field): return "\(field) must not contain control characters or angle brackets."
        case .gpgUnavailable: return "Could not start gpg — is GnuPG installed and on PATH?"
        case .gpgFailed(let status, let message): return "gpg exited \(status): \(message)"
        case .noFingerprint: return "gpg produced no fingerprint — key generation failed."
        }
    }
}

// MARK: - Validation

/// Accepts `foo@bar.tld`-style addresses: one @, non-empty local + domain, a
/// dot in the domain. Mirrors EMAIL_RE in the TS reference.
private let emailRegex = try! NSRegularExpression(pattern: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#)

private func matches(_ regex: NSRegularExpression, _ value: String) -> Bool {
    let range = NSRange(value.startIndex..<value.endIndex, in: value)
    return regex.firstMatch(in: value, range: range) != nil
}

/// Validate the identity that goes into the key's user ID. Throws on invalid input.
func validateKeyGenIdentity(name: String, email: String) throws {
    guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        throw PGPKeyGenError.nameRequired
    }
    let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmedEmail.isEmpty else { throw PGPKeyGenError.emailRequired }
    guard matches(emailRegex, trimmedEmail) else { throw PGPKeyGenError.invalidEmail }
}

/// Reject anything that could break out of one line of the batch parameter
/// file. The TS reference hands a structured object to openpgp.js and needs no
/// such guard; a line-oriented gpg parameter file does.
private func assertSafeUserIDField(_ value: String, _ field: String) throws {
    for scalar in value.unicodeScalars {
        if CharacterSet.controlCharacters.contains(scalar) {
            throw PGPKeyGenError.unsafeField(field)
        }
    }
    // gpg's own user-ID grammar: these would corrupt the "Name <Email>" form.
    if value.contains("<") || value.contains(">") {
        throw PGPKeyGenError.unsafeField(field)
    }
}

// MARK: - Generation

/// Generate an ASCII-armored PGP key pair. ECC (Curve25519) is fast;
/// RSA-4096 can take a few seconds.
func generatePGPKeyPair(_ options: PGPKeyGenOptions) throws -> PGPKeyPair {
    try validateKeyGenIdentity(name: options.name, email: options.email)

    let name = options.name.trimmingCharacters(in: .whitespacesAndNewlines)
    let email = options.email.trimmingCharacters(in: .whitespacesAndNewlines)
    try assertSafeUserIDField(name, "Name")
    try assertSafeUserIDField(email, "Email")

    // An empty-string passphrase would still encrypt the key; only a real
    // passphrase should. Mirrors `passphrase ? passphrase : undefined`.
    let passphrase = (options.passphrase?.isEmpty ?? true) ? nil : options.passphrase
    if let passphrase { try assertSafeUserIDField(passphrase, "Passphrase") }

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

    let parameters = buildKeyParameters(
        algorithm: options.algorithm, name: name, email: email, passphrase: passphrase)
    _ = try runGPG(home: home, arguments: ["--gen-key"], stdin: parameters)

    let fingerprint = try readFingerprint(home: home)

    let publicKey = try runGPG(
        home: home, arguments: ["--armor", "--export", fingerprint], passphrase: passphrase)
    let privateKey = try runGPG(
        home: home, arguments: ["--armor", "--export-secret-keys", fingerprint], passphrase: passphrase)
    let revocationCertificate = readRevocationCertificate(home: home, fingerprint: fingerprint)

    return PGPKeyPair(
        publicKey: publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + "\n",
        privateKey: privateKey.trimmingCharacters(in: .whitespacesAndNewlines) + "\n",
        // The TS reference returns getFingerprint(), which is lowercase.
        fingerprint: fingerprint.lowercased(),
        revocationCertificate: revocationCertificate)
}

/// The gpg `--gen-key` parameter file. `%no-protection` is required when no
/// passphrase is given, otherwise gpg refuses to create an unprotected key in
/// batch mode.
private func buildKeyParameters(
    algorithm: PGPKeyGenAlgorithm, name: String, email: String, passphrase: String?
) -> String {
    var lines: [String] = ["%echo Generating OpenPGP key"]

    switch algorithm {
    case .ecc:
        // v6 openpgp.js `type: 'curve25519'` == Ed25519 primary + Curve25519 subkey.
        lines += [
            "Key-Type: eddsa", "Key-Curve: ed25519", "Key-Usage: sign,cert",
            "Subkey-Type: ecdh", "Subkey-Curve: cv25519", "Subkey-Usage: encrypt",
        ]
    case .rsa2048, .rsa4096:
        let bits = algorithm == .rsa4096 ? 4096 : 2048
        lines += [
            "Key-Type: rsa", "Key-Length: \(bits)", "Key-Usage: sign,cert",
            "Subkey-Type: rsa", "Subkey-Length: \(bits)", "Subkey-Usage: encrypt",
        ]
    }

    lines += [
        "Name-Real: \(name)",
        "Name-Email: \(email)",
        "Expire-Date: 0", // openpgp.js default: no expiry.
    ]
    lines.append(passphrase.map { "Passphrase: \($0)" } ?? "%no-protection")
    lines.append("%commit")

    return lines.joined(separator: "\n") + "\n"
}

/// A gpg colon-listing `fpr` record carries 40 hex chars in field 10.
private let fingerprintRegex = try! NSRegularExpression(
    pattern: #"^fpr(?::[^:]*){8}:([0-9A-Fa-f]{40}):"#, options: [.anchorsMatchLines])

/// Read the freshly generated key's v4 fingerprint from a colon listing.
private func readFingerprint(home: EphemeralGnupgHome) throws -> String {
    let listing = try runGPG(home: home, arguments: ["--list-keys", "--with-colons"])
    let range = NSRange(listing.startIndex..<listing.endIndex, in: listing)
    guard
        let match = fingerprintRegex.firstMatch(in: listing, range: range),
        let captured = Range(match.range(at: 1), in: listing)
    else { throw PGPKeyGenError.noFingerprint }
    return String(listing[captured])
}

/// gpg writes a revocation certificate automatically at generation time, into
/// `openpgp-revocs.d/<FINGERPRINT>.rev`. It is commented out with a leading
/// ':' so it cannot be imported by accident; uncomment it the way gpg's own
/// instructions say to. Returns "" when the file is absent.
private func readRevocationCertificate(home: EphemeralGnupgHome, fingerprint: String) -> String {
    let path = home.url
        .appendingPathComponent("openpgp-revocs.d")
        .appendingPathComponent("\(fingerprint.uppercased()).rev")
    guard let raw = try? String(contentsOf: path, encoding: .utf8) else { return "" }

    var armored: [String] = []
    var inBlock = false
    for rawLine in raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") {
        let line = rawLine.hasPrefix(":")
            ? String(rawLine.dropFirst()).trimmingCharacters(in: .whitespaces)
            : rawLine
        if line.hasPrefix("-----BEGIN PGP PUBLIC KEY BLOCK-----") { inBlock = true }
        guard inBlock else { continue }
        armored.append(line)
        if line.hasPrefix("-----END PGP PUBLIC KEY BLOCK-----") { break }
    }
    return armored.isEmpty ? "" : armored.joined(separator: "\n") + "\n"
}

// 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 PGPKeyGenError.gpgUnavailable }

    // Drain both pipes on background queues while writing stdin, or a large
    // armored export fills the OS pipe buffer and both sides block forever.
    var outData = Data()
    var errData = Data()
    let group = DispatchGroup()
    let queue = DispatchQueue(label: "pgp-keygen.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 PGPKeyGenError.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])
    }

    /// Best-effort teardown; gpg-agent socket leftovers are not worth throwing over.
    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 →