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 →