jpeg-obscura v1: Pixel-Domain Diffusion Design

Date: 2026-08-31 • Status: DESIGN PHASE (pre-implementation review)

Purpose: Add value diffusion to v0.5 permutation for genuine cryptographic security (requirement 4)

Conceptual Overview

v0.5 (permutation-only): blocks are shuffled but pixel values unchanged → vulnerable to edge-continuity reconstruction attacks.

v1 (permutation + diffusion): after shuffling blocks, XOR each pixel byte with a keyed offset stream → makes reconstruction exponentially harder.

Speed: Still pixel-domain (no coefficient tooling), full decode/encode cycle as in v0.5.

Security: Meaningfully satisfies requirement 4 ('hard to decode without the key').

Throwaway: Diffusion logic gets rewritten (not ported) at v1.5 for coefficient domain. This is deliberate.

Architecture: Two Phases

Phase 1: Permutation (unchanged from v0.5)

1. Load image → numpy array

2. Snap region to 8×8 block grid

3. Generate deterministic permutation from KDF-derived perm_key

4. Permute blocks: rearrange pixels in the region

Phase 2: Diffusion (NEW in v1)

1. After permutation, iterate over every pixel in the region (raster order)

2. For each pixel channel (R, G, B), XOR with byte from deterministic stream

3. Outside region: leave pixels untouched

Formula: new_pixel = old_pixel XOR stream_byte

Key Derivation (Modified from v0.5)

v0.5: KDF → (perm_key_32B + check_key_32B)

v1: KDF → (perm_key_32B + diffusion_key_32B)

Rationale

Two independent keys prevent cross-contamination between permutation PRNG and diffusion stream. Same HMAC-SHA256 counter-mode infrastructure, different keys and domains.

Implementation Specifics

Diffusion Stream Generation

Same DeterministicStream class as permutation

Key: diffusion_key (32 bytes from KDF)

Domain: 'jpeg-obscura/v1/diffusion' (domain separation from v0.5)

Output: Counter-mode HMAC-SHA256 producing 64-byte blocks

Consumption: One byte per pixel channel in raster order

Pixel Processing Order

Raster order (top-to-bottom, left-to-right):

for y in [region_y .. region_y+region_h): for x in [region_x .. region_x+region_w): for ch in [R,G,B]: pixel[y,x,ch] ^= stream.byte()

Deterministic, reproducible, order-independent (same region always produces same diffusion regardless of when scramble ran).

Restoration Flow

Forward (scramble): Permute blocks → Diffuse bytes → Save JPEG

Backward (restore): Load JPEG → Un-diffuse bytes (XOR with same stream) → Un-permute blocks

Un-diffuse is identical operation (XOR is self-inverse).

Schema Versioning

Stored in metadata:

• schema_version=0: v0.5 (permutation only)

• schema_version=1: v1 (permutation + diffusion)

CLI auto-detects and routes to correct logic.

Security Properties

What v1 Achieves

✓ Pixel values meaningfully obscured (XOR'd with random bytes)

✓ Edge-continuity attacks fail (pixel values don't match across shuffled blocks)

✓ Genuine key-dependent security (requires breaking KDF or finding XOR plaintext)

✓ Fast (pixel domain, no coefficient tooling needed)

What v1 Does NOT Achieve (v1.5+ scope)

✗ Byte-exact restoration on re-encode (JPEG lossy requantizes)

✗ Coefficient-domain security (vulnerable to FFT/DCT analysis at boundaries)

Code Changes Summary

New: diffusion.py

• diffuse_region(array, region, diffusion_key, block_size=8): XOR pixels with stream

• undiffuse_region(array, region, diffusion_key, block_size=8): Same function (XOR is self-inverse)

Modified

• kdf.py: Clarify output labels (perm_key, diffusion_key instead of perm_key, check_key)

• transform.py: Call diffuse_region() after permute_blocks() in scramble path

• cli.py: Schema version detection; route scramble/restore by version

• metadata.py: schema_version=1

Testing Strategy

Unit: Diffusion Properties

• diffuse(diffuse(x)) == x (XOR is self-inverse) ✓

• Same key+region → same output (deterministic) ✓

• Different key → different output (key-dependent) ✓

Integration: File Round-Trip

Load image → Scramble (v1) → Save JPEG → Load → Restore → Compare pixels

Expected: Mean Δ ≤ 0.5 bytes (JPEG lossy baseline, same as v0.5)

Verify: Correct passphrase restores; wrong passphrase produces garbage

Samples

Create 3 new samples from different WebDAV images

Find main subject (face, building, art) and define region

Scramble + restore to demonstrate effectiveness

Keep v0.5 samples for version comparison

CLI (No User-Facing Change)

Same commands as v0.5:

jpeg-obscura scramble INPUT.jpg OUTPUT.jpg --region X,Y,W,H --passphrase PASS

jpeg-obscura restore INPUT.jpg OUTPUT.jpg --passphrase PASS --region X,Y,W,H --salt HEX

Internally: auto-detect schema_version, use v0.5 or v1 logic transparently.

Deliverables

□ Design review (this doc)

□ diffusion.py module

□ kdf.py: clarify output labels

□ transform.py: add diffuse call

□ cli.py: schema version routing

□ Unit tests (in-memory)

□ Integration tests (JPEG file)

□ 3 new sample images with restore demo

□ Git tag v1-implementation

Open Questions for Review

1. Is global counter-mode stream (vs per-block seed) sufficient? → YES, proven in permutation

2. Defer metadata embedding to v1.5? → YES, maintain v0.5 compatibility model

3. Handle alpha channel? → YES, treat as 4th byte in RGBA

version 1  ·  created 2026-08-31  ·  updated 2026-08-31  ·  tags jpeg-obscura, v1, design, diffusion