Skip to content

Age File Encryption — TypeScript source

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

This is the TypeScript 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): one simple, explicit, authenticated
// envelope instead of PGP's web of signatures, key packets, and config.
//
// age's passphrase mode wraps a file key with an scrypt-derived key; this
// implementation delivers the same security properties with Web Crypto
// primitives: PBKDF2-SHA256 (100k iterations) key stretching, a fresh random
// salt per encryption, and AES-256-GCM authenticated encryption. 100%
// client-side: no data leaves the browser.
//
// 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.

export const AGE_HEADER = 'cosmodev-age-v1';
export const SALT_LENGTH = 16;
export const IV_LENGTH = 12;
export const ITERATIONS = 100_000;

const HEADER_BYTES = new TextEncoder().encode(AGE_HEADER);
const HEADER_LENGTH = HEADER_BYTES.length; // 15

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

/** True when `data` starts with the cosmodev-age-v1 magic header. */
export function isAgeEncrypted(data: ArrayBuffer): boolean {
  if (data.byteLength < HEADER_LENGTH) return false;
  const bytes = new Uint8Array(data, 0, HEADER_LENGTH);
  for (let i = 0; i < HEADER_LENGTH; i++) {
    if (bytes[i] !== HEADER_BYTES[i]) return false;
  }
  return true;
}

function subtle(): SubtleCrypto {
  const c = globalThis.crypto;
  if (!c?.subtle) {
    throw new Error('Web Crypto is not available in this browser context.');
  }
  return c.subtle;
}

async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
  const material = await subtle().importKey(
    'raw',
    new TextEncoder().encode(passphrase),
    'PBKDF2',
    false,
    ['deriveKey']
  );
  return subtle().deriveKey(
    { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

/** Encrypt `data` under `passphrase`. Returns header || salt || IV || ciphertext+tag. */
export async function ageEncrypt(data: ArrayBuffer, passphrase: string): Promise<ArrayBuffer> {
  if (!passphrase) {
    throw new Error('Passphrase must not be empty.');
  }
  if (data.byteLength === 0) {
    throw new Error('Input data is empty - nothing to encrypt.');
  }
  const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
  const key = await deriveKey(passphrase, salt);
  const ciphertext = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv }, key, data));
  const out = new Uint8Array(HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + ciphertext.length);
  out.set(HEADER_BYTES, 0);
  out.set(salt, HEADER_LENGTH);
  out.set(iv, HEADER_LENGTH + SALT_LENGTH);
  out.set(ciphertext, HEADER_LENGTH + SALT_LENGTH + IV_LENGTH);
  return out.buffer;
}

/**
 * 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).
 */
export async function ageDecrypt(data: ArrayBuffer, passphrase: string): Promise<ArrayBuffer> {
  if (!passphrase) {
    throw new Error('Passphrase must not be empty.');
  }
  if (!isAgeEncrypted(data)) {
    throw new Error('Not an age-encrypted file (missing cosmodev-age-v1 header).');
  }
  if (data.byteLength < OVERHEAD) {
    throw new Error(
      `Input is too short to be an age-encrypted file (needs at least ${OVERHEAD} bytes: header + salt + IV + auth tag).`
    );
  }
  const bytes = new Uint8Array(data);
  const salt = bytes.slice(HEADER_LENGTH, HEADER_LENGTH + SALT_LENGTH);
  const iv = bytes.slice(HEADER_LENGTH + SALT_LENGTH, HEADER_LENGTH + SALT_LENGTH + IV_LENGTH);
  const ciphertext = bytes.slice(HEADER_LENGTH + SALT_LENGTH + IV_LENGTH);
  const key = await deriveKey(passphrase, salt);
  try {
    return await subtle().decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
  } catch {
    // A GCM auth-tag failure means the key did not match (wrong passphrase) or
    // the payload was modified after encryption.
    throw new Error('Decryption failed: wrong passphrase or corrupted file.');
  }
}

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 →