The Problem

A journalist photographs a sensitive source, a witness, or a risky scene. Modern AI can work out where a photo was taken from background detail alone: faces, shop signs, a recognisable facade, no GPS metadata involved. Publishing the picture can expose both the source and the journalist.

Blurring the sensitive parts is not enough on its own, for two reasons. Blur is reversible: Gaussian blur and pixelation both leak enough to be attacked. And nobody can check the rest: a reader has no way to know whether the unblurred part of the picture is the real scene or has been quietly edited, and the journalist cannot prove it is, since proving it would mean handing over the original.

What it does

The journalist drops the photo into TrueMask before publishing. Everything happens on their machine. Faces are found with Human AI (@vladmandic/human), signs, plates, and documents with Tesseract OCR. Every detected region is blacked out irreversibly and exported as a lossless PNG. The image is cut into a fixed grid, and a cryptographic root is computed over every block that was not redacted. A zero-knowledge circuit on Midnight records that root, asserting that the untouched blocks of the published image hash identically to the same blocks of the original.

The original image never leaves the machine. Anyone holding the published photo can later re-run the same block hashing and check it against the chain. If a single pixel outside the redactions has changed, the roots diverge and verification fails.

How we built it

At the center of TrueMask is a Compact smart contract on Midnight, with three circuits: submit_redaction, verify_integrity, and a pure circuit called compute_preserved_root. That third one matters more than it sounds like it should, because both the browser and the chain call it to compute the same hash, so the two sides can never quietly drift apart.

A ZK circuit can't afford to hash a full photo. A 1920x1080 image comes out to 8,160 blocks, and hashing each one inside the circuit just isn't viable. So we do that work outside the circuit instead: split every image into a fixed grid, hash each block that wasn't redacted, fold those into 16 lane digests, and only that final root, a single persistentHash over the vector, ever reaches Compact. The circuit's cost stays constant no matter how big the photo is. We also bind the block index and the grid dimensions into every leaf hash, so blocks can't be silently reordered, cropped, or resized without breaking the match.

The vision pipeline handles detection, block splitting, hashing, and blackout redaction. It's platform agnostic TypeScript, shared between the browser and our test suite. Face detection runs on Human (@vladmandic/human), faces only. We turned off its body, hand, and object tracking on purpose, since those were producing false positives we didn't need. Text, signs, and license plates get caught by Tesseract OCR reading whatever characters appear as text. Every flagged region gets a solid, irreversible blackout, never blur or pixelation, since both leak enough detail to be reversed, and we export as lossless PNG, since JPEG's re-quantization would break the block hashes.

The frontend is Next.js 16, wired through useMidnight.ts to Midnight's six providers: indexer, proof, zk-config, private state, and the wallet provider. We built those individually, since no single factory function exists for this in the SDK. A TrueMaskAPI class handles deploy, join, submitRedaction, and verifyIntegrity. Verification is a read: no wallet, no cost, no setup. Publishing a new record is the one write in the whole pipeline, and it's the only step that actually needs a signature and a fee.

Challenges we ran into

The biggest one was Preprod's RPC WebSocket. We got everything else working end to end against real infrastructure: a funded Lace wallet, a real tNIGHT balance registered and delegated for tDUST generation, a confirmed "Generate tDUST" transaction. Then publishing hung indefinitely at proof submission, with no error at all.

We ruled out our own stack step by step. Docker logs were clean. Our local proof server tested healthy. We tried both Local and Remote proof server modes, a fresh wallet, and a second Preprod RPC provider. Nothing changed. The failure pattern matched, detail for detail, something another team at this same hackathon documented independently: every HTTP endpoint (node, indexer, proof server) came back healthy, and only the WebSocket rejected connections. That told us it's a Preprod-wide reliability issue, not something tied to our code or our choice of wallet.

We didn't want a hang like that to freeze the UI mid-demo with no way out, so we bounded every wallet call with a timeout (20 seconds to connect, 60 to publish) and added a Cancel button. A generation counter tracks each publish attempt, so if Cancel is pressed and an old promise from a timed-out attempt resolves late anyway, it's ignored instead of overwriting whatever the user did next. A stuck attempt now shows a clear message instead of a spinner that never resolves, and that message says plainly that the image is already fully protected and verifiable offline, whether or not the on-chain write ever completes.

We also stopped hardcoding the wallet check to Lace specifically. useMidnight.ts now enumerates window.midnight for any wallet implementing Midnight's DApp Connector API, checking Lace's known alias first so an existing install keeps resolving the same way it always did, then falling back to whatever else is injected, in case one wallet handles Preprod's WebSocket better than another.

The second real challenge was keeping the browser-computed root and the circuit-computed root identical. Compact exposes compute_preserved_root directly to TypeScript as a pure circuit, so we could check the two sides agree without needing a proof or a wallet at all. We pinned that agreement with a dedicated integration test, rather than trusting that a hash construction described in two separate places would stay in sync on its own.

Accomplishments that we're proud of

Every layer of the pipeline is checked, not just claimed. We have 10 contract tests, 48 api tests (41 of those exercise the vision pipeline directly: grid splitting, block hashing, root computation, blackout redaction), and an end-to-end trace confirming the browser-computed root matches the compiled circuit's root byte for byte. Tamper detection works exactly as it should: change one pixel outside an approved region, and verify_integrity fails.

The constant-cost design holds up under real load. A 12-megapixel photo hashes in under 9 seconds inside our Web Worker, batched and yielded so the tab stays responsive, whether the circuit is checking a 640x480 image or a 4032x3024 one.

When Preprod's infrastructure blocked us, we wrote that down instead of hiding it. Publishing was tested against real infrastructure and hit a documented, external failure, and that's in our README as plainly as everything that does work, right alongside what verify_integrity actually proves and what it doesn't: tamper-evidence and policy-binding, not proof of which original the prover started from. That last limit is inherent to committing to an image only the prover holds, and we'd rather say so than let the demo imply more than the math backs up.

What we learned

Shrinking a ZK circuit's input isn't a detail you add later. It's the design itself. A circuit that tried to hash a full photo directly would never have compiled at a usable k-value. Moving that work outside the circuit, down to a fixed-width commitment the circuit only has to check, is what made block-scale image integrity possible in Compact at all.

We also learned to keep a clear line between what a proof establishes and what a product promises. verify_integrity gives strong tamper-evidence and policy-binding, but it can't prove the prover was honest about their starting image. That limit is worth stating plainly, rather than letting a confident demo suggest the cryptography guarantees more than it does.

And infrastructure failures still need a product-level answer. A hung WebSocket isn't something application code can fix, but a frozen UI with no way out is still our failure, not the network's. Bounding every wallet call and being upfront about what already works offline turned an external blocker into a graceful fallback instead of a broken demo.

What's next for TrueMask

Getting a live record published on Preprod, or Mainnet once it's available, is the immediate next step. The code path is built and already tested end to end against real infrastructure. What's missing is a reliable WebSocket connection, not more implementation work.

Beyond that, we want to integrate a lightweight local Vision-Language Model (like Florence-2) to let journalists type custom redaction prompts, like "redact all military uniforms" or "hide all tattoos," and add a dedicated license-plate detector instead of relying on OCR to catch plate characters incidentally, and build a public verification directory so a reader can check a record's full history, not just a single upload, without needing the journalist's original link. Further out, we're interested in selective disclosure for the redaction policy itself, so a court could see which categories of region were protected without the journalist having to explain the reasoning behind each individual redaction.

Built With

Share this project:

Updates

Submission history