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 →