Why Our Stegofile Tool Failed With Brave
Canvas Fingerprinting, Brave Shields, and How It Was Fixed
The Stegofile Concealer is a tool for hiding files and text inside PNG images using LSB steganography and optional AES-GCM encryption. Thanks to my visitors Xandra and Dave B., who flagged a bug in the script. Despite seemingly correct encryption and steganographic embedding, extracting the data would sometimes fail. The output file was simply corrupt.
After a bit of trial and error I tracked down the cause and could reproduce the problem. It runs deep in a browser privacy mechanism that Brave enables by default when Shields are up, aimed at preventing canvas fingerprinting.
What Is Canvas Fingerprinting?
Actually a good thing. But what exactly does canvas fingerprinting do in the context of browser fingerprinting? It is a method for recognizing users without setting cookies. Websites run JavaScript that tests or measures the browser. Screen resolution, installed fonts, operating system, GPU. This combination is often unique enough to form a fingerprint. That something like this happens, we already knew. But it goes further.
Canvas fingerprinting is a particularly effective variant of this. The technique uses the HTML canvas element for drawing. A script secretly draws text or a shape onto an invisible canvas and then reads the pixels back via getImageData(). The trick is that due to differences in GPUs, drivers, operating system rendering engines, and fonts, the pixels look slightly different on every device. Invisible to the human eye, but unique enough and above all measurable and reproducible.
// Classic canvas fingerprinting
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
ctx.fillText('Sphinx of black quartz', 10, 10);
var fingerprint = canvas.toDataURL(); // unique per device
The image itself plays no role. Websites, for example companies dropping tracking pixels, draw something themselves and read it back themselves. It is purely about how your browser and hardware perform the rendering, in order to track you across different websites.
What Brave Does About It
You can think what you want about Brave, and I am deliberately not linking to them here. But what Brave does is effective. It puts a simple but powerful block on canvas fingerprinting. All return values from getImageData(), toDataURL(), and toBlob() are tainted with minimal deterministic noise. The noise is based on a random seed per session and origin. Websites always get slightly falsified pixel values back and can no longer build a stable fingerprint across sessions or sites.
From a privacy standpoint an elegant solution. From a developer standpoint it is less pleasant, a double-edged sword that complicates things.
The Problem With Steganography
As already described in the tool and the accompanying blog post, LSB steganography works by manipulating the least significant bit of each color channel per pixel. A red pixel with value 200 becomes 201 if a stored bit is a 1, or stays 200 if it is a 0. The visual difference is invisible to the human eye. The method has one iron requirement. What is written in must come back out. Pixel accuracy is not a convenience feature, it is a hard requirement.
Brave and other browsers with canvas fingerprinting protection do not think in terms of use cases. They see getImageData() and blindly add noise. They see toBlob(), bam, and garbage data gets mixed in again to break the fingerprint. Whether it is actually fingerprinting or legitimate pixel processing is not distinguished. How could it be?
Concretely, here is what happened in our case.
During embedding, the carrier image was drawn onto a canvas via drawImage() and read back with getImageData(). The returned pixels already contained noise, so our embedded bits were written on top of already slightly corrupted pixels. During saving, toBlob() then added another layer of noise to the output file. The downloaded PNG had incorrect pixel values in multiple spots, which caused an AES-GCM authentication error during extraction because the auth tag no longer matched.
Decryption failed (OperationError) - flags=0x3 payloadLen=103675
The self-test, which ran entirely in memory, passed anyway. It never touched a canvas API. The finished file was unusable. Sorry about that if you as a secret agent just lost all your data, mea culpa, you should probably find a different job anyway.
The Fix: Bypass the Canvas API Entirely
The only clean path was to replace the canvas API for both critical operations.
Output: Instead of canvas.toBlob() we now write the PNG directly in JavaScript. A custom encoder assembles the file byte by byte. PNG signature, IHDR chunk with image dimensions and color type, IDAT chunk with pixel data in DEFLATE stored blocks (uncompressed but fully under our control), CRC32 and Adler-32 checksums all come from the script. No browser API is involved, so no noise is possible.
Input: Instead of drawImage() + getImageData() the script now uses the ImageDecoder API from the WebCodecs standard. It decodes PNG files directly into memory and delivers the raw data via VideoFrame.copyTo(), a direct memory copy with no canvas detour. Without having taken the fingerprinting protection fully apart, in our use case it only affected canvas APIs and thankfully not WebCodecs. As a fallback for older browsers without WebCodecs support, the classic canvas path remains in place.
Embed: ImageData → pure JS PNG encoder → Blob
Extract: Blob → ImageDecoder → VideoFrame.copyTo → ImageData
The important thing is to always immediately test the return trip before sending images whose hidden content might otherwise be unrecoverable.
What Remains
Canvas fingerprinting is a real tracking problem and Brave's protection against it is justified. Our fix is not an attack on privacy but the technically correct response to an API design intended for rendering, not for pixel-accurate data processing.
Why you should not only encrypt your data but also hide it is explained in Alice and Bob Are Now Lawfully Monitored by the Three-Letter Agency.
You can find the Stegofile Concealer where it always was, now with a version number in the footer. If you already downloaded it, you need to reload it. Version 0.1.15 is live. Please keep testing and reporting! Thank you all.
Peace Out Alexander