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 →