Skip to content

Text Encryptor — Swift source

Encrypt or decrypt text with a password using AES-256-GCM. Share the ciphertext safely - only someone with the password can read it.

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

// text-encryptor — password-based text encryption (PBKDF2 + AES-256-GCM).
//
// Language: Swift 5.9+ (Foundation + CryptoKit + CommonCrypto for PBKDF2)
// Ported from src/lib/text-encryptor.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Format: PBKDF2 (SHA-256, 100k iterations, 16-byte random salt) derives an
// AES-256 key from the password; AES-GCM encrypts with a 12-byte random IV.
// The Base64 output packs salt + IV + ciphertext (+ GCM tag), so every
// encryption is unique and self-contained — decrypt needs only the string and
// the password.

import Foundation
import CryptoKit
import CommonCrypto

let pbkdf2Iterations = 100_000
let saltBytes = 16
let ivBytes = 12
/// Smallest valid packed payload: salt + IV + one AES-GCM block (tag).
let minBytes = saltBytes + ivBytes + 16

let base64Alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")

// MARK: - Base64 codec (pure implementation, mirrors the TS reference)

/// [UInt8] -> canonical Base64.
func bytesToBase64(_ bytes: [UInt8]) -> String {
    var out = ""
    var i = 0
    while i < bytes.count {
        let b0 = Int(bytes[i])
        let b1 = i + 1 < bytes.count ? Int(bytes[i + 1]) : nil
        let b2 = i + 2 < bytes.count ? Int(bytes[i + 2]) : nil
        out.append(base64Alphabet[b0 >> 2])
        out.append(base64Alphabet[((b0 & 0x03) << 4) | (b1 ?? 0) >> 4])
        out.append(b1 == nil ? "=" : base64Alphabet[((b1! & 0x0f) << 2) | (b2 ?? 0) >> 6])
        out.append(b2 == nil ? "=" : base64Alphabet[b2! & 0x3f])
        i += 3
    }
    return out
}

/// Base64 -> [UInt8]. Accepts surrounding whitespace; throws on any other
/// non-alphabet character, wrong length, or misplaced padding.
func base64ToBytes(_ b64: String) throws -> [UInt8] {
    let clean = b64.filter { !$0.isWhitespace }
    if clean.isEmpty { throw TextEncryptorError.invalidBase64("input is empty") }
    if clean.count % 4 != 0 { throw TextEncryptorError.invalidBase64("length must be a multiple of 4") }
    let chars = Array(clean)
    if let bad = chars.first(where: { !base64Alphabet.contains($0) && $0 != "=" }) {
        throw TextEncryptorError.invalidBase64("unexpected character \"\(bad)\"")
    }
    if let badPad = chars.firstIndex(of: "=") {
        let tail = chars[(badPad + 1)...]
        if badPad < chars.count - 2 || tail.contains(where: { $0 != "=" }) {
            throw TextEncryptorError.invalidBase64("misplaced padding")
        }
    }
    var outLength = (clean.count / 4) * 3
    if clean.hasSuffix("==") { outLength -= 2 }
    else if clean.hasSuffix("=") { outLength -= 1 }
    var bytes = [UInt8](repeating: 0, count: outLength)
    var p = 0
    var i = 0
    while i < chars.count {
        let c0 = base64Alphabet.firstIndex(of: chars[i])!
        let c1 = base64Alphabet.firstIndex(of: chars[i + 1])!
        let c2 = chars[i + 2] == "=" ? nil : base64Alphabet.firstIndex(of: chars[i + 2])
        let c3 = chars[i + 3] == "=" ? nil : base64Alphabet.firstIndex(of: chars[i + 3])
        if p < outLength { bytes[p] = UInt8((c0 << 2) | (c1 >> 4)); p += 1 }
        if let c2 = c2, p < outLength { bytes[p] = UInt8(((c1 & 0x0f) << 4) | (c2 >> 2)); p += 1 }
        if let c2 = c2, let c3 = c3, p < outLength { bytes[p] = UInt8(((c2 & 0x03) << 6) | c3); p += 1 }
        i += 4
    }
    return bytes
}

// MARK: - Errors

enum TextEncryptorError: Error, CustomStringConvertible {
    case nothingToEncrypt
    case emptyPassword
    case nothingToDecrypt
    case invalidBase64(String)
    case ciphertextTooShort
    case keyDerivationFailed
    case decryptionFailed

