Skip to content

Age File Encryption — Zig source

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

This is the Zig 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: Zig 0.14 (standard library only)
//! Ported from: src/lib/age-encryption.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! One simple, explicit, authenticated envelope instead of PGP's web of
//! signatures, key packets, and config. The TS reference wraps a file key
//! with Web Crypto (PBKDF2-SHA256, 100k iterations + AES-256-GCM); this port
//! delivers the same security properties with Zig's standard library:
//! `std.crypto.pwhash.pbkdf2` for key stretching, a fresh random salt per
//! encryption, and `std.crypto.aead.aes_gcm.Aes256Gcm` for authenticated
//! encryption. 100% local: no data leaves the machine.
//!
//! Wire format (age-style header + body):
//!   "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.

const std = @import("std");

pub const AGE_HEADER = "cosmodev-age-v1";
pub const SALT_LENGTH = 16;
pub const IV_LENGTH = 12;
pub const ITERATIONS = 100_000;

pub const HEADER_LENGTH = AGE_HEADER.len; // 15
pub const HEADER_BYTES = AGE_HEADER.*; // comptime [15]u8

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

pub const Error = error{
    EmptyPassphrase,
    EmptyInput,
    NotAgeEncrypted,
    InputTooShort,
    DecryptionFailed,
    OutOfMemory,
};

const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm;
const Pbkdf2 = std.crypto.pwhash.pbkdf2;
const Sha256 = std.crypto.hash.sha2.Sha256;

/// True when `data` starts with the cosmodev-age-v1 magic header.
pub fn isAgeEncrypted(data: []const u8) bool {
    if (data.len < HEADER_LENGTH) return false;
    return std.mem.eql(u8, data[0..HEADER_LENGTH], AGE_HEADER);
}

/// Stretch the passphrase into a 256-bit AES-GCM key (PBKDF2-SHA256, 100k
/// iterations over the 16-byte salt).
fn deriveKey(passphrase: []const u8, salt: *const [SALT_LENGTH]u8, out: *[32]u8) void {
    Pbkdf2.sha256(passphrase, salt, ITERATIONS, out) catch unreachable;
}

/// Encrypt `data` under `passphrase`. Returns header || salt || IV || ciphertext+tag.
/// Caller owns the returned slice.
pub fn ageEncrypt(allocator: std.mem.Allocator, data: []const u8, passphrase: []const u8) Error![]u8 {
    if (passphrase.len == 0) return Error.EmptyPassphrase;
    if (data.len == 0) return Error.EmptyInput;

    var salt: [SALT_LENGTH]u8 = undefined;
    var iv: [IV_LENGTH]u8 = undefined;
    std.crypto.random.bytes(&salt);
    std.crypto.random.bytes(&iv);

    var key: [32]u8 = undefined;
    deriveKey(passphrase, &salt, &key);

    const out = try allocator.alloc(u8, HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + data.len + TAG_LENGTH);
    errdefer allocator.free(out);

    @memcpy(out[0..HEADER_LENGTH], AGE_HEADER);
    @memcpy(out[HEADER_LENGTH .. HEADER_LENGTH + SALT_LENGTH], &salt);
    @memcpy(out[HEADER_LENGTH + SALT_LENGTH .. HEADER_LENGTH + SALT_LENGTH + IV_LENGTH], &iv);

    Aes256Gcm.encrypt(
        out[HEADER_LENGTH + SALT_LENGTH + IV_LENGTH ..][0 .. data.len + TAG_LENGTH],
        data,
        AGE_HEADER, // authenticate the magic header as associated data
        iv,
        key,
    ) catch unreachable;
    return out;
}

/// Decrypt a payload produced by `ageEncrypt`. Fails when the data lacks the
/// cosmodev-age-v1 header, the passphrase is wrong, or the payload was
/// corrupted/tampered (GCM auth-tag failure). Caller owns the returned slice.
pub fn ageDecrypt(allocator: std.mem.Allocator, data: []const u8, passphrase: []const u8) Error![]u8 {
    if (passphrase.len == 0) return Error.EmptyPassphrase;
    if (!isAgeEncrypted(data)) return Error.NotAgeEncrypted;
    if (data.len < OVERHEAD) return Error.InputTooShort;

    const salt = data[HEADER_LENGTH .. HEADER_LENGTH + SALT_LENGTH][0..SALT_LENGTH];
    const iv = data[HEADER_LENGTH + SALT_LENGTH .. HEADER_LENGTH + SALT_LENGTH + IV_LENGTH][0..IV_LENGTH];
    const ciphertext = data[HEADER_LENGTH + SALT_LENGTH + IV_LENGTH ..];

    var key: [32]u8 = undefined;
    deriveKey(passphrase, salt, &key);

    const out = try allocator.alloc(u8, ciphertext.len - TAG_LENGTH);
    errdefer allocator.free(out);
    Aes256Gcm.decrypt(
        out,
        ciphertext,
        AGE_HEADER,
        iv.*,
        key,
    ) catch {
        // A GCM auth-tag failure means the key did not match (wrong
        // passphrase) or the payload was modified after encryption.
        allocator.free(out);
        return Error.DecryptionFailed;
    };
    return out;
}

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 →