@@ -91,7 +91,7 @@
}
# stegoWrap . subtitle {
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
margin : 0 ;
font-size : 14 px ;
}
@@ -112,7 +112,7 @@
}
. stegoHint {
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
font-size : 13 px ;
margin : 0 0 12 px ;
}
@@ -126,7 +126,7 @@
. stegoFormRow label {
font-size : 13 px ;
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
margin-bottom : 4 px ;
}
@@ -158,7 +158,7 @@
}
. stegoStatus {
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
font-size : 13 px ;
margin : 8 px 0 ;
min-height : 1.2 em ;
@@ -166,7 +166,7 @@
. stegoInfo {
font-size : 13 px ;
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
margin : 8 px 0 0 ;
white-space : pre-wrap ;
}
@@ -200,7 +200,7 @@
. stegoProgressFiles {
margin-top : 8 px ;
font-size : 12 px ;
color : var ( - - muted - color ) ;
color : var ( - - primary - color ) ;
font-family : var ( - - font - mono , "SF Mono" , "Fira Code" , "Consolas" , monospace ) ;
max-height : 120 px ;
overflow-y : auto ;
@@ -302,9 +302,88 @@
font-size : 13 px ;
}
/* ---------- FAQ items ---------- */
. stegoFaqItem {
border : 1 px solid var ( - - border - color ) ;
border-radius : 6 px ;
margin-bottom : 10 px ;
overflow : hidden ;
}
. stegoFaqItem : last-child { margin-bottom : 0 ; }
. stegoFaqToggle {
padding : 12 px 14 px ;
font-size : 15 px ;
font-weight : 600 ;
background : var ( - - background - color ) ;
width : 100 % ;
}
. stegoFaqToggle : hover {
background : var ( - - secondary - color ) ;
}
. stegoFaqItem . stegoCollapsibleContent {
padding : 0 14 px 14 px 14 px ;
margin-top : 0 ;
}
. stegoCollapsibleContent ul {
padding-left : 20 px ;
margin : 8 px 0 ;
}
. stegoCollapsibleContent li {
margin-bottom : 4 px ;
}
/* ---------- Example walkthrough ---------- */
. stegoExampleH3 {
font-size : 15 px ;
font-weight : 700 ;
color : var ( - - accent - color ) ;
margin : 16 px 0 8 px 0 ;
}
. stegoExampleH4 {
font-size : 14 px ;
font-weight : 600 ;
color : var ( - - primary - color ) ;
margin : 14 px 0 6 px 0 ;
}
. stegoExampleOutput {
font-family : var ( - - font - mono , "SF Mono" , "Fira Code" , "Consolas" , monospace ) ;
background : var ( - - background - color ) ;
padding : 8 px 12 px ;
border-radius : 6 px ;
border : 1 px solid var ( - - border - color ) ;
color : var ( - - accent - color ) ;
font-size : 13 px ;
margin : 8 px 0 ;
}
. stegoTableWrap {
overflow-x : auto ;
margin : 10 px 0 ;
}
. stegoTable {
width : 100 % ;
border-collapse : collapse ;
font-size : 12 px ;
font-family : var ( - - font - mono , "SF Mono" , "Fira Code" , "Consolas" , monospace ) ;
}
. stegoTable th ,
. stegoTable td {
border : 1 px solid var ( - - border - color ) ;
padding : 4 px 8 px ;
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 : 12 px ;
margin-top : 24 px ;
}
@@ -403,37 +482,299 @@
< 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 h alf
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 natur al.
< / 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 ~2– 4 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 >
This project is inspired by research into < strong > linguistic
steganography< / strong > — the art of hiding information within natural
language. Traditional steganography hides data in images, audio, or
file metadata, but text-based steganography is harder because text is
compact and every character is visible.
< / p >
< p >
The breakthrough idea is to use a < strong > language model's own
probability distribution< / strong > as the carrier channel. Instead of
tweaking pixels or LSBs, you steer which token the model emits next,
encoding one secret bit per token. Key inspirations include:
< / p >
< ul >
< li > < strong > Entropy coding approaches< / strong > (e.g. Cachin's
information-theoretic steganography) that treat the cover as a
probability distribution and map secret bits onto it.< / li >
< li > < strong > "Dissociated Press" / Markov-chain steganography< / strong >
— early experiments that used statistical text generation to hide
messages, limited by the poor quality of pre-LLM language models.< / li >
< li > < strong > Modern LLM steganography research< / strong > — papers that
use GPT-style models to achieve near-perfect cover text quality
while encoding multiple bits per token via arithmetic or
half-splitting codes.< / li >
< / ul >
< p >
The half-splitting scheme used here is one of the simplest robust
approaches: 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.
< / p >
< / div >
< / div >
< / section >
@@ -1237,8 +1578,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 +1589,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 ) ;
}
} ) ;
} ) ;
// ---------------------------------------------------------------------------