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-encryption — passphrase-based file encryption in the spirit of the age
 *                  format (age-encryption.org/v1).
 *
 * Language: C (C11, standard library + OpenSSL 3.x libcrypto — C has no crypto
 *           in its standard library; libcrypto is the de-facto native choice)
 * Source:   CosmoDev polyglot showcase port of the Age File 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 port
 * delivers the same security properties with the same primitives the TS
 * reference uses via Web Crypto: PBKDF2-SHA256 (100k iterations) key
 * stretching, a fresh random salt per encryption, and AES-256-GCM
 * authenticated encryption.
 *
 * 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.
 *
 * Note on tag placement: Web Crypto appends the GCM tag to the ciphertext.
 * OpenSSL hands it back separately, so this port appends it explicitly — the
 * output is interchangeable with the TS implementation's.
 *
 * Build: cc -std=c11 age.c -lcrypto
 */

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/rand.h>

/* ---------------------------------------------------------------- format --- */

#define AGE_HEADER    "cosmodev-age-v1"
#define HEADER_LENGTH 15u /* strlen(AGE_HEADER) */
#define SALT_LENGTH   16u
#define IV_LENGTH     12u
#define TAG_LENGTH    16u
#define KEY_LENGTH    32u /* AES-256 */
#define ITERATIONS    100000

/* GCM appends a 16-byte auth tag, so the smallest possible encrypted payload
 * is header + salt + IV + tag = 59 bytes. */
#define OVERHEAD (HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + TAG_LENGTH)

/* Owning byte buffer. `data` is heap-allocated; free with age_buf_free(). */
typedef struct {
    uint8_t *data;
    size_t   len;
} age_buf;

/* Result of an encrypt/decrypt call. On failure `ok` is false and `error`
 * holds a caller-facing message (never a raw OpenSSL string — the TS
 * reference deliberately collapses crypto failures into one message so a
 * decrypt oracle cannot distinguish "wrong key" from "tampered"). */
typedef struct {
    bool    ok;
    age_buf out;
    char    error[160];
} age_result;

void age_buf_free(age_buf *b)
{
    if (b == NULL || b->data == NULL) {
        return;
    }
    OPENSSL_cleanse(b->data, b->len);
    free(b->data);
    b->data = NULL;
    b->len  = 0;
}

static age_result age_fail(const char *msg)
{
    age_result r = { .ok = false, .out = { NULL, 0 } };
    snprintf(r.error, sizeof r.error, "%s", msg);
    return r;
}

/* ------------------------------------------------------------- detection --- */

/* True when `data` starts with the cosmodev-age-v1 magic header. */
bool is_age_encrypted(const uint8_t *data, size_t len)
{
    if (data == NULL || len < HEADER_LENGTH) {
        return false;
    }
    return memcmp(data, AGE_HEADER, HEADER_LENGTH) == 0;
}

/* --------------------------------------------------------------- key kdf --- */

/* PBKDF2-SHA256(passphrase, salt, 100_000) -> 32-byte AES-256 key. */
static bool derive_key(const char *passphrase,
                       const uint8_t salt[SALT_LENGTH],
                       uint8_t key[KEY_LENGTH])
{
    return PKCS5_PBKDF2_HMAC(passphrase, (int)strlen(passphrase),
                             salt, (int)SALT_LENGTH,
                             ITERATIONS, EVP_sha256(),
                             (int)KEY_LENGTH, key) == 1;
}

/* ------------------------------------------------------------- encrypting --- */

/*
 * Encrypt `data` under `passphrase`.
 * Returns header || salt || IV || ciphertext || tag.
 */
age_result age_encrypt(const uint8_t *data, size_t len, const char *passphrase)
{
    if (passphrase == NULL || *passphrase == '\0') {
        return age_fail("Passphrase must not be empty.");
    }
    if (len == 0) {
        return age_fail("Input data is empty - nothing to encrypt.");
    }

    uint8_t salt[SALT_LENGTH];
    uint8_t iv[IV_LENGTH];
    if (RAND_bytes(salt, (int)SALT_LENGTH) != 1 ||
        RAND_bytes(iv, (int)IV_LENGTH) != 1) {
        return age_fail("Secure random number generator is unavailable.");
    }

    uint8_t key[KEY_LENGTH];
    if (!derive_key(passphrase, salt, key)) {
        return age_fail("Key derivation failed.");
    }

    /* header || salt || IV || ciphertext (== plaintext length for GCM) || tag */
    size_t   out_len = HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + len + TAG_LENGTH;
    uint8_t *out     = malloc(out_len);
    if (out == NULL) {
        OPENSSL_cleanse(key, sizeof key);
        return age_fail("Out of memory.");
    }

    memcpy(out, AGE_HEADER, HEADER_LENGTH);
    memcpy(out + HEADER_LENGTH, salt, SALT_LENGTH);
    memcpy(out + HEADER_LENGTH + SALT_LENGTH, iv, IV_LENGTH);

    uint8_t *body = out + HEADER_LENGTH + SALT_LENGTH + IV_LENGTH;

    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    if (ctx == NULL) {
        free(out);
        OPENSSL_cleanse(key, sizeof key);
        return age_fail("Out of memory.");
    }

    bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 &&
              EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, (int)IV_LENGTH, NULL) == 1 &&
              EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv) == 1;

    int written = 0;
    int total   = 0;
    if (ok) {
        ok = EVP_EncryptUpdate(ctx, body, &written, data, (int)len) == 1;
        total += written;
    }
    if (ok) {
        ok = EVP_EncryptFinal_ex(ctx, body + total, &written) == 1;
        total += written;
    }
    if (ok) {
        /* Append the tag so the payload matches Web Crypto's layout. */
        ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG,
                                 (int)TAG_LENGTH, body + total) == 1;
    }

    EVP_CIPHER_CTX_free(ctx);
    OPENSSL_cleanse(key, sizeof key);

    if (!ok || (size_t)total != len) {
        OPENSSL_cleanse(out, out_len);
        free(out);
        return age_fail("Encryption failed.");
    }

    age_result r = { .ok = true, .out = { out, out_len } };
    r.error[0]   = '\0';
    return r;
}

