Child design of Reversible Region Scrambling in JPEGs. This specifies the smallest implementation that validates keyed, region-local block permutation in the pixel domain using Pillow. It is implementation-ready, but deliberately not secure against jigsaw reconstruction and not pixel-lossless because JPEG is decoded and re-encoded.
Given a JPEG, a rectangular selection, and a passphrase, produce an ordinary JPEG whose selected region is visually obscured by a deterministic keyed permutation of fixed-size pixel blocks. Given that output JPEG and the same passphrase, reconstruct a recognisable approximation of the selected region without needing the original file.
v0.5 succeeds when it proves the end-to-end mechanics: region normalisation, key derivation, deterministic Fisher–Yates permutation, metadata carriage, inverse permutation, CLI ergonomics, and measurable localisation of changes. It does not claim confidentiality: permutation alone leaks local content and may be attacked by edge-continuity/jigsaw solvers.
the permutation mapping and its inverse, when applied to the same decoded pixel array before any lossy encoding.
encoding the scrambled pixels changes values through colour conversion, chroma subsampling, DCT quantisation, and ringing. Decoding and unpermuting therefore restores an approximation of the pixels that entered the first encode, not the original JPEG bytes or exact original pixels.
survival of third-party resizing, cropping, metadata stripping, rotation, or recompression. Same-size recompression may remain partly recoverable if metadata and the block grid survive, but this is observational rather than an acceptance guarantee.
| In scope | Out of scope |
|---|---|
| One rectangular region; RGB or grayscale input; 8×8 or configurable square blocks; passphrase-based scrambling and restoration; embedded metadata plus explicit CLI overrides; deterministic cross-run behaviour; ordinary JPEG output. | Diffusion/value masking; multiple or non-rectangular regions; coefficient-domain editing; byte- or pixel-exact preservation; resizing/cropping recovery; browser extension; payments; adversarial confidentiality; concealment of the region location. |
jpeg-obscura scramble INPUT.jpg OUTPUT.jpg \
--region X,Y,W,H --passphrase-file PATH [--block-size 8] \
[--quality keep|1..100] [--subsampling keep|0|1|2] [--metadata-out FILE]
jpeg-obscura restore INPUT.jpg OUTPUT.jpg \
--passphrase-file PATH [--metadata FILE] \
[--region X,Y,W,H --block-size 8 --salt HEX] \
[--quality keep|1..100] [--subsampling keep|0|1|2]
jpeg-obscura inspect INPUT.jpg [--json]
A direct --passphrase option may exist for interactive use but must warn that command-line arguments can be exposed through shell history and process listings. Default input is a hidden terminal prompt; automation uses --passphrase-file or standard input. Refuse to overwrite an existing output unless --force is supplied.
Open with Pillow, apply EXIF orientation once using ImageOps.exif_transpose so coordinates refer to the displayed orientation, then convert to RGB. Grayscale may be retained only if the implementation keeps a single-channel path equally well tested; CMYK, palette, and unusual modes are normalised to RGB. The output records orientation as normal and does not preserve an orientation transform that would move the region after scrambling.
Preserve safe descriptive EXIF/ICC data where Pillow supports it, but remove or normalise orientation. Metadata preservation is best-effort in v0.5 and must not be confused with pixel preservation.
Coordinates use the oriented image's pixel space: half-open rectangle [x, x+w) × [y, y+h). Validate positive width and height and a non-empty intersection with the image. The block grid is anchored at image origin (0,0), making geometry independent of the selected rectangle.
Let B be block size. The effective region contains only complete B×B grid cells: left/top round down to multiples of B; right/bottom round up, then clamp to floor(image_dimension/B) * B. A trailing image strip smaller than B is never moved. If clamping leaves fewer than two complete blocks, fail with a clear error. Return and embed the effective rectangle, and print it when it differs from the requested rectangle.
Permute all full B×B blocks in row-major order inside the effective rectangle. This rule avoids partial edge blocks of unequal shape and makes the inverse unambiguous. Default B=8 aligns with JPEG's common DCT unit while still operating purely on decoded pixels.
Generate a fresh 16-byte random salt for each scramble. Encode the passphrase as UTF-8 and derive 64 bytes with scrypt using N=32768, r=8, p=1. First 32 bytes are the permutation key; reserve the remaining 32 bytes for metadata key-check/domain separation and later experiments. Store salt and KDF parameters, never the passphrase or derived key.
Define a cross-language deterministic random stream as HMAC-SHA256(permutation_key, "jpeg-obscura/v0.5/permutation" || uint64_be(counter)) for counters 0,1,…, concatenating digests. Implement unbiased randbelow(n) with rejection sampling; do not use modulo reduction or Python's version-dependent random.shuffle.
Build permutation p with Fisher–Yates over block indices 0…n−1, iterating i from n−1 down to 1 and swapping i with randbelow(i+1). Specify the move convention once: output block slot p[i] receives original block i. Restoration uses the exact inverse mapping. Unit tests must lock this convention with published small vectors.
Atomic publish means write a complete temporary file in the output directory, flush/close it, then replace the target only after all processing succeeds. On failure, remove only that known temporary file and leave any pre-existing target untouched.
Embed a custom APP15 JPEG segment immediately after SOI. Payload: ASCII magic JOSC; one-byte envelope version 0x01; four-byte big-endian JSON length; UTF-8 canonical JSON. Standard decoders ignore unknown APP segments. Also support writing the same JSON as a sidecar for debugging and recovery from tools that strip APP metadata.
{
"schema": "jpeg-obscura/v0.5",
"operation": "scrambled",
"image_width": 1920,
"image_height": 1080,
"requested_region": [101, 77, 603, 411],
"effective_region": [96, 72, 608, 416],
"block_size": 8,
"block_count": 3952,
"kdf": {"name": "scrypt", "n": 32768, "r": 8, "p": 1, "salt_b64u": "..."},
"permutation": {"name": "fisher-yates-hmac-sha256-v1"},
"orientation_normalised": true,
"key_check_b64u": "..."
}
Compute key_check as the first 16 bytes of HMAC-SHA256(check_key, canonical_json_without_key_check). This detects wrong keys and accidental metadata damage; it is not an authenticity guarantee against an attacker and does not make weak passphrases safe. Reject duplicate JOSC segments, unknown required fields, impossible dimensions, excessive KDF parameters, or metadata larger than a conservative limit such as 16 KiB.
For predictable v0.5 behaviour, default to Pillow quality 95 and 4:4:4 subsampling (subsampling=0), with optimisation optional. A keep mode may use Pillow's best-effort quality="keep" and subsampling="keep" for JPEG input, but it is not a lossless copy and must not be the only tested path. Record the actual policy used in metadata for diagnostics.
Do not promise that pixels outside the region remain identical. Measure outside-region error and report it in tests, but exact preservation is deferred to coefficient-domain versions.
src/jpeg_obscura/
cli.py argument parsing, prompts, exit codes
image.py orientation, mode conversion, JPEG I/O
geometry.py rectangle validation and block enumeration
kdf.py scrypt and key separation
stream.py HMAC counter stream and unbiased randbelow
permutation.py Fisher–Yates and inverse mapping
transform.py block extraction/copy for scramble and restore
metadata.py APP15 and sidecar encode/decode/validation
errors.py typed user-facing failures
tests/
test_geometry.py
test_stream_vectors.py
test_permutation.py
test_transform.py
test_metadata.py
test_roundtrip.py
test_cli.py
Core functions accept arrays/bytes and explicit parameter objects; they do not print, prompt, or read global state. CLI owns user interaction. Use NumPy for block copying if adopted, but keep the transform semantics simple enough to test against a small pure-Python reference implementation.
| Condition | Behaviour |
|---|---|
| Invalid/empty region or fewer than two blocks | Exit 2; explain requested and effective geometry. |
| No JOSC metadata on restore | Exit 3; suggest --metadata or explicit parameters. |
| Wrong passphrase or damaged metadata | Exit 4; write no output. |
| Unsupported schema/version or unsafe KDF values | Exit 5; write no output. |
| Input decode or output encode failure | Exit 6; preserve target and report the underlying file error concisely. |
| Output exists without --force | Exit 7; do not modify it. |
v1 adds keyed value diffusion because permutation alone does not meet the hard-to-decode requirement. v1.5 moves to DCT coefficients to localise re-encoding damage. v2 handles rigorous coefficient-category constraints, chroma/MCU alignment, and exact outside-region preservation. Resize-robust coordinates and recovery after metadata-stripping platforms remain separate research questions and must not silently expand v0.5.
Detailed design completed 2026-08-31. No implementation has started. The first coding milestone is the pure geometry/permutation transform with exact in-memory round-trip tests.