Files
client/www/llm-steganography.html

1866 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>LLM STEGANOGRAPHY</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
<!-- ================================================================
PAGE-SPECIFIC STYLES
================================================================
Reuses project CSS variables (var(--primary-color), etc.) so the
demo matches the rest of the app and respects dark mode.
================================================================ -->
<style>
#stegoWrap {
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 8px 16px 48px;
box-sizing: border-box;
text-align: left;
}
#stegoWrap header {
text-align: center;
margin-bottom: 24px;
}
#stegoWrap header h1 {
margin: 0 0 6px;
font-size: 28px;
color: var(--primary-color);
}
#stegoWrap .subtitle {
color: var(--muted-color);
margin: 0;
font-size: 14px;
}
.stegoCard {
background: var(--secondary-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 18px 20px;
margin-bottom: 18px;
color: var(--primary-color);
}
.stegoCard h2 {
margin: 0 0 12px;
font-size: 18px;
color: var(--primary-color);
}
.stegoHint {
color: var(--muted-color);
font-size: 13px;
margin: 0 0 12px;
}
.stegoFormRow {
display: flex;
flex-direction: column;
margin-bottom: 12px;
}
.stegoFormRow:last-child { margin-bottom: 0; }
.stegoFormRow label {
font-size: 13px;
color: var(--muted-color);
margin-bottom: 4px;
}
.stegoFormRow input[type="text"],
.stegoFormRow input[type="number"],
.stegoFormRow textarea {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--primary-color);
padding: 9px 11px;
font-size: 14px;
font-family: var(--font-family);
width: 100%;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.stegoFormRow textarea {
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
resize: vertical;
line-height: 1.45;
}
.stegoFormRow input:focus,
.stegoFormRow textarea:focus {
border-color: var(--accent-color);
}
.stegoStatus {
color: var(--muted-color);
font-size: 13px;
margin: 8px 0;
min-height: 1.2em;
}
.stegoInfo {
font-size: 13px;
color: var(--muted-color);
margin: 8px 0 0;
white-space: pre-wrap;
}
.stegoInfo.match-success {
color: var(--accent-color);
font-weight: 600;
}
.stegoInfo.match-fail {
color: var(--accent-color);
font-weight: 600;
}
.stegoProgressBarContainer {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
height: 14px;
overflow: hidden;
margin-top: 8px;
}
.stegoProgressBar {
height: 100%;
width: 0%;
background: var(--accent-color);
transition: width 0.2s ease;
}
.stegoProgressFiles {
margin-top: 8px;
font-size: 12px;
color: var(--muted-color);
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
max-height: 120px;
overflow-y: auto;
}
.stegoProgressFiles div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stegoProgressSection {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 12px;
margin: 10px 0;
}
.stegoProgressLine {
font-size: 13px;
color: var(--primary-color);
margin-bottom: 6px;
}
.stegoProgressLine:last-child { margin-bottom: 0; }
.stegoCharHighlight {
display: inline-block;
background: var(--accent-color);
color: var(--secondary-color);
font-weight: 700;
padding: 1px 8px;
border-radius: 4px;
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
min-width: 1.2em;
text-align: center;
}
.stegoTokenDisplay {
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
background: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 1px 6px;
border-radius: 4px;
color: var(--accent-color);
}
.stegoRecoveredBox {
display: inline-block;
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
background: var(--secondary-color);
border: 1px solid var(--accent-color);
color: var(--accent-color);
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
min-height: 1.4em;
}
.stegoCollapsibleToggle {
background: none;
border: none;
color: var(--primary-color);
font-size: 18px;
font-weight: 600;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
text-align: left;
font-family: var(--font-family);
}
.stegoCollapsibleToggle .chevron {
transition: transform 0.2s;
font-size: 14px;
}
.stegoCollapsibleToggle[aria-expanded="true"] .chevron {
transform: rotate(90deg);
}
.stegoCollapsibleContent {
margin-top: 12px;
}
.stegoCollapsibleContent p,
.stegoCollapsibleContent li {
color: var(--primary-color);
}
.stegoCollapsibleContent ol {
padding-left: 20px;
}
.stegoCollapsibleContent code {
background: var(--background-color);
padding: 1px 5px;
border-radius: 4px;
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
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);
font-size: 12px;
margin-top: 24px;
}
.stegoFooter a {
color: var(--accent-color);
text-decoration: none;
}
.stegoFooter a:hover { text-decoration: underline; }
.stegoHidden { display: none !important; }
</style>
<div id="stegoWrap">
<header>
<h1>LLM Steganography Demo</h1>
<p class="subtitle">Hide secret messages in innocent-looking AI-generated text</p>
</header>
<!-- Model loading section -->
<section id="stegoLoadingSection" class="stegoCard">
<h2>Model Loading</h2>
<p id="stegoLoadingStatus" class="stegoStatus">Initializing...</p>
<div class="stegoProgressBarContainer">
<div id="stegoProgressBar" class="stegoProgressBar"></div>
</div>
<div id="stegoProgressFiles" class="stegoProgressFiles"></div>
</section>
<!-- Main app (hidden until model is loaded) -->
<main id="stegoApp" class="stegoHidden">
<!-- Shared Parameters -->
<section class="stegoCard">
<h2>Shared Parameters</h2>
<p class="stegoHint">Both encoder and decoder must use the same context and key.</p>
<div class="stegoFormRow">
<label for="stegoContextInput">Context / Prompt</label>
<input type="text" id="stegoContextInput" value="I like to eat" autocomplete="off">
</div>
<div class="stegoFormRow">
<label for="stegoKeyInput">Shared Key (integer)</label>
<input type="number" id="stegoKeyInput" value="42" step="1" autocomplete="off">
</div>
</section>
<!-- Encode -->
<section class="stegoCard">
<h2>Encode</h2>
<div class="stegoFormRow">
<label for="stegoSecretInput">Secret Message</label>
<input type="text" id="stegoSecretInput" value="HELLO" autocomplete="off">
</div>
<button id="stegoEncodeBtn" class="btn">Encode</button>
<p id="stegoEncodeStatus" class="stegoStatus"></p>
<div id="stegoEncodeProgress" class="stegoProgressSection stegoHidden">
<div class="stegoProgressLine">
<span>Encoding character <span id="stegoEncodeCurrentChar" class="stegoCharHighlight"></span> (<span id="stegoEncodeCharIndex">0</span>/<span id="stegoEncodeTotalChars">0</span>)</span>
</div>
<div class="stegoProgressLine">
<span id="stegoEncodeBitInfo">bit 0 of 0</span>
</div>
<div class="stegoProgressBarContainer">
<div id="stegoEncodeProgressBar" class="stegoProgressBar"></div>
</div>
</div>
<div class="stegoFormRow">
<label for="stegoCoverOutput">Cover Text</label>
<textarea id="stegoCoverOutput" readonly rows="4"></textarea>
</div>
<p id="stegoEncodeInfo" class="stegoInfo"></p>
</section>
<!-- Decode -->
<section class="stegoCard">
<h2>Decode</h2>
<button id="stegoDecodeBtn" class="btn">Decode</button>
<p id="stegoDecodeStatus" class="stegoStatus"></p>
<div id="stegoDecodeProgress" class="stegoProgressSection stegoHidden">
<div class="stegoProgressLine">
<span>Processing token: <span id="stegoDecodeCurrentToken" class="stegoTokenDisplay"></span> → bit <span id="stegoDecodeRecoveredBit">-</span></span>
</div>
<div class="stegoProgressLine">
<span id="stegoDecodeBitInfo">bit 0 of 0</span>
</div>
<div class="stegoProgressBarContainer">
<div id="stegoDecodeProgressBar" class="stegoProgressBar"></div>
</div>
<div class="stegoProgressLine">
<span>Recovered so far: <span id="stegoDecodeRecoveredChars" class="stegoRecoveredBox"></span></span>
</div>
</div>
<div class="stegoFormRow">
<label for="stegoRecoveredOutput">Recovered Message</label>
<textarea id="stegoRecoveredOutput" readonly rows="2"></textarea>
</div>
<p id="stegoMatchIndicator" class="stegoInfo"></p>
</section>
<!-- ================================================================
FAQ SECTION
================================================================
Each question is its own collapsible card using the same
stegoCollapsibleToggle / stegoCollapsibleContent pattern.
================================================================ -->
<section class="stegoCard">
<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>
<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>
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>
</main>
<footer class="stegoFooter">
<p>Runs entirely in your browser via <a href="https://github.com/xenova/transformers.js" target="_blank" rel="noopener">Transformers.js</a> · GPT-2 model loaded locally</p>
</footer>
</div>
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar, injectHeaderLoginButton,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[llm-steganography.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// This page is a standalone demo that does not require Nostr auth.
// Default to 'none' so visitors are never prompted to log in; the
// sidenav logout button doubles as a "Sign in" entry point if a user
// wants to access relay/blossom/AI sections.
return 'none';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated) {
await injectHeaderLoginButton();
return;
}
if (authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[template.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[llm-steganography.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[llm-steganography.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
<!-- ================================================================
LLM STEGANOGRAPHY DEMO
================================================================
Loads GPT-2 via Transformers.js and wires up the encode/decode UI.
Kept as a separate module so it doesn't interfere with the page's
NDK/auth initialization above.
================================================================ -->
<script type="module">
import {
AutoTokenizer,
AutoModelForCausalLM,
env,
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
import { encode, decode } from "./js/stego.mjs";
const MODEL_ID = "Xenova/gpt2";
env.allowLocalModels = false;
// ---------------------------------------------------------------------------
// DOM references
// ---------------------------------------------------------------------------
const loadingSection = document.getElementById("stegoLoadingSection");
const loadingStatus = document.getElementById("stegoLoadingStatus");
const progressBar = document.getElementById("stegoProgressBar");
const progressFiles = document.getElementById("stegoProgressFiles");
const app = document.getElementById("stegoApp");
const contextInput = document.getElementById("stegoContextInput");
const keyInput = document.getElementById("stegoKeyInput");
const secretInput = document.getElementById("stegoSecretInput");
const encodeBtn = document.getElementById("stegoEncodeBtn");
const encodeStatus = document.getElementById("stegoEncodeStatus");
const coverOutput = document.getElementById("stegoCoverOutput");
const encodeInfo = document.getElementById("stegoEncodeInfo");
const encodeProgress = document.getElementById("stegoEncodeProgress");
const encodeCurrentChar = document.getElementById("stegoEncodeCurrentChar");
const encodeCharIndex = document.getElementById("stegoEncodeCharIndex");
const encodeTotalChars = document.getElementById("stegoEncodeTotalChars");
const encodeBitInfo = document.getElementById("stegoEncodeBitInfo");
const encodeProgressBar = document.getElementById("stegoEncodeProgressBar");
const decodeBtn = document.getElementById("stegoDecodeBtn");
const decodeStatus = document.getElementById("stegoDecodeStatus");
const recoveredOutput = document.getElementById("stegoRecoveredOutput");
const matchIndicator = document.getElementById("stegoMatchIndicator");
const decodeProgress = document.getElementById("stegoDecodeProgress");
const decodeCurrentToken = document.getElementById("stegoDecodeCurrentToken");
const decodeRecoveredBit = document.getElementById("stegoDecodeRecoveredBit");
const decodeBitInfo = document.getElementById("stegoDecodeBitInfo");
const decodeProgressBar = document.getElementById("stegoDecodeProgressBar");
const decodeRecoveredChars = document.getElementById("stegoDecodeRecoveredChars");
// Legacy single-toggle references removed — FAQ section now uses
// multiple .stegoFaqToggle buttons handled generically below.
// State
let model = null;
let tokenizer = null;
let lastCoverText = null;
let lastNumBits = null;
let lastSecret = null;
// ---------------------------------------------------------------------------
// Collapsible FAQ toggles
// ---------------------------------------------------------------------------
// 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);
}
});
});
// ---------------------------------------------------------------------------
// Model loading with progress
// ---------------------------------------------------------------------------
async function loadModel() {
loadingStatus.textContent = "Loading model...";
const progressCallback = (info) => {
if (info.status === "initiate") {
addProgressFile(info.file, "queued");
} else if (info.status === "download") {
addProgressFile(info.file, "downloading", info.progress);
} else if (info.status === "progress") {
updateProgressFile(info.file, info.progress);
updateOverallProgress();
} else if (info.status === "done") {
updateProgressFile(info.file, 100, true);
updateOverallProgress();
}
};
try {
const device = await maybeGetWebGPU();
if (device) {
env.backends.onnx.wasm.proxy = false;
loadingStatus.textContent = "WebGPU detected — loading model...";
} else {
loadingStatus.textContent = "Loading model (WASM backend)...";
}
tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID, {
progress_callback: progressCallback,
});
model = await AutoModelForCausalLM.from_pretrained(MODEL_ID, {
progress_callback: progressCallback,
dtype: "int8",
});
loadingStatus.textContent = "Model ready!";
progressBar.style.width = "100%";
setTimeout(() => {
loadingSection.classList.add("stegoHidden");
app.classList.remove("stegoHidden");
}, 400);
} catch (err) {
loadingStatus.textContent = "Failed to load model.";
console.error(err);
addProgressFile(`Error: ${err.message || err}`, "error");
}
}
async function maybeGetWebGPU() {
try {
if (typeof navigator !== "undefined" && navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
return adapter || null;
}
} catch (_) {}
return null;
}
const fileProgress = new Map();
function addProgressFile(file, state, progress = 0) {
if (!file) return;
if (!fileProgress.has(file)) {
const div = document.createElement("div");
div.dataset.file = file;
progressFiles.appendChild(div);
fileProgress.set(file, div);
}
updateProgressFile(file, progress, state === "done");
}
function updateProgressFile(file, progress, done = false) {
const div = fileProgress.get(file);
if (!div) return;
const pct = done ? 100 : Math.round(progress || 0);
const name = file.split("/").pop();
div.textContent = `${name}${pct}%`;
}
function updateOverallProgress() {
if (fileProgress.size === 0) return;
let total = 0;
for (const div of fileProgress.values()) {
const m = div.textContent.match(/(\d+)%/);
if (m) total += parseInt(m[1], 10);
}
const avg = total / fileProgress.size;
progressBar.style.width = `${Math.min(100, avg)}%`;
}
// ---------------------------------------------------------------------------
// Encode button
// ---------------------------------------------------------------------------
encodeBtn.addEventListener("click", async () => {
if (!model || !tokenizer) return;
const secret = secretInput.value;
const context = contextInput.value;
const key = parseInt(keyInput.value, 10);
if (!secret) {
encodeStatus.textContent = "Please enter a secret message.";
return;
}
if (Number.isNaN(key)) {
encodeStatus.textContent = "Shared key must be an integer.";
return;
}
setBusy(true);
encodeStatus.textContent = "Encoding...";
coverOutput.value = "";
encodeInfo.textContent = "";
matchIndicator.textContent = "";
recoveredOutput.value = "";
encodeProgress.classList.remove("stegoHidden");
encodeProgressBar.style.width = "0%";
encodeTotalChars.textContent = String(secret.length);
encodeCharIndex.textContent = "0";
encodeCurrentChar.textContent = "";
encodeBitInfo.textContent = `bit 0 of ${secret.length * 8}`;
try {
await new Promise(r => setTimeout(r, 0));
const result = await encode(
model, tokenizer, secret, context, key, 3,
(textSoFar, bitNum, totalBits, currentChar, charIndex, totalChars) => {
coverOutput.value = textSoFar;
encodeCurrentChar.textContent = currentChar;
encodeCharIndex.textContent = String(charIndex);
encodeTotalChars.textContent = String(totalChars);
encodeBitInfo.textContent = `bit ${bitNum} of ${totalBits}`;
const pct = totalBits > 0 ? (bitNum / totalBits) * 100 : 0;
encodeProgressBar.style.width = `${pct}%`;
encodeStatus.textContent =
`Encoding character "${currentChar}" (${charIndex}/${totalChars}) — bit ${bitNum} of ${totalBits}`;
}
);
lastCoverText = result.coverText;
lastNumBits = result.numBits;
lastSecret = secret;
coverOutput.value = result.coverText;
encodeProgressBar.style.width = "100%";
const genTokens = result.tokenCount - tokenizeContextLength(context);
const dataTokens = genTokens - 3;
encodeInfo.textContent =
`Encoded "${secret}" (${result.numBits} bits) in ${dataTokens} tokens ` +
`+ 3 padding tokens. Total tokens: ${result.tokenCount}.`;
encodeStatus.textContent = "Encoding complete.";
encodeProgress.classList.add("stegoHidden");
} catch (err) {
console.error(err);
encodeStatus.textContent = `Error: ${err.message || err}`;
encodeProgress.classList.add("stegoHidden");
} finally {
setBusy(false);
}
});
// ---------------------------------------------------------------------------
// Decode button
// ---------------------------------------------------------------------------
decodeBtn.addEventListener("click", async () => {
if (!model || !tokenizer) return;
if (!lastCoverText || lastNumBits == null) {
decodeStatus.textContent = "Encode a message first.";
return;
}
const context = contextInput.value;
const key = parseInt(keyInput.value, 10);
if (Number.isNaN(key)) {
decodeStatus.textContent = "Shared key must be an integer.";
return;
}
setBusy(true);
decodeStatus.textContent = "Decoding...";
recoveredOutput.value = "";
matchIndicator.textContent = "";
matchIndicator.className = "stegoInfo";
decodeProgress.classList.remove("stegoHidden");
decodeProgressBar.style.width = "0%";
decodeCurrentToken.textContent = "";
decodeRecoveredBit.textContent = "-";
decodeBitInfo.textContent = `bit 0 of ${lastNumBits}`;
decodeRecoveredChars.textContent = "";
try {
await new Promise(r => setTimeout(r, 0));
const recovered = await decode(
model, tokenizer, lastCoverText, context, key, lastNumBits, 3,
(bitsRecovered, totalBits, recoveredChars, currentToken, recoveredBit) => {
decodeCurrentToken.textContent = `"${currentToken}"`;
decodeRecoveredBit.textContent = String(recoveredBit);
decodeBitInfo.textContent = `Decoding... bit ${bitsRecovered} of ${totalBits}`;
decodeRecoveredChars.textContent = recoveredChars;
const pct = totalBits > 0 ? (bitsRecovered / totalBits) * 100 : 0;
decodeProgressBar.style.width = `${pct}%`;
decodeStatus.textContent =
`Processing token: "${currentToken}" → bit ${recoveredBit}`;
}
);
recoveredOutput.value = recovered;
decodeProgressBar.style.width = "100%";
const dataTokens = lastNumBits;
if (recovered === lastSecret) {
matchIndicator.textContent =
`Decoded ${lastNumBits} bits from ${dataTokens} tokens\n` +
`Recovered message: ${recovered}\n` +
`✓ Match!`;
matchIndicator.className = "stegoInfo match-success";
} else {
matchIndicator.textContent =
`Decoded ${lastNumBits} bits from ${dataTokens} tokens\n` +
`Recovered message: ${recovered}\n` +
`✗ Mismatch. Expected "${lastSecret}", got "${recovered}".`;
matchIndicator.className = "stegoInfo match-fail";
}
decodeStatus.textContent = "Decoding complete.";
decodeProgress.classList.add("stegoHidden");
} catch (err) {
console.error(err);
decodeStatus.textContent = `Error: ${err.message || err}`;
decodeProgress.classList.add("stegoHidden");
} finally {
setBusy(false);
}
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setBusy(busy) {
encodeBtn.disabled = busy;
decodeBtn.disabled = busy;
}
function tokenizeContextLength(context) {
if (!tokenizer) return 0;
try {
const inputs = tokenizer(context, { add_special_tokens: false });
let ids = inputs?.input_ids ?? inputs;
if (ids && typeof ids.data !== "undefined") return ids.data.length;
if (Array.isArray(ids)) {
return Array.isArray(ids[0]) ? ids[0].length : ids.length;
}
return 0;
} catch (_) {
return 0;
}
}
// Kick off model loading on page load
loadModel();
</script>
</body>
</html>