Methodology · Folio M
full technical walk-through
§ How it works

The whole machine, end to end.

The shortest possible summary: when you drop a screenshot, your browser computes its SHA-256 hash and sends the 32-byte hash (never the image) to a Cloudflare Worker. The Worker signs the hash with our ECDSA P-256 key, mints a short receipt ID, and asynchronously anchors the hash into the timestamp network via OpenTimestamps. Your browser then composites a QR code onto the image, hashes the composite, and tells the Worker that stamped hash too — so neither the clean original nor the stamped version can be substituted without detection.

The data flow, step by step

  1. You drop a screenshot at /seal. The file goes into a Blob in JavaScript memory.
  2. Hash in browser. We call crypto.subtle.digest("SHA-256", blob) — the standard Web Crypto API, built into every browser. The result is 32 bytes / 64 hex characters.
  3. POST /api/seal with { original_hash, note?, source_url? }. The Worker receives the hash, mints an 8-char base62 receipt ID, signs a canonical-JSON payload { format, id, original_hash, signed_at, signer_kid } with ECDSA P-256 / SHA-256, persists a row to D1, and fires off a non-blocking OpenTimestamps submission to three external anchoring calendars in parallel.
  4. Worker responds with { id, url, signed_at, signature_b64, signer_kid, original_hash }.
  5. Generate QR + composite. Browser renders a branded QR (rounded modules, editorial palette, center r-mark) via qr-code-styling at error-correction level H, composites it onto the original image at the bottom-right corner with a § SEALED · RECEIPTS.YOU kicker and r/<id> label, and exports the result as PNG via canvas.toBlob().
  6. Hash the composite. Same SHA-256 routine, applied to the stamped PNG bytes.
  7. POST /api/seal/finalize with { id, stamped_hash }. The Worker performs UPDATE receipts SET stamped_hash = ? WHERE id = ? AND stamped_hash IS NULL — anti-tamper: once recorded, the stamped hash is immutable.
  8. You download the stamped image and share the receipt URL. Anyone scanning the QR lands at receipts.you/r/<id>, which is rendered server-side by the same Worker reading the same D1 row.
  9. Verification: the verification page lets anyone drop the image (either clean original or stamped composite) to compute its SHA-256 locally and POST { id, hash } to /api/verify. The Worker returns one of: match_original, match_stamped, or mismatch.

The dual-hash anti-tamper design

We store two hashes per receipt — original and stamped — and the receipt verifies against either. This blocks the obvious attack:

  • Attack: copy a real QR from someone else's sealed screenshot, paste it onto a fake image, claim the QR is your receipt.
  • Defense: the verification page computes the SHA-256 of the image you uploaded. It matches neither the original_hash (the clean original someone else sealed) nor the stamped_hash (the stamped composite they shared) → mismatch verdict. Page tells the verifier: “this QR is real, but the image attached to it is not.”

The QR is just a discoverability mechanism — it tells the verifier where to look up the canonical hashes. The hashes are the source of truth.

The verdict ladder (after platform recompression)

The dual-hash design alone is too strict for one common case: someone sees a screenshot on Twitter, downloads the JPEG Twitter served them (re-encoded + downscaled from the original), and tries to verify it. SHA-256 is bit-exact, so every byte change — including platform recompression — returns mismatch. That makes the receipt unverifiable through the channels screenshots actually travel through.

To fix this without storing anyone's image, we compute two perceptual hashes at seal time — pHash (DCT-based) and dHash (gradient-based) — both 64-bit, both computed in the browser, both stored on the receipt as 16 hex chars. They survive recompression, mild resize, brightness shifts, and small crops. They do not survive heavy editing.

At verify time we compute the same perceptual hashes on the uploaded image and return one of five verdicts depending on SHA match + Hamming distance:

  • match_original — SHA bytes match the clean original. Strongest claim.
  • match_stamped — SHA bytes match the QR-stamped composite.
  • recompressed — SHA fails but pHash distance ≤ 8 bits. This is what Twitter/WhatsApp/Instagram does to your file.
  • similar — pHash distance 9–18 bits. Cropped, mildly edited, screenshot-of-screenshot.
  • mismatch — pHash distance > 18 bits or no pHash on record. QR points to a real receipt but the image is different.

Perceptual hashes are not cryptographic — a determined attacker can craft an image that collides with a given pHash. So we present them as a heuristic alongside the cryptographic SHA verdict, never instead of it. The honest framing: the SHA is strict math, the pHash answers the question “is this substantively the same image after a normal re-share?”.

