Skip to content

Image Steganography — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package steganography is the Go twin of CosmoDev's src/lib/steganography.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure, never panics; returns errors or zero values. The tests in
// steganography_test.go share vectors with src/lib/steganography.test.ts so
// the two implementations are held to the same contract.
//
// Wire format (mirrors the TS lib exactly):
//
//	4-byte big-endian header, then the body. The header's top bit is an
//	encryption flag (1 = body is salt+IV+AES-256-GCM ciphertext, 0 = body is
//	raw UTF-8); the low 31 bits are the body length in bytes.
//
// Payload bits are written MSB-first, one per R/G/B channel in raster order
// (Alpha is never touched): bit i lands in pixel i/3, channel i%3.
// Capacity = width*height*3/8 payload bytes, rounded down to whole bytes.
//
// Like the TS lib (which works on ImageData-shaped objects, not Image
// objects), this package operates on RGBA byte buffers — image decode/encode
// plumbing stays out.
package steganography

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/pbkdf2"
	"crypto/rand"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"fmt"
	"unicode/utf8"
)

const (
	// PBKDF2Iterations is the PBKDF2-SHA256 iteration count for deriving the
	// AES-256 key from a password. Mirrors PBKDF2_ITERATIONS in the TS lib.
	PBKDF2Iterations = 100_000
	// SaltBytes is the random salt length prepended to an encrypted body.
	SaltBytes = 16
	// IVBytes is the random GCM nonce length (12 is the GCM standard size).
	IVBytes = 12
	// GCMTagBytes is the AES-GCM authentication tag length.
	GCMTagBytes = 16
	// EncryptionOverheadBytes is the salt+IV+GCM tag overhead added to the
	// body when a password is used. Mirrors ENCRYPTION_OVERHEAD_BYTES.
	EncryptionOverheadBytes = SaltBytes + IVBytes + GCMTagBytes
	// HeaderBytes is the 4-byte length header's size; it is also stored in
	// the pixels, so it consumes capacity. Mirrors HEADER_BYTES.
	HeaderBytes = 4

	encryptedFlag uint32 = 0x8000_0000
	lengthMask    uint32 = 0x7fff_ffff
)

// Errors mirror the TS lib's thrown messages; both test suites match on this
// text (the TS one by substring, this one by errors.Is / substring).
var (
	// ErrInvalidDimensions: width and height must be positive integers. (The
	// TS lib also rejects non-integers — unrepresentable as a Go int.)
	ErrInvalidDimensions = errors.New("width and height must be positive integers")
	// ErrEmptyPassword: an empty (but present) password was supplied.
	ErrEmptyPassword = errors.New("password must not be empty")
	// ErrNoMessage: the pixels carry no valid payload.
	ErrNoMessage = errors.New("no hidden message found in this image")
	// ErrPasswordRequired: the payload is encrypted but no password was given.
	ErrPasswordRequired = errors.New("this image contains an encrypted message - a password is required")
	// ErrNotPasswordProtected: a password was given but the payload is plaintext.
	ErrNotPasswordProtected = errors.New("this message is not password-protected - extract without a password")
	// ErrDecryptionFailed: GCM authentication failed (wrong password or
	// corrupted data). Mirrors the TS catch-and-rethrow in decryptBytes.
	ErrDecryptionFailed = errors.New("decryption failed - wrong password or corrupted data")
	// ErrBadPixelData: Data is not exactly width*height*4 bytes. Go-specific
	// guard: the TS lib cannot panic on a short buffer (typed-array indexing
	// silently drops out-of-range reads/writes), but this port must not panic.
	ErrBadPixelData = errors.New("pixel data must be exactly width*height*4 bytes")
)

// ImageData is the minimal structural type satisfied by the browser's
// ImageData. Mirrors StegoImageData in the TS lib.
type ImageData struct {
	Width  int
	Height int
	Data   []byte // RGBA, one byte per channel, len == Width*Height*4
}

// CalculateCapacity returns the max payload bytes (header + body) an image of
// this size can carry. Mirrors calculateCapacity in the TS lib.
func CalculateCapacity(width, height int) (int, error) {
	if width <= 0 || height <= 0 {
		return 0, ErrInvalidDimensions
	}
	return width * height * 3 / 8, nil
}

// validate checks the pixel-buffer shape so the bit loops below can never
// index out of range.
func (img ImageData) validate() error {
	if len(img.Data) != img.Width*img.Height*4 {
		return fmt.Errorf("%w: got %d bytes for %dx%d", ErrBadPixelData, len(img.Data), img.Width, img.Height)
	}
	return nil
}

// newGCM derives the AES-256 key (PBKDF2-SHA256, 100k iterations) from
// password+salt and returns a GCM AEAD. Mirrors deriveKey() in the TS lib —
// the derived bytes are identical, so payloads interoperate across TS/Go.
func newGCM(password string, salt []byte) (cipher.AEAD, error) {
	key, err := pbkdf2.Key(sha256.New, password, salt, PBKDF2Iterations, 32)
	if err != nil {
		return nil, fmt.Errorf("deriving key: %w", err)
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("creating cipher: %w", err)
	}
	return cipher.NewGCM(block)
}

// encryptBytes AES-256-GCM encrypts plain and returns packed
// salt+IV+ciphertext(+tag). Mirrors encryptBytes() in the TS lib: Seal
// appends the 16-byte tag, exactly like WebCrypto's ciphertext.
func encryptBytes(plain []byte, password string) ([]byte, error) {
	salt := make([]byte, SaltBytes)
	iv := make([]byte, IVBytes)
	if _, err := rand.Read(salt); err != nil {
		return nil, fmt.Errorf("reading random salt: %w", err)
	}
	if _, err := rand.Read(iv); err != nil {
		return nil, fmt.Errorf("reading random IV: %w", err)
	}
	gcm, err := newGCM(password, salt)
	if err != nil {
		return nil, err
	}
	packed := make([]byte, 0, SaltBytes+IVBytes+len(plain)+GCMTagBytes)
	packed = append(packed, salt...)
	packed = append(packed, iv...)
	return gcm.Seal(packed, iv, plain, nil), nil
}

