Got a message on LinkedIn from Nathan a few days ago. He’d found inksec.io, looked at one of my locked writeups, opened developer tools, found the lock-blur class on the content div, removed it, and read the whole thing. He was nice about it — “just wanted to let you know” energy — but it was a fair callout.

He was right. The blur was always just CSS. The content was sitting in the DOM in plain HTML, and the password was hardcoded in the page source as a JavaScript string. Anyone who knew to look could have the answers in about ten seconds with no tools beyond a browser.

What the lock was actually doing

The locked writeups exist because platforms like CyberDefenders and BTLO ask you not to publish solutions to active challenges. The lock was my way of honouring that while still being able to show the work to recruiters. Enter a password, content decrypts, you can read the writeup. The intent was solid. The implementation wasn’t.

filter: blur(6px) and pointer-events: none in CSS is not security. It’s a vibe. Anyone who’s used developer tools for five minutes knows you can toggle CSS properties directly in the inspector. The content was always there — I was just making it slightly annoying to look at.

What I changed

The fix I landed on was client-side AES encryption using CryptoJS. The idea is that the actual writeup content never appears in the page at all — only a ciphertext blob. When you enter the correct password the browser decrypts it and injects the HTML. Inspector shows gibberish instead of answers.

The stack:

The encryption uses EVP_BytesToKey with MD5 — the same key derivation CryptoJS defaults to — so CryptoJS.AES.decrypt(ciphertext, password) in the browser cleanly reverses what the Python script produced. Fresh random salt and IV on every build, so the ciphertext changes each deploy.

From the inspector now you see this:

<div id="enc-payload" data-ct="U2FsdGVkX1..." data-ct2="" style="display:none"></div>
<div id="lock-content" class="lab-content"></div>

Empty content div. Encrypted blob in a data attribute. That’s it.

The TAP

While I was in there I added a secondary password field — tap: in the lab frontmatter. The idea is a per-lab temporary access pass: if someone needs to see a specific writeup and I want to give them access without handing out the global recruiter password, I add a random code to that lab, push, give them the code. To revoke it I remove the line and push again. The build script encrypts the content twice — once with the main password, once with the TAP — and stores both ciphertexts. The browser tries both. Same concept as Azure Temporary Access Pass, just for a static site.

The honest bit

When I first shipped the encryption I added a caveat: the source markdown was still sitting in a public GitHub repo. Someone determined enough could skip the site entirely and read the answers there. The DOM inspection path was gone, but the repo path wasn’t. I knew it, I wrote it down, and I moved on.

That gap is now closed.

A few weeks after Nathan’s message I realised that the public repo wasn’t just a theoretical problem — platforms like CyberDefenders and BTLO explicitly ask you not to publish solutions to active challenges. A public repo with lab writeups in plaintext markdown is effectively doing exactly that, regardless of what the deployed site shows.

The fix was migrating from GitHub Pages to Cloudflare Pages. GitHub Pages requires a public repo on the free plan — that’s the constraint that kept the source exposed. Cloudflare Pages has no such requirement. The repo is now private. The deployed site still builds and deploys automatically on every push, the encryption step still runs at build time, and inksec.io still resolves exactly as before. The only thing that changed is that the source is no longer readable by anyone with a browser.

The threat model is now actually complete. The DOM path is gone. The repo path is gone. What’s left is someone who has both the password and the ability to intercept the decrypted content in memory — which is outside the scope of what this lock was ever meant to stop.

Appreciate the message, Nathan. It kicked off more than just a CSS fix.