Skip to content

Age File Encryption — Swift source

Encrypt and decrypt files with age — a modern, simple alternative to PGP. Password-based encryption runs entirely in your browser.

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

// age-encryption — passphrase-based file encryption in the spirit of the age
// format (age-encryption.org/v1).
//
// Language: Swift 5.9+ (Foundation + CryptoKit + CommonCrypto)
// Ported from src/lib/age-encryption.ts
// display source — part of CosmoDev's polyglot tool pages
//
// age's passphrase mode wraps a file key with an scrypt-derived key; this
// implementation delivers the same security properties as the TS reference
// with system primitives: PBKDF2-SHA256 (100k iterations) key stretching via
// CommonCrypto (CryptoKit has no PBKDF2), a fresh random salt per encryption,
// and AES-256-GCM authenticated encryption via CryptoKit.
//
// Wire format (age-style header + body), byte-identical to the TS reference:
//   "cosmodev-age-v1" (15 B ASCII magic) || salt (16 B) || IV (12 B)
//   || AES-256-GCM ciphertext + tag (16 B)
// The header makes the format self-describing and detectable; the 256-bit key
// is derived from the passphrase, so the same file + passphrase never encrypts
// to the same bytes and the passphrase is never derivable from the output.

import Foundation
import CryptoKit
import CommonCrypto

let AGE_HEADER = "cosmodev-age-v1"
let SALT_LENGTH = 16
let IV_LENGTH = 12
let ITERATIONS = 100_000

let HEADER_BYTES = Array(AGE_HEADER.utf8)
let HEADER_LENGTH = HEADER_BYTES.count  // 15

// GCM appends a 16-byte auth tag to the ciphertext; the smallest possible
// encrypted payload is therefore header + salt + IV + tag = 59 bytes.
let TAG_LENGTH = 16
let OVERHEAD = HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + TAG_LENGTH

enum AgeError: Error, LocalizedError {
    case emptyPassphrase
    case emptyInput
    case notAgeEncrypted
    case tooShort(Int)
    case decryptionFailed

    var errorDescription: String? {
        switch self {
        case .emptyPassphrase:
            return "Passphrase must not be empty."
        case .emptyInput:
            return "Input data is empty - nothing to encrypt."
        case .notAgeEncrypted:
            return "Not an age-encrypted file (missing cosmodev-age-v1 header)."
        case .tooShort(let min):
            return "Input is too short to be an age-encrypted file (needs at least \(min) bytes: header + salt + IV + auth tag)."
        case .decryptionFailed:
            return "Decryption failed: wrong passphrase or corrupted file."
        }
    }
}

// MARK: - Format detection

/// True when `data` starts with the cosmodev-age-v1 magic header.
func isAgeEncrypted(_ data: [UInt8]) -> Bool {
    if data.count < HEADER_LENGTH { return false }
    return HEADER_BYTES.indices.allSatisfy { data[$0] == HEADER_BYTES[$0] }
}

// MARK: - Key derivation

/// PBKDF2-SHA256 (100k iterations) -> a 256-bit key.
/// (CommonCrypto supplies PBKDF2 — CryptoKit has no password-based KDF.)
func deriveKey(_ passphrase: String, _ salt: [UInt8]) -> SymmetricKey {
    var key = [UInt8](repeating: 0, count: 32)
    let pw = Array(passphrase.utf8).map { CChar(bitPattern: $0) }
    _ = CCKeyDerivationPBKDF(
        CCPBKDFAlgorithm(kCCPBKDF2),
        pw, pw.count,
        salt, salt.count,
        CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
        UInt32(ITERATIONS),
        &key, key.count)
    return SymmetricKey(data: Data(key))
}

/// 32 CSPRNG bytes — SystemRandomNumberGenerator is the system CSPRNG
/// (SecRandom on Apple platforms), the counterpart of crypto.getRandomValues.
func randomBytes(_ length: Int) -> [UInt8] {
    var csprng = SystemRandomNumberGenerator()
    return (0..<length).map { _ in UInt8.random(in: .min ... .max, using: &csprng) }
}

// MARK: - Encrypt / decrypt

/// Encrypt `data` under `passphrase`. Returns header || salt || IV || ciphertext+tag.
func ageEncrypt(_ data: [UInt8], passphrase: String) throws -> [UInt8] {
    if passphrase.isEmpty { throw AgeError.emptyPassphrase }
    if data.isEmpty { throw AgeError.emptyInput }

    let salt = randomBytes(SALT_LENGTH)
    let iv = randomBytes(IV_LENGTH)
    let key = deriveKey(passphrase, salt)
    let nonce = try AES.GCM.Nonce(data: Data(iv))
    // GCM seal output = ciphertext || 16-byte tag — the exact concatenation
    // the TS reference stores (WebCrypto's encrypt result).
    let sealed = try AES.GCM.seal(Data(data), using: key, nonce: nonce)
    let body = sealed.ciphertext + sealed.tag

    var out = [UInt8]()
    out.reserveCapacity(OVERHEAD + data.count)
    out += HEADER_BYTES
    out += salt
    out += iv
    out += body
    return out
}

/// Decrypt a payload produced by `ageEncrypt`. Throws when the data lacks the
/// cosmodev-age-v1 header, the passphrase is wrong, or the payload was
/// corrupted/tampered (GCM auth-tag failure).
func ageDecrypt(_ data: [UInt8], passphrase: String) throws -> [UInt8] {
    if passphrase.isEmpty { throw AgeError.emptyPassphrase }
    if !isAgeEncrypted(data) { throw AgeError.notAgeEncrypted }
    if data.count < OVERHEAD { throw AgeError.tooShort(OVERHEAD) }

    let salt = Array(data[HEADER_LENGTH..<(HEADER_LENGTH + SALT_LENGTH)])
    let iv = Array(data[(HEADER_LENGTH + SALT_LENGTH)..<(HEADER_LENGTH + SALT_LENGTH + IV_LENGTH)])
    let body = Array(data[(HEADER_LENGTH + SALT_LENGTH + IV_LENGTH)...])
    let ciphertext = body.dropLast(TAG_LENGTH)
    let tag = Data(body.suffix(TAG_LENGTH))
    let key = deriveKey(passphrase, salt)
    do {
        // A GCM auth-tag failure means the key did not match (wrong
        // passphrase) or the payload was modified after encryption.
        let box = try AES.GCM.SealedBox(
            nonce: AES.GCM.Nonce(data: Data(iv)),
            ciphertext: Data(ciphertext),
            tag: tag)
        return Array(try AES.GCM.open(box, using: key))
    } catch {
        throw AgeError.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 →