Image Steganography — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// steganography — least-significant-bit (LSB) steganography on RGBA pixel
// data, with optional AES-256-GCM encryption.
//
// Language: Swift 5.9+ (Foundation + CryptoKit + CommonCrypto)
// Ported from src/lib/steganography.ts
// display source — part of CosmoDev's polyglot tool pages
//
// 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 TS reference: PBKDF2-SHA256 (100k iterations,
// 16-byte random salt) derives a 256-bit key via CommonCrypto; CryptoKit's
// AES-GCM encrypts with a 12-byte random IV. Works on any RGBA buffer, so
// tests build synthetic pixels and callers pass CGImage-derived data.
import Foundation
import CryptoKit
import CommonCrypto
let PBKDF2_ITERATIONS = 100_000
let STEG_SALT_BYTES = 16
let STEG_IV_BYTES = 12
let GCM_TAG_BYTES = 16
/// Salt + IV + GCM tag overhead added to the body when a password is used.
let ENCRYPTION_OVERHEAD_BYTES = STEG_SALT_BYTES + STEG_IV_BYTES + GCM_TAG_BYTES
/// The 4-byte length header is also stored in the pixels, so it consumes capacity.
let HEADER_BYTES = 4
// MARK: - Types
/// RGBA pixel data as the browser's ImageData shape: width * height pixels,
/// 4 bytes each (R, G, B, A).
struct StegoImageData {
let width: Int
let height: Int
let data: [UInt8]
}
enum StegoError: Error, LocalizedError {
case invalidDimensions
case emptyPassword
case messageTooLong(bodyBytes: Int, maxBody: Int)
case noHiddenMessage
case encryptedNeedsPassword
case notPasswordProtected
case decryptionFailed
var errorDescription: String? {
switch self {
case .invalidDimensions:
return "Width and height must be positive integers"
case .emptyPassword:
return "Password must not be empty"
case .messageTooLong(let body, let max):
return "Message too long: \(body) bytes with overhead, but this image can hold at most \(max) bytes of message"
case .noHiddenMessage:
return "No hidden message found in this image"
case .encryptedNeedsPassword:
return "This image contains an encrypted message - a password is required"
case .notPasswordProtected:
return "This message is not password-protected - extract without a password"
case .decryptionFailed:
return "Decryption failed - wrong password or corrupted data"
}
}
}
// MARK: - Capacity
/// Max payload bytes (header + body) an image of this size can carry.
func calculateCapacity(width: Int, height: Int) throws -> Int {
if width <= 0 || height <= 0 { throw StegoError.invalidDimensions }
return width * height * 3 / 8
}
// MARK: - Encryption (mirrors src/lib/text-encryptor.ts in the TS build)
/// PBKDF2-SHA256 (100k iterations) -> a 256-bit AES-GCM key.
func deriveKey(password: String, salt: [UInt8]) -> SymmetricKey {
var keyBytes = [UInt8](repeating: 0, count: 32)
let pw = Array(password.utf8).map { CChar(bitPattern: $0) }
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
pw, pw.count,
salt, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(PBKDF2_ITERATIONS),
&keyBytes, keyBytes.count)
return SymmetricKey(data: Data(keyBytes))
}
/// 32 CSPRNG bytes — SystemRandomNumberGenerator is the system CSPRNG
/// (SecRandom on Apple platforms), the counterpart of crypto.getRandomValues.
func randomBytes(_ length: Int) -> [UInt8] {
var csprng = SystemRandomNumberGenerator()
return (0..<length).map { _ in UInt8.random(in: .min ... .max, using: &csprng) }
}
/// AES-256-GCM encrypt bytes -> packed salt + IV + ciphertext (+ tag).
func encryptBytes(_ plain: [UInt8], password: String) throws -> [UInt8] {
let salt = randomBytes(STEG_SALT_BYTES)
let iv = randomBytes(STEG_IV_BYTES)
let key = deriveKey(password: password, salt: salt)
let sealed = try AES.GCM.seal(Data(plain), using: key,
nonce: AES.GCM.Nonce(data: Data(iv)))
// sealed.ciphertext + sealed.tag == WebCrypto's encrypt output shape.
return salt + iv + sealed.ciphertext + sealed.tag
}
/// Unpack and AES-256-GCM decrypt a salt + IV + ciphertext payload.
func decryptBytes(_ packed: [UInt8], password: String) throws -> [UInt8] {
let salt = Array(packed.prefix(STEG_SALT_BYTES))
let iv = Array(packed.dropFirst(STEG_SALT_BYTES).prefix(STEG_IV_BYTES))
let body = Array(packed.dropFirst(STEG_SALT_BYTES + STEG_IV_BYTES))
let key = deriveKey(password: password, salt: salt)
do {
let box = try AES.GCM.SealedBox(
nonce: AES.GCM.Nonce(data: Data(iv)),
ciphertext: Data(body.dropLast(GCM_TAG_BYTES)),
tag: Data(body.suffix(GCM_TAG_BYTES)))
return Array(try AES.GCM.open(box, using: key))
} catch {
throw StegoError.decryptionFailed
}
}
// MARK: - Bit embedding
/// Write `payload` into the LSBs of the R/G/B channels; returns copied pixels.
func embedBits(_ data: [UInt8], payload: [UInt8]) -> [UInt8] {
var out = data // copy - the input is never mutated
let totalBits = payload.count * 8
for i in 0..<totalBits {
let byte = payload[i >> 3]
let bit = (byte >> UInt8(7 - (i & 7))) & 1
let px = i / 3
let channel = i % 3
let idx = px * 4 + channel
out[idx] = (out[idx] & 0xfe) | bit
}
return out
}
/// Read `count` payload bytes back out of the R/G/B LSBs.
func extractBits(_ data: [UInt8], offsetBytes: Int, count: Int) -> [UInt8] {
var out = [UInt8](repeating: 0, count: count)
let startBit = offsetBytes * 8
for i in 0..<(count * 8) {
let bitIndex = startBit + i
let px = bitIndex / 3
let channel = bitIndex % 3
let bit = data[px * 4 + channel] & 1
out[i >> 3] |= bit << UInt8(7 - (i & 7))
}
return out
}
/// Big-endian UInt32 from a 4-byte buffer.
func readHeader(_ bytes: [UInt8]) -> UInt32 {
(UInt32(bytes[0]) << 24) | (UInt32(bytes[1]) << 16)
| (UInt32(bytes[2]) << 8) | UInt32(bytes[3])
}
// MARK: - Public API
/// 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.
func hideMessage(_ imageData: StegoImageData, message: String, password: String? = nil) throws -> StegoImageData {
if let password = password, password.isEmpty {
throw StegoError.emptyPassword
}
let capacity = try calculateCapacity(width: imageData.width, height: imageData.height)
let plain = Array(message.utf8)
let body = try password != nil ? encryptBytes(plain, password: password!) : plain
var payload = [UInt8](repeating: 0, count: HEADER_BYTES + body.count)
// Top bit = encryption flag; low 31 bits = body length. Big-endian.
let header = UInt32(body.count) | (password != nil ? 0x8000_0000 : 0)
payload[0] = UInt8((header >> 24) & 0xff)
payload[1] = UInt8((header >> 16) & 0xff)
payload[2] = UInt8((header >> 8) & 0xff)
payload[3] = UInt8(header & 0xff)
payload.replaceSubrange(HEADER_BYTES..., with: body)
if payload.count > capacity {
throw StegoError.messageTooLong(bodyBytes: body.count, maxBody: capacity - HEADER_BYTES)
}
return StegoImageData(
width: imageData.width,
height: imageData.height,
data: embedBits(imageData.data, payload: payload))
}
/// Read the hidden message out of `imageData`'s pixels. Throws when the pixels
/// carry no valid payload, 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).
func extractMessage(_ imageData: StegoImageData, password: String? = nil) throws -> String {
if let password = password, password.isEmpty {
throw StegoError.emptyPassword
}
let capacity = try calculateCapacity(width: imageData.width, height: imageData.height)
let header = readHeader(extractBits(imageData.data, offsetBytes: 0, count: HEADER_BYTES))
let encrypted = (header & 0x8000_0000) != 0
let length = Int(header & 0x7fff_ffff)
if length == 0 && !encrypted { return "" }
let minLength = encrypted ? STEG_SALT_BYTES + STEG_IV_BYTES + GCM_TAG_BYTES : 1
if HEADER_BYTES + length > capacity || length < minLength {
throw StegoError.noHiddenMessage
}
let body = extractBits(imageData.data, offsetBytes: HEADER_BYTES, count: length)
if !encrypted {
if password != nil {
throw StegoError.notPasswordProtected
}
// Strict UTF-8 decode — invalid bytes mean "not a real payload",
// matching the TS TextDecoder(fatal: true) behavior.
guard let text = String(bytes: body, encoding: .utf8) else {
throw StegoError.noHiddenMessage
}
return text
}
guard let password = password else {
throw StegoError.encryptedNeedsPassword
}
let plain = try decryptBytes(body, password: password)
return String(decoding: plain, as: UTF8.self)
}
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 →