Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions PROJECTS/beginner/steganography-multi-tool/.style.yapf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[style]
based_on_style = pep8
column_limit = 89
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0
24 changes: 24 additions & 0 deletions PROJECTS/beginner/steganography-multi-tool/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
default:
@just --list

lint:
uv run ruff check .
uv run mypy src/

format:
uv run yapf -r -i src/ tests/

check-format:
uv run yapf -r -d src/ tests/

test:
uv run pytest tests/ -m "unit"

test-all:
uv run pytest tests/

run *ARGS:
uv run stego {{ARGS}}

install:
bash install.sh
89 changes: 89 additions & 0 deletions PROJECTS/beginner/steganography-multi-tool/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
[project]
name = "steganography-multi-tool"
version = "0.1.0"
description = "Multi-format steganography toolkit: hide and extract data across images, audio, text, QR codes, PDFs, and git metadata"
requires-python = ">=3.14"
dependencies = [
"typer>=0.15.0",
"rich>=14.0.0",
]

[project.scripts]
stego = "stego_multi.cli:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/stego_multi"]

[dependency-groups]
dev = [
"ruff>=0.11.0",
"mypy>=1.15.0",
"yapf>=0.43.0",
"pytest>=8.3.0",
"pytest-cov>=6.0.0",
"hypothesis>=6.130.0",
]

[tool.ruff]
line-length = 89
indent-width = 4
target-version = "py314"
src = ["src"]

[tool.ruff.lint]
select = [
"E", "W", "F", "B", "S", "C90", "N", "UP",
"SIM", "PTH", "PERF", "RUF", "PL", "TRY", "LOG",
]
ignore = [
"S101",
"TRY003",
"PLR2004",
"PLR0913",
"E501",
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "PLR2004", "S105", "S106"]

[tool.ruff.lint.mccabe]
max-complexity = 12

[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
show_error_codes = true
show_column_numbers = true
pretty = true
mypy_path = "src"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-markers",
"--tb=short",
]
markers = [
"unit: fast unit tests with no I/O",
"integration: tests requiring real file system",
"slow: long-running tests",
]

[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*"]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"\\.\\.\\.",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
stego_multi
Multi-format steganography toolkit.
"""

__version__ = "0.1.0"
111 changes: 111 additions & 0 deletions PROJECTS/beginner/steganography-multi-tool/src/stego_multi/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
stego_multi command-line interface.
"""

import sys
from pathlib import Path
from typing import Annotated

import typer
from rich.console import Console

from stego_multi import __version__
from stego_multi.techniques import zero_width


app = typer.Typer(
help = "Multi-format steganography toolkit.",
no_args_is_help = True,
)
zw_app = typer.Typer(
help = "Zero-width character steganography.",
no_args_is_help = True,
)
app.add_typer(zw_app, name = "zero-width")

err_console = Console(stderr = True)


def _write_text(text: str, output: Path | None) -> None:
"""Write text as UTF-8: to a file if given, otherwise to stdout."""
if output is not None:
output.write_text(text, encoding = "utf-8")
else:
sys.stdout.buffer.write(text.encode("utf-8") + b"\n")


def _version_callback(value: bool) -> None:
if value:
print(f"stego {__version__}")
raise typer.Exit()


@app.callback()
def main(
version: Annotated[
bool,
typer.Option(
"--version",
callback = _version_callback,
is_eager = True,
help = "Show version and exit.",
),
] = False,
) -> None:
"""Multi-format steganography toolkit."""


@zw_app.command("hide")
def zw_hide(
message: Annotated[str,
typer.Option("--message",
"-m",
help = "Secret message to hide.")],
carrier: Annotated[
str,
typer.Option("--carrier",
"-c",
help = "Visible text to carry the message."),
],
output: Annotated[
Path | None,
typer.Option("--output",
"-o",
help = "Write result to a UTF-8 file."),
] = None,
) -> None:
"""Hide a message inside carrier text using invisible characters."""
encoded = zero_width.embed(carrier, message.encode("utf-8"))
_write_text(encoded, output)


@zw_app.command("reveal")
def zw_reveal(
text: Annotated[
str | None,
typer.Option("--text",
"-t",
help = "Text containing a hidden message."),
] = None,
input_file: Annotated[
Path | None,
typer.Option("--input",
"-i",
help = "Read carrier text from a UTF-8 file."),
] = None,
) -> None:
"""Reveal a message hidden inside text."""
if text is not None and input_file is not None:
err_console.print("[red]Error:[/red] use either --text or --input, not both")
raise typer.Exit(code = 1)
if input_file is not None:
text = input_file.read_text(encoding = "utf-8")
if text is None:
err_console.print("[red]Error:[/red] provide --text or --input")
raise typer.Exit(code = 1)
try:
payload = zero_width.extract(text)
except ValueError as exc:
err_console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(code = 1) from exc
sys.stdout.buffer.write(payload + b"\n")
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Steganography techniques."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
Zero-width character steganography.

Hides arbitrary bytes inside a visible carrier text by encoding them as
sequences of invisible Unicode characters appended to the text.
"""

# Two zero-width characters carry the binary payload: one per bit.
# Both render with zero width and don't affect how the carrier looks.
ZERO = "\u200b" # ZERO WIDTH SPACE -> bit 0
ONE = "\u200c" # ZERO WIDTH NON-JOINER -> bit 1

# The payload length is stored as a fixed 32-bit prefix so the extractor
# knows exactly how many bytes to read back.
_LENGTH_BITS = 32
_MAX_PAYLOAD = (1 << _LENGTH_BITS) - 1


def _bytes_to_bits(data: bytes) -> str:
"""Convert bytes to a string of '0'/'1' characters, MSB first."""
return "".join(format(byte, "08b") for byte in data)


def _bits_to_bytes(bits: str) -> bytes:
"""Convert a string of '0'/'1' characters back to bytes."""
return bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8))


def embed(carrier: str, payload: bytes) -> str:
"""Hide ``payload`` inside ``carrier``.

Returns the carrier text with an invisible zero-width suffix that
encodes a 32-bit length prefix followed by the payload bits.
"""
if len(payload) > _MAX_PAYLOAD:
raise ValueError(f"payload too large: {len(payload)} bytes (max {_MAX_PAYLOAD})")

length_bits = format(len(payload), f"0{_LENGTH_BITS}b")
all_bits = length_bits + _bytes_to_bits(payload)
hidden = "".join(ONE if bit == "1" else ZERO for bit in all_bits)
return carrier + hidden


def extract(text: str) -> bytes:
"""Recover the payload hidden in ``text``.

Raises ValueError if no zero-width data is present or it is malformed.
"""
bits = "".join("1" if ch == ONE else "0" for ch in text if ch in (ZERO, ONE))
if len(bits) < _LENGTH_BITS:
raise ValueError("no zero-width payload found")

length = int(bits[: _LENGTH_BITS], 2)
payload_bits = bits[_LENGTH_BITS : _LENGTH_BITS + length * 8]
if len(payload_bits) < length * 8:
raise ValueError("truncated zero-width payload")

return _bits_to_bytes(payload_bits)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Round-trip tests for zero-width steganography."""

import pytest
from hypothesis import given
from hypothesis import strategies as st

from stego_multi.techniques import zero_width


pytestmark = pytest.mark.unit

# Carrier text that doesn't already contain our zero-width markers,
# matching the documented assumption of the technique.
carrier_text = st.text(
).filter(lambda s: zero_width.ZERO not in s and zero_width.ONE not in s)


@given(carrier = carrier_text, payload = st.binary())
def test_round_trip(carrier: str, payload: bytes) -> None:
encoded = zero_width.embed(carrier, payload)
assert zero_width.extract(encoded) == payload


@given(carrier = carrier_text, payload = st.binary())
def test_carrier_visually_unchanged(carrier: str, payload: bytes) -> None:
encoded = zero_width.embed(carrier, payload)
visible = encoded.replace(zero_width.ZERO, "").replace(zero_width.ONE, "")
assert visible == carrier


def test_empty_payload_round_trips() -> None:
encoded = zero_width.embed("hello", b"")
assert zero_width.extract(encoded) == b""


def test_extract_without_payload_raises() -> None:
with pytest.raises(ValueError, match = "no zero-width payload"):
zero_width.extract("just plain text")
Loading