The Snitch Tracker — three-layer invisible fingerprint

Snitch Tracker mints invisibly-watermarked variants of a sealed image, one per recipient, so a leak can be traced back to the recipient whose copy escaped. Three signals are embedded in parallel — each tuned to a different attack class — and/track tries them in cheapest-first order at extraction time:

  1. Layer 1 — LSB stego on the blue channel. The 10-char variant id (plus a 32-bit CRC and a 16-bit magic marker) is encoded into the least significant bit of selected blue-channel pixels, three-times redundant. Survives lossless re-encoding (PNG passthrough, AirDrop, email attachments, Signal-as-document). Destroyed by any JPEG re-compression.
  2. Layer 2 — paired block-DC modulation on the green channel. The image is tiled into 8×8 blocks; pseudo- random pairs are picked and their green-channel means nudged in opposite directions. The watermark bit is the sign of the inter-pair difference, so underlying image content cancels out. Survives JPEG q60+ without resize. Dies under resize because the 8×8 grid shifts.
  3. Layer 3 — DWT+DCT+SVD on the U (chroma) channel. The image is split into 4 quadrants; each quadrant is processed independently. For each quadrant we extract the U (chroma) channel, apply a 1-level Haar wavelet transform, tile the LL band into 4×4 blocks, take the 2D DCT of each block, run power iteration to find the largest singular value, and shift that singular value into one of two quantization cells depending on the bit being encoded. Each of the 128 payload bits is embedded into many blocks per quadrant, so a majority vote at extraction time recovers the bits even after JPEG quantization. Then at extraction the verifier's file is searched against multiple resize and crop hypotheses; the first one whose recovered bytes pass CRC32 wins. This is a browser-side port of the ShieldMnt invisible-watermark dwtDctSvd algorithm with three changes specific to our use case: per-quadrant redundancy (so partial crops keep at least one quadrant intact), original-dimensions hint at extract (so platform resize is undone), and CRC-validated recovery (so the search picks the right hypothesis instead of guessing via confidence heuristics).

Two test corpora are run in CI on every change to the watermark code:

  1. scripts/test-robust-watermark.mjs — synthetic 512×512 images, 20 trials per attack class, attacks simulated via direct DCT-block quantization (no YUV subsampling). This rig validates the algorithm's pure-frequency behavior.
  2. scripts/test-robust-watermark-real.mjs — real JPEG encode/decode round-trip via jpeg-js, which exercises the full YCbCr conversion + 4:2:0 chroma subsampling + DCT quantization pipeline. This is the rig that validates against the actual social-platform attack vectors. The first rig was insufficient on its own — it missed chroma subsampling damage, which the vol3 audit caught.

