From ecb91bd47e3afd5b6382737bb1b8f003dd92ccc9 Mon Sep 17 00:00:00 2001 From: Tatang Haryadi Date: Sun, 16 Aug 2026 11:52:49 +0700 Subject: [PATCH 1/2] Add Grain: hide an AES-256-GCM-encrypted file inside a PNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side only — PBKDF2 key derivation, AES-256-GCM encryption behind a passphrase, then least-significant-bit steganography to write the ciphertext into a carrier PNG's pixels. Nothing is ever uploaded; grain.html sets connect-src 'none'. Sixth argued exception to the JS-file rule, alongside js/ask.js, js/game.js, js/mocap.js, js/mocap-retarget.js and js/iris.js (AGENTS.md). scripts/check_grain.py pins the header format, KDF and ciphertext against committed fixtures generated with Node's webcrypto and a from-scratch stdlib PNG encoder, since stdlib Python has no AES-GCM to verify the encryption itself against. --- .claude/rules/grain.md | 8 + .github/workflows/ci.yml | 7 + AGENTS.md | 22 +- css/grain.css | 272 ++++++++++++++ grain.html | 221 +++++++++++ index.html | 6 + js/grain.js | 494 +++++++++++++++++++++++++ scripts/check_grain.py | 240 ++++++++++++ scripts/fixtures/grain/carrier.png | Bin 0 -> 1147 bytes scripts/fixtures/grain/ciphertext.bin | 1 + scripts/fixtures/grain/derived_key.hex | 1 + scripts/fixtures/grain/filename.txt | 1 + scripts/fixtures/grain/passphrase.txt | 1 + scripts/fixtures/grain/plaintext.bin | 1 + scripts/fixtures/grain/stego.png | Bin 0 -> 1148 bytes sitemap.xml | 5 + specs/F07_GRAIN.md | 212 +++++++++++ specs/PRD.md | 43 ++- 18 files changed, 1529 insertions(+), 6 deletions(-) create mode 100644 .claude/rules/grain.md create mode 100644 css/grain.css create mode 100644 grain.html create mode 100644 js/grain.js create mode 100644 scripts/check_grain.py create mode 100644 scripts/fixtures/grain/carrier.png create mode 100644 scripts/fixtures/grain/ciphertext.bin create mode 100644 scripts/fixtures/grain/derived_key.hex create mode 100644 scripts/fixtures/grain/filename.txt create mode 100644 scripts/fixtures/grain/passphrase.txt create mode 100644 scripts/fixtures/grain/plaintext.bin create mode 100644 scripts/fixtures/grain/stego.png create mode 100644 specs/F07_GRAIN.md diff --git a/.claude/rules/grain.md b/.claude/rules/grain.md new file mode 100644 index 0000000..6cbf40b --- /dev/null +++ b/.claude/rules/grain.md @@ -0,0 +1,8 @@ +--- +paths: + - 'js/grain.js' + - 'grain.html' + - 'css/grain.css' +--- + +@specs/F07_GRAIN.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 531616a..2b66c48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,13 @@ jobs: - name: Palette is in step across every copy run: python3 scripts/check_palette.py + # Cannot re-run js/grain.js's AES-256-GCM against its own fixtures — + # stdlib Python has no AES-GCM — so this is a regression pin, not a + # proof, and says so in its own header comment: see + # scripts/check_grain.py for exactly what it does and does not catch. + - name: Grain fixtures still match js/grain.js's format + run: python3 scripts/check_grain.py + - name: .nojekyll present and sitemap in step run: python3 scripts/check_repo.py diff --git a/AGENTS.md b/AGENTS.md index 23fa274..0070c75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ A static personal site: plain HTML and CSS, no build step, no package manager, served by GitHub Pages from `main`. There is nothing to install and nothing to compile. Edit the files directly. -**There are exactly five JavaScript files of our own: `js/ask.js`, `js/game.js`, -`js/mocap.js`, `js/mocap-retarget.js` and `js/iris.js`.** Everything else — the work index on the home page — is +**There are exactly six JavaScript files of our own: `js/ask.js`, `js/game.js`, +`js/mocap.js`, `js/mocap-retarget.js`, `js/iris.js` and `js/grain.js`.** Everything else — the work index on the home page — is [htmx](https://htmx.org) asking for static HTML fragments under `fragments/` and swapping them in. htmx arrives from a CDN pinned by version and SRI digest. @@ -103,6 +103,18 @@ mechanism to actually be true, and the CSP violation line it produces in the console is deliberate, not a regression — see that section before "cleaning up" the console output on `iris.html`. +`js/grain.js` earns the sixth exception on different grounds again: it is not +a boundary in front of a vendored runtime at all, it is the whole feature. +Grain encrypts a file with a passphrase (AES-256-GCM via WebCrypto, key +stretched through PBKDF2) and hides the ciphertext in a carrier PNG's pixels +with least-significant-bit steganography, entirely with browser-native APIs — +`crypto.subtle` and `` — and nothing vendored. There is no markup this +could be instead: a passphrase-derived key, an authenticated 42-byte header +and a bit-level pixel encoding are computation, not a document a server could +have sent. `grain.html` carries `connect-src 'none'`, tighter than the `'self'` +every htmx page needs, because this page has no fragment to ask for in the +first place — see [specs/F07_GRAIN.md](specs/F07_GRAIN.md). + Do not introduce a bundler, framework or package manager to solve a problem that a few lines of CSS would solve. The absence of a toolchain is a design decision, not an oversight — see [ARCHITECTURE.md](ARCHITECTURE.md#no-build-step). `game/` is @@ -188,6 +200,12 @@ not pretend to cover them. **A green CI does not mean a change is verified.** 12. **[CI]** If any colour changed, `python3 scripts/check_palette.py`. The palette is written out in **five** places and nothing but this script keeps them in step; see the bullet under "Other things not to break". +13. **[CI]** If you touched `js/grain.js`'s header format, bit order or crypto + parameters, `python3 scripts/check_grain.py`. It is a regression pin against + the fixtures in `scripts/fixtures/grain/`, not a proof — stdlib Python has no + AES-GCM to check the encryption itself against, so read the script's own + header comment for exactly what it can and cannot catch. If it fails on a + deliberate format change, regenerate the fixtures rather than editing the pin. ## Accessibility invariants diff --git a/css/grain.css b/css/grain.css new file mode 100644 index 0000000..461c3c0 --- /dev/null +++ b/css/grain.css @@ -0,0 +1,272 @@ +/* Grain. Layout only — every colour below is one of css/style.css's shared + custom properties, so this file is not a sixth place + scripts/check_palette.py would have to track. */ + +/* Neither section on this page ever gets the `hidden` attribute — encode and + decode are both always present — but the sample-carrier and the + result panels do, and the browser's own `[hidden]` rule is too low + specificity to survive this file's other selectors. Same fix as + css/iris.css and css/game.css. */ +[hidden] { + display: none !important; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--bg-alt); + color: var(--text); + padding: 0.5rem 1rem; + border-radius: 0 0 8px 0; + z-index: 10; +} + +.skip-link:focus { + left: 0; +} + +.grain--page { + display: block; + max-width: var(--measure); + margin: 0 auto; + padding: 0 1.5rem 3rem; +} + +.grain--head { + max-width: var(--measure); + margin: 0 auto; + padding: 2rem 1.5rem 1rem; +} + +.grain--sub { + max-width: var(--measure); + color: var(--text-muted); +} + +.grain--sub a { + color: var(--accent-text); + text-decoration: underline; +} + +.grain--honest { + font-size: 0.9rem; +} + +.grain--notice { + color: var(--text-muted); + min-height: 1.25em; +} + +.grain--section { + margin: 2rem 0; + padding: 1.25rem 1.5rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 8px; +} + +.grain--section h2 { + margin-top: 0; +} + +.grain--field { + margin: 0 0 1.5rem; +} + +.grain--field-heading { + margin: 0 0 0.35rem; +} + +.grain--field-hint { + margin: 0 0 0.75rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.grain--drop { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--bg); + border: 1px dashed var(--border); + border-radius: 6px; +} + +.grain--drop.grain--drop-over { + border-color: var(--accent-text); +} + +.grain--file-label { + font: inherit; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 1rem; + border-radius: 6px; + color: var(--bg); + background: var(--accent-text); + border: 2px solid var(--accent-text); +} + +.grain--file-label:hover { + color: var(--accent-text); + background: var(--bg); +} + +.grain--file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.grain--file-input:focus-visible + .grain--file-label, +.grain--file-label:has(+ .grain--file-input:focus-visible) { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +.grain--or { + margin: 0.75rem 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.grain--sample { + font: inherit; + cursor: pointer; + padding: 0.5rem 1rem; + border-radius: 6px; + color: var(--accent-text); + background: var(--bg); + border: 2px solid var(--accent-text); +} + +.grain--sample:hover { + color: var(--bg); + background: var(--accent-text); +} + +.grain--sample:focus-visible { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +.grain--carrier-preview, +.grain--stego-preview { + display: block; + margin: 1rem 0; + max-width: 100%; + max-height: 240px; + border: 1px solid var(--border); + border-radius: 6px; +} + +.grain--capacity { + margin: 0.75rem 0 0; + font-size: 0.9rem; +} + +.grain--label { + display: block; + margin: 0 0 0.25rem; + font-weight: 600; +} + +.grain--pass-row { + margin: 0 0 0.75rem; +} + +.grain--input { + font: inherit; + width: 100%; + max-width: 24rem; + padding: 0.5rem 0.75rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.grain--input:focus-visible { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +.grain--pass-show-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; +} + +.grain--checkbox:focus-visible { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +.grain--pass-hint { + margin: 0; + color: var(--text-muted); + font-size: 0.85rem; +} + +.grain--action { + font: inherit; + font-weight: 600; + cursor: pointer; + padding: 0.6rem 1.25rem; + border-radius: 6px; + color: var(--bg); + background: var(--accent-text); + border: 2px solid var(--accent-text); +} + +.grain--action:hover { + color: var(--accent-text); + background: var(--bg); +} + +.grain--action:focus-visible { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +.grain--action[aria-disabled='true'] { + cursor: progress; + opacity: 0.6; +} + +.grain--result { + margin-top: 1.5rem; +} + +.grain--download { + display: inline-block; + font-weight: 600; + color: var(--accent-text); + text-decoration: underline; +} + +.grain--download:focus-visible { + outline: 3px solid var(--accent-text); + outline-offset: 2px; +} + +@media (max-width: 750px) { + .grain--head { + padding: 1.5rem 1rem 0.75rem; + } + + .grain--page { + padding: 0 1rem 2rem; + } + + .grain--drop { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/grain.html b/grain.html new file mode 100644 index 0000000..7a3d62b --- /dev/null +++ b/grain.html @@ -0,0 +1,221 @@ + + + + + + + Grain | Tatang Haryadi + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Grain

