This counter is a website, yet it contains no HTML, no JavaScript from the site and no CSS. It is 392 bytes of WebAssembly, sealed with AES-256-GCM, and a tiny custom browser is the only thing that can open it. This is episode 1 of a series where I try to train an AI to write raw binary and build the browser its pages must pass. In the previous experiment, a local LLM (qwen2.5:7b) wrote raw .wasm bytes and scored 0/15 valid. Today we build the browser.
You will design a binary page format (CWP1), write a packer that compiles WAT with wabt and seals it, hand-write a 392-byte counter site that draws its own seven-segment digits into raw memory, and build a browser shell with four security gates. Then we try to break it, including feeding it the AI’s real output.
⚡ TL;DR – CWP1 format: 4-byte magic
CWP1+ 12-byte nonce + AES-256-GCM ciphertext + 16-byte auth tag (32 bytes of overhead). – Four gates: magic check → GCM decrypt and verify →WebAssembly.validate→ instantiate with zero imports. – Plain HTML: rejected at gate 1. One flipped bit: rejected at gate 2. Wrong key: gate 2. The AI’s bytes sealed with the real key: rejected at gate 3. – Encryption proves who sent the bytes, not that the bytes are any good.
Before you start
- Node.js 22 (for
node:cryptoand Web Crypto). - wabt to compile WAT to wasm (
npm i wabtor thewat2wasmCLI). - Python 3 for a static file server (any static server works).
- A Chromium-based browser used purely as a display surface.
- Honest limits of the setup in the video: one shared machine, 8 CPU cores, 15 GB RAM, no GPU. One shared demo key and no key exchange. The browser shell cannot open normal websites, and that is the point.
Step 1: Design the CWP1 page format
CWP1 stands for “CheatCoders Wasm Page, version 1”. Layout:
| Bytes | Field |
|---|---|
| 0 to 3 | Magic, ASCII CWP1 |
| 4 to 15 | 12 random bytes: the GCM nonce, new for every page |
| 16 to n-17 | Ciphertext of the wasm module |
| last 16 | GCM authentication tag |
Why encrypt at all? Mostly integrity. AES-GCM does not just hide the bytes, it proves nobody changed them. Change anything, even in the header, and decryption refuses. Only pages sealed with my key will run. Real browsers get this from HTTPS and code signing. What encryption cannot do is make bad code good. Keep that in mind for Step 8.
Step 2: Generate a 256-bit key
// keygen.mjs
import { randomBytes } from "node:crypto";
import { writeFileSync } from "node:fs";
writeFileSync("site.key", randomBytes(32)); // 32 bytes = 256-bit key
The packer and the browser both hold this key. In a real system you would never ship a shared symmetric key in a client; this is a demo.
Step 3: Write the packer
// pack.mjs
import { createCipheriv, randomBytes } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import wabtInit from "wabt";
const MAGIC = Buffer.from("CWP1");
const KEY = readFileSync("site.key");
function seal(wasm) {
const nonce = randomBytes(12); // NEVER reuse a nonce with GCM
const c = createCipheriv("aes-256-gcm", KEY, nonce);
c.setAAD(MAGIC); // header is authenticated, not hidden
const ct = Buffer.concat([c.update(wasm), c.final()]);
return Buffer.concat([MAGIC, nonce, ct, c.getAuthTag()]);
}
const [src, out] = process.argv.slice(2);
let wasm;
if (src.endsWith(".wat")) {
const wabt = await wabtInit();
wasm = Buffer.from(wabt.parseWat(src, readFileSync(src, "utf8")).toBinary({}).buffer);
} else {
// raw hex, e.g. what the AI wrote: turned into bytes as-is, no fixing
wasm = Buffer.from(readFileSync(src, "utf8").match(/[0-9a-fA-F]{2}/g).join(""), "hex");
}
console.log(`wasm ${wasm.length} bytes, valid: ${WebAssembly.validate(wasm)}`);
const page = seal(wasm);
writeFileSync(out, page);
console.log(`sealed ${page.length} bytes`);
Reusing a nonce with GCM is the classic way to break it, so seal creates a fresh one every call. The magic is passed as additional authenticated data: readable, but tamper-proof. The packer reports validity but seals regardless; deciding is the browser’s job.
Step 4: Hand-write a website in WAT
Because this browser only runs WebAssembly, the site is written in WebAssembly text, by hand. The page exports one 64 KiB memory page, keeps the count in a global, and stores a 10-byte table saying which of the seven segments to light for each digit. It has no DOM and no canvas API; it just writes pixels.
(module
(memory (export "mem") 1)
(global $n (mut i32) (i32.const 0))
;; segment masks for 0-9: bit0 top, 1 top-right, 2 bottom-right, 3 bottom, 4 bottom-left, 5 top-left, 6 middle
(data (i32.const 64000) "\3f\06\5b\4f\66\6d\7d\07\7f\6f")
;; fill rectangle on a 160x100 RGBA screen
(func $rect (param $x i32) (param $y i32) (param $w i32) (param $h i32) (param $c i32)
(local $i i32) (local $j i32)
(loop $rows
(local.set $i (i32.const 0))
(loop $cols
(i32.store
(i32.mul (i32.add (i32.mul (i32.add (local.get $y) (local.get $j)) (i32.const 160))
(i32.add (local.get $x) (local.get $i))) (i32.const 4))
(local.get $c))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $cols (i32.lt_u (local.get $i) (local.get $w))))
(local.set $j (i32.add (local.get $j) (i32.const 1)))
(br_if $rows (i32.lt_u (local.get $j) (local.get $h)))))
;; $seg, $digit, render and click follow the same pattern
)
The address of a pixel is (y * 160 + x) * 4, and each store writes one 32-bit colour. $seg draws one segment if its bit is set; $digit loads the mask from the data table and draws up to seven segments (top, two right, bottom, two left, middle), exactly like a digital clock. The page exports only two functions: click adds one and wraps at 1,000, and render clears the screen to dark blue and draws hundreds, tens and ones.
Pack it:
node keygen.mjs
node pack.mjs counter.wat site/counter.cwp
# wasm 392 bytes, valid: true
# sealed 424 bytes
424 minus 392 is exactly 32 bytes of overhead: 4 magic + 12 nonce + 16 tag. A hex dump shows CWP1 in plain sight and nothing readable after it; even the wasm header 00 61 73 6d is gone.
Step 5: Build the browser shell with four gates
The browser uses a Chromium window only as a display, like a web view. It never renders HTML from a site. A site gets exactly one 160 by 100 canvas, scaled up.
// shell.js (runs in the shell page, not in any site)
const KEY_BYTES = /* demo key baked in */ new Uint8Array([/* 32 bytes */]);
const key = await crypto.subtle.importKey("raw", KEY_BYTES, "AES-GCM", false, ["decrypt"]);
const MAGIC = new TextEncoder().encode("CWP1");
async function open(url) {
const buf = new Uint8Array(await (await fetch(url)).arrayBuffer());
// Gate 1: magic
if (!MAGIC.every((b, i) => buf[i] === b)) throw new Error("Rejected: not a CWP1 page");
// Gate 2: decrypt + authenticate (Web Crypto expects ciphertext||tag)
let wasm;
try {
wasm = new Uint8Array(await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: buf.slice(4, 16), additionalData: MAGIC, tagLength: 128 },
key, buf.slice(16)));
} catch { throw new Error("Rejected: decrypt failed (tampered or wrong key)"); }
// Gate 3: structural validation
if (!WebAssembly.validate(wasm)) throw new Error("Rejected: not valid WebAssembly");
// Gate 4: run with ZERO imports
const { instance } = await WebAssembly.instantiate(wasm, {});
return instance.exports;
}
function draw(ex, ctx) {
ex.render();
const px = new Uint8ClampedArray(ex.mem.buffer, 0, 160 * 100 * 4); // first 64,000 bytes
ctx.putImageData(new ImageData(px, 160, 100), 0, 0);
}
With an empty import object, the page cannot touch the DOM, the network or anything else. It only has its own memory. A click on the canvas calls click() then draw().
Serve the site folder with any plain web server; the server is not picky, the browser is:
python3 -m http.server 8800 --directory site
Type the address, press Go: “Decrypted 392 bytes. Running. Exports: mem, click, render.” The counter appears, and every click counts. Every frame is drawn by WebAssembly into raw memory.
Step 6: Test the gates from the terminal
To test many pages quickly, test.mjs repeats the same four gates with Node’s crypto, clicks the counter 42 times, renders, and counts lit pixels:
import { createDecipheriv } from "node:crypto";
import { readFileSync } from "node:fs";
const KEY = readFileSync(process.argv[3] || "site.key");
const buf = readFileSync(process.argv[2]);
if (buf.subarray(0, 4).toString() !== "CWP1") throw "gate 1: not CWP1";
const d = createDecipheriv("aes-256-gcm", KEY, buf.subarray(4, 16));
d.setAAD(buf.subarray(0, 4)); d.setAuthTag(buf.subarray(-16));
const wasm = Buffer.concat([d.update(buf.subarray(16, -16)), d.final()]); // throws = gate 2
if (!WebAssembly.validate(wasm)) throw "gate 3: invalid wasm";
const { instance: { exports: e } } = await WebAssembly.instantiate(wasm, {});
for (let i = 0; i < 42; i++) e.click();
e.render();
const m = new Uint32Array(e.mem.buffer, 0, 16000);
console.log("ok", Object.keys(e), "lit:", m.filter(p => p === 0xff00bfff).length);
The real page: OK, three exports, and after 42 clicks 1,588 amber pixels, which is the number 42 drawn in segments.
Step 7: Try to break it
- A normal HTML page: rejected at gate 1, “not a CWP1 page”. The thing every other browser is built to show is the first thing this one refuses.
- One bit flipped at byte 100: rejected at gate 2. GCM cannot authenticate it. One bit out of roughly 3,400, and nothing ran.
- Right page, wrong key: rejected at gate 2.
Step 8: Feed it the AI’s real bytes
The AI’s raw-hex answer to “add two numbers” from the previous experiment is 167 bytes. I sealed it with the real key, so it is a perfectly authentic page.
- Gate 1: passed.
- Gate 2: decryption passed.
- Gate 3: not valid WebAssembly. Rejected.
That is the lesson of this episode. Encryption proves who sent the bytes; it says nothing about whether the bytes are any good. The AI has to earn gate 3, and then gate 4, on its own.
Scoreboard
| Page | Result |
|---|---|
| Hand-written counter | Runs, clicks work |
| Plain HTML | Rejected, gate 1 |
| One flipped bit | Rejected, gate 2 |
| Wrong key | Rejected, gate 2 |
| AI’s bytes, sealed properly | Rejected, gate 3 |
| AI-written pages that run | 0 so far |
Troubleshooting
- Web Crypto throws
OperationErroron a good page: Web Crypto expects ciphertext and tag concatenated, and the same AAD used when sealing. Passbuf.slice(16)andadditionalData: MAGIC. - Node decrypt fails but the browser works: in Node you must call
setAuthTag()beforefinal()and pass the ciphertext without the tag. - Canvas shows garbage: confirm the screen is 160×100×4 = 64,000 bytes starting at offset 0, and that your data table lives above that (here at 64,000).
- Colours look swapped: wasm stores little-endian, so
i32.storeof0xAABBGGRRlands as R, G, B, A in memory. - Instantiate fails with a LinkError: the page expects imports. Gate 4 gives it none by design.
Best practices
- Never reuse a GCM nonce under the same key; generate 12 random bytes per page.
- Authenticate headers with AAD so they cannot be swapped.
- Validate structure (
WebAssembly.validate) separately from authenticity. - Give untrusted code zero imports and expose capabilities explicitly if ever needed.
- For anything beyond a demo, use per-site keys or signatures and a real key-exchange story.
FAQ
Is this more secure than a normal browser?
It has a far smaller attack surface because it runs one tiny thing with no APIs, but it is a demo with a shared key. Real browsers rely on HTTPS, sandboxing and code signing.
Why WebAssembly and not raw machine code?
WebAssembly is portable, validated before execution and sandboxed. It is the closest thing to a binary website that browsers really run.
Why does the AI fail at gate 3?
Its bytes have a correct header but broken section lengths, so the module is structurally invalid. The next episodes build a dataset of real, validated wasm files to train a small (~0.5B) model with LoRA on CPU.
What does the 32-byte overhead buy?
The magic lets the browser reject non-pages instantly, the nonce makes encryption safe, and the 16-byte tag guarantees integrity.
Key takeaways
- A complete counter website fits in 392 bytes of WebAssembly, 424 bytes sealed.
- Four gates (magic, decrypt-and-verify, validate, zero-import run) stopped HTML, tampering and wrong keys.
- Authenticity and correctness are different checks. The AI’s properly sealed bytes still failed validation.
- The browser was the easy part. Getting an AI-written page through gate 3 is the challenge for the rest of the series.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- n8n vs AI Agent: I Gave Both the Same Job (Local, Ollama)
- Local AI Video Generator: Build Your Own App (Free, Python, No GPU)
- AI Wrote Raw Binary Instead of Code. Here’s What Happened (WebAssembly)
- I Built a Browser That Only Runs Encrypted Binary (Ep 1)
- RAG From Scratch: Chunking, Embeddings, Reranking and Evaluation (Real Numbers)
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.