Skip to content

Age File Encryption — C# source

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

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

// Age File Encryption — passphrase-based file encryption in the spirit of the
// age format (age-encryption.org/v1).
//
// Language: C# (.NET 8+, standard library only)
// Source:   CosmoDev polyglot showcase port of the Age Encryption tool, ported
//           from src/lib/age-encryption.ts (the canonical TypeScript
//           implementation).
// License:  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 with .NET primitives:
// PBKDF2-SHA256 (100k iterations) key stretching, a fresh random salt per
// encryption, and AES-256-GCM authenticated encryption.
//
// 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)

using System;
using System.Security.Cryptography;
using System.Text;

public static class AgeEncryption
{
    public const string AgeHeader = "cosmodev-age-v1";
    public const int SaltLength = 16;
    public const int IvLength = 12;
    public const int Iterations = 100_000;

    private static readonly byte[] HeaderBytes = Encoding.ASCII.GetBytes(AgeHeader);
    private static readonly int HeaderLength = HeaderBytes.Length; // 15

    // GCM appends a 16-byte auth tag; the smallest possible encrypted payload
    // is therefore header + salt + IV + tag = 59 bytes.
    private const int TagLength = 16;
    private static readonly int Overhead = HeaderLength + SaltLength + IvLength + TagLength;

    /// <summary>True when <paramref name="data"/> starts with the cosmodev-age-v1 magic header.</summary>
    public static bool IsAgeEncrypted(byte[] data)
    {
        if (data.Length < HeaderLength) return false;
        for (var i = 0; i < HeaderLength; i++)
        {
            if (data[i] != HeaderBytes[i]) return false;
        }
        return true;
    }

    /// <summary>PBKDF2-SHA256 (100k iterations) → a 256-bit AES key.</summary>
    private static byte[] DeriveKey(string passphrase, byte[] salt) =>
        Rfc2898DeriveBytes.Pbkdf2(
            Encoding.UTF8.GetBytes(passphrase), salt, Iterations, HashAlgorithmName.SHA256, 32);

    /// <summary>
    /// Encrypt <paramref name="data"/> under <paramref name="passphrase"/>.
    /// Returns header || salt || IV || ciphertext+tag.
    /// </summary>
    public static byte[] AgeEncrypt(byte[] data, string passphrase)
    {
        if (string.IsNullOrEmpty(passphrase))
        {
            throw new ArgumentException("Passphrase must not be empty.");
        }
        if (data.Length == 0)
        {
            throw new ArgumentException("Input data is empty - nothing to encrypt.");
        }

        var salt = new byte[SaltLength];
        var iv = new byte[IvLength];
        RandomNumberGenerator.Fill(salt);
        RandomNumberGenerator.Fill(iv);

        var key = DeriveKey(passphrase, salt);
        var ciphertext = new byte[data.Length + TagLength];
        try
        {
            using var aes = new AesGcm(key, TagLength);
            aes.Encrypt(iv, data, ciphertext[..data.Length], ciphertext[data.Length..]);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(key);
        }

        var output = new byte[HeaderLength + SaltLength + IvLength + ciphertext.Length];
        Buffer.BlockCopy(HeaderBytes, 0, output, 0, HeaderLength);
        Buffer.BlockCopy(salt, 0, output, HeaderLength, SaltLength);
        Buffer.BlockCopy(iv, 0, output, HeaderLength + SaltLength, IvLength);
        Buffer.BlockCopy(ciphertext, 0, output, HeaderLength + SaltLength + IvLength, ciphertext.Length);
        return output;
    }

    /// <summary>
    /// Decrypt a payload produced by <see cref="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).
    /// </summary>
    public static byte[] AgeDecrypt(byte[] data, string passphrase)
    {
        if (string.IsNullOrEmpty(passphrase))
        {
            throw new ArgumentException("Passphrase must not be empty.");
        }
        if (!IsAgeEncrypted(data))
        {
            throw new FormatException("Not an age-encrypted file (missing cosmodev-age-v1 header).");
        }
        if (data.Length < Overhead)
        {
            throw new FormatException(
                $"Input is too short to be an age-encrypted file (needs at least {Overhead} bytes: header + salt + IV + auth tag).");
        }

        var salt = data[HeaderLength..(HeaderLength + SaltLength)];
        var iv = data[(HeaderLength + SaltLength)..(HeaderLength + SaltLength + IvLength)];
        var ciphertext = data[(HeaderLength + SaltLength + IvLength)..];
        var plaintext = new byte[ciphertext.Length - TagLength];

        var key = DeriveKey(passphrase, salt);
        try
        {
            using var aes = new AesGcm(key, TagLength);
            aes.Decrypt(iv, ciphertext[..plaintext.Length], ciphertext[plaintext.Length..], plaintext);
        }
        catch (CryptographicException)
        {
            throw new CryptographicException("Decryption failed: wrong passphrase or corrupted file.");
        }
        finally
        {
            CryptographicOperations.ZeroMemory(key);
        }
        return plaintext;
    }
}

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 →