Compare commits

...

7 Commits

3 changed files with 405 additions and 47 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
www/models/
hamburger_morphing/
client/
ndk/

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.58",
"VERSION_NUMBER": "0.7.58",
"BUILD_DATE": "2026-06-29T00:18:18.870Z"
"VERSION": "v0.7.65",
"VERSION_NUMBER": "0.7.65",
"BUILD_DATE": "2026-06-29T00:46:57.220Z"
}

View File

@@ -91,7 +91,7 @@
}
#stegoWrap .subtitle {
color: var(--muted-color);
color: var(--primary-color);
margin: 0;
font-size: 14px;
}
@@ -112,7 +112,7 @@
}
.stegoHint {
color: var(--muted-color);
color: var(--primary-color);
font-size: 13px;
margin: 0 0 12px;
}
@@ -126,7 +126,7 @@
.stegoFormRow label {
font-size: 13px;
color: var(--muted-color);
color: var(--primary-color);
margin-bottom: 4px;
}
@@ -158,7 +158,7 @@
}
.stegoStatus {
color: var(--muted-color);
color: var(--primary-color);
font-size: 13px;
margin: 8px 0;
min-height: 1.2em;
@@ -166,7 +166,7 @@
.stegoInfo {
font-size: 13px;
color: var(--muted-color);
color: var(--primary-color);
margin: 8px 0 0;
white-space: pre-wrap;
}
@@ -200,7 +200,7 @@
.stegoProgressFiles {
margin-top: 8px;
font-size: 12px;
color: var(--muted-color);
color: var(--primary-color);
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
max-height: 120px;
overflow-y: auto;
@@ -302,9 +302,88 @@
font-size: 13px;
}
/* ---------- FAQ items ---------- */
.stegoFaqItem {
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 10px;
overflow: hidden;
}
.stegoFaqItem:last-child { margin-bottom: 0; }
.stegoFaqToggle {
padding: 12px 14px;
font-size: 15px;
font-weight: 600;
background: var(--background-color);
width: 100%;
}
.stegoFaqToggle:hover {
background: var(--secondary-color);
}
.stegoFaqItem .stegoCollapsibleContent {
padding: 0 14px 14px 14px;
margin-top: 0;
}
.stegoCollapsibleContent ul {
padding-left: 20px;
margin: 8px 0;
}
.stegoCollapsibleContent li {
margin-bottom: 4px;
}
/* ---------- Example walkthrough ---------- */
.stegoExampleH3 {
font-size: 15px;
font-weight: 700;
color: var(--accent-color);
margin: 16px 0 8px 0;
}
.stegoExampleH4 {
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
margin: 14px 0 6px 0;
}
.stegoExampleOutput {
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
background: var(--background-color);
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
color: var(--accent-color);
font-size: 13px;
margin: 8px 0;
}
.stegoTableWrap {
overflow-x: auto;
margin: 10px 0;
}
.stegoTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
}
.stegoTable th,
.stegoTable td {
border: 1px solid var(--border-color);
padding: 4px 8px;
text-align: left;
color: var(--primary-color);
}
.stegoTable th {
background: var(--background-color);
font-weight: 600;
}
.stegoTable tr:nth-child(even) td {
background: var(--background-color);
}
.stegoFooter {
text-align: center;
color: var(--muted-color);
color: var(--primary-color);
font-size: 12px;
margin-top: 24px;
}
@@ -403,37 +482,302 @@
<p id="stegoMatchIndicator" class="stegoInfo"></p>
</section>
<!-- How It Works -->
<!-- ================================================================
FAQ SECTION
================================================================
Each question is its own collapsible card using the same
stegoCollapsibleToggle / stegoCollapsibleContent pattern.
================================================================ -->
<section class="stegoCard">
<h2>
<button id="stegoHowToggle" class="stegoCollapsibleToggle" aria-expanded="false">
How It Works <span class="chevron"></span>
<h2 style="text-align: center; margin-bottom: 16px;">FAQ</h2>
<!-- Q1: What is the point of this? -->
<div class="stegoFaqItem">
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
What is the point of this? <span class="chevron"></span>
</button>
</h2>
<div id="stegoHowContent" class="stegoCollapsibleContent stegoHidden">
<p>
This demo uses a <strong>half-splitting entropy coding</strong> scheme
built on top of GPT-2's next-token probability distribution.
</p>
<ol>
<li>The secret message is converted to a bit string (UTF-8 → bits).</li>
<li>For each secret bit, GPT-2 produces a probability distribution over
the entire vocabulary for the next token.</li>
<li>Tokens are sorted by probability (descending) and split into two
halves at the 50% cumulative probability mark.</li>
<li>Bit <code>0</code> → the next token is sampled from the
<em>first</em> (higher-probability) half; bit <code>1</code>
from the <em>second</em> half.</li>
<li>A shared-key PRNG (mulberry32) selects the exact token within the
chosen half, so the decoder can reproduce the same random draws.</li>
<li>The decoder re-runs GPT-2 on the same context, observes which half
each cover token fell into, and recovers the bits → original message.</li>
</ol>
<p>
Because both sides share the same model, context, and PRNG seed, the
decoder can perfectly reconstruct the hidden bits. The resulting cover
text reads like normal GPT-2 output, hiding the secret in plain sight.
</p>
<div class="stegoCollapsibleContent stegoHidden">
<p>
This demo lets you <strong>hide a secret message inside ordinary-looking
AI-generated text</strong>. The output reads like a normal sentence a
language model might produce, but it secretly encodes your message bit
by bit.
</p>
<p>
The practical point is <em>covert communication</em>: two people who
share a key can exchange messages that, to anyone watching, look like
innocuous GPT-2 text. There's no obvious ciphertext, no encrypted file,
and no metadata screaming "this is encrypted." The secret is hidden in
plain sight.
</p>
<p>
It's also a neat demonstration of how much information is packed into
every token a language model emits — each token can carry a full secret
bit while still looking natural.
</p>
</div>
</div>
<!-- Q2: How does it work? -->
<div class="stegoFaqItem">
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
How does it work? <span class="chevron"></span>
</button>
<div class="stegoCollapsibleContent stegoHidden">
<p>
This demo uses a <strong>half-splitting entropy coding</strong> scheme
built on top of GPT-2's next-token probability distribution.
</p>
<ol>
<li>The secret message is converted to a bit string (UTF-8 → bits).</li>
<li>For each secret bit, GPT-2 produces a probability distribution over
the entire vocabulary for the next token.</li>
<li>Tokens are sorted by probability (descending) and split into two
halves at the 50% cumulative probability mark.</li>
<li>Bit <code>0</code> → the next token is sampled from the
<em>first</em> (higher-probability) half; bit <code>1</code>
from the <em>second</em> half.</li>
<li>A shared-key PRNG (mulberry32) selects the exact token within the
chosen half, so the decoder can reproduce the same random draws.</li>
<li>The decoder re-runs GPT-2 on the same context, observes which half
each cover token fell into, and recovers the bits → original message.</li>
</ol>
<p>
Because both sides share the same model, context, and PRNG seed, the
decoder can perfectly reconstruct the hidden bits. The resulting cover
text reads like normal GPT-2 output, hiding the secret in plain sight.
</p>
</div>
</div>
<!-- Q3: Can you give me a simple example? -->
<div class="stegoFaqItem">
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
Can you give me a simple example of how it works? <span class="chevron"></span>
</button>
<div class="stegoCollapsibleContent stegoHidden">
<p>
To make this tangible, let's use a <strong>toy model with a small
vocabulary of 11 words</strong> so you can see every number. The real
scheme works identically, just with vocabularies of ~50,000 tokens and
floating-point probabilities.
</p>
<h3 class="stegoExampleH3">Setup</h3>
<p>
<strong>The model:</strong> A tiny "LLM" with a fixed vocabulary of 11
words: <code>{apple, date, banana, cherry, pie, juice, tart, sauce, for, with, and}</code>.
At each step, the model assigns a probability to all 11 words based on
the context. The probabilities change as the context grows, and they
always sum to 1.0.
</p>
<p>
<strong>Shared context/prompt:</strong> <code>"I like to eat"</code>
both Alice and Bob have this. It is not secret, just shared.
</p>
<p>
<strong>Shared secret key:</strong> Used to seed a PRNG, producing the
random stream: <code>0.15, 0.62, 0.40, ...</code>
</p>
<p>
<strong>Secret message:</strong> The bits <code>0 1 1</code> (3 bits).
</p>
<h3 class="stegoExampleH3">Encoding (Alice's Side)</h3>
<h4 class="stegoExampleH4">Step 1: Alice runs the model</h4>
<p>
Alice feeds <code>"I like to eat"</code> into the model. After "I like
to eat", fruits are the most natural continuation, so they get the
highest probabilities:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.20</td><td>[0.00, 0.20)</td></tr>
<tr><td>date</td><td>0.15</td><td>[0.20, 0.35)</td></tr>
<tr><td>banana</td><td>0.10</td><td>[0.35, 0.45)</td></tr>
<tr><td>cherry</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
<tr><td>juice</td><td>0.12</td><td>[0.70, 0.82)</td></tr>
<tr><td>tart</td><td>0.08</td><td>[0.82, 0.90)</td></tr>
<tr><td>sauce</td><td>0.05</td><td>[0.90, 0.95)</td></tr>
<tr><td>for</td><td>0.03</td><td>[0.95, 0.98)</td></tr>
<tr><td>with</td><td>0.015</td><td>[0.98, 0.995)</td></tr>
<tr><td>and</td><td>0.005</td><td>[0.995, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>The probabilities sum to 1.0, and the intervals partition [0, 1).</p>
<h4 class="stegoExampleH4">Step 2: Alice encodes her first secret bit</h4>
<p>
Alice's first secret bit is <code>0</code> → "look in the <strong>first
half</strong> [0.00, 0.50)." The words in that half are apple, date,
banana, and cherry. Alice uses her next random number, <code>0.15</code>,
to pick within [0.00, 0.50). The value <code>0.15</code> falls in
[0.00, 0.20), which is <strong>apple</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "apple" → text so far: "I like to eat apple"</p>
<h4 class="stegoExampleH4">Step 3: Alice runs the model again</h4>
<p>
Context is now <code>"I like to eat apple"</code>. After "apple", food
preparations like pie and tart become more likely:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.15</td><td>[0.00, 0.15)</td></tr>
<tr><td>date</td><td>0.10</td><td>[0.15, 0.25)</td></tr>
<tr><td>banana</td><td>0.08</td><td>[0.25, 0.33)</td></tr>
<tr><td>cherry</td><td>0.07</td><td>[0.33, 0.40)</td></tr>
<tr><td>for</td><td>0.06</td><td>[0.40, 0.46)</td></tr>
<tr><td>with</td><td>0.04</td><td>[0.46, 0.50)</td></tr>
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
<tr><td>tart</td><td>0.16</td><td>[0.70, 0.86)</td></tr>
<tr><td>juice</td><td>0.08</td><td>[0.86, 0.94)</td></tr>
<tr><td>sauce</td><td>0.04</td><td>[0.94, 0.98)</td></tr>
<tr><td>and</td><td>0.02</td><td>[0.98, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>
Alice's next bit is <code>1</code> → "look in the <strong>second half</strong>
[0.50, 1.00)." Random number <code>0.62</code> → scaled:
<code>0.50 + 0.62 × 0.50 = 0.81</code>, which falls in [0.70, 0.86) →
<strong>tart</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "tart" → text so far: "I like to eat apple tart"</p>
<h4 class="stegoExampleH4">Step 4: Alice runs the model again</h4>
<p>
Context: <code>"I like to eat apple tart"</code>. After "apple tart",
connectors like "with" and "and" become most likely:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.10</td><td>[0.00, 0.10)</td></tr>
<tr><td>date</td><td>0.08</td><td>[0.10, 0.18)</td></tr>
<tr><td>banana</td><td>0.07</td><td>[0.18, 0.25)</td></tr>
<tr><td>cherry</td><td>0.06</td><td>[0.25, 0.31)</td></tr>
<tr><td>pie</td><td>0.08</td><td>[0.31, 0.39)</td></tr>
<tr><td>juice</td><td>0.06</td><td>[0.39, 0.45)</td></tr>
<tr><td>for</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
<tr><td>tart</td><td>0.06</td><td>[0.50, 0.56)</td></tr>
<tr><td>sauce</td><td>0.04</td><td>[0.56, 0.60)</td></tr>
<tr><td>with</td><td>0.18</td><td>[0.60, 0.78)</td></tr>
<tr><td>and</td><td>0.22</td><td>[0.78, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>
Alice's next bit is <code>1</code> → second half [0.50, 1.00). Random
number <code>0.40</code> → scaled: <code>0.50 + 0.40 × 0.50 = 0.70</code>,
falls in [0.60, 0.78) → <strong>with</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "with" → text so far: "I like to eat apple tart with"</p>
<h4 class="stegoExampleH4">Alice is done</h4>
<p>
Alice sends Bob the text: <strong>"I like to eat apple tart with"</strong>
(plus whatever padding she wants to make it look like a complete
sentence). To any observer, this looks like someone generated a
sentence about food. Nothing suspicious.
</p>
<h3 class="stegoExampleH3">Decoding (Bob's Side)</h3>
<p>
Bob has the received text, the same model, the same shared context
<code>"I like to eat"</code>, and the same shared key (so the same
random stream: <code>0.15, 0.62, 0.40, ...</code>).
</p>
<h4 class="stegoExampleH4">Step D1: Bob runs the model</h4>
<p>
Bob feeds <code>"I like to eat"</code> into the model and gets the
<strong>same</strong> distribution Alice got. He sees Alice chose
<strong>apple</strong>, interval [0.00, 0.20). "Which half?" First half
[0.00, 0.50) → <strong>bit <code>0</code></strong>
</p>
<h4 class="stegoExampleH4">Step D2: Bob runs the model again</h4>
<p>
Context: <code>"I like to eat apple"</code>. Same distribution as Alice's
Step 3. Bob sees Alice chose <strong>tart</strong>, interval [0.70, 0.86).
"Which half?" Second half [0.50, 1.00) → <strong>bit <code>1</code></strong>
</p>
<h4 class="stegoExampleH4">Step D3: Bob runs the model again</h4>
<p>
Context: <code>"I like to eat apple tart"</code>. Same distribution as
Alice's Step 4. Bob sees Alice chose <strong>with</strong>, interval
[0.60, 0.78). "Which half?" Second half [0.50, 1.00) →
<strong>bit <code>1</code></strong>
</p>
<h3 class="stegoExampleH3">Result</h3>
<p>
Bob has recovered: <code>0 1 1</code> — exactly the message Alice sent.
</p>
<h3 class="stegoExampleH3">Key Points</h3>
<ol>
<li><strong>The LLM never saw the secret message.</strong> The secret bits controlled which word was picked from the distribution. The LLM just produced distributions.</li>
<li><strong>The output looks natural.</strong> "I like to eat apple tart with" is a perfectly normal sentence. A censor seeing this has no reason to be suspicious.</li>
<li><strong>Both sides run the same model with the same context.</strong> This is why they get the same distributions. If Bob had a different model or different context, decoding would fail.</li>
<li><strong>The shared key provides the random stream.</strong> Without it, a censor could run the same model and try to decode. With the key, the censor can't reproduce the random choices.</li>
<li><strong>Each token encodes roughly 1 bit</strong> in this toy example (2-way split). With arithmetic coding over a 50,000-word vocabulary, you can encode ~24 bits per token.</li>
<li><strong>The "half" splitting is the simplified version.</strong> The real scheme (from the <a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor paper</a>) uses full arithmetic coding, which is more efficient. But the principle is the same: secret bits steer selection within the model's probability distribution.</li>
</ol>
</div>
</div>
<!-- Q4: What is your inspiration for this? -->
<div class="stegoFaqItem">
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
What is your inspiration for this? <span class="chevron"></span>
</button>
<div class="stegoCollapsibleContent stegoHidden">
<p>
The direct inspiration for this project is a
<a href="https://primal.net/e/nevent1qqstxmgsd0egtq57gj8mghckthsv79dhegh7xh97hwtwve6l56g4s9sqtjd7k" target="_blank" rel="noopener">post by waxwing on Nostr</a>
that walks through the core idea of using a language model's
next-token probability distribution as a steganographic carrier
channel. The post explains how secret bits can steer token selection
while preserving the model's output distribution, making the
resulting cover text statistically indistinguishable from normal
model output.
</p>
<p>
That post references the academic paper that formalized this
approach:
</p>
<ul>
<li>
<strong><a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor: A Simple and Practical Steganographic Protocol for LLMs</a></strong>
— this paper introduces the entropy-coding mechanism that makes
the output distribution-preserving. Instead of a naive 2-way
split, the full scheme uses arithmetic coding over the model's
probability distribution, achieving roughly 24 bits per token
while remaining information-theoretically undetectable to a censor
who only observes the channel.
</li>
</ul>
<p>
The half-splitting scheme implemented in this demo is a simplified
version of Meteor's approach: it guarantees exactly one bit per
token, is symmetric (encoder and decoder run the same logic), and
produces text that's indistinguishable from normal model output to
a casual reader. The full arithmetic-coding version from the paper
is more efficient but the core principle is identical — secret bits
steer selection within the model's probability distribution.
</p>
</div>
</div>
</section>
@@ -1198,8 +1542,12 @@
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
import { encode, decode } from "./js/stego.mjs";
// Use locally-hosted model files (./models/Xenova/gpt2/) so the page
// works over Tor/onion origins where HuggingFace CORS is blocked.
const MODEL_ID = "Xenova/gpt2";
env.allowLocalModels = false;
env.allowLocalModels = true;
env.localModelPath = "./models/";
env.allowRemoteModels = false;
// ---------------------------------------------------------------------------
// DOM references
@@ -1237,8 +1585,8 @@
const decodeProgressBar = document.getElementById("stegoDecodeProgressBar");
const decodeRecoveredChars = document.getElementById("stegoDecodeRecoveredChars");
const howToggle = document.getElementById("stegoHowToggle");
const howContent = document.getElementById("stegoHowContent");
// Legacy single-toggle references removed — FAQ section now uses
// multiple .stegoFaqToggle buttons handled generically below.
// State
let model = null;
@@ -1248,12 +1596,19 @@
let lastSecret = null;
// ---------------------------------------------------------------------------
// Collapsible "How It Works"
// Collapsible FAQ toggles
// ---------------------------------------------------------------------------
howToggle.addEventListener("click", () => {
const expanded = howToggle.getAttribute("aria-expanded") === "true";
howToggle.setAttribute("aria-expanded", String(!expanded));
howContent.classList.toggle("stegoHidden", expanded);
// Each .stegoFaqToggle button toggles the visibility of the next
// .stegoCollapsibleContent sibling inside its .stegoFaqItem container.
document.querySelectorAll(".stegoFaqToggle").forEach((toggle) => {
toggle.addEventListener("click", () => {
const expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!expanded));
const content = toggle.nextElementSibling;
if (content) {
content.classList.toggle("stegoHidden", expanded);
}
});
});
// ---------------------------------------------------------------------------