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 →