    var description: String {
        switch self {
        case .nothingToEncrypt: return "Nothing to encrypt: input is empty"
        case .emptyPassword: return "Password must not be empty"
        case .nothingToDecrypt: return "Nothing to decrypt: input is empty"
        case .invalidBase64(let why): return "Invalid Base64: \(why)"
        case .ciphertextTooShort: return "Ciphertext too short - not a valid salt + IV + AES-GCM payload"
        case .keyDerivationFailed: return "PBKDF2 key derivation failed"
        case .decryptionFailed: return "Decryption failed - wrong password or corrupted ciphertext"
        }
    }
}

// MARK: - Key derivation

/// PBKDF2-SHA256 (100k iterations) -> a CryptoKit AES-256 key.
func deriveKey(password: String, salt: [UInt8]) throws -> SymmetricKey {
    let passwordBytes = Array(password.utf8)
    var derived = [UInt8](repeating: 0, count: 32)
    let status = salt.withUnsafeBufferPointer { saltPtr in
        passwordBytes.withUnsafeBufferPointer { pwPtr in
            CCKeyDerivationPBKDF(
                CCPBKDFAlgorithm(kCCPBKDF2),
                pwPtr.baseAddress, passwordBytes.count,
                saltPtr.baseAddress, salt.count,
                CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
                UInt32(pbkdf2Iterations),
                &derived, derived.count
            )
        }
    }
    guard status == kCCSuccess else { throw TextEncryptorError.keyDerivationFailed }
    return SymmetricKey(data: Data(derived))
}

// SystemRandomNumberGenerator is backed by a cryptographically secure source
// on Apple platforms, so it stands in for crypto.getRandomValues.
private func randomBytes(_ count: Int) -> [UInt8] {
    var rng = SystemRandomNumberGenerator()
    return (0..<count).map { _ in UInt8.random(in: .min ... .max, using: &rng) }
}

// MARK: - Public API

/// Encrypt UTF-8 `plaintext` under `password` and return the Base64 string
/// packing salt + IV + AES-256-GCM ciphertext. Throws if either input is empty.
func encryptText(_ plaintext: String, password: String) throws -> String {
    guard !plaintext.isEmpty else { throw TextEncryptorError.nothingToEncrypt }
    guard !password.isEmpty else { throw TextEncryptorError.emptyPassword }
    let salt = randomBytes(saltBytes)
    let iv = randomBytes(ivBytes)
    let key = try deriveKey(password: password, salt: salt)
    let sealed = try AES.GCM.seal(Data(plaintext.utf8), using: key, nonce: AES.GCM.Nonce(data: Data(iv)))
    // combined = ciphertext || 16-byte GCM tag (never nil: a nonce was supplied).
    guard let combined = sealed.combined else { throw TextEncryptorError.decryptionFailed }
    var packed = [UInt8]()
    packed.reserveCapacity(saltBytes + ivBytes + combined.count)
    packed += salt
    packed += iv
    packed += [UInt8](combined)
    return bytesToBase64(packed)
}

/// Decrypt a Base64 string produced by `encryptText` back to plaintext. Throws
/// on empty input/password, invalid Base64, a payload too short to be one of
/// ours, or a GCM authentication failure (wrong password / tampered data).
func decryptText(_ ciphertext: String, password: String) throws -> String {
    let trimmed = ciphertext.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { throw TextEncryptorError.nothingToDecrypt }
    guard !password.isEmpty else { throw TextEncryptorError.emptyPassword }
    let packed = try base64ToBytes(trimmed)
    guard packed.count >= minBytes else { throw TextEncryptorError.ciphertextTooShort }
    let salt = Array(packed[0..<saltBytes])
    let iv = Array(packed[saltBytes..<(saltBytes + ivBytes)])
    let data = Array(packed[(saltBytes + ivBytes)...])
    let key = try deriveKey(password: password, salt: salt)
    do {
        let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: Data(iv)),
                                        ciphertext: Data(data.dropLast(16)),
                                        tag: Data(data.suffix(16)))
        let plain = try AES.GCM.open(box, using: key)
        guard let text = String(data: plain, encoding: .utf8) else {
            throw TextEncryptorError.decryptionFailed
        }
        return text
    } catch let error as TextEncryptorError {
        throw error
    } catch {
        throw TextEncryptorError.decryptionFailed
    }
}

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 →