Skip to content

Image Steganography — Ruby source

Hide a secret message inside a PNG image or extract a hidden message from one. Uses least-significant-bit encoding with optional AES encryption.

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

# Steganography — least-significant-bit (LSB) hiding on RGBA pixel data,
# with optional AES-256-GCM encryption via OpenSSL.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Steganography tool, ported
#           from src/lib/steganography.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Pure logic - no React, no Canvas, no DOM. Pixels are a flat RGBA byte
# buffer (binary String); the browser obtains it from canvas ImageData, and
# anything else can build a synthetic one. This port carries the LSB
# encode/decode math and the optional AES-256-GCM body encryption; the
# Canvas read/write glue stays browser-side.
#
# Wire format (the "payload" hidden in the pixels):
#   4-byte big-endian header, then the body. The header's top bit is an
#   encryption flag (1 = body is salt+IV+AES-GCM ciphertext, 0 = body is raw
#   UTF-8); the low 31 bits are the body length in bytes. The flag makes the
#   "password required" / "not password-protected" errors deterministic.
#
# Payload bits are written MSB-first, one per R/G/B channel in raster order
# (Alpha is never touched): bit i lands in pixel floor(i/3), channel i%3.
# Capacity = floor(width * height * 3 / 8) payload bytes.

require 'openssl'
require 'securerandom'

module Steganography
  PBKDF2_ITERATIONS = 100_000
  SALT_BYTES = 16
  IV_BYTES = 12
  GCM_TAG_BYTES = 16
  # Salt + IV + GCM tag overhead added to the body when a password is used.
  ENCRYPTION_OVERHEAD_BYTES = SALT_BYTES + IV_BYTES + GCM_TAG_BYTES
  # The 4-byte length header is also stored in the pixels, so it consumes
  # capacity.
  HEADER_BYTES = 4

  class << self
    # Max payload bytes (header + body) an image of this size can carry.
    def calculate_capacity(width, height)
      unless width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive?
        raise ArgumentError, 'Width and height must be positive integers'
      end

      (width * height * 3) / 8
    end

    # Hide +message+ inside a copy of the image's pixels (LSB of R/G/B) and
    # return the modified image Hash ({ width:, height:, data: }). With
    # +password+, the message body is AES-256-GCM encrypted first. Raises if
    # the message (including header and encryption overhead) exceeds the
    # image capacity, or on an empty password.
    def hide_message(image, message, password = nil)
      raise ArgumentError, 'Password must not be empty' if password == ''

      capacity = calculate_capacity(image[:width], image[:height])
      plain = message.encode('UTF-8').b
      body = password ? encrypt_bytes(plain, password) : plain
      payload = [body.bytesize | (password ? 0x8000_0000 : 0)].pack('N') + body
      if payload.bytesize > capacity
        max_body = capacity - HEADER_BYTES
        raise ArgumentError,
              "Message too long: #{body.bytesize} bytes with overhead, but " \
              "this image can hold at most #{max_body} bytes of message"
      end

      { width: image[:width], height: image[:height], data: embed_bits(image[:data], payload) }
    end

    # Read the hidden message out of the image's pixels. Raises when the
    # pixels carry no valid payload ("No hidden message found"), when the
    # payload is encrypted but no password is given, when a password is given
    # but the payload is plaintext, and on a wrong password (GCM
    # authentication failure).
    def extract_message(image, password = nil)
      raise ArgumentError, 'Password must not be empty' if password == ''

      capacity = calculate_capacity(image[:width], image[:height])
      header = extract_bits(image[:data], 0, HEADER_BYTES).unpack1('N')
      encrypted = (header & 0x8000_0000) != 0
      length = header & 0x7fff_ffff
      return '' if length.zero? && !encrypted
      if HEADER_BYTES + length > capacity ||
         length < (encrypted ? SALT_BYTES + IV_BYTES + GCM_TAG_BYTES : 1)
        raise ArgumentError, 'No hidden message found in this image'
      end

      body = extract_bits(image[:data], HEADER_BYTES, length)
      unless encrypted
        if password
          raise ArgumentError,
                'This message is not password-protected - extract without a password'
        end

        text = body.dup.force_encoding(Encoding::UTF_8)
        raise ArgumentError, 'No hidden message found in this image' unless text.valid_encoding?

        return text
      end
      unless password
        raise ArgumentError,
              'This image contains an encrypted message - a password is required'
      end

      decrypt_bytes(body, password).force_encoding(Encoding::UTF_8)
    end

    private

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

    # AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag).
    def encrypt_bytes(plain, password)
      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(plain) + cipher.final
      salt + iv + ciphertext + cipher.auth_tag
    end

    # Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload.
    def decrypt_bytes(packed, password)
      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 - GCM_TAG_BYTES, GCM_TAG_BYTES)
      data = body.byteslice(0, body.bytesize - GCM_TAG_BYTES)
      key = derive_key(password, salt)

      decipher = OpenSSL::Cipher.new('aes-256-gcm')
      decipher.decrypt
      decipher.key = key
      decipher.iv = iv
      decipher.auth_tag = tag
      decipher.update(data) + decipher.final
    rescue OpenSSL::Cipher::CipherError
      raise ArgumentError, 'Decryption failed - wrong password or corrupted data'
    end

    # Write +payload+ into the LSBs of the R/G/B channels; returns copied
    # pixels (the input is never mutated).
    def embed_bits(data, payload)
      out = data.dup.b
      total_bits = payload.bytesize * 8
      (0...total_bits).each do |i|
        byte = payload.getbyte(i >> 3)
        bit = (byte >> (7 - (i & 7))) & 1
        px = i / 3
        channel = i % 3
        idx = px * 4 + channel
        out.setbyte(idx, (out.getbyte(idx) & 0xfe) | bit)
      end
      out
    end

    # Read +count+ payload bytes back out of the R/G/B LSBs, starting
    # +offset_bytes+ into the payload.
    def extract_bits(data, offset_bytes, count)
      out = ("\x00" * count).b
      start_bit = offset_bytes * 8
      (0...(count * 8)).each do |i|
        bit_index = start_bit + i
        px = bit_index / 3
        channel = bit_index % 3
        bit = data.getbyte(px * 4 + channel) & 1
        byte_index = i >> 3
        out.setbyte(byte_index, out.getbyte(byte_index) | (bit << (7 - (i & 7))))
      end
      out
    end
  end
end

Also available in 9 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 →