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 →