+

+ Hide a file inside a picture. The file is encrypted with a + passphrase first — real AES-256-GCM, not an obfuscation — and only + the ciphertext is ever written into the image's pixels. Nothing + here is uploaded anywhere; encrypting, hiding and reading all + happen in this tab. +

+

Back to the rest of the site

+

+ Worth saying plainly: a picture with more entropy in its low bits + than a camera or a renderer would ever produce is not invisible to + a statistical steganalysis tool, only to a human eye and to a + server that never receives it. Treat this as a way to carry a file + inside a picture, not as a way to defeat forensic inspection. +

+
+ + + + +
+ +
+

Hide a file

+ +
+

1. Pick a carrier image

+

+ A PNG or any image your browser can decode. It is never sent + anywhere and never modified in place — hiding a file in it + produces a second, new PNG. +

+
+ + +
+

or

+ + + + +

+
+ +
+

2. Pick the file to hide

+

+ Any file type. Its name and contents are both encrypted before + a single bit of it touches the carrier. +

+
+ + +
+ +

+
+ +
+

3. Set a passphrase

+

+ This is the only key. There is no recovery: lose the + passphrase and the hidden file is unreadable, by design. +

+
+ + +
+
+ + +
+
+ + +
+

+ Longer is what actually matters here: this page runs 600,000 + rounds of key derivation specifically to make a short + passphrase expensive to guess, but it cannot make a short one + as strong as a long one. +

+

+
+ + +

+ + +
+ +
+

Reveal a hidden file

+ +
+

1. Pick a stego image

+

+ A PNG produced by the tool above. Any resave through a lossy + format, or through an editor that recompresses pixels, + destroys the hidden bits — this only ever works on the exact + PNG bytes this page wrote. +

+
+ + +
+

+
+ +
+

2. Enter the passphrase

+
+ + +
+
+ + +
+
+ + +

+ + +
+ +
+ + + diff --git a/index.html b/index.html index bb51121..55b789e 100755 --- a/index.html +++ b/index.html @@ -517,6 +517,12 @@

What a machine checks, so you do not have to trust me

describing something no longer on the page. +

Checks like these are not the only place this repository does + real work rather than a demo of one. Grain + hides a file inside a picture — real AES-256-GCM encryption + behind a passphrase, not an obfuscation — entirely in your + browser, with nothing uploaded.

