A closed-loop, offline, educational reproduction of low-contrast optical signaling through an LCD display, inspired by the static bright-on-bright QR experiment described by Mordechai Guri in Optical air-gap exfiltration attack via invisible images.
This repository does not collect files, keystrokes, credentials, or personal data. Its primary
encoder accepts only synthetic identifiers matching LAB-000. A separate bounded exercise can
transmit at most 256 bytes deliberately staged in fixtures/; the receiver works entirely
offline and writes only to recovered/.
Current result: a real high-resolution iPhone photograph of the MacBook display was
reconstructed offline and independently decoded as LAB-001. The bounded binary format also
passes an image-based multi-frame integration test with exact SHA-256 verification. A physical
multi-frame camera trial remains future work.
Project at a glance
Link to section: Project at a glance| Property | Value |
|---|---|
| Primary experiment | Static, low-contrast, bright-on-bright QR |
| Primary payload | Synthetic identifier matching LAB-NNN |
| Physical result | LAB-001 recovered from an iPhone photograph |
| QR baseline | Version 3, error correction M, four-module quiet zone |
| Binary exercise | Explicit fixture of 1–256 bytes |
| Binary packet payload | 14 fixture bytes per QR frame |
| Integrity | QR error correction, packet CRC32, final SHA-256 |
| Network activity | None during rendering or recovery |
| Supported Python | 3.12 or newer |
| Verified checks | 6 tests, Ruff, strict Mypy |
Academic purpose
Link to section: Academic purposeAn air gap removes ordinary network connectivity, but it does not eliminate every physical way that information can leave a computer. Displays, status lights, speakers, electromagnetic emissions, heat, vibration, and power consumption can all become subjects of covert-channel research. This project studies one narrow case: whether a camera can recover deliberately generated information from a low-contrast QR image shown on an LCD.
The project is intended to teach four ideas:
- Digital information can be represented through a physical signal even when no network is present.
- A signal that is difficult for a person to notice may still be measurable by a camera and image-processing pipeline.
- Reliable communication requires framing, ordering, error detection, and end-to-end verification—not only an encoder and decoder.
- Defensive evaluation must distinguish a laboratory proof of concept from a practical attack under real operational constraints.
The objective is not to conceal real activity or move useful private files. The software uses synthetic identifiers and a deliberately small fixture format so students can study the channel without building a general-purpose collection or bulk-transfer system.
Research questions
Link to section: Research questionsThe repository can support controlled experiments around questions such as:
- At what contrast does a static QR stop being recoverable with a particular camera?
- How do camera distance, viewing angle, focus, exposure, and display brightness affect decode reliability?
- Does preserving the QR quiet zone matter more than increasing contrast?
- How much does LCD-camera moire affect a conventional decoder?
- Can grid sampling recover a code after ordinary thresholding fails?
- What is the difference between module error rate and successful payload recovery?
- How many repetitions are needed for a small multi-frame fixture?
- How far is ideal capacity from experimentally observed throughput?
Each question should be tested by changing one controlled variable at a time and preserving both successful and failed captures.
Scope and safety model
Link to section: Scope and safety modelRun the experiment only with equipment you own or are explicitly authorized to test. Keep the camera, display, source bytes, captures, and recovered output inside the controlled laboratory.
The project intentionally excludes:
- automatic file discovery;
- arbitrary home-directory access;
- clipboard, browser, password-manager, or Keychain access;
- keystroke, screen-content, or credential collection;
- persistence, startup agents, or background execution;
- camera compromise or remote camera control;
- network forwarding, cloud upload, or remote command and control;
- silent overwrite of recovered results;
- operational transmission of large files.
The binary lab accepts only an explicitly named file under fixtures/, rejects symbolic links,
limits the fixture to 256 bytes, and writes only under recovered/. The 15 MiB command is a
non-transmitting arithmetic estimator. It does not open a 15 MiB file or generate its frames.
System model
Link to section: System modelThe experiment has four roles. One device can perform more than one role, but describing them separately makes the evidence easier to reason about.
| Role | Responsibility |
|---|---|
| Transmitter | Encodes known synthetic data and displays low-contrast QR images |
| Optical channel | LCD pixels, room lighting, distance, angle, and physical obstructions |
| Camera | Samples the displayed light into an image with exposure, focus, noise, and compression |
| Receiver | Processes captured images, decodes QR payloads, validates packets, and records results |
For the single-frame experiment, the logical channel is:
LAB-001
-> QR version 3 / error correction M
-> bright-on-bright LCD image
-> iPhone photograph
-> grayscale and contrast reconstruction
-> OpenCV QR decoding
-> exact comparison with LAB-001For the bounded binary experiment, the logical channel is longer:
known fixture bytes
-> SHA-256 transfer identifier
-> numbered 14-byte chunks
-> CRC32-protected packets
-> Base85 text representation
-> one QR image per packet
-> camera captures
-> QR text recovery
-> Base85 decoding and CRC32 checks
-> deduplication and ordering
-> final SHA-256 comparison
-> recovered bytesThis distinction matters. QR error correction operates within one image. Packet CRC32 detects a frame that decoded incorrectly. Sequence numbers repair capture order. SHA-256 verifies the entire reassembled fixture. These mechanisms solve different problems.
Status
Link to section: Status- Synthetic transmitter-to-camera-simulation-to-decoder round trip: verified
- First physical iPhone capture: signal visible, automatic decode failed because the saved image was cropped tightly and contained glare and display debris
- Physical retest with a high-resolution original photo:
LAB-001recovered successfully - Bounded synthetic binary packet round trip: implemented and covered by integration tests
Requirements
Link to section: Requirements- Apple-silicon or Intel Mac
- Python 3.12 or newer
uv- A second camera, such as an iPhone
No internet connection is required after dependencies have been installed. The transmitter is a local HTML file, and decoding happens locally.
Repository layout
Link to section: Repository layoutoptical-airgap-lab/
├── README.md
├── pyproject.toml
├── fixtures/ # Explicit synthetic inputs, ignored by Git
├── artifacts/ # Generated HTML, QR frames, and manifests
├── captures/ # Original camera images staged for recovery
├── recovered/ # Hash-verified reconstructed fixtures
├── docs/
│ ├── EXPERIMENT_PROTOCOL.md
│ ├── RESULTS.md
│ └── BINARY_TRANSFER_LAB.md
├── src/optical_airgap_lab/
│ ├── encoding.py # QR matrix creation
│ ├── rendering.py # PNG and local HTML rendering
│ ├── decoding.py # Conventional offline reconstruction
│ ├── grid_decoding.py # Frame-filling module-grid recovery
│ ├── binary_transfer.py # Bounded packet framing and validation
│ ├── binary_render_cli.py # Synthetic fixture transmitter command
│ ├── binary_recover_cli.py # Synthetic fixture receiver command
│ └── estimate_cli.py # Non-transmitting capacity arithmetic
└── tests/ # Integration and boundary testsGenerated fixtures, captures, artifacts, and recovered files are ignored by Git. Preserve important experimental evidence separately and document its hashes rather than committing large camera files accidentally.
Install
Link to section: Installgit clone https://github.com/hideouts-io/optical-airgap-lab.git
cd optical-airgap-lab
uv sync --all-groupsConfirm that the commands are available:
./.venv/bin/optical-render --help
./.venv/bin/optical-decode --help
./.venv/bin/optical-render-fixture --help
./.venv/bin/optical-recover-fixture --help
./.venv/bin/optical-estimate --helpThe project uses the local virtual environment created by uv. Dependencies are not installed
globally.
Quick start
Link to section: Quick startThis quick start proves the software path without requiring a camera. It generates a static transmitter, applies deterministic camera-like distortion, and decodes the result.
1. Generate a known transmitter
Link to section: 1. Generate a known transmittercd optical-airgap-lab
uv run optical-render \
--payload LAB-001 \
--html artifacts/quickstart-transmitter.html \
--reference artifacts/quickstart-reference.png \
--background 248 \
--contrast 12 \
--qr-version 3 \
--qr-border 4 \
--error-correction M \
--canvas-width 1200 \
--canvas-height 900 \
--module-pixels 18Expected output begins with:
Rendered payload=LAB-001 contrast=4.71%2. Simulate a camera capture
Link to section: 2. Simulate a camera captureuv run optical-simulate \
--input artifacts/quickstart-reference.png \
--output artifacts/quickstart-capture.png \
--width 1600 \
--height 1200 \
--noise 0.8 \
--blur 0.35 \
--jpeg-quality 94 \
--seed 20260801Expected output:
Simulated capture=artifacts/quickstart-capture.png3. Decode and verify LAB-001
Link to section: 3. Decode and verify LAB-001uv run optical-decode \
--input artifacts/quickstart-capture.png \
--expected LAB-001 \
--reconstruction artifacts/quickstart-reconstruction.pngA successful result resembles:
Decoded payload=LAB-001 method=dynamic-range reconstruction=artifacts/quickstart-reconstruction.pngThe exact successful method can vary with the input. Success requires the decoded payload to
match LAB-001; merely detecting a QR-shaped region is insufficient.
4. Run the verification suite
Link to section: 4. Run the verification suiteuv run pytest -q
uv run ruff check .
uv run mypyAfter this software-only baseline passes, continue with the physical display-and-camera procedure.
Static transmitter experiment
Link to section: Static transmitter experimentThe initial baseline uses a 4.71% intensity difference. It is intentionally easier to capture than the paper's lowest-contrast conditions.
uv run optical-render \
--payload LAB-001 \
--html artifacts/transmitter.html \
--reference artifacts/reference.png \
--background 248 \
--contrast 12 \
--qr-version 3 \
--qr-border 4 \
--error-correction M \
--canvas-width 1200 \
--canvas-height 900 \
--module-pixels 18Open artifacts/transmitter.html in Safari. Enter full screen with Control-Command-F and wait
for the browser controls to disappear.
The transmitter page contains no network requests. It renders a locally encoded matrix to an HTML canvas with image smoothing disabled so every module remains aligned to display pixels. The accompanying reference PNG is useful for confirming the intended matrix independently of a camera.
Why begin at contrast 12 or 20?
Link to section: Why begin at contrast 12 or 20?The intensity difference is expressed relative to the eight-bit grayscale range:
contrast percentage = 100 × contrast delta / 255For example, a background of 248 and a contrast delta of 20 produces foreground modules at 228 and an intensity difference of approximately 7.84%. A larger baseline makes geometry, focus, and exposure problems easier to diagnose before contrast becomes the main experimental variable.
Photograph the display
Link to section: Photograph the display- Clean the MacBook display.
- Hold the iPhone parallel to the display, approximately 30 to 50 cm away.
- Include the complete white area around the QR. Do not crop tightly around its outer modules.
- Tap and hold on the white display to lock focus and exposure. Reduce exposure slightly if the display is clipped to featureless white.
- Take a normal still photo. The iPhone does not need to recognize the QR itself.
- Transfer the original image to the Mac without taking a screenshot of it.
- Export it as JPEG if the camera produced a HEIC file.
- Save it as
captures/static-001.jpginside the repository.
Reconstruct and decode
Link to section: Reconstruct and decodecd optical-airgap-lab
./.venv/bin/optical-decode \
--input captures/static-001.jpg \
--expected LAB-001 \
--reconstruction artifacts/static-001-reconstruction.pngA successful run prints the payload and the image-processing method that recovered it. It also
writes a high-contrast reconstruction to reconstruction.png.
Decoder pipeline
Link to section: Decoder pipelineThe conventional decoder creates several explicit candidates from the original color image:
- Convert the image to grayscale.
- Stretch the observed dynamic range using robust percentiles.
- Apply contrast-limited adaptive histogram equalization.
- Apply an unsharp mask to strengthen module boundaries.
- Produce global Otsu and adaptive-threshold candidates.
- Ask OpenCV to detect and decode each candidate.
- Require the decoded value to equal the expected synthetic identifier.
If the active QR fills almost the entire photograph and lacks a usable quiet zone, the frame-filling recovery path can estimate the module grid. It uses the QR finder, separator, timing, alignment, and dark modules to fit the geometry. Those modules are structural and do not contain the synthetic payload. After alignment, it samples all 29 × 29 active modules, removes smooth illumination variation, calibrates the dark-module threshold from structural modules, renders the observed matrix with a clean quiet zone, and submits that matrix to OpenCV.
The fallback does not replace the observed payload modules with the expected LAB-001 matrix.
The final payload still comes from OpenCV's QR decoder and must match the requested synthetic
identifier.
Interpreting a successful decode
Link to section: Interpreting a successful decodeA successful decode proves that this capture contained enough information for the receiver to recover the selected identifier. It does not prove that the image was invisible to every human, that the same result generalizes to other displays or cameras, or that a high-rate channel is practical. Record the precise conditions and limit conclusions to the tested configuration.
Interpreting a failed decode
Link to section: Interpreting a failed decodeA failed decode is meaningful evidence when the original capture and parameters are preserved. It may indicate insufficient contrast, but it can also result from missing quiet zone, focus, glare, moire, perspective, display clipping, image rescaling, or compression. Repeat at the same settings before changing a variable.
Synthetic binary transfer
Link to section: Synthetic binary transferThe binary exercise demonstrates that the optical channel carries bytes rather than only
human-readable strings. It is deliberately bounded to files from 1 through 256 bytes that are
placed explicitly in fixtures/. It does not search the computer for files, follow symbolic
links, use the network, or read clipboard, browser, credential, keystroke, or personal data.
The end-to-end path is:
synthetic binary fixture
-> numbered and checksummed 14-byte packets
-> Base85 transport encoding
-> low-contrast QR frames on Machine A
-> photographs from an owned camera
-> offline QR reconstruction on Machine B
-> packet ordering and CRC32 validation
-> SHA-256-verified file in recovered/Packet format
Link to section: Packet formatEach QR frame contains one packet with the following fields:
| Field | Size | Purpose |
|---|---|---|
| Magic | 4 bytes | Identifies the OAL1 laboratory format |
| Transfer ID | 8 bytes | First eight bytes of the fixture SHA-256 |
| Sequence | 1 byte | Zero-based position of this packet |
| Total | 1 byte | Number of packets in the transfer |
| Payload length | 1 byte | Number of fixture bytes in this packet |
| Payload | 1–14 bytes | Synthetic binary data |
| CRC32 | 4 bytes | Detects packet corruption |
The packet is Base85-encoded so QR readers that return text can transport arbitrary packet bytes. Base85 is transport encoding, not encryption. QR error correction repairs some visual damage, CRC32 detects a corrupted packet, and the complete SHA-256 verifies the reassembled fixture.
Conceptually, the implementation performs:
body = header + payload_chunk
packet = body + crc32(body)
qr_payload = base85_encode(packet)The corresponding pure framing and validation functions are implemented in
src/optical_airgap_lab/binary_transfer.py.
Why the fixture is chunked
Link to section: Why the fixture is chunkedA QR symbol has finite capacity, and error correction consumes some of it. Version three with error correction M is deliberately retained so the binary exercise resembles the static single-frame experiment. The packet must therefore reserve space for identity, ordering, length, and integrity fields. Fourteen bytes remain for fixture content in each frame.
Chunking also makes failure observable. A student can determine whether a particular frame was missed or corrupted instead of receiving one unexplained invalid output file.
Why Base85 is present
Link to section: Why Base85 is presentSome QR decoder APIs return a Unicode string even when the original QR was created from bytes. Arbitrary binary packets may contain byte sequences that are not valid UTF-8. Base85 converts the packet into a reversible ASCII representation with less overhead than Base64. It is not a confidentiality control: anyone who photographs and decodes the frame can recover its packet.
Integrity versus confidentiality
Link to section: Integrity versus confidentialityThis project provides integrity checks but no encryption:
- QR error correction helps reconstruct visually damaged symbols.
- CRC32 detects accidental corruption of one decoded packet.
- SHA-256 verifies that the complete recovered fixture matches the sender's fixture.
- None of these mechanisms prevents an observer from reading the transmitted fixture.
Do not place secret or personal data into the lab. Adding encryption would not make unapproved collection or transmission acceptable and is outside this project's scope.
Machine A: create a deterministic fixture
Link to section: Machine A: create a deterministic fixtureCreate a known 128-byte sequence rather than using a personal file:
cd optical-airgap-lab
./.venv/bin/python -c \
'from pathlib import Path; Path("fixtures/sample.bin").write_bytes(bytes(range(128)))'Render the fixture as reference PNG frames and a keyboard-controlled HTML transmitter:
./.venv/bin/optical-render-fixture \
--input fixtures/sample.bin \
--frames artifacts/binary-001 \
--html artifacts/binary-001.html \
--background 248 \
--contrast 20 \
--frame-ms 1500The command prints the fixture's SHA-256 and creates:
artifacts/binary-001.html: full-screen transmitter;artifacts/binary-001/frame-NNN.png: individual reference frames;artifacts/binary-001/manifest.json: fixture size, frame count, and SHA-256.
Open artifacts/binary-001.html on Machine A. It starts paused. Use the left and right arrow
keys to select individual frames. Press Space to start or pause automatic playback. The page
visibly identifies itself as an academic synthetic-fixture experiment.
Capture the frames
Link to section: Capture the framesMachine B needs a camera capable of photographing Machine A's display. An owned iPhone can also act as the capture intermediary.
- Capture at least one clear original photograph of every distinct frame.
- Preserve the complete QR quiet zone and avoid screenshots or messaging compression.
- Record the SHA-256 printed on Machine A; it is experimental ground truth, not a secret.
- Place only the frame photographs in
captures/run-001/on Machine B.
Duplicate captures are accepted if they decode to identical packet contents. A missing frame, conflicting duplicate, mixed transfer, invalid CRC32, or incorrect final SHA-256 causes an explicit failure.
For a first multi-frame physical trial, leave automatic playback paused and capture each frame manually. This removes timing and synchronization as variables. After manual recovery works, automatic playback can be studied as a separate experiment using the same fixture and contrast.
Suggested capture log fields are:
| Field | Example |
|---|---|
| Experiment ID | binary-001 |
| Sender display | MacBook Pro model and resolution |
| Camera | iPhone model and camera application |
| Fixture size | 128 bytes |
| Unique frames | 10 |
| Contrast delta | 20 |
| Frame duration | 1500 ms |
| Distance | 40 cm |
| Display brightness | 75% |
| Ambient light | Indirect indoor lighting |
| Captures attempted | 12 |
| Frames decoded | 10 unique packets |
| Final SHA-256 | Exact 64-character value |
Machine B: reconstruct the fixture
Link to section: Machine B: reconstruct the fixtureRun the offline receiver from the repository root:
cd optical-airgap-lab
./.venv/bin/optical-recover-fixture \
--captures captures/run-001 \
--expected-sha256 REPLACE_WITH_THE_64_CHARACTER_SENDER_HASH \
--output recovered/sample.binThe command decodes each QR, verifies its packet CRC32, deduplicates and orders the packets,
reassembles the binary bytes, and compares the final SHA-256 before writing
recovered/sample.bin. Existing output files are never overwritten.
Verify the sender and receiver independently:
# Machine A
shasum -a 256 fixtures/sample.bin
# Machine B
shasum -a 256 recovered/sample.binIf both files are available in the same controlled workspace, compare their bytes directly:
./.venv/bin/python -c \
'from pathlib import Path; print(Path("fixtures/sample.bin").read_bytes() == Path("recovered/sample.bin").read_bytes())'Record the contrast, frame duration, frame count, display brightness, camera model, distance, failed captures, retries, and both hashes. The main academic result is measured reliability and channel capacity, not merely whether one run succeeded.
Fifteen MiB feasibility model
Link to section: Fifteen MiB feasibility modelThe operational binary exercise intentionally refuses inputs larger than 256 bytes. The
optical-estimate command performs frame-count arithmetic for larger hypothetical sizes without
reading or transmitting a file.
Fifteen mebibytes is 15 × 1024 × 1024 = 15,728,640 bytes. Estimate it at five successfully
decoded frames per second:
./.venv/bin/optical-estimate \
--bytes 15728640 \
--frames-per-second 5 \
--repetitions 1With 14 fixture bytes per frame, the estimate is:
1,123,475unique QR frames;224,695ideal seconds at five decoded frames per second;- approximately
62.42ideal hours.
| Ideal decoded rate | Unique frames | Ideal duration |
|---|---|---|
| 1 frame/s | 1,123,475 | about 13.00 days |
| 5 frames/s | 1,123,475 | about 62.42 hours |
| 10 frames/s | 1,123,475 | about 31.21 hours |
Add --repetitions 3, for example, to model displaying every frame three times. This triples
the displayed-frame count and ideal duration.
These values are theoretical lower bounds, not demonstrated throughput. Camera exposure, display refresh interaction, synchronization, dropped frames, decoding failures, repetitions, and physical handling would increase the duration substantially. This project has validated a static physical frame and an image-based multi-packet round trip, not a sustained high-rate physical video channel. Use AirDrop, removable media, or an authenticated network protocol for an authorized real 15 MB transfer.
Calculation
Link to section: CalculationLet:
Dbe the hypothetical data size in bytes;Pbe fixture payload bytes carried per QR frame;Rbe the number of times each unique frame is displayed;Fbe the successfully decoded frame rate.
The lower-bound model is:
unique frames = ceil(D / P)
displayed frames = unique frames × R
ideal seconds = displayed frames / FFor the current packet format:
D = 15,728,640 bytes
P = 14 bytes/frame
R = 1
F = 5 frames/second
unique frames = ceil(15,728,640 / 14)
= 1,123,475
ideal seconds = 1,123,475 / 5
= 224,695 seconds
= 62.42 hoursThis estimate assumes every unique frame is captured and decoded successfully at the stated rate. Real experiments need a measured frame-success probability, repeated frames, and a strategy for identifying missing packets. The estimator intentionally does not implement those bulk-transfer mechanisms.
Why 15 MiB is not an operational project goal
Link to section: Why 15 MiB is not an operational project goalThe physical experiment has demonstrated recovery of a static identifier. Automated tests have demonstrated a small image-based multi-packet round trip. Neither result establishes continuous high-rate physical throughput. More than one million unique frames would introduce major capture, storage, synchronization, deduplication, and experiment-duration problems.
The academically useful conclusion is that channel capacity must be evaluated quantitatively. A proof that one frame works does not imply that megabyte-scale transfer is practical.
Measurements and analysis
Link to section: Measurements and analysisRecommended dependent variables
Link to section: Recommended dependent variablesMeasure outcomes that can be reproduced rather than describing the code as merely visible or invisible:
- successful payload decode: yes or no;
- function-pattern geometry correlation;
- function-module calibration accuracy;
- active-module disagreement count when the reference is known;
- unique packets recovered;
- packet CRC failures;
- repeated captures per successful packet;
- frame success rate;
- effective recovered payload bytes per second;
- final SHA-256 match.
Recommended independent variables
Link to section: Recommended independent variablesChange only one of these between comparable runs:
- foreground/background contrast delta;
- display brightness;
- camera distance;
- horizontal or vertical angle;
- ambient lighting;
- camera exposure;
- focus mode;
- image format and compression;
- frame duration;
- QR error-correction level, if studied in a separate branch of the experiment.
Suggested trial table
Link to section: Suggested trial table| Trial | Contrast | Distance | Angle | Brightness | Format | Decode | Notes |
|---|---|---|---|---|---|---|---|
| 001 | 20 | 40 cm | 0° | 75% | Original JPEG | — | Baseline |
| 002 | 16 | 40 cm | 0° | 75% | Original JPEG | — | Contrast only |
| 003 | 12 | 40 cm | 0° | 75% | Original JPEG | — | Contrast only |
Use explicit values in the real record. Do not fill a failed trial with an inferred reason unless the evidence supports it.
Bit and frame error concepts
Link to section: Bit and frame error conceptsModule disagreement and payload failure are related but not identical. QR error correction can recover a payload even when several sampled modules differ from the reference. Conversely, a small number of errors in unfavorable positions can prevent decoding. Report both the raw module comparison, when available, and the final decode result.
For multi-frame work, frame success rate can be calculated as:
frame success rate = successfully decoded captures / total attempted capturesEffective fixture throughput is:
effective bytes per second = verified recovered fixture bytes / total experiment secondsInclude setup, retries, and missing-frame recovery in the time measurement when evaluating a real procedure. Excluding them produces only an idealized decoder rate.
Contrast progression
Link to section: Contrast progressionEstablish a physical baseline before reducing contrast.
--contrast | Intensity difference | Purpose |
|---|---|---|
| 20 | 7.84% | Troubleshooting baseline |
| 16 | 6.27% | Strong baseline |
| 12 | 4.71% | Initial experiment |
| 8 | 3.14% | Low contrast |
| 6 | 2.35% | Near the paper's reported bright/static threshold |
| 5 | 1.96% | Below that reported threshold; hardware dependent |
Change only contrast between runs. Preserve the original photos and record the camera distance, angle, ambient light, display brightness, and decode result.
Troubleshooting
Link to section: TroubleshootingThe iPhone does not show a QR notification
Link to section: The iPhone does not show a QR notificationThat is expected at low contrast and is not the success criterion. Save the original photograph and run the offline decoder.
The QR is visible but OpenCV cannot decode it
Link to section: The QR is visible but OpenCV cannot decode itCheck the quiet zone first. Preserve white space around all four sides, keep the camera parallel to the display, and use the original image rather than a screenshot. Clean the display and avoid reflections or debris crossing the modules. Retry at contrast 20 before reducing contrast.
The image looks uniformly white
Link to section: The image looks uniformly whiteThe camera may be clipping highlights. Reduce exposure slightly or lower display brightness while keeping other variables recorded. Confirm the reference PNG contains the intended pattern.
The capture contains vertical or curved stripes
Link to section: The capture contains vertical or curved stripesThis is commonly caused by interaction between the LCD pixel structure and camera sampling. Change distance slightly while preserving a straight-on angle. Do not apply arbitrary filters to the original evidence; save processed derivatives separately.
Grid reconstruction says its geometry is unreliable
Link to section: Grid reconstruction says its geometry is unreliableThe frame-filling fallback expects a nearly straight-on, high-resolution image in which the active version-three QR occupies most of the frame. Use a conventional capture with a complete quiet zone when possible. Do not treat a template correlation alone as a decoded payload.
A binary packet fails CRC32
Link to section: A binary packet fails CRC32Retake that frame. CRC failure means the recovered packet bytes are not accepted as evidence. Do not silently discard or repair them by copying bytes from the sender.
The receiver reports missing sequences
Link to section: The receiver reports missing sequencesCompare the reported sequence numbers with the transmitter's total. Photograph the missing frames and add those original images to the capture directory. Identical duplicates are safe; conflicting duplicates stop the transfer.
The final SHA-256 differs
Link to section: The final SHA-256 differsTreat the run as failed. Confirm that Machine B used the hash printed for the same Machine A fixture and that captures from different runs were not mixed. The receiver does not write a successful output when the hash differs.
The transmitter refuses the fixture path
Link to section: The transmitter refuses the fixture pathRun the command from the repository root and place the deliberate input directly under
fixtures/. Absolute paths outside that directory and symbolic links are rejected by design.
The transmitter refuses a file larger than 256 bytes
Link to section: The transmitter refuses a file larger than 256 bytesThat is the intended safety boundary. Use optical-estimate for hypothetical capacity analysis.
Use a conventional authorized transfer mechanism for real files.
Complete Python API examples
Link to section: Complete Python API examplesThe command-line tools are thin wrappers around typed Python functions. The following examples
show the code path directly. Run them from the repository root after uv sync --all-groups.
Create a static QR matrix
Link to section: Create a static QR matrixfrom numpy.typing import NDArray
import numpy as np
from optical_airgap_lab.encoding import create_qr_mask
qr_mask: NDArray[np.bool_] = create_qr_mask(
"LAB-001",
3,
4,
"M",
)
print(qr_mask.shape)The version-three active code has 29 × 29 modules. With the required four-module quiet zone on each side, the returned matrix is 37 × 37.
Render a reference PNG and local transmitter HTML
Link to section: Render a reference PNG and local transmitter HTMLfrom pathlib import Path
from optical_airgap_lab.encoding import create_qr_mask
from optical_airgap_lab.rendering import (
build_transmitter_html,
render_reference_image,
write_png,
write_text,
)
payload: str = "LAB-001"
qr_mask = create_qr_mask(payload, 3, 4, "M")
reference = render_reference_image(
qr_mask,
1200,
900,
18,
248,
12,
)
html = build_transmitter_html(
qr_mask,
payload,
248,
12,
)
write_png(reference, Path("artifacts/api-reference.png"))
write_text(html, Path("artifacts/api-transmitter.html"))The numeric arguments are explicit by design:
| Argument | Value | Meaning |
|---|---|---|
| Canvas width | 1200 | Reference PNG width in pixels |
| Canvas height | 900 | Reference PNG height in pixels |
| Module pixels | 18 | Pixels used for each QR module |
| Background | 248 | Eight-bit grayscale background intensity |
| Contrast | 12 | Amount subtracted for dark modules |
Simulate a deterministic camera capture
Link to section: Simulate a deterministic camera capturefrom pathlib import Path
from optical_airgap_lab.encoding import create_qr_mask
from optical_airgap_lab.rendering import render_reference_image, write_png
from optical_airgap_lab.simulation import simulate_camera_capture
qr_mask = create_qr_mask("LAB-001", 3, 4, "M")
transmitter = render_reference_image(
qr_mask,
1200,
900,
18,
248,
12,
)
capture = simulate_camera_capture(
transmitter,
1600,
1200,
0.8,
0.35,
94,
20260801,
)
write_png(capture, Path("artifacts/api-simulated-capture.png"))The simulation applies perspective, a horizontal illumination gradient, Gaussian blur, Gaussian noise, and a JPEG encode/decode cycle. The seed makes the result reproducible.
Decode an image and require the expected identifier
Link to section: Decode an image and require the expected identifierfrom pathlib import Path
from optical_airgap_lab.decoding import decode_expected_payload, read_color_image
from optical_airgap_lab.rendering import write_png
capture = read_color_image(Path("artifacts/api-simulated-capture.png"))
result = decode_expected_payload(capture, "LAB-001")
print(result.payload)
print(result.method)
write_png(result.reconstructed_image, Path("artifacts/api-reconstruction.png"))decode_expected_payload validates the requested LAB-NNN identifier, reconstructs candidate
images, decodes the observed QR, and raises PayloadMismatchError if a different value is
observed.
Inspect all conventional reconstruction candidates
Link to section: Inspect all conventional reconstruction candidatesfrom pathlib import Path
from optical_airgap_lab.decoding import (
build_reconstruction_candidates,
read_color_image,
)
from optical_airgap_lab.rendering import write_png
capture = read_color_image(Path("artifacts/api-simulated-capture.png"))
for method, candidate in build_reconstruction_candidates(capture):
output = Path("artifacts") / f"candidate-{method}.png"
write_png(candidate, output)
print(method, output)This is useful for academic comparison of dynamic-range stretching, CLAHE, unsharp masking, Otsu thresholding, and adaptive thresholding. Candidate images are derivatives; preserve the original capture separately.
Packetize an in-memory synthetic binary fixture
Link to section: Packetize an in-memory synthetic binary fixturefrom optical_airgap_lab.binary_transfer import (
create_manifest,
encode_packet,
packetize_fixture,
)
fixture: bytes = bytes(range(64))
manifest = create_manifest(fixture)
packets = packetize_fixture(fixture)
encoded_packets: tuple[str, ...] = tuple(encode_packet(packet) for packet in packets)
print(manifest.sha256)
print(manifest.size)
print(manifest.frames)
print(encoded_packets[0])For 64 fixture bytes, manifest.frames is five because every QR packet carries at most 14
fixture bytes.
Convert binary packets into QR matrices
Link to section: Convert binary packets into QR matricesfrom numpy.typing import NDArray
import numpy as np
from optical_airgap_lab.binary_transfer import encode_packet, packetize_fixture
from optical_airgap_lab.encoding import create_qr_mask_from_bytes
fixture: bytes = bytes(range(64))
encoded_packets = tuple(
encode_packet(packet).encode("ascii")
for packet in packetize_fixture(fixture)
)
qr_masks: tuple[NDArray[np.bool_], ...] = tuple(
create_qr_mask_from_bytes(encoded_packet, 3, 4, "M")
for encoded_packet in encoded_packets
)
print(len(qr_masks))
print(qr_masks[0].shape)This API is for already validated laboratory packet bytes. The user-facing fixture command adds the directory, symbolic-link, size, manifest, and output protections described earlier.
Decode and reassemble packet text
Link to section: Decode and reassemble packet textfrom optical_airgap_lab.binary_transfer import (
create_manifest,
decode_packet,
encode_packet,
packetize_fixture,
reassemble_packets,
)
fixture: bytes = bytes(range(64))
manifest = create_manifest(fixture)
transport_text = tuple(
encode_packet(packet)
for packet in packetize_fixture(fixture)
)
decoded_packets = tuple(
decode_packet(encoded_packet)
for encoded_packet in reversed(transport_text)
)
recovered: bytes = reassemble_packets(decoded_packets, manifest.sha256)
assert recovered == fixture
print(manifest.sha256)Reversing transport_text demonstrates that sequence numbers, rather than capture filename
order, determine reconstruction order.
Read a deliberately staged fixture safely
Link to section: Read a deliberately staged fixture safelyfrom pathlib import Path
from optical_airgap_lab.binary_transfer import create_manifest, read_fixture
repository = Path.cwd()
fixture_path = repository / "fixtures" / "sample.bin"
fixture = read_fixture(fixture_path, repository / "fixtures")
manifest = create_manifest(fixture)
print(manifest)read_fixture resolves the path, requires it to remain under the specified fixture directory,
rejects a symbolic-link input, requires a regular file, and enforces the 1–256 byte boundary.
Estimate hypothetical capacity without reading a file
Link to section: Estimate hypothetical capacity without reading a filefrom optical_airgap_lab.binary_transfer import CHUNK_BYTES, estimate_frame_count
data_bytes: int = 15 * 1024 * 1024
decoded_frames_per_second: float = 5.0
repetitions: int = 1
unique_frames = estimate_frame_count(data_bytes)
displayed_frames = unique_frames * repetitions
ideal_seconds = displayed_frames / decoded_frames_per_second
print(CHUNK_BYTES)
print(unique_frames)
print(ideal_seconds)
print(ideal_seconds / 3600)This prints 14 payload bytes per frame, 1,123,475 unique frames, 224,695 ideal seconds, and approximately 62.42 ideal hours. It performs arithmetic only.
Handle expected experiment failures explicitly
Link to section: Handle expected experiment failures explicitlyfrom pathlib import Path
from optical_airgap_lab.decoding import decode_expected_payload, read_color_image
from optical_airgap_lab.errors import DecodeError, ImageReadError, PayloadMismatchError
try:
capture = read_color_image(Path("captures/static-001.jpg"))
result = decode_expected_payload(capture, "LAB-001")
except ImageReadError as error:
print(f"Input failure: {error}")
raise
except PayloadMismatchError as error:
print(f"Validation failure: {error}")
raise
except DecodeError as error:
print(f"Reconstruction failure: {error}")
raise
else:
print(result.payload, result.method)The example reports the specific failure and re-raises it. It does not silently substitute an expected payload or treat a visible pattern as a successful decode.
Command reference
Link to section: Command referenceoptical-render
Link to section: optical-renderCreates one static synthetic identifier transmitter and reference PNG. It requires a payload
matching LAB-NNN, explicit QR parameters, grayscale intensities, and output paths.
optical-render
--payload LAB-NNN
--html PATH
--reference PATH
--background INTEGER
--contrast INTEGER
--qr-version INTEGER
--qr-border INTEGER
--error-correction {L,M,Q,H}
--canvas-width INTEGER
--canvas-height INTEGER
--module-pixels INTEGER| Option | Required | Meaning |
|---|---|---|
--payload | Yes | Synthetic identifier matching LAB-NNN |
--html | Yes | Output path for the local transmitter page |
--reference | Yes | Output path for the reference PNG |
--background | Yes | Background grayscale intensity from 1 through 255 |
--contrast | Yes | Positive amount subtracted from the background |
--qr-version | Yes | QR version from 1 through 40 |
--qr-border | Yes | Quiet-zone width of at least four modules |
--error-correction | Yes | QR error correction: L, M, Q, or H |
--canvas-width | Yes | Reference PNG width in pixels |
--canvas-height | Yes | Reference PNG height in pixels |
--module-pixels | Yes | Reference pixels per QR module |
optical-decode
Link to section: optical-decodeReads one camera image, reconstructs candidate QR images offline, requires an expected
LAB-NNN, and writes the successful high-contrast reconstruction.
optical-decode
--input PATH
--expected LAB-NNN
--reconstruction PATH| Option | Required | Meaning |
|---|---|---|
--input | Yes | PNG or JPEG camera image readable by OpenCV |
--expected | Yes | Exact permitted identifier expected from the experiment |
--reconstruction | Yes | Output path for successful high-contrast evidence |
optical-simulate
Link to section: optical-simulateCreates a deterministic camera-like derivative with perspective, illumination variation, noise, blur, and JPEG compression for integration testing.
optical-simulate
--input PATH
--output PATH
--width INTEGER
--height INTEGER
--noise FLOAT
--blur FLOAT
--jpeg-quality INTEGER
--seed INTEGER| Option | Required | Meaning |
|---|---|---|
--input | Yes | Grayscale reference transmitter PNG |
--output | Yes | Simulated capture output path |
--width | Yes | Output width, at least 320 pixels |
--height | Yes | Output height, at least 240 pixels |
--noise | Yes | Nonnegative Gaussian-noise standard deviation |
--blur | Yes | Nonnegative Gaussian-blur sigma |
--jpeg-quality | Yes | JPEG quality from 1 through 100 |
--seed | Yes | Integer seed for reproducible noise |
optical-render-fixture
Link to section: optical-render-fixtureReads one 1–256 byte regular file inside fixtures/, creates checksummed packets, writes
reference frames and a manifest under artifacts/, and creates a local HTML sequence player.
Existing outputs are not silently overwritten.
optical-render-fixture
--input PATH
--frames DIRECTORY
--html PATH
--background INTEGER
--contrast INTEGER
--frame-ms INTEGER| Option | Required | Meaning |
|---|---|---|
--input | Yes | Regular 1–256 byte file inside fixtures/ |
--frames | Yes | Empty or new output directory inside artifacts/ |
--html | Yes | New transmitter HTML path inside artifacts/ |
--background | Yes | Background grayscale intensity |
--contrast | Yes | Foreground intensity delta |
--frame-ms | Yes | Playback duration per frame, at least 250 ms |
optical-recover-fixture
Link to section: optical-recover-fixtureReads PNG or JPEG captures under captures/, decodes and validates every packet, requires the
sender's SHA-256, and writes only under recovered/. It fails on missing, corrupt, conflicting,
or mixed packets and refuses to overwrite an existing output.
optical-recover-fixture
--captures DIRECTORY
--expected-sha256 HEX_DIGEST
--output PATH| Option | Required | Meaning |
|---|---|---|
--captures | Yes | Directory under captures/ containing PNG or JPEG frames |
--expected-sha256 | Yes | Sender's complete 64-character hexadecimal SHA-256 |
--output | Yes | New output file path inside recovered/ |
optical-estimate
Link to section: optical-estimateCalculates hypothetical frame counts and ideal durations from a byte count, decoded frame rate, and repetition count. It does not read a source file or create QR frames.
optical-estimate
--bytes INTEGER
--frames-per-second FLOAT
--repetitions INTEGER| Option | Required | Meaning |
|---|---|---|
--bytes | Yes | Positive hypothetical byte count |
--frames-per-second | Yes | Positive hypothetical successfully decoded frame rate |
--repetitions | Yes | Positive number of displays per unique frame |
Development workflow
Link to section: Development workflowInstall runtime and development dependencies
Link to section: Install runtime and development dependenciesuv sync --all-groupsThe lock file records the resolved environment. Add dependencies to pyproject.toml and refresh
the project environment with uv; do not install packages globally for this repository.
Run the complete checks
Link to section: Run the complete checksuv run pytest -q
uv run ruff check .
uv run mypyRun one integration module
Link to section: Run one integration moduleuv run pytest -q tests/test_integration.py
uv run pytest -q tests/test_binary_transfer.pyInspect available source files
Link to section: Inspect available source filesrg --files src tests docsInspect the command entry points
Link to section: Inspect the command entry pointsuv run optical-render --help
uv run optical-decode --help
uv run optical-simulate --help
uv run optical-render-fixture --help
uv run optical-recover-fixture --help
uv run optical-estimate --helpDesign principles used by the code
Link to section: Design principles used by the code- Pure functions handle encoding, reconstruction, packet framing, and verification.
- CLI modules are responsible only for argument parsing and explicit filesystem effects.
- External image and file inputs are validated before use.
- Errors use specific exception types and include actionable context.
- Required CLI values are explicit; experiment-critical values are not hidden behind defaults.
- Original camera evidence is never modified by reconstruction functions.
- Tests exercise real QR encoding and OpenCV decoding instead of mocked decoders.
Main modules
Link to section: Main modules| Module | Responsibility |
|---|---|
payload.py | Restrict the primary payload to LAB-NNN |
encoding.py | Build typed boolean QR matrices |
rendering.py | Render reference PNGs and local HTML transmitters |
simulation.py | Apply deterministic camera-like impairments |
decoding.py | Build reconstruction candidates and perform OpenCV decoding |
grid_decoding.py | Recover tightly cropped version-three module grids |
binary_transfer.py | Frame, encode, validate, and reassemble bounded packets |
errors.py | Define specific experiment exception types |
Adding an experiment without weakening the boundary
Link to section: Adding an experiment without weakening the boundaryKeep new work synthetic and closed-loop. Add a new pure function for one responsibility, expose only the arguments the experiment needs, validate external data at the boundary, and add a real integration test. Do not broaden fixture directories, remove the size limit, add automatic data collection, add network forwarding, or silently recover from integrity failures.
Defensive lessons
Link to section: Defensive lessonsThe project can also be used to discuss mitigations without assuming that every display is an active covert channel. Relevant defensive controls include:
- preventing unauthorized code execution on isolated systems;
- application allowlisting and signed-software enforcement;
- monitoring unexpected full-screen graphics or rapid display changes;
- restricting cameras and personal devices near sensitive displays;
- physical screen placement and controlled viewing areas;
- privacy filters where appropriate;
- recording and investigating unusual display behavior;
- minimizing secrets displayed or processed on systems whose physical environment is not controlled.
The primary control remains preventing compromise of the transmitting system. Optical controls are defense in depth, not a substitute for system integrity.
Reproducibility checklist
Link to section: Reproducibility checklistBefore a trial:
- Not done: Confirm all equipment is owned or explicitly authorized.
- Not done: Confirm the payload is
LAB-NNNor a generated fixture of at most 256 bytes. - Not done: Record fixture SHA-256 before display.
- Not done: Record MacBook, browser, display, and camera models.
- Not done: Record display brightness, True Tone, and Night Shift state.
- Not done: Record contrast, distance, angle, lighting, and frame duration.
- Not done: Clean the display and preserve a full QR quiet zone.
- Not done: Run the automated verification commands.
After a trial:
- Not done: Preserve original camera files unchanged.
- Not done: Store reconstructions separately from originals.
- Not done: Record every attempted capture, including failures.
- Not done: Record decoder method and errors exactly.
- Not done: Confirm packet CRC32 results.
- Not done: Confirm final SHA-256 for binary fixtures.
- Not done: State whether the result was simulated, image-based, or physically photographed.
- Not done: Avoid generalizing beyond the tested hardware and conditions.
Known limitations
Link to section: Known limitations- The primary physical result is one static version-three QR identifier.
- The multi-packet round trip has been verified using generated QR images, not a sustained physical video capture.
- The grid fallback assumes a nearly frame-filling version-three QR.
- OpenCV behavior and camera image processing may vary by platform and version.
- Human perceptibility is not measured by a single operator's impression.
- The capacity estimator ignores real losses unless repetitions are entered explicitly.
- The 256-byte fixture boundary prevents bulk operational use by design.
- Results from one MacBook and iPhone do not establish performance for other hardware.
Frequently asked questions
Link to section: Frequently asked questionsIs the QR actually invisible?
Link to section: Is the QR actually invisible?The experiment uses low contrast, but perceptibility depends on the observer, display, viewing conditions, contrast, and image duration. Describe it as low-contrast unless a proper human-study protocol supports a stronger statement.
Why did the phone not open a link?
Link to section: Why did the phone not open a link?The transmitted value is a synthetic identifier or packet, not necessarily a URL. More importantly, the phone's notification behavior is not the decoder used by this experiment.
Does the binary lab transfer an image or document?
Link to section: Does the binary lab transfer an image or document?It transfers bytes and therefore does not assign meaning to their format. Operational inputs are limited to 256 deliberately staged synthetic bytes. A deterministic byte fixture is recommended for the first experiment.
Can the 256-byte limit be raised to send a large file?
Link to section: Can the 256-byte limit be raised to send a large file?Not in this repository. Large sizes can be modeled with optical-estimate, while legitimate
files should be moved with an approved conventional mechanism.
Is Base85 encryption?
Link to section: Is Base85 encryption?No. It is a reversible representation that keeps binary packets compatible with text-returning QR decoders.
Why use both CRC32 and SHA-256?
Link to section: Why use both CRC32 and SHA-256?CRC32 identifies accidental corruption within one packet. SHA-256 verifies the completely reassembled fixture against the sender's recorded value.
Why are failed captures valuable?
Link to section: Why are failed captures valuable?They define the channel boundary. A report containing only successful images cannot show how contrast, distance, angle, or camera conditions affect reliability.
Verification
Link to section: Verificationuv run pytest -q
uv run ruff check .
uv run mypyThe current verification baseline is:
- six tests passed;
- Ruff passed;
- strict Mypy passed;
- a 37-byte fixture was packetized into three QR images, decoded, reordered, reassembled, and verified by SHA-256;
- the high-resolution physical capture was reconstructed and decoded as
LAB-001.
Tests cover the simulated low-contrast round trip, the frame-filling grid reconstruction, binary QR packet round trip, input validation, contrast validation, and rejection of fixtures larger than 256 bytes.
The detailed capture procedure and experiment record are in
docs/EXPERIMENT_PROTOCOL.md and
docs/RESULTS.md. A focused copy of the two-machine synthetic binary exercise
is also available in docs/BINARY_TRANSFER_LAB.md.
Reference
Link to section: ReferenceM. Guri, "Optical air-gap exfiltration attack via invisible images," Journal of Information Security and Applications, vol. 46, pp. 222–230, 2019. doi:10.1016/j.jisa.2019.02.004
License
Link to section: LicenseOptical Air-Gap Lab is released under the MIT License.