/* ------------------------------------------------------------- decrypting --- */

/*
 * Decrypt a payload produced by age_encrypt(). Fails when the data lacks the
 * cosmodev-age-v1 header, the passphrase is wrong, or the payload was
 * corrupted/tampered (GCM auth-tag failure).
 */
age_result age_decrypt(const uint8_t *data, size_t len, const char *passphrase)
{
    if (passphrase == NULL || *passphrase == '\0') {
        return age_fail("Passphrase must not be empty.");
    }
    if (!is_age_encrypted(data, len)) {
        return age_fail("Not an age-encrypted file (missing cosmodev-age-v1 header).");
    }
    if (len < OVERHEAD) {
        age_result r = age_fail("");
        snprintf(r.error, sizeof r.error,
                 "Input is too short to be an age-encrypted file (needs at least "
                 "%u bytes: header + salt + IV + auth tag).",
                 (unsigned)OVERHEAD);
        return r;
    }

    const uint8_t *salt = data + HEADER_LENGTH;
    const uint8_t *iv   = data + HEADER_LENGTH + SALT_LENGTH;
    const uint8_t *body = data + HEADER_LENGTH + SALT_LENGTH + IV_LENGTH;

    /* The trailing TAG_LENGTH bytes of the body are the GCM tag. */
    size_t         ct_len = len - OVERHEAD;
    const uint8_t *tag    = body + ct_len;

    uint8_t key[KEY_LENGTH];
    if (!derive_key(passphrase, salt, key)) {
        return age_fail("Key derivation failed.");
    }

    /* malloc(0) may return NULL; keep one spare byte so `out` is always valid. */
    uint8_t *out = malloc(ct_len + 1);
    if (out == NULL) {
        OPENSSL_cleanse(key, sizeof key);
        return age_fail("Out of memory.");
    }

    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
    if (ctx == NULL) {
        free(out);
        OPENSSL_cleanse(key, sizeof key);
        return age_fail("Out of memory.");
    }

    bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 &&
              EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, (int)IV_LENGTH, NULL) == 1 &&
              EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv) == 1;

    int written = 0;
    int total   = 0;
    if (ok && ct_len > 0) {
        ok = EVP_DecryptUpdate(ctx, out, &written, body, (int)ct_len) == 1;
        total += written;
    }
    if (ok) {
        /* EVP_CTRL_GCM_SET_TAG takes a non-const pointer even though it only
         * reads; the cast keeps our own API honestly const-correct. */
        ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, (int)TAG_LENGTH,
                                 (void *)(uintptr_t)tag) == 1;
    }
    if (ok) {
        /* Final is where GCM verifies the tag: != 1 means wrong key or tamper. */
        ok = EVP_DecryptFinal_ex(ctx, out + total, &written) == 1;
        total += written;
    }

    EVP_CIPHER_CTX_free(ctx);
    OPENSSL_cleanse(key, sizeof key);

    if (!ok) {
        OPENSSL_cleanse(out, ct_len + 1);
        free(out);
        /* One message for both causes — do not leak which check failed. */
        return age_fail("Decryption failed: wrong passphrase or corrupted file.");
    }

    age_result r = { .ok = true, .out = { out, (size_t)total } };
    r.error[0]   = '\0';
    return r;
}

/* -------------------------------------------------------------- demo main --- */

#ifdef AGE_DEMO
int main(void)
{
    const char *msg  = "attack at dawn";
    const char *pass = "correct horse battery staple";

    age_result enc = age_encrypt((const uint8_t *)msg, strlen(msg), pass);
    if (!enc.ok) {
        fprintf(stderr, "encrypt: %s\n", enc.error);
        return 1;
    }
    printf("encrypted %zu bytes -> %zu bytes (age header: %s)\n",
           strlen(msg), enc.out.len,
           is_age_encrypted(enc.out.data, enc.out.len) ? "yes" : "no");

    age_result dec = age_decrypt(enc.out.data, enc.out.len, pass);
    if (!dec.ok) {
        fprintf(stderr, "decrypt: %s\n", dec.error);
        age_buf_free(&enc.out);
        return 1;
    }
    printf("round-trip: %.*s\n", (int)dec.out.len, (const char *)dec.out.data);

    age_result bad = age_decrypt(enc.out.data, enc.out.len, "wrong");
    printf("wrong passphrase: %s\n", bad.ok ? "ACCEPTED (bug!)" : bad.error);

    age_buf_free(&enc.out);
    age_buf_free(&dec.out);
    age_buf_free(&bad.out);
    return 0;
}
#endif

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 →