Skip to content

Text Encryptor — Ruby source

Encrypt or decrypt text with a password using AES-256-GCM. Share the ciphertext safely - only someone with the password can read it.

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

# Text Encryptor — password-based text encryption via OpenSSL.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Text Encryptor tool,
#           ported from src/lib/text-encryptor.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Format: PBKDF2 (SHA-256, 100k iterations, 16-byte random salt) derives an
# AES-256 key from the password; AES-GCM encrypts with a 12-byte random IV.
# The Base64 output packs salt + IV + ciphertext (+ GCM tag), so every
# encryption is unique and self-contained - decrypt needs only the string and
# the password.
#
# Mirrors the PBKDF2 + AES-256-GCM pattern the file-encryptor uses, applied
# to UTF-8 strings.

require 'openssl'
require 'securerandom'

module TextEncryptor
  PBKDF2_ITERATIONS = 100_000
  SALT_BYTES = 16
  IV_BYTES   = 12
  # Smallest valid packed payload: salt + IV + one AES-GCM block (tag).
  MIN_BYTES = SALT_BYTES + IV_BYTES + 16

  ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
  BASE64_RE = /\A[A-Za-z0-9+/]+={0,2}\z/

  module_function

  # Binary String -> canonical Base64 (pack 'm0' emits the same alphabet,
  # padding, and 3-byte grouping as the hand-rolled TS codec).
  def bytes_to_base64(bytes)
    [bytes].pack('m0')
  end

  # Base64 -> binary String. Accepts surrounding whitespace; raises on any
  # other non-alphabet character, wrong length, or misplaced padding.
  def base64_to_bytes(b64)
    clean = b64.gsub(/\s+/, '')
    raise ArgumentError, 'Invalid Base64: input is empty' if clean.empty?
    unless clean.size % 4 == 0
      raise ArgumentError, 'Invalid Base64: length must be a multiple of 4'
    end
    unless clean.match?(BASE64_RE)
      bad = clean.chars.find { |c| !ALPHABET.include?(c) && c != '=' }
      raise ArgumentError, %(Invalid Base64: unexpected character "#{bad || '?'}")
    end
    clean.unpack1('m0')
  end

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

  # Encrypt UTF-8 +plaintext+ under +password+ and return the Base64 string
  # packing salt + IV + AES-256-GCM ciphertext. Raises if either input is
  # empty.
  def encrypt_text(plaintext, password)
    raise ArgumentError, 'Nothing to encrypt: input is empty' if plaintext.empty?
    raise ArgumentError, 'Password must not be empty' if password.empty?

    salt = SecureRandom.random_bytes(SALT_BYTES)
    iv   = SecureRandom.random_bytes(IV_BYTES)
    key  = derive_key(password, salt)

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

    bytes_to_base64(salt + iv + ciphertext + tag)
  end

  # Decrypt a Base64 string produced by #encrypt_text back to plaintext.
  # Raises on empty input/password, invalid Base64, a payload too short to be
  # one of ours, or a GCM authentication failure (wrong password / tampered
  # data).
  def decrypt_text(ciphertext, password)
    if ciphertext.strip.empty?
      raise ArgumentError, 'Nothing to decrypt: input is empty'
    end
    raise ArgumentError, 'Password must not be empty' if password.empty?

    packed = base64_to_bytes(ciphertext.strip)
    if packed.bytesize < MIN_BYTES
      raise ArgumentError,
            'Ciphertext too short - not a valid salt + IV + AES-GCM payload'
    end

    salt = packed.byteslice(0, SALT_BYTES)
    iv   = packed.byteslice(SALT_BYTES, IV_BYTES)
    body = packed.byteslice(SALT_BYTES + IV_BYTES, packed.bytesize)
    tag        = body.byteslice(body.bytesize - 16, 16)
    data       = body.byteslice(0, body.bytesize - 16)
    key = derive_key(password, salt)

    decipher = OpenSSL::Cipher.new('aes-256-gcm')
    decipher.decrypt
    decipher.key = key
    decipher.iv  = iv
    decipher.auth_tag = tag
    begin
      (decipher.update(data) + decipher.final).force_encoding(Encoding::UTF_8)
    rescue OpenSSL::Cipher::CipherError
      raise 'Decryption failed - wrong password or corrupted ciphertext'
    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 →