Skip to content

Age File Encryption — Ruby source

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

This is the Ruby 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), via OpenSSL.
#
# Language: Ruby (3.1+, 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
# port delivers the same security properties with OpenSSL 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)
# 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.

require 'openssl'
require 'securerandom'

module AgeEncryption
  AGE_HEADER = 'cosmodev-age-v1'
  SALT_LENGTH = 16
  IV_LENGTH   = 12
  ITERATIONS  = 100_000

  HEADER_BYTES = AGE_HEADER.dup.force_encoding('BINARY')
  HEADER_LENGTH = HEADER_BYTES.bytesize # 15

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

  module_function

  # True when +data+ starts with the cosmodev-age-v1 magic header.
  def age_encrypted?(data)
    data.byteslice(0, HEADER_LENGTH) == HEADER_BYTES
  end

  # PBKDF2-SHA256 (100k iterations) -> 256-bit AES key (binary String).
  def derive_key(passphrase, salt)
    OpenSSL::KDF.pbkdf2_hmac(passphrase, salt: salt, iterations: ITERATIONS,
                                         length: 32, hash: 'SHA-256')
  end

  # Encrypt +data+ (binary String) under +passphrase+.
  # Returns header || salt || IV || ciphertext + tag.
  def age_encrypt(data, passphrase)
    raise ArgumentError, 'Passphrase must not be empty.' if passphrase.empty?
    raise ArgumentError, 'Input data is empty - nothing to encrypt.' if data.empty?

    salt = SecureRandom.random_bytes(SALT_LENGTH)
    iv   = SecureRandom.random_bytes(IV_LENGTH)
    key  = derive_key(passphrase, salt)

    cipher = OpenSSL::Cipher.new('aes-256-gcm')
    cipher.encrypt
    cipher.key = key
    cipher.iv  = iv
    ciphertext = cipher.update(data) + cipher.final
    tag = cipher.auth_tag

    HEADER_BYTES + salt + iv + ciphertext + tag
  end

  # Decrypt a payload produced by #age_encrypt. Raises when the data lacks
  # the cosmodev-age-v1 header, the passphrase is wrong, or the payload was
  # corrupted/tampered (GCM auth-tag failure).
  def age_decrypt(data, passphrase)
    raise ArgumentError, 'Passphrase must not be empty.' if passphrase.empty?
    unless age_encrypted?(data)
      raise ArgumentError,
            'Not an age-encrypted file (missing cosmodev-age-v1 header).'
    end
    if data.bytesize < OVERHEAD
      raise ArgumentError,
            "Input is too short to be an age-encrypted file (needs at least " \
            "#{OVERHEAD} bytes: header + salt + IV + auth tag)."
    end

    head = HEADER_LENGTH
    salt = data.byteslice(head, SALT_LENGTH)
    iv   = data.byteslice(head + SALT_LENGTH, IV_LENGTH)
    body = data.byteslice(head + SALT_LENGTH + IV_LENGTH, data.bytesize)
    tag        = body.byteslice(body.bytesize - TAG_LENGTH, TAG_LENGTH)
    ciphertext = body.byteslice(0, body.bytesize - TAG_LENGTH)
    key = derive_key(passphrase, salt)

    decipher = OpenSSL::Cipher.new('aes-256-gcm')
    decipher.decrypt
    decipher.key = key
    decipher.iv  = iv
    decipher.auth_tag = tag
    begin
      decipher.update(ciphertext) + decipher.final
    rescue OpenSSL::Cipher::CipherError
      # A GCM auth-tag failure means the key did not match (wrong passphrase)
      # or the payload was modified after encryption.
      raise 'Decryption failed: wrong passphrase or corrupted file.'
    end
  end
end

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 →