+

On how this was built

A frontier model wrote most of the code here, and saying so is not diff --git a/js/grain.js b/js/grain.js new file mode 100644 index 0000000..6c27fa4 --- /dev/null +++ b/js/grain.js @@ -0,0 +1,494 @@ +// Grain: hide an encrypted file inside a PNG's pixels. +// +// Two independent techniques, stacked. AES-256-GCM behind a passphrase does +// the actual protecting; least-significant-bit steganography just gives the +// ciphertext somewhere unremarkable to sit. Losing either half loses the +// file: the passphrase is the only key (no recovery), and re-saving the +// stego PNG through anything that recompresses pixels destroys the hidden +// bits before decryption ever runs. +// +// Nothing here calls fetch, XHR or WebSocket — this page's +// Content-Security-Policy (connect-src 'none') makes that a guarantee the +// browser enforces, not just a claim this file makes about itself. + +const MAGIC = new Uint8Array([0x47, 0x52, 0x4e, 0x31]); // "GRN1" +const VERSION = 1; +const HEADER_LEN = 42; // magic(4) + version(1) + flags(1) + kdfIter(4) + salt(16) + iv(12) + ctLen(4) +const SALT_LEN = 16; +const IV_LEN = 12; +const GCM_TAG_LEN = 16; +const KDF_ITERATIONS = 600000; + +// A stego image's header is trusted only as far as this range: without it, a +// crafted file could name an arbitrarily large iteration count and turn +// "enter the wrong passphrase" into a multi-minute hang. Checked before +// deriveKey ever runs, not after. +const MIN_KDF_ITERATIONS = 100000; +const MAX_KDF_ITERATIONS = 2000000; + +// 40 megapixels, checked before either flow reads pixels back out of a +// canvas. Uncapped, a large-enough image turns getImageData into hundreds of +// megabytes this page never needed to allocate. +const MAX_PIXELS = 40_000_000; + +const els = { + carrierInput: document.getElementById('grain--carrier'), + carrierDrop: document.getElementById('grain--carrier-drop'), + carrierPreview: document.getElementById('grain--carrier-preview'), + sampleBtn: document.getElementById('grain--sample'), + sampleImg: document.getElementById('grain--sample-img'), + payloadInput: document.getElementById('grain--payload'), + payloadDrop: document.getElementById('grain--payload-drop'), + payloadName: document.getElementById('grain--payload-name'), + pass: document.getElementById('grain--pass'), + passConfirm: document.getElementById('grain--pass-confirm'), + passShow: document.getElementById('grain--pass-show'), + passHint: document.getElementById('grain--pass-hint'), + passStatus: document.getElementById('grain--pass-status'), + capacity: document.getElementById('grain--capacity'), + encodeBtn: document.getElementById('grain--encode'), + encodeStatus: document.getElementById('grain--encode-status'), + encodeResult: document.getElementById('grain--encode-result'), + download: document.getElementById('grain--download'), + stegoPreview: document.getElementById('grain--stego-preview'), + stegoInput: document.getElementById('grain--stego'), + stegoDrop: document.getElementById('grain--stego-drop'), + stegoName: document.getElementById('grain--stego-name'), + decodePass: document.getElementById('grain--decode-pass'), + decodePassShow: document.getElementById('grain--decode-pass-show'), + decodeBtn: document.getElementById('grain--decode'), + decodeStatus: document.getElementById('grain--decode-status'), + decodeResult: document.getElementById('grain--decode-result'), + extracted: document.getElementById('grain--extracted'), +}; + +let carrierBitmap = null; +let carrierPreviewUrl = null; // revoked whenever the carrier changes, unless it's the sample image +let payloadFile = null; +let stegoBitmap = null; +let encoding = false; +let decoding = false; +let downloadUrl = null; +let extractedUrl = null; + +function setStatus(el, message) { + el.textContent = message; +} + +// ---- Bit-level container I/O ------------------------------------------- + +function embedBits(data, bytes) { + const totalBits = bytes.length * 8; + let bitIndex = 0; + for (let p = 0; p < data.length && bitIndex < totalBits; p += 4) { + for (let c = 0; c < 3 && bitIndex < totalBits; c++) { + const bit = (bytes[bitIndex >> 3] >> (7 - (bitIndex & 7))) & 1; + data[p + c] = (data[p + c] & 0xfe) | bit; + bitIndex++; + } + } +} + +function extractBits(data, byteCount) { + const out = new Uint8Array(byteCount); + const totalBits = byteCount * 8; + let bitIndex = 0; + for (let p = 0; p < data.length && bitIndex < totalBits; p += 4) { + for (let c = 0; c < 3 && bitIndex < totalBits; c++) { + const bit = data[p + c] & 1; + out[bitIndex >> 3] |= bit << (7 - (bitIndex & 7)); + bitIndex++; + } + } + return out; +} + +function buildHeader(kdfIter, salt, iv, ctLen) { + const header = new Uint8Array(HEADER_LEN); + const view = new DataView(header.buffer); + header.set(MAGIC, 0); + header[4] = VERSION; + header[5] = 0; // flags, reserved + view.setUint32(6, kdfIter, false); + header.set(salt, 10); + header.set(iv, 26); + view.setUint32(38, ctLen, false); + return header; +} + +function parseHeader(bytes) { + for (let i = 0; i < MAGIC.length; i++) { + if (bytes[i] !== MAGIC[i]) { + throw new Error( + "That doesn't look like a Grain stego image — its header is " + + 'missing the expected signature. Either it was not hidden by ' + + 'this page, or it was re-saved by something that touched its pixels.' + ); + } + } + const version = bytes[4]; + if (version !== VERSION) { + throw new Error( + `This file was hidden with header version ${version}, which this ` + + `page (version ${VERSION}) does not understand.` + ); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + kdfIter: view.getUint32(6, false), + salt: bytes.slice(10, 26), + iv: bytes.slice(26, 38), + ctLen: view.getUint32(38, false), + }; +} + +// ---- Crypto -------------------------------------------------------------- + +async function deriveKey(passphrase, salt, iterations) { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +// ---- Carrier / stego image loading --------------------------------------- + +async function loadBitmap(source) { + const bitmap = await createImageBitmap(source, { + colorSpaceConversion: 'none', + premultiplyAlpha: 'none', + }); + if (bitmap.width * bitmap.height > MAX_PIXELS) { + const pixels = bitmap.width * bitmap.height; + bitmap.close(); + throw new Error( + `That image is ${bitmap.width}x${bitmap.height} (${pixels.toLocaleString()} ` + + `pixels), over this page's ${MAX_PIXELS.toLocaleString()}-pixel cap. Pick a smaller one.` + ); + } + return bitmap; +} + +function bitmapToImageData(bitmap) { + const canvas = document.createElement('canvas'); + // Sized from the decoded bitmap, not from any CSS layout size — there is + // none, since this canvas is never inserted into the page. + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + return { canvas, ctx, imageData: ctx.getImageData(0, 0, canvas.width, canvas.height) }; +} + +function capacityBytes(bitmap) { + return Math.floor((bitmap.width * bitmap.height * 3) / 8); +} + +function updateCapacity() { + if (!carrierBitmap) { + els.capacity.textContent = ''; + return; + } + const bytes = capacityBytes(carrierBitmap); + const usable = Math.max(0, bytes - HEADER_LEN - 2); + els.capacity.textContent = + `${carrierBitmap.width}x${carrierBitmap.height}. Can carry up to ` + + `${usable.toLocaleString()} encrypted bytes (before its filename).`; +} + +function setCarrier(bitmap, previewSrc, isObjectUrl) { + if (carrierBitmap) carrierBitmap.close(); + carrierBitmap = bitmap; + if (carrierPreviewUrl) { + URL.revokeObjectURL(carrierPreviewUrl); + carrierPreviewUrl = null; + } + if (isObjectUrl) carrierPreviewUrl = previewSrc; + els.carrierPreview.src = previewSrc; + els.carrierPreview.hidden = false; + updateCapacity(); +} + +async function onCarrierFile(file) { + setStatus(els.encodeStatus, ''); + try { + const bitmap = await loadBitmap(file); + setCarrier(bitmap, URL.createObjectURL(file), true); + } catch (err) { + setStatus(els.encodeStatus, err.message); + } +} + +async function useSample() { + setStatus(els.encodeStatus, ''); + try { + if (!els.sampleImg.complete) { + await els.sampleImg.decode(); + } + const bitmap = await loadBitmap(els.sampleImg); + setCarrier(bitmap, els.sampleImg.src, false); + } catch (err) { + setStatus(els.encodeStatus, err.message); + } +} + +async function onStegoFile(file) { + setStatus(els.decodeStatus, ''); + els.stegoName.textContent = `Chosen: ${file.name}`; + try { + if (stegoBitmap) stegoBitmap.close(); + stegoBitmap = await loadBitmap(file); + } catch (err) { + stegoBitmap = null; + setStatus(els.decodeStatus, err.message); + } +} + +function onPayloadFile(file) { + payloadFile = file; + els.payloadName.textContent = `Chosen: ${file.name} (${file.size.toLocaleString()} bytes)`; +} + +// ---- Drag and drop --------------------------------------------------------- + +function wireDropZone(dropEl, inputEl, onFile) { + dropEl.addEventListener('dragover', (event) => { + event.preventDefault(); + dropEl.classList.add('grain--drop-over'); + }); + dropEl.addEventListener('dragleave', () => { + dropEl.classList.remove('grain--drop-over'); + }); + dropEl.addEventListener('drop', (event) => { + event.preventDefault(); + dropEl.classList.remove('grain--drop-over'); + const file = event.dataTransfer.files[0]; + if (file) onFile(file); + }); + inputEl.addEventListener('change', () => { + const file = inputEl.files[0]; + if (file) onFile(file); + }); +} + +// ---- Passphrase show/hide --------------------------------------------------- + +function wirePassShow(checkbox, ...inputs) { + checkbox.addEventListener('change', () => { + const type = checkbox.checked ? 'text' : 'password'; + for (const input of inputs) input.type = type; + }); +} + +// ---- Encode --------------------------------------------------------------- + +async function handleEncode() { + if (encoding || els.encodeBtn.getAttribute('aria-disabled') === 'true') return; + + setStatus(els.encodeStatus, ''); + if (!carrierBitmap) { + setStatus(els.encodeStatus, 'Pick a carrier image first.'); + return; + } + if (!payloadFile) { + setStatus(els.encodeStatus, 'Pick a file to hide first.'); + return; + } + const passphrase = els.pass.value; + if (!passphrase) { + setStatus(els.encodeStatus, 'Enter a passphrase.'); + return; + } + if (passphrase !== els.passConfirm.value) { + setStatus(els.encodeStatus, 'The two passphrases do not match.'); + return; + } + + encoding = true; + els.encodeBtn.setAttribute('aria-disabled', 'true'); + setStatus(els.encodeStatus, 'Encrypting…'); + + try { + const payloadBytes = new Uint8Array(await payloadFile.arrayBuffer()); + const nameBytes = new TextEncoder().encode(payloadFile.name); + if (nameBytes.length > 0xffff) { + throw new Error('That filename is too long to encode.'); + } + + const plaintext = new Uint8Array(2 + nameBytes.length + payloadBytes.length); + new DataView(plaintext.buffer).setUint16(0, nameBytes.length, false); + plaintext.set(nameBytes, 2); + plaintext.set(payloadBytes, 2 + nameBytes.length); + + const capacity = capacityBytes(carrierBitmap); + const ctLen = plaintext.length + GCM_TAG_LEN; + if (HEADER_LEN + ctLen > capacity) { + throw new Error( + `The encrypted payload is ${(HEADER_LEN + ctLen).toLocaleString()} bytes, ` + + `but this carrier can only hold ${capacity.toLocaleString()}. Pick a larger ` + + `image or a smaller file.` + ); + } + + const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); + const key = await deriveKey(passphrase, salt, KDF_ITERATIONS); + + // The header is authenticated but not itself encrypted: passing it as + // AES-GCM's additionalData binds every field in it — including the + // salt and iv a decoder will trust — to this exact ciphertext, so + // tampering with any of them fails the auth tag instead of quietly + // decrypting to garbage. + const header = buildHeader(KDF_ITERATIONS, salt, iv, ctLen); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: header }, key, plaintext) + ); + + const container = new Uint8Array(HEADER_LEN + ciphertext.length); + container.set(header, 0); + container.set(ciphertext, HEADER_LEN); + + const { canvas, ctx, imageData } = bitmapToImageData(carrierBitmap); + const data = imageData.data; + + // Force full opacity before embedding. A carrier with partial + // transparency can have its RGB channels rewritten by + // premultiplication when some browsers decode it back, which would + // corrupt the low bit this page is about to set — fully opaque + // pixels have no such path. + for (let p = 0; p < data.length; p += 4) { + data[p + 3] = 255; + } + + embedBits(data, container); + ctx.putImageData(imageData, 0, 0); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('Could not encode the PNG.'))), + 'image/png' + ); + }); + + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + downloadUrl = URL.createObjectURL(blob); + els.download.href = downloadUrl; + els.stegoPreview.src = downloadUrl; + els.encodeResult.hidden = false; + setStatus( + els.encodeStatus, + `Done. ${container.length.toLocaleString()} encrypted bytes hidden in a ` + + `${canvas.width}x${canvas.height} PNG.` + ); + els.download.focus(); + } catch (err) { + setStatus(els.encodeStatus, err.message); + } finally { + encoding = false; + els.encodeBtn.setAttribute('aria-disabled', 'false'); + } +} + +// ---- Decode ----------------------------------------------------------------- + +async function handleDecode() { + if (decoding || els.decodeBtn.getAttribute('aria-disabled') === 'true') return; + + setStatus(els.decodeStatus, ''); + if (!stegoBitmap) { + setStatus(els.decodeStatus, 'Pick a stego PNG first.'); + return; + } + const passphrase = els.decodePass.value; + if (!passphrase) { + setStatus(els.decodeStatus, 'Enter the passphrase.'); + return; + } + + decoding = true; + els.decodeBtn.setAttribute('aria-disabled', 'true'); + setStatus(els.decodeStatus, 'Reading…'); + + try { + const { imageData } = bitmapToImageData(stegoBitmap); + const data = imageData.data; + const capacity = capacityBytes(stegoBitmap); + + if (capacity < HEADER_LEN) { + throw new Error('That image is too small to hold a Grain header.'); + } + + const headerBytes = extractBits(data, HEADER_LEN); + const { kdfIter, salt, iv, ctLen } = parseHeader(headerBytes); + + if (kdfIter < MIN_KDF_ITERATIONS || kdfIter > MAX_KDF_ITERATIONS) { + throw new Error( + `This file's header claims ${kdfIter.toLocaleString()} key-derivation rounds, ` + + `outside the range this page will run (${MIN_KDF_ITERATIONS.toLocaleString()}–` + + `${MAX_KDF_ITERATIONS.toLocaleString()}). Refusing rather than deriving a key ` + + `at an attacker-chosen cost.` + ); + } + if (ctLen > capacity - HEADER_LEN) { + throw new Error( + "This file's header claims more hidden data than the image has room for — " + + 'it is not a genuine Grain stego PNG.' + ); + } + + const container = extractBits(data, HEADER_LEN + ctLen); + const ciphertext = container.slice(HEADER_LEN); + + const key = await deriveKey(passphrase, salt, kdfIter); + let plaintext; + try { + plaintext = new Uint8Array( + await crypto.subtle.decrypt({ name: 'AES-GCM', iv, additionalData: headerBytes }, key, ciphertext) + ); + } catch { + throw new Error( + 'Decryption failed. Either the passphrase is wrong, or this image was altered ' + + 'after Grain hid the file in it.' + ); + } + + const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength); + const nameLen = view.getUint16(0, false); + const filename = new TextDecoder().decode(plaintext.slice(2, 2 + nameLen)); + const fileBytes = plaintext.slice(2 + nameLen); + + if (extractedUrl) URL.revokeObjectURL(extractedUrl); + extractedUrl = URL.createObjectURL(new Blob([fileBytes])); + els.extracted.href = extractedUrl; + els.extracted.download = filename || 'grain-recovered'; + els.decodeResult.hidden = false; + setStatus(els.decodeStatus, `Recovered "${filename}", ${fileBytes.length.toLocaleString()} bytes.`); + els.extracted.focus(); + } catch (err) { + setStatus(els.decodeStatus, err.message); + } finally { + decoding = false; + els.decodeBtn.setAttribute('aria-disabled', 'false'); + } +} + +// ---- Wiring ----------------------------------------------------------------- + +wireDropZone(els.carrierDrop, els.carrierInput, onCarrierFile); +wireDropZone(els.payloadDrop, els.payloadInput, onPayloadFile); +wireDropZone(els.stegoDrop, els.stegoInput, onStegoFile); +wirePassShow(els.passShow, els.pass, els.passConfirm); +wirePassShow(els.decodePassShow, els.decodePass); +els.sampleBtn.addEventListener('click', useSample); +els.encodeBtn.addEventListener('click', handleEncode); +els.decodeBtn.addEventListener('click', handleDecode); diff --git a/scripts/check_grain.py b/scripts/check_grain.py new file mode 100644 index 0000000..6dbc667 --- /dev/null +++ b/scripts/check_grain.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Check the fixtures under scripts/fixtures/grain/ against js/grain.js's format. + +This cannot re-implement AES-256-GCM to prove js/grain.js encrypts correctly — +stdlib Python has no AES-GCM, and adding one would mean taking on a dependency +this repo's no-package-manager stance rules out. So it does the next best +thing, in three independent steps: + + 1. Structural: parses stego.png's pixels and the 42-byte header itself, + without trusting anything js/grain.js claims about its own format. This + catches a header field, a bit order or a channel choice that drifted + from what the shipped code actually does. + 2. Cross-check: hashlib.pbkdf2_hmac (Python's own PBKDF2-HMAC-SHA-256) is run + against the same salt and iteration count the header carries, and held + against scripts/fixtures/grain/derived_key.hex — a raw AES key exported + from a real WebCrypto deriveKey() call during fixture generation (see the + comment in gen_fixtures, kept out of the repo since it is a one-off + script, not part of the shipped page). Two independent implementations of + the same derivation agreeing is a real check, not a circular one. + 3. Regression pin: AES-GCM is deterministic given a fixed key, iv, + additionalData and plaintext, so the ciphertext this script extracts from + stego.png should equal the one committed at + scripts/fixtures/grain/ciphertext.bin, generated the same way. + +Step 3 is a regression pin, not a proof. A systematic bug present in both the +generation and this check — the wrong AAD, a byte order flip applied +consistently — would reproduce identically here and pass invisibly. What it +does catch is any future change to js/grain.js's header layout, bit order or +crypto parameters that isn't matched by regenerating the fixtures, which is +the drift this script exists to catch. + +Stdlib only, by design. See ARCHITECTURE.md#continuous-integration. +""" + +import hashlib +import struct +import sys +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "scripts" / "fixtures" / "grain" + +HEADER_LEN = 42 +MAGIC = b"GRN1" +VERSION = 1 + +failures = [] + + +def fail(message): + failures.append(message) + + +def paeth(a, b, c): + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + if pb <= pc: + return b + return c + + +def unfilter(raw, width, height, bpp): + """Reverse PNG's per-scanline filtering. All five filter types, because + a real browser encoder chooses one per row rather than using one type + throughout.""" + stride = width * bpp + out = bytearray(stride * height) + pos = 0 + prev = bytearray(stride) + for y in range(height): + filt = raw[pos] + pos += 1 + line = bytearray(raw[pos : pos + stride]) + pos += stride + for i in range(stride): + a = line[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + if filt == 0: + pass + elif filt == 1: + line[i] = (line[i] + a) & 0xFF + elif filt == 2: + line[i] = (line[i] + b) & 0xFF + elif filt == 3: + line[i] = (line[i] + (a + b) // 2) & 0xFF + elif filt == 4: + line[i] = (line[i] + paeth(a, b, c)) & 0xFF + else: + raise ValueError(f"unknown PNG filter type {filt}") + out[y * stride : (y + 1) * stride] = line + prev = line + return bytes(out) + + +def read_png_rgba(path): + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"{path.name}: not a PNG (bad signature)") + pos = 8 + idat = b"" + width = height = bit_depth = color_type = None + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + tag = data[pos + 4 : pos + 8] + payload = data[pos + 8 : pos + 8 + length] + if tag == b"IHDR": + width, height, bit_depth, color_type = struct.unpack(">IIBB", payload[:10]) + elif tag == b"IDAT": + idat += payload + pos += 12 + length + if width is None: + raise ValueError(f"{path.name}: no IHDR chunk") + if bit_depth != 8 or color_type != 6: + raise ValueError( + f"{path.name}: expected 8-bit RGBA (PNG color type 6) -- js/grain.js's " + f"canvas round trip always produces this -- got bit depth {bit_depth}, " + f"color type {color_type}" + ) + raw = zlib.decompress(idat) + return width, height, unfilter(raw, width, height, bpp=4) + + +def extract_bits(data, byte_count): + """The same LSB read js/grain.js's extractBits() does: the low bit of + each pixel's R, G, B channel, row-major, MSB-first within each byte.""" + out = bytearray(byte_count) + total_bits = byte_count * 8 + bit_index = 0 + for p in range(0, len(data), 4): + if bit_index >= total_bits: + break + for c in range(3): + if bit_index >= total_bits: + break + bit = data[p + c] & 1 + out[bit_index >> 3] |= bit << (7 - (bit_index & 7)) + bit_index += 1 + return bytes(out) + + +def parse_header(header): + if header[:4] != MAGIC: + raise ValueError(f"bad magic {header[:4]!r}, expected {MAGIC!r}") + version = header[4] + if version != VERSION: + raise ValueError(f"unexpected header version {version}, expected {VERSION}") + (kdf_iter,) = struct.unpack(">I", header[6:10]) + salt = header[10:26] + iv = header[26:38] + (ct_len,) = struct.unpack(">I", header[38:42]) + return kdf_iter, salt, iv, ct_len + + +def main(): + stego_path = FIXTURES / "stego.png" + carrier_path = FIXTURES / "carrier.png" + if not stego_path.is_file() or not carrier_path.is_file(): + fail("scripts/fixtures/grain/carrier.png and stego.png must both exist") + return report() + + # 1. Structural: independently parse the PNG and the 42-byte header. + try: + cw, ch, _ = read_png_rgba(carrier_path) + sw, sh, sdata = read_png_rgba(stego_path) + except ValueError as exc: + fail(str(exc)) + return report() + + if (cw, ch) != (sw, sh): + fail(f"carrier.png is {cw}x{ch} but stego.png is {sw}x{sh} -- they should be the same carrier") + return report() + + header = extract_bits(sdata, HEADER_LEN) + try: + kdf_iter, salt, iv, ct_len = parse_header(header) + except ValueError as exc: + fail(f"stego.png header: {exc}") + return report() + + capacity = (sw * sh * 3) // 8 + if HEADER_LEN + ct_len > capacity: + fail( + f"stego.png's header claims {ct_len} ciphertext bytes, but a " + f"{sw}x{sh} image only has room for {capacity - HEADER_LEN}" + ) + return report() + + # Alpha must read back fully opaque everywhere. js/grain.js forces this + # before embedding, specifically so a later decode's premultiplication + # cannot corrupt the low bit this page just wrote. + if any(sdata[i] != 255 for i in range(3, len(sdata), 4)): + fail("stego.png has a non-opaque pixel -- alpha should be forced to 255 before embedding") + + container = extract_bits(sdata, HEADER_LEN + ct_len) + ciphertext = container[HEADER_LEN:] + + # 2. Cross-check against a real WebCrypto-derived key. + passphrase = (FIXTURES / "passphrase.txt").read_text(encoding="utf-8").strip() + want_key = (FIXTURES / "derived_key.hex").read_text(encoding="utf-8").strip() + got_key = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, kdf_iter, dklen=32).hex() + if got_key != want_key: + fail( + "PBKDF2 cross-check failed: hashlib.pbkdf2_hmac(passphrase, salt, " + "kdf_iter) does not match derived_key.hex, a key exported from a " + "real WebCrypto deriveKey() call. Either the KDF parameters " + "drifted from js/grain.js's deriveKey(), or the fixture is stale." + ) + + # 3. Regression pin against the committed ciphertext. + want_ct = (FIXTURES / "ciphertext.bin").read_bytes() + if ciphertext != want_ct: + fail( + f"stego.png's embedded ciphertext ({len(ciphertext)} bytes) does not " + f"match the committed scripts/fixtures/grain/ciphertext.bin pin -- see " + "this script's module docstring for what that pin can and cannot prove" + ) + + return report() + + +def report(): + if failures: + print("Grain fixture check failed:\n") + for message in failures: + print(f" {message}") + return 1 + print( + "Grain fixtures check out: stego.png's header and ciphertext parse " + "correctly, hashlib.pbkdf2_hmac matches the WebCrypto-derived key, and " + "the ciphertext matches its committed pin." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fixtures/grain/carrier.png b/scripts/fixtures/grain/carrier.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ce359ae1de1c7dd0057475358e733f4e729e91 GIT binary patch literal 1147 zcmV->1cdvEP)c?|7ZR34*7B8Q5QqNtdqVo{<} zag>UMc~lfjmK08Y`#wPs1hh@WeCMx4P>wNaE!JEWIn!z3!_<&)zAQ|@u_Ke+0A%{)DKdedKw6uR>C>O>oi$4F%r?m{E}fTdSvjLr$mCoVoG96czD@{4+E~KY520vITD}5M3SnH zb7`cQ$9jJuXu4KlbLbAcZnTjy`kFrtCir`+Jfy3$xWSP1tR--Tgrk1jxRM=A$D;$t z%t%Amya0#b%j`LOn`7;*REl5GWiyVjBy@z0MRNSysmyUDU25!1c@edqnPqM)?c2o< zYEmLM9ws|3o7&2Y^uMh^LA#mdZaugrze9RUmfW$q)EnwB;I#^2)sba*)LRlW;lvpu zZ|>~~<84+PbDJ_SlP$z^^;HfAHc*uRjKX^0db; z&4Ylu0i;g;N`-MU?Y^g&a=!L}DZsT7-odlT~O< zb%!(0=lCG94aZQLfKzEE5iieiQniwslKb?= ze(LCF8}j|5wS>-)kZHb+>%qbFo;|>f_B6f{7a&Bg%^^GV617D)=JNb?M85o zl0#aiOm3ep^=hUJY+TRmI5$ixcj5L{%0BJGoOa9RZt_Lm+^RuotQk|o9)yD)?GzpP zm&W=@-c8Ix#YhA59n09BWl4OK6UDOLG_MY0G%ya;{0zQnEo6tnRT3>5xETJ7mf}Ii zxVL4H7(e` zf5C43QPMr9b1hAUf9~obJ86cVu{|4nJrJ^vxlny?C(nflL?*vs=%0mC&UqqQsyHcn zz)g!z`a*_~FBDkTA;x8n7QfgUb4qGWU8ys#U-~dhHJlZa1b&Ro2EyMr- literal 0 HcmV?d00001 diff --git a/scripts/fixtures/grain/ciphertext.bin b/scripts/fixtures/grain/ciphertext.bin new file mode 100644 index 0000000..1834f61 --- /dev/null +++ b/scripts/fixtures/grain/ciphertext.bin @@ -0,0 +1 @@ +T[Qiwq CqW$ؙ[Ԭ1p?l,N΃O0=-{t[kb#Zʅp \ No newline at end of file diff --git a/scripts/fixtures/grain/derived_key.hex b/scripts/fixtures/grain/derived_key.hex new file mode 100644 index 0000000..5a180f4 --- /dev/null +++ b/scripts/fixtures/grain/derived_key.hex @@ -0,0 +1 @@ +83d3c18c39dfb29651f75a4d38e80b62811012b996826cd779acec3b4ad122a8 \ No newline at end of file diff --git a/scripts/fixtures/grain/filename.txt b/scripts/fixtures/grain/filename.txt new file mode 100644 index 0000000..c395bde --- /dev/null +++ b/scripts/fixtures/grain/filename.txt @@ -0,0 +1 @@ +secret.txt \ No newline at end of file diff --git a/scripts/fixtures/grain/passphrase.txt b/scripts/fixtures/grain/passphrase.txt new file mode 100644 index 0000000..eccaeaa --- /dev/null +++ b/scripts/fixtures/grain/passphrase.txt @@ -0,0 +1 @@ +grain-fixture-passphrase-2026 \ No newline at end of file diff --git a/scripts/fixtures/grain/plaintext.bin b/scripts/fixtures/grain/plaintext.bin new file mode 100644 index 0000000..8240a2b --- /dev/null +++ b/scripts/fixtures/grain/plaintext.bin @@ -0,0 +1 @@ +Grain hides this text inside a PNG. diff --git a/scripts/fixtures/grain/stego.png b/scripts/fixtures/grain/stego.png new file mode 100644 index 0000000000000000000000000000000000000000..0ece2c896e3bfa1ba88e6cfbc0958a0396fc666e GIT binary patch literal 1148 zcmV-?1cUpDP)Z&V0KoB|P%I_%5@|_B(XQxLlH_R=(MVVt{h>s46)DyA{CPr`nGESxl+^Co^+%bE zL{dzpRaRF!LM18DAB>vsJ|98Q5U>;*uxzsh+w$g+)#OH*jz2BV;b=zhV!GlW-s)VU zjEcx{FXxK%HfdTitK32UyLf7? zGU*H|U~EPS_O-QqKlBi(sDpyAL2l;i@TSoegAq2&b#NkRi#G`cArwB8a7!;9VS?jc z93_WXd-@njDkVkwRW!Ob@-em*le6z|ZXdyVV?$ECEK$abc~I)kKvw`}<`KB~$Ffl- zLsliHxKByb^eYHc150sFt0%JhF^BtKQ(`m3lXYVt%M8l~5gsgJo7jiUh_#gEMWMc* zh^9^!RtpR9in`49Gj-&sRg@ca(z@gm!pxl?@l|NEt7QUmLu;x%9C#sJ$`_Y{nDsJ} zm1a`*`20wIN**=k=Xupzjh^LS*sp#@K>7!ywOX7U98aCdimvtZ5awmr5q#5?_@Nb? znH@$$*iL%o$&7Ez!BI;=h(j4kl3OSWp3taz!-p}Sndmx}g}c8bRB1W; zBRiNa^;^j#j?y{ls3T4y9KV3z-B3i7MkPnYcc|R_l-B$nMm1?*t80L-^E6_j?2#+n zsA^uxbHi|S+;<_`pNd~`E_*tPIA&VGb)VaG98jZI(Sxw6vlHp$K??jvQTuO){t;`JEv>oX&B0+M|6tu8-8d0(Hn%^eMI7jmna+txxGb~_XU$MerSsj zs%1&)g1IQSE#ZFQDh5;$nCb7u#Z^Xl>~Rh$&r;lel_ul640*LNC7~C$QcX5>8IeBK zl1u(#R5Ew|s|vv4V+4X^S|~pUL{XNOg!)=GnghRLZF3p#wSTfBOGQq@Ys&dVt5^^1 zO=j5U&tmxlXJT}H$#q&sRdh7%XA;p-XY=*ALWI~w`Q*hEb6ru*tCk0hF>1%&qmMwT zHqwg|IMHEE-Q@Ya_VvIZB?xgvBth@?6Zcgbg{$+aPd~@o+G>mj?=fHW3?b=ez_>q}_;pQdxE2DE|ZQUg%;*+#monthly 0.5 + + https://tatangharyadi.github.io/grain.html + monthly + 0.5 + diff --git a/specs/F07_GRAIN.md b/specs/F07_GRAIN.md new file mode 100644 index 0000000..201adf2 --- /dev/null +++ b/specs/F07_GRAIN.md @@ -0,0 +1,212 @@ +# F07: Grain, a file hidden inside a picture + +**Status:** implemented, pending human verification. `grain.html`, +`css/grain.css` and `js/grain.js` are written and pass every check this repo +can run without a browser: `scripts/check_grain.py` cross-checks the header +format, KDF and ciphertext against a fixture pair (`scripts/fixtures/grain/`) +generated with Node's `webcrypto` and a from-scratch stdlib PNG encoder — see +that script's own header for exactly what it proves and what it only pins. +What still needs a human in a browser is a full encode → decode round trip +against a real `` and `crypto.subtle`, the clean-console/network check +that `connect-src 'none'` actually holds, and every item in "Acceptance +criteria" below marked Human. + +--- + +## Overview + +Grain hides an arbitrary file inside a PNG. The file is encrypted first — +AES-256-GCM, keyed by a passphrase stretched through PBKDF2 — and only the +ciphertext, never the plaintext, is written into the carrier image's pixels +via least-significant-bit (LSB) steganography. Decoding reverses both steps: +extract the bits, then decrypt with the same passphrase. + +This is not a puzzle or an obfuscation. The encryption is real and +authenticated: a wrong passphrase or a tampered stego image both fail the +same way, an AES-GCM authentication error, and Grain does not try to tell +those two cases apart (see "Container format" below). What LSB steganography +buys is concealment from casual inspection, not from statistical +steganalysis, and the page says so in its own prose rather than overselling +it. + +Everything runs against browser-native APIs — `crypto.subtle` and +`` — with nothing vendored and nothing fetched. `grain.html` sets +`connect-src 'none'`, tighter than any other page on the site, because unlike +`js/ask.js`, `js/mocap.js` or `js/iris.js` there is no same-origin model or +runtime this page needs to load either: it has no fetch of any kind to make. + +--- + +## Key files + +| File | Role | +| --- | --- | +| `grain.html` | The page: carrier/payload pickers, passphrase fields, encode/decode actions, result panels, the `connect-src 'none'; img-src 'self' blob: data:` CSP meta tag | +| `css/grain.css` | Layout only — drop zones, file-input visually-hidden-but-focusable pattern, result previews. Introduces no literal colour; every value is a `var(--...)` token from `css/style.css`. | +| `js/grain.js` | Header build/parse, LSB embed/extract, PBKDF2 key derivation, AES-256-GCM encrypt/decrypt, canvas pixel I/O, DOM wiring | +| `scripts/check_grain.py` | CI regression pin against `scripts/fixtures/grain/` — see its own header comment for what it proves and what it only pins | +| `scripts/fixtures/grain/` | Committed fixtures: a carrier PNG, a stego PNG with a known file hidden in it, the plaintext/ciphertext/derived key that produced it, and the passphrase and filename used | + +--- + +## Architecture + +``` +Encode + carrier image (any format the browser decodes) ──┐ + payload file (any type) ───────────────────────┐ │ + passphrase ──┐ │ │ + │ │ │ + ▼ ▼ ▼ + PBKDF2-HMAC-SHA-256 nameLen(2) ‖ filename ‖ payload bytes + (600,000 iters, random │ + 16-byte salt) │ + │ │ + ▼ │ + non-extractable AES-256-GCM key │ + │ │ + └──── AES-GCM encrypt(random 12-byte iv, AAD = header) ◄──┘ + │ + ciphertext (+ 16-byte tag) + │ + header = magic ‖ version ‖ flags ‖ kdfIter ‖ salt ‖ iv ‖ ctLen (42 bytes, + built once ctLen is known — ctLen = plaintext.length + 16, since + AES-GCM ciphertext length depends only on plaintext length) + │ + header ‖ ciphertext (the container) + │ + createImageBitmap(carrier) → canvas → ImageData + │ + LSB-embed container into R/G/B low bits, row-major, MSB-first; + force every pixel's alpha to 255 first + │ + canvas.toBlob('image/png') → stego PNG + +Decode + stego PNG ──► createImageBitmap → canvas → ImageData + │ + LSB-extract 42-byte header → parse → validate magic/version and + 100,000 ≤ kdfIter ≤ 2,000,000 (refused before deriveKey is ever called) + │ + LSB-extract ctLen more bytes → ciphertext + │ + passphrase + header's salt/kdfIter → PBKDF2 → AES-256-GCM key + │ + AES-GCM decrypt(iv, AAD = header, ciphertext) → plaintext, or a single + generic failure ("wrong passphrase, or the image was altered") — AES-GCM + cannot and does not distinguish the two + │ + nameLen ‖ filename ‖ payload → a Blob URL offered as a download +``` + +--- + +## Container format + +A fixed 42-byte header, then the ciphertext: + +| Field | Bytes | Notes | +| --- | --- | --- | +| magic | 4 | `"GRN1"` | +| version | 1 | `1` | +| flags | 1 | reserved, `0` | +| kdfIter | 4 | uint32, big-endian | +| salt | 16 | random, per encode | +| iv | 12 | random, per encode | +| ctLen | 4 | uint32, big-endian — ciphertext length including the 16-byte GCM tag | + +All 42 header bytes are passed as AES-GCM's `additionalData`, so every field +— including the salt and iv a decoder is about to trust — is bound into the +ciphertext's own authentication tag. A single flipped bit anywhere in the +header fails decryption instead of silently decrypting against the wrong +salt or iv. + +The encrypted plaintext itself is `nameLen(2, big-endian) ‖ filename (UTF-8) +‖ payload bytes` — the hidden file's name is inside the encryption boundary, +never cleartext anywhere in the container. + +`ctLen` is computed as `plaintext.length + 16` before encrypting, rather than +by encrypting once to measure it and again with the final header as AAD: AES- +GCM ciphertext length depends only on plaintext length, never on +`additionalData`, so one `crypto.subtle.encrypt()` call is enough. + +--- + +## Scope cuts + +- **PNG carrier output only.** Any lossy re-encode (JPEG, WebP lossy, a + resave through an editor that recompresses) destroys the low bits Grain + just wrote. The page states this rather than trying to detect or survive + it. +- **No multi-file or archive support.** One payload file per stego image. +- **No steganalysis resistance.** LSB embedding raises the low-bit entropy of + every touched channel above what a camera or renderer would produce; a + statistical detector looking for exactly that is not defeated by anything + here. Grain's own prose says this plainly rather than implying otherwise. +- **No key recovery.** The passphrase is the only key; there is no + server-side or local escrow of any kind, by design — there is no server at + all. +- **A 40-megapixel cap** on any loaded image (carrier or stego), to bound + `getImageData` memory use, and a `[100,000, 2,000,000]` KDF-iteration range + enforced on decode *before* `deriveKey` runs, so a maliciously crafted + stego file cannot name an absurd iteration count to hang the tab. + +--- + +## The DOM contract + +| Id | Element | Contract | +| --- | --- | --- | +| `grain--carrier-drop` | `div` | Drop zone wrapping the carrier file input | +| `grain--carrier` | `input type="file" accept="image/*"` | Visually hidden; styled via its `