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 →