Empirical robustness (CRC-validated recovery):

  • Identity (no attack): 100%
  • Real JPEG q40–q90 at native dimensions: 100%
  • Resize 0.5× to 2× then real JPEG q85: 100%
  • 1200×800 → 1080-long-edge + real JPEG q85 (Instagram pipeline, the vol3 auditor's gauntlet): 100%
  • Crop ≤50% from any single edge (synthetic): 100%
  • JPEG q40 + crop 25%: 100%
  • Crop ≥60% (most of the image removed): 0%
  • Rotation more than a few degrees: 0% (no geometric sync template in v1)
  • Adversarial-removal attack with full algorithm knowledge: no protection (this is a heuristic defense, not a cryptographic one).

How the extractor handles platform resize without knowing the original dimensions: it tries a battery of candidate sizes — the receipt's original_w/original_h when known (stored at seal time), a set of scale factors applied to the leaked file's own dimensions (covers any platform downscale), and a list of common absolute screenshot widths. The CRC validator on the recovered payload short-circuits on the first hit, so the typical case is fast even though the search space is large.

The cryptography

  • Hash: SHA-256. 256-bit collision resistance. The Web Crypto API is hardware-accelerated and identical in browsers, Workers, and openssl.
  • Signature: ECDSA P-256 (a.k.a. prime256v1, secp256r1) with SHA-256. Industry standard, NIST-approved, supported natively by every modern client.
  • Signing key storage: an encrypted Wrangler Secret in Cloudflare's infrastructure. The private key never appears in source code, the marketing site, browser memory, or D1.
  • Public verification key: published at /.well-known/receipts-pubkey.pem. Anyone can re-verify a receipt offline with openssl dgst -verify.

The external timestamp anchor (OpenTimestamps)

Every hash is also submitted to the OpenTimestamps calendar network as a parallel, independent proof of existence. The calendars aggregate many digests into a Merkle tree, mine the root into an anchor transaction, and publish an upgraded proof that walks the Merkle path from your hash all the way to a confirmed anchor block header.

Why it matters: if receipts.you disappears tomorrow, the upgraded OTS proof still verifies independently. The trust chain collapses to the timestamp network's proof-of-work, which is uncensorable and has fifteen years of history backing it. We use this as a defense-in-depth, not the primary trust mechanism, but it's there for the paranoid.

What we store

For every receipt, a single row in Cloudflare D1:

id              TEXT (8 chars, base62)
original_hash   TEXT (64 hex chars — SHA-256)
stamped_hash    TEXT (64 hex chars — set on finalize)
signed_at       TEXT (ISO-8601 UTC)
signature_b64   TEXT (base64 ECDSA signature)
signer_kid      TEXT (key identifier, lets us rotate later)
note            TEXT (optional, user-supplied, max 280 chars)
source_url      TEXT (optional, user-supplied, not verified)
ip_country      TEXT (2-letter country code, for abuse rate-limit only)
ua_hash         TEXT (SHA-256(UA + day) — no UA stored)
phash_original  TEXT (64-bit pHash, hex, for the verify verdict ladder)
dhash_original  TEXT (64-bit dHash, hex, same purpose)
ots_pending     BLOB (OpenTimestamps pending proof, ~200 bytes)
ots_upgraded    BLOB (post-anchor upgraded proof, set hours later)

The full OTS proof bytes are exposed at /api/r/<id>/ots — anyone can download the raw .ots file and verify the anchor offline with the standard ots CLI. The signing keys are published at /.well-known/receipts-keys.json (JWKS) and per-kid at /.well-known/keys/<kid>.pem — every receipt embeds the signer_kid of the key that signed it, so historical receipts always verify against the right key even after rotation.

Each row is ~400 bytes. One million receipts = ~400 MB, well inside Cloudflare D1's 5 GB free tier. We never store the image itself. Even if a court compelled us to produce “the image for receipt X”, we have nothing — only the hash. The receipt holder is the custodian.

What this proves

  • An image with this exact SHA-256 hash existed at receipts.you at the timestamp above (signed by our key, anchored externally).
  • The image has not been altered since (any modification produces a different hash → mismatch).
  • The QR code on the stamped image is genuine (cryptographically tied to the same receipt ID).

What this does NOT prove

  • That what's depicted in the image is real (we can't verify content; anyone can seal anything).
  • That the original screenshot was authentic at capture time (we sealed what you uploaded; we don't see your screen).
  • That you took the screenshot (we have no way to verify authorship).
  • That a court must accept the receipt as evidence (we are a notary-grade timestamping service, not an admissibility certifier).

The product is byte-level provenance since sealing, not objective truth. If you screenshot a real tweet, seal it, and the tweet later gets deleted, your receipt is dated cryptographic evidence that this exact file existed in receipts.you's ledger before the dispute. The receipt doesn't certify possession or authorship — it certifies the timestamp. The earlier you seal, the stronger your position. That's the value.

Why Cloudflare-only?

  • Free tier covers MVP scale. Workers (100k req/day), D1 (5GB + 100k writes/day), Pages (unlimited). Zero recurring cost until ~10× our current traffic.
  • Single dashboard, single bill. If we ever pay, it's one place.
  • Edge runtime, low latency worldwide. The sealing endpoint is ~50ms from any populated continent.
  • No AI services anywhere — this is symmetric crypto and signed bytes, not pattern detection. We do not, and cannot, ask a model whether your screenshot is real; we attest to what its bytes were when you sealed them. Per-receipt marginal cost is essentially zero.

Verify offline with openssl

# 1. Get our public verification key
curl -sO https://receipts.you/.well-known/receipts-pubkey.pem

# 2. Reconstruct the canonical-JSON payload from the receipt's JSON
#    (drop signature_b64, sort keys alphabetically, no whitespace)

# 3. Decode the base64 signature
echo "<signature_b64>" | base64 -d > sig.bin

# 4. Verify
openssl dgst -sha256 -verify <(openssl ec -in receipts-pubkey.pem -pubin) \
             -signature sig.bin canonical_payload.json
# Expected: Verified OK

Or just use the verifier in your browser — drop the image at any /r/<id> page, get the verdict in <100ms.

Drop a screenshot →
free · no signup · stays in your browser