Image Steganography — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Steganography — least-significant-bit (LSB) message hiding on RGBA pixel
// data, with optional AES-256-GCM encryption.
//
// Language: Kotlin 1.9+ (JVM), javax.crypto only.
// Ported from src/lib/steganography.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference (which
// drives Web Crypto): the TS side is async because SubtleCrypto is; the JVM's
// javax.crypto calls are synchronous, so this port is too. Works on any
// width/height/RGBA byte triple — synthetic buffers in tests, a Bitmap's
// pixels on Android, or BufferedImage raster data on the desktop.
//
// 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.
//
// Encryption mirrors the text-encryptor tool: PBKDF2-SHA256 (100k iterations,
// 16-byte random salt) derives an AES-256 key; GCM encrypts with a 12-byte
// random IV.
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.CodingErrorAction
import java.security.SecureRandom
private const val PBKDF2_ITERATIONS = 100_000
private const val SALT_BYTES = 16
private const val IV_BYTES = 12
private const val GCM_TAG_BYTES = 16
/** Salt + IV + GCM tag overhead added to the body when a password is used. */
const val 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. */
const val HEADER_BYTES = 4
private const val TAG_BITS = GCM_TAG_BYTES * 8
/** Pixel buffer shape: width x height of RGBA quadruples. */
data class StegoImageData(
val width: Int,
val height: Int,
val data: ByteArray,
) {
override fun equals(other: Any?): Boolean =
other is StegoImageData && other.width == width && other.height == height &&
other.data.contentEquals(data)
override fun hashCode(): Int = 31 * 31 * width + height * 31 + data.contentHashCode()
}
/** Max payload bytes (header + body) an image of this size can carry. */
fun calculateCapacity(width: Int, height: Int): Int {
if (width <= 0 || height <= 0) {
throw IllegalArgumentException("Width and height must be positive integers")
}
return width * height * 3 / 8
}
private val RANDOM = SecureRandom()
/** PBKDF2-SHA256 (100k iterations) -> AES-256 key. */
private fun deriveKey(password: String, salt: ByteArray): SecretKeySpec {
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, 256)
return SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
}
/** AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag). */
private fun encryptBytes(plain: ByteArray, password: String): ByteArray {
val salt = ByteArray(SALT_BYTES).also(RANDOM::nextBytes)
val iv = ByteArray(IV_BYTES).also(RANDOM::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, deriveKey(password, salt), GCMParameterSpec(TAG_BITS, iv))
val encrypted = cipher.doFinal(plain)
return salt + iv + encrypted
}
/** Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload. */
private fun decryptBytes(packed: ByteArray, password: String): ByteArray {
val salt = packed.copyOfRange(0, SALT_BYTES)
val iv = packed.copyOfRange(SALT_BYTES, SALT_BYTES + IV_BYTES)
val data = packed.copyOfRange(SALT_BYTES + IV_BYTES, packed.size)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, deriveKey(password, salt), GCMParameterSpec(TAG_BITS, iv))
cipher.doFinal(data)
} catch (_: Exception) {
throw IllegalArgumentException("Decryption failed - wrong password or corrupted data")
}
}
/** Strict UTF-8 decode: invalid sequences throw (mirrors TextDecoder fatal:true). */
private fun decodeUtf8Strict(bytes: ByteArray): String {
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
return decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
}
/** Write `payload` into the LSBs of the R/G/B channels; returns copied pixels. */
private fun embedBits(data: ByteArray, payload: ByteArray): ByteArray {
val out = data.copyOf() // copy - the input is never mutated
val totalBits = payload.size * 8
for (i in 0 until totalBits) {
val bit = (payload[i shr 3].toInt() shr (7 - (i and 7))) and 1
val px = i / 3
val channel = i % 3
val idx = px * 4 + channel
out[idx] = ((out[idx].toInt() and 0xFE) or bit).toByte()
}
return out
}
/** Read `count` payload bytes back out of the R/G/B LSBs. */
private fun extractBits(data: ByteArray, offsetBytes: Int, count: Int): ByteArray {
val out = ByteArray(count)
val startBit = offsetBytes * 8
for (i in 0 until count * 8) {
val bitIndex = startBit + i
val px = bitIndex / 3
val channel = bitIndex % 3
val bit = data[px * 4 + channel].toInt() and 1
out[i shr 3] = (out[i shr 3] + (bit shl (7 - (i and 7)))).toByte()
}
return out
}
/** Big-endian u32 -> 4 bytes. */
private fun u32Bytes(n: Int): ByteArray = byteArrayOf(
(n ushr 24).toByte(), (n ushr 16).toByte(), (n ushr 8).toByte(), n.toByte(),
)
private fun readU32(bytes: ByteArray): Int {
var n = 0
for (b in bytes) n = (n shl 8) or (b.toInt() and 0xFF)
return n
}
/**
* Hide `message` inside a copy of `imageData`'s pixels (LSB of R/G/B) and
* return the modified pixel data. With `password`, the message body is
* AES-256-GCM encrypted first. Throws if the message (including header and
* encryption overhead) exceeds the image capacity, or on an empty password.
*/
fun hideMessage(imageData: StegoImageData, message: String, password: String? = null): StegoImageData {
if (password != null && password.isEmpty()) {
throw IllegalArgumentException("Password must not be empty")
}
val capacity = calculateCapacity(imageData.width, imageData.height)
val plain = message.toByteArray(Charsets.UTF_8)
val body = if (password != null) encryptBytes(plain, password) else plain
val header = body.size or (if (password != null) 0x8000_0000.toInt() else 0)
val payload = u32Bytes(header) + body
if (payload.size > capacity) {
val maxBody = capacity - HEADER_BYTES
throw IllegalArgumentException(
"Message too long: ${body.size} bytes with overhead, but this image can hold at most $maxBody bytes of message"
)
}
return StegoImageData(imageData.width, imageData.height, embedBits(imageData.data, payload))
}
/**
* Read the hidden message out of `imageData`'s pixels. Throws 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).
*/
fun extractMessage(imageData: StegoImageData, password: String? = null): String {
if (password != null && password.isEmpty()) {
throw IllegalArgumentException("Password must not be empty")
}
val capacity = calculateCapacity(imageData.width, imageData.height)
val header = readU32(extractBits(imageData.data, 0, HEADER_BYTES))
val encrypted = (header and 0x8000_0000.toInt()) != 0
val length = header and 0x7fff_ffff
if (length == 0 && !encrypted) return ""
if (HEADER_BYTES + length > capacity ||
length < if (encrypted) SALT_BYTES + IV_BYTES + GCM_TAG_BYTES else 1
) {
throw IllegalArgumentException("No hidden message found in this image")
}
val body = extractBits(imageData.data, HEADER_BYTES, length)
if (!encrypted) {
if (password != null) {
throw IllegalArgumentException("This message is not password-protected - extract without a password")
}
return try {
decodeUtf8Strict(body)
} catch (_: Exception) {
throw IllegalArgumentException("No hidden message found in this image")
}
}
if (password == null) {
throw IllegalArgumentException("This image contains an encrypted message - a password is required")
}
val plain = decryptBytes(body, password)
return String(plain, Charsets.UTF_8)
}
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 →