// decryptBytes unpacks and AES-256-GCM decrypts a salt+IV+ciphertext payload.
// Mirrors decryptBytes() in the TS lib, including the wrong-password failure.
func decryptBytes(packed []byte, password string) ([]byte, error) {
	if len(packed) < SaltBytes+IVBytes+GCMTagBytes {
		return nil, ErrDecryptionFailed
	}
	salt := packed[:SaltBytes]
	iv := packed[SaltBytes : SaltBytes+IVBytes]
	data := packed[SaltBytes+IVBytes:]
	gcm, err := newGCM(password, salt)
	if err != nil {
		return nil, err
	}
	plain, err := gcm.Open(nil, iv, data, nil)
	if err != nil {
		return nil, ErrDecryptionFailed
	}
	return plain, nil
}

// embedBits writes payload into the LSBs of the R/G/B channels of a copy of
// data and returns it. Mirrors embedBits() in the TS lib — the input is never
// mutated.
func embedBits(data, payload []byte) []byte {
	out := make([]byte, len(data))
	copy(out, data)
	for i := 0; i < len(payload)*8; i++ {
		bit := (payload[i>>3] >> (7 - i&7)) & 1
		idx := i/3*4 + i%3
		out[idx] = out[idx]&0xfe | bit
	}
	return out
}

// extractBits reads count payload bytes back out of the R/G/B LSBs, starting
// at offsetBytes into the payload stream. Mirrors extractBits() in the TS lib.
func extractBits(data []byte, offsetBytes, count int) []byte {
	out := make([]byte, count)
	startBit := offsetBytes * 8
	for i := 0; i < count*8; i++ {
		bitIndex := startBit + i
		bit := data[bitIndex/3*4+bitIndex%3] & 1
		out[i>>3] |= bit << (7 - i&7)
	}
	return out
}

// HideMessage hides message inside a copy of img's pixels (LSB of R/G/B) and
// returns the modified image. With a non-nil non-empty password the message
// body is AES-256-GCM encrypted first.
//
// The password is a *string so nil means "no password" (plaintext) while a
// non-nil empty string is an error — mirroring the TS lib's
// undefined/''/value tristate (the same pattern slugify's Options.Separator
// uses). It fails with ErrEmptyPassword, ErrInvalidDimensions /
// ErrBadPixelData, or a "Message too long" error when the payload (header +
// body + encryption overhead) exceeds the image capacity.
func HideMessage(img ImageData, message string, password *string) (ImageData, error) {
	if password != nil && *password == "" {
		return ImageData{}, ErrEmptyPassword
	}
	if err := img.validate(); err != nil {
		return ImageData{}, err
	}
	capacity, err := CalculateCapacity(img.Width, img.Height)
	if err != nil {
		return ImageData{}, err
	}
	body := []byte(message)
	if password != nil {
		if body, err = encryptBytes(body, *password); err != nil {
			return ImageData{}, err
		}
	}
	header := uint32(len(body))
	if password != nil {
		header |= encryptedFlag
	}
	payload := make([]byte, HeaderBytes+len(body))
	binary.BigEndian.PutUint32(payload[:HeaderBytes], header)
	copy(payload[HeaderBytes:], body)
	if len(payload) > capacity {
		return ImageData{}, fmt.Errorf(
			"Message too long: %d bytes with overhead, but this image can hold at most %d bytes of message",
			len(body), capacity-HeaderBytes)
	}
	return ImageData{
		Width:  img.Width,
		Height: img.Height,
		Data:   embedBits(img.Data, payload),
	}, nil
}

// ExtractMessage reads the hidden message out of img's pixels. It fails with
// ErrNoMessage when the pixels carry no valid payload (including a corrupted
// one that decodes to invalid UTF-8), ErrPasswordRequired when the payload is
// encrypted but no password is given, ErrNotPasswordProtected when a password
// is given but the payload is plaintext, and ErrDecryptionFailed on a wrong
// password (GCM authentication failure).
func ExtractMessage(img ImageData, password *string) (string, error) {
	if password != nil && *password == "" {
		return "", ErrEmptyPassword
	}
	if err := img.validate(); err != nil {
		return "", err
	}
	capacity, err := CalculateCapacity(img.Width, img.Height)
	if err != nil {
		return "", err
	}
	header := binary.BigEndian.Uint32(extractBits(img.Data, 0, HeaderBytes))
	encrypted := header&encryptedFlag != 0
	length := header & lengthMask
	if length == 0 && !encrypted {
		return "", nil
	}
	minLength := 1
	if encrypted {
		minLength = SaltBytes + IVBytes + GCMTagBytes
	}
	if int64(HeaderBytes)+int64(length) > int64(capacity) || int64(length) < int64(minLength) {
		return "", ErrNoMessage
	}
	body := extractBits(img.Data, HeaderBytes, int(length))
	if !encrypted {
		if password != nil {
			return "", ErrNotPasswordProtected
		}
		if !utf8.Valid(body) {
			return "", ErrNoMessage
		}
		return string(body), nil
	}
	if password == nil {
		return "", ErrPasswordRequired
	}
	plain, err := decryptBytes(body, *password)
	if err != nil {
		return "", err
	}
	return string(plain), nil
}

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 →