#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![deny(missing_docs)]
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use zeroize::Zeroizing;
use stenoxide_core::cost::hill::HillCostProvider;
use stenoxide_core::cost::CostProvider;
use stenoxide_core::generate::{
generate_container, ContainerDimensions, GenerateError, GenerateReport, DEFAULT_CONTAINER_SIDE,
MIN_CONTAINER_SIDE,
};
use stenoxide_core::image_io::buffer::ImageBuffer;
use stenoxide_core::image_io::phash::compute_stable_phash;
use stenoxide_core::image_io::validate::{load_and_validate, ValidationError};
use stenoxide_core::pipeline::{EmbedPipeline, EmbedReport, PipelineError};
use stenoxide_core::stego::sizer::{compute_capacity, EmbeddingMode, SizerError};
mod payload;
mod progress;
mod scan;
const EXTRACTION_FAILED: &str = "Could not extract the payload.";
const PASSWORD_PROMPT: &str = "Password: ";
const END_OF_MESSAGE: &str = ".";
const GENERATE_LONG_ABOUT: &str = "\
Build a container around a message instead of hiding it inside an existing image.
For when there is no usable photograph — a camera that only writes JPEG, no way
to move pictures across from a phone. The container is drawn sample by sample,
each one conditioned on the ciphertext bit it carries, so a container holding a
message and one holding nothing are draws from the same distribution and no
detector can separate them. It carries about 1.4 MB, against the 8 KB an image
of the same size admits by embedding.
The default container is 2000x2000, the smallest and least conspicuous the mode
draws. A payload that does not fit needs a larger one: raise --width and
--height together (each at least 2000). Capacity grows with the pixel count, so
the error printed when a payload overflows names a size that would hold it.
It does not hide that the container was generated. It looks like a synthetic
texture, and a folder full of them is itself the thing worth explaining. Prefer
a photograph of your own that has never been published, whenever you have one.";
const TYPING_GUIDANCE: &str = "\
Message to hide. It may span as many lines as you need.
Finish with a line containing a single dot: .
";
#[derive(Parser)]
#[command(name = "stenoxide", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Scan(ScanArgs),
Embed {
#[arg(long, value_name = "PATH")]
input: PathBuf,
#[arg(long, value_name = "PATH")]
output: PathBuf,
#[arg(long, value_name = "PATH")]
payload: Option<PathBuf>,
},
#[command(long_about = GENERATE_LONG_ABOUT)]
Generate {
#[arg(long, value_name = "PATH")]
output: PathBuf,
#[arg(long, value_name = "PATH")]
input: Option<PathBuf>,
#[arg(long, value_name = "PIXELS", default_value_t = DEFAULT_CONTAINER_SIDE,
value_parser = clap::value_parser!(u32).range(i64::from(MIN_CONTAINER_SIDE)..))]
width: u32,
#[arg(long, value_name = "PIXELS", default_value_t = DEFAULT_CONTAINER_SIDE,
value_parser = clap::value_parser!(u32).range(i64::from(MIN_CONTAINER_SIDE)..))]
height: u32,
},
Extract {
#[arg(long, value_name = "PATH")]
input: PathBuf,
#[arg(long, value_name = "PATH")]
payload_out: Option<PathBuf>,
#[arg(long, requires = "payload_out")]
force: bool,
},
}
#[derive(Args)]
struct ScanArgs {
#[arg(value_name = "PATH", default_value = ".")]
path: String,
#[arg(long, short = 'a')]
all: bool,
#[arg(long, short = 'r')]
recursive: bool,
#[arg(long)]
json: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let outcome = match &cli.command {
Command::Scan(args) => scan::run(args),
Command::Embed {
input,
output,
payload,
} => run_embed(input, output, payload.as_deref()),
Command::Generate {
output,
input,
width,
height,
} => run_generate(output, input.as_deref(), *width, *height),
Command::Extract {
input,
payload_out,
force,
} => run_extract(input, payload_out.as_deref(), *force),
};
match outcome {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("{message}");
ExitCode::FAILURE
}
}
}
fn read_password() -> Result<Zeroizing<Vec<u8>>, String> {
rpassword::prompt_password(PASSWORD_PROMPT)
.map(|password| Zeroizing::new(password.into_bytes()))
.map_err(|err| format!("Error: could not read the password: {err}"))
}
fn read_plaintext() -> Result<Zeroizing<Vec<u8>>, String> {
let stdin = io::stdin();
if stdin.is_terminal() {
eprint!("{TYPING_GUIDANCE}");
let message = collect_typed_lines(&mut stdin.lock())
.map_err(|err| format!("Error: could not read the message you typed: {err}"))?;
eprintln!("Read {} bytes.", message.len());
return Ok(message);
}
let mut plaintext = Zeroizing::new(Vec::new());
stdin
.lock()
.read_to_end(&mut plaintext)
.map_err(|err| format!("Error: could not read the message from standard input: {err}"))?;
Ok(plaintext)
}
fn collect_typed_lines(input: &mut impl BufRead) -> io::Result<Zeroizing<Vec<u8>>> {
let mut message = Zeroizing::new(Vec::new());
let mut line = Zeroizing::new(Vec::new());
loop {
line.clear();
if input.read_until(b'\n', &mut line)? == 0 {
break;
}
if strip_line_ending(&line) == END_OF_MESSAGE.as_bytes() {
break;
}
message.extend_from_slice(&line);
}
let without_trailing_newline = strip_line_ending(&message).len();
message.truncate(without_trailing_newline);
Ok(message)
}
fn strip_line_ending(line: &[u8]) -> &[u8] {
let line = match line.strip_suffix(b"\n") {
Some(rest) => rest,
None => line,
};
match line.strip_suffix(b"\r") {
Some(rest) => rest,
None => line,
}
}
fn load_container(path: &Path) -> Result<ImageBuffer, String> {
load_and_validate(path).map_err(|error| describe_rejection(path, &error))
}
fn describe_rejection(path: &Path, error: &ValidationError) -> String {
let file = path.display();
match error {
ValidationError::JpegDetected => format!(
"Error: {file} is a JPEG and cannot be used as a container.\n \
Convert it to PNG first: magick input.jpg output.png\n \
Note that a PNG converted from a JPEG is refused as well; the \
container must never have been JPEG-compressed."
),
ValidationError::WebpDetected => format!(
"Error: {file} is a WebP and cannot be used as a container.\n \
Only PNG containers that have never been through a lossy codec are \
supported."
),
ValidationError::NotPng => format!(
"Error: {file} is not a PNG image.\n \
Containers must be PNG files of at least 2000x2000 pixels."
),
ValidationError::ImageTooSmall { width, height, min } => format!(
"Error: {file} is {width}x{height}, which is too small.\n \
Both sides must be at least {min} pixels."
),
ValidationError::ImageTooLarge {
width,
height,
pixels,
max,
} => format!(
"Error: {file} is {width}x{height}, which is {} megapixels.\n \
Analysing an image that size needs more memory than this limit \
allows,\n \
so it is refused immediately rather than left to exhaust the \
machine.\n \
The maximum is {} megapixels; scale it down or use another photo.",
pixels / (1024 * 1024),
max / (1024 * 1024)
),
ValidationError::UnsupportedColorSpace { .. } => format!(
"Error: the pixel layout of {file} is not supported.\n \
Use an 8-bit or 16-bit RGB, RGBA or grayscale PNG."
),
ValidationError::JpegArtifactsDetected { .. } => format!(
"Error: {file} was JPEG-compressed at some point and re-saved as a \
PNG.\n \
The 8x8 block grid it left behind is exactly what a steganalyst \
looks for.\n \
Use a photo straight from a camera that was never saved as a JPEG."
),
ValidationError::IoError(_) | ValidationError::DecodingError(_) => {
format!("Error: {file}: {error}")
}
}
}
fn run_embed(input: &Path, output: &Path, payload: Option<&Path>) -> Result<(), String> {
drop(load_container(input)?);
let source = match payload {
Some(path) => Some((payload::open_payload_file(path)?, path)),
None => None,
};
let password = read_password()?;
let plaintext = match source {
Some((handle, path)) => payload::read_payload_file(handle, path)?,
None => read_plaintext()?,
};
if plaintext.is_empty() {
return Err("Error: the message is empty; nothing to hide.".to_string());
}
let activity = progress::Activity::start();
let outcome = EmbedPipeline::default_secure().embed(input, plaintext, password, output);
activity.finish();
let report = outcome.map_err(|err| describe_embed_failure(&err))?;
print_report(&report, output);
Ok(())
}
fn describe_embed_failure(error: &PipelineError) -> String {
let PipelineError::Sizer(SizerError::PayloadTooLarge {
payload,
available,
deficit,
}) = error
else {
return format!("Error: {error}");
};
format!(
"Error: the payload does not fit in this container.\n \
Compressed and encrypted it is {payload} bytes; the container admits \
{available}.\n \
It is {deficit} bytes over.\n \
That first figure is the payload after compression, not the size of \
the file:\n \
text shrinks a great deal, so a much larger file may still fit and a \
smaller one may not.\n \
Use a container of higher resolution; stenoxide scan reports what each \
one can carry."
)
}
fn run_generate(
output: &Path,
input: Option<&Path>,
width: u32,
height: u32,
) -> Result<(), String> {
let dimensions =
ContainerDimensions::new(width, height).map_err(|err| describe_generate_failure(&err))?;
let source = match input {
Some(path) => Some((payload::open_payload_file(path)?, path)),
None => None,
};
let password = read_password()?;
let plaintext = match source {
Some((handle, path)) => payload::read_payload_file(handle, path)?,
None => read_plaintext()?,
};
if plaintext.is_empty() {
return Err("Error: the message is empty; nothing to hide.".to_string());
}
let activity = progress::Activity::start();
let outcome = generate_container(plaintext, password, dimensions, output);
activity.finish();
let report = outcome.map_err(|err| describe_generate_failure(&err))?;
print_generate_report(&report, output);
Ok(())
}
fn describe_generate_failure(error: &GenerateError) -> String {
match error {
GenerateError::PayloadTooLarge {
payload,
available,
deficit,
recommended_side,
} => describe_payload_too_large(*payload, *available, *deficit, *recommended_side),
GenerateError::DimensionsOutOfRange { .. } => format!(
"Error: {error}.\n \
Set the size with --width and --height; both must be at least 2000 \
pixels."
),
other => format!("Error: {other}"),
}
}
fn describe_payload_too_large(
payload: usize,
available: usize,
deficit: usize,
recommended_side: Option<u32>,
) -> String {
let advice = match recommended_side {
Some(side) => format!(
"A larger container would hold it: at about {side}x{side} it fits.\n \
Ask for one with --width {side} --height {side} (both must be at \
least 2000).\n \
Any width and height whose area is at least that will do; the \
suggestion is square only because one number is easier to quote."
),
None => "No permitted container is large enough for a payload this size; \
a generated container is capped at 128 megapixels.\n \
Split the payload, or compress it further before hiding it."
.to_owned(),
};
format!(
"Error: the payload does not fit in the requested container.\n \
Compressed and encrypted it is {payload} bytes; the container admits \
{available}.\n \
It is {deficit} bytes over.\n \
That first figure is the payload after compression, not the size of \
the file.\n \
{advice}"
)
}
fn print_generate_report(report: &GenerateReport, output: &Path) {
let (width, height) = report.image_dimensions;
println!("Container generated at {}", output.display());
println!(" Image dimensions: {width}x{height}");
println!(
" Payload carried: {} of {} bytes, compressed",
report.payload_bytes, report.capacity_bytes
);
println!(
"\nThis container does not hide that it was generated. It hides which of \
several\ngenerated containers carries a message. Prefer an unpublished \
photograph of your\nown whenever you have one."
);
}
fn print_report(report: &EmbedReport, output: &Path) {
let (width, height) = report.image_dimensions;
println!("Stego image written to {}", output.display());
println!(" Image dimensions: {width}x{height}");
println!(" Pixels modified: {}", report.pixels_modified);
println!(" Payload embedded: {} bytes", report.payload_bytes);
println!(" Effective rate: {:.6} bpp", report.effective_bpp);
}
fn run_extract(input: &Path, payload_out: Option<&Path>, force: bool) -> Result<(), String> {
drop(load_container(input)?);
if let Some(path) = payload_out {
if !force {
refuse_existing_destination(path)?;
}
}
let password = read_password()?;
let activity = progress::Activity::start();
let outcome = EmbedPipeline::default_secure()
.extract(input, password)
.map_err(|_| EXTRACTION_FAILED.to_string());
activity.finish();
let (plaintext, _report) = outcome?;
match payload_out {
Some(path) => write_recovered_file(path, plaintext.as_slice(), force),
None => write_recovered_to_stdout(plaintext.as_slice()),
}
}
fn refuse_existing_destination(path: &Path) -> Result<(), String> {
if path.is_dir() || !path.exists() {
return Ok(());
}
Err(format!(
"Error: {} already exists and would be overwritten.\n \
Choose another path, or pass --force to replace it.",
path.display()
))
}
fn write_recovered_file(requested: &Path, plaintext: &[u8], force: bool) -> Result<(), String> {
let destination = payload::resolve_output_path(requested, plaintext);
payload::write_payload_file(&destination, plaintext, force)
.map_err(|_| EXTRACTION_FAILED.to_string())
}
#[derive(Debug, PartialEq, Eq)]
enum StdoutDelivery {
Raw,
RefuseBinary,
}
fn stdout_delivery(stdout_is_terminal: bool, plaintext: &[u8]) -> StdoutDelivery {
if stdout_is_terminal && std::str::from_utf8(plaintext).is_err() {
StdoutDelivery::RefuseBinary
} else {
StdoutDelivery::Raw
}
}
fn write_recovered_to_stdout(plaintext: &[u8]) -> Result<(), String> {
match stdout_delivery(io::stdout().is_terminal(), plaintext) {
StdoutDelivery::Raw => io::stdout()
.write_all(plaintext)
.and_then(|()| io::stdout().flush())
.map_err(|_| EXTRACTION_FAILED.to_string()),
StdoutDelivery::RefuseBinary => Err(format!(
"The payload is {} bytes of binary data and will not be written to the terminal.\n \
Redirect it to a file (for example: > payload.bin) or pass --payload-out PATH.",
plaintext.len()
)),
}
}
fn container_capacity(image: &ImageBuffer) -> Option<usize> {
compute_stable_phash(image).ok()?;
let cost_map = HillCostProvider::new().compute(image).ok()?;
Some(compute_capacity(&cost_map, EmbeddingMode::Symmetric).available_bytes())
}
fn terminal_renders_unicode() -> bool {
#[cfg(windows)]
{
if io::stdout().is_terminal() {
return console_output_code_page() == 65_001;
}
}
true
}
#[cfg(windows)]
fn console_output_code_page() -> u32 {
extern "system" {
fn GetConsoleOutputCP() -> u32;
}
#[allow(unsafe_code)]
unsafe {
GetConsoleOutputCP()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn typed(keystrokes: &str) -> String {
let mut input = io::Cursor::new(keystrokes.as_bytes().to_vec());
let message = collect_typed_lines(&mut input).expect("a cursor cannot fail to read");
String::from_utf8(message.to_vec()).expect("the fixtures are all UTF-8")
}
#[test]
fn a_lone_dot_ends_the_message() {
assert_eq!(typed("a secret\n.\n"), "a secret");
assert_eq!(typed("a secret\r\n.\r\n"), "a secret");
}
#[test]
fn nothing_after_the_terminator_is_taken() {
assert_eq!(typed("kept\n.\nnot this\n"), "kept");
}
#[test]
fn the_message_may_span_several_lines() {
assert_eq!(typed("one\ntwo\n\nfour\n.\n"), "one\ntwo\n\nfour");
}
#[test]
fn end_of_file_still_ends_the_message() {
assert_eq!(typed("a secret\n"), "a secret");
assert_eq!(typed("no newline at all"), "no newline at all");
assert_eq!(typed(""), "");
}
#[test]
fn a_dot_within_a_line_is_text() {
assert_eq!(
typed("Meet me at six. Bring it.\n.\n"),
"Meet me at six. Bring it."
);
assert_eq!(typed("..\n.\n"), "..");
assert_eq!(typed(" .\n.\n"), " .");
}
#[test]
fn a_message_that_is_only_the_terminator_is_empty() {
assert!(typed(".\n").is_empty());
}
#[test]
fn invalid_utf8_is_carried_through_unchanged() {
let mut input = io::Cursor::new(b"caf\xe9\n.\n".to_vec());
let message = collect_typed_lines(&mut input).expect("a cursor cannot fail to read");
assert_eq!(message.as_slice(), b"caf\xe9");
}
#[test]
fn a_payload_that_does_not_fit_is_told_by_how_much() {
let message = describe_embed_failure(&PipelineError::Sizer(SizerError::PayloadTooLarge {
payload: 30_000,
available: 22_016,
deficit: 7_984,
}));
assert!(message.contains("30000"), "got: {message}");
assert!(message.contains("22016"), "got: {message}");
assert!(message.contains("7984"), "got: {message}");
assert!(
message.contains("after compression"),
"the message must not read as a claim about the file's size: {message}"
);
assert!(message.contains("stenoxide scan"), "got: {message}");
}
#[test]
fn other_failures_are_printed_as_the_layer_phrased_them() {
let error = PipelineError::Validation(ValidationError::NotPng);
let message = describe_embed_failure(&error);
assert_eq!(message, format!("Error: {error}"));
}
#[test]
fn an_oversized_generated_payload_names_a_size_and_the_flags() {
let message = describe_generate_failure(&GenerateError::PayloadTooLarge {
payload: 1_782_778,
available: 1_499_980,
deficit: 282_798,
recommended_side: Some(2_200),
});
assert!(message.contains("1782778"), "got: {message}");
assert!(message.contains("1499980"), "got: {message}");
assert!(message.contains("282798"), "got: {message}");
assert!(
message.contains("after compression"),
"the message must not read as a claim about the file's size: {message}"
);
assert!(message.contains("2200x2200"), "got: {message}");
assert!(message.contains("--width 2200"), "got: {message}");
assert!(message.contains("--height 2200"), "got: {message}");
}
#[test]
fn a_payload_beyond_every_container_is_told_plainly() {
let message = describe_generate_failure(&GenerateError::PayloadTooLarge {
payload: 60_000_000,
available: 1_499_980,
deficit: 58_500_020,
recommended_side: None,
});
assert!(message.contains("No permitted container"), "got: {message}");
assert!(message.contains("128 megapixels"), "got: {message}");
assert!(!message.contains("--width"), "no size to reach: {message}");
}
#[test]
fn an_out_of_range_size_names_the_flags() {
let message = describe_generate_failure(&GenerateError::DimensionsOutOfRange {
width: 1_500,
height: 3_000,
min_side: MIN_CONTAINER_SIDE,
max_pixels: stenoxide_core::generate::MAX_CONTAINER_PIXELS,
});
assert!(message.starts_with("Error:"), "got: {message}");
assert!(message.contains("1500x3000"), "got: {message}");
assert!(message.contains("--width"), "got: {message}");
assert!(message.contains("--height"), "got: {message}");
}
#[test]
fn only_an_existing_file_blocks_the_destination() {
let directory = tempfile::TempDir::new().expect("temporary directory");
assert!(refuse_existing_destination(directory.path()).is_ok());
assert!(refuse_existing_destination(&directory.path().join("new.zip")).is_ok());
let taken = directory.path().join("taken.zip");
std::fs::write(&taken, b"already here").expect("fixture write");
let message =
refuse_existing_destination(&taken).expect_err("an existing file must be refused");
assert!(message.contains("taken.zip"), "got: {message}");
assert!(message.contains("--force"), "got: {message}");
}
#[test]
fn a_redirection_takes_binary_and_text_alike() {
assert_eq!(stdout_delivery(false, b"plain text"), StdoutDelivery::Raw);
assert_eq!(
stdout_delivery(false, &[0xFF, 0xD8, 0xFF, 0xE0]),
StdoutDelivery::Raw,
"a redirection must take a JPEG's bytes unchanged"
);
}
#[test]
fn a_terminal_shows_text_but_refuses_binary() {
assert_eq!(
stdout_delivery(true, b"a readable message"),
StdoutDelivery::Raw
);
assert_eq!(
stdout_delivery(true, &[0xFF, 0xD8, 0xFF, 0xE0]),
StdoutDelivery::RefuseBinary
);
}
#[test]
fn accented_text_is_shown_on_a_terminal() {
assert_eq!(
stdout_delivery(true, "café — ñandú".as_bytes()),
StdoutDelivery::Raw
);
}
#[test]
fn the_guidance_states_how_to_finish() {
assert!(
TYPING_GUIDANCE.contains(END_OF_MESSAGE),
"the guidance must name the terminator, got: {TYPING_GUIDANCE:?}"
);
assert!(
TYPING_GUIDANCE.is_ascii(),
"the guidance is printed before the console's code page is known"
);
}
}