Rotation Range-Coding (RRC) Steganography — 大規模言語モデルによって生成された自然言語テキストに秘密メッセージを埋め込む、効率的かつ証明可能に安全な言語的ステガノグラフィ手法。
論文: Efficient Provably Secure Linguistic Steganography via Range Coding (ACL 2026)
| ステップ | 説明 |
|---|---|
| 埋め込み (アルゴリズム 3) | 秘密メッセージを10進数値に変換し、LMの確率分布とPRNG生成オフセットに導かれながら、縮小する区間内で反復的に回転させる。各回転ステップが1つのトークンを生成する。 |
| 抽出 (アルゴリズム 4) | ステゴテキスト上でLMを再実行して区間の境界を復元し、元の10進数値へ逆回転してから二値化する。 |
回転メカニズムは、バニラなレンジコーディング・ステガノグラフィの2つの問題を解決する:
.
├── rrc_core.py # Algorithms 3 and 4, shared by every entry point
├── RRC_embed.py # Embedding CLI
├── RRC_extract.py # Extraction CLI (reports recovery accuracy)
├── test_roundtrip.py # Round-trip test across several message lengths
├── fastest_embed.py # Embedding-speed benchmark
├── 0.Prompts.tsv # 1000 prompts (first ~10 words of C4 sequences)
└── requirements.txt
エンコーダとデコーダは、ループの別々のコピーを保持するのではなく、rrc_core.py を共有する。両者はビット単位で一致しなければならず、独立に保守された2つのコピー間の相違は、ラウンドトリップの失敗としてのみ表面化し、目視では決して現れない。
pip install -r requirements.txt
python test_roundtrip.py # GPT-2, 32/128/512 bits
python test_roundtrip.py --model meta-llama/Llama-2-7b-hf
python RRC_embed.py \
--language_model meta-llama/Llama-2-7b-hf \
--bit_length 128 --key 42 --top_k -1
python RRC_extract.py \
--language_model meta-llama/Llama-2-7b-hf \
--bit_length 128 --key 42 \
--input_file 1.RC_decimal_Llama-2-7b-hf_bit128.tsv
エンコーダは、埋め込んだ秘密とそれを運んだトークンIDを記録するため、抽出は復元率を報告する。2つの受信側モデルが評価される:
ids — 受信側にはトークン列が与えられる。これは符号化方式を分離する。100%未満は数値的または論理的な障害である。text — 受信側にはデトークン化された文字列が与えられ、テキストチャネルが必要とするように再度トークン化される。再トークン化は送信側の分割を常に再現するとは限らないため、これは下限値である。2つの測定値の差は、符号化方式ではなくトークン化の曖昧さを表す。Llama-2-7b の128ビットでは: ids は100%、text は約91%。重要: 送信側と受信側は、言語モデル、キー、ビット長、top-k設定を共有しなければならず、また同じハードウェアとライブラリのバージョンで実行する必要がある。数値要件を参照。
RRC_embed.py と RRC_extract.py で共有:
| 引数 | デフォルト | 説明 |
|---|---|---|
--language_model | meta-llama/Llama-3.1-8B | HuggingFaceモデルID |
--bit_length | 128 | ビット単位の秘密メッセージ長 |
--top_k | -1 | Top-k切り捨て; -1 = 全語彙 |
--key | 42 | 対称鍵 K (PRNGシード) |
--precision_margin | 40 | 2**bit_length が必要とする桁数を超える10進桁数 |
--max_ctx_len | 3900 | KVキャッシュウィンドウ |
--device | auto | cuda または cpu |
RRC_embed.py はさらに --prompts、--output、--limit、--seed、--max_tokens、--resume、--no_verify、およびプロンプトセットをプロセス間でシャーディングするための --part / --part_max を受け取る (--part_max 3 と --part 0,1,2 で、GPUごとに1つ)。
RRC_extract.py はさらに --input_file、--mode {ids,text,both}、--limit、--verbose を受け取る。
RRCは幅 2**l の区間を幅 ~1 まで狭めるため、復元値は 2**l 倍の相対誤差に敏感である: 128ビットでは、チェーン内のどこかで1e-16の相対摂動が、0.5の許容範囲に対して結果を ~1e22 変位させる。抽出は緩やかに劣化しない — メッセージを返すか、無関係なビットを返すかのどちらかである。Llama-2-7b での測定:
| 設定 | 確率の相対摂動 | 抽出 |
|---|---|---|
| 同じバッチ1デコードの繰り返し | 0 (ビット単位で同一) | 成功 |
| バッチ2 / 8 / 16 での同じ行 | ~1e-2 | 失敗 |
| プロンプトの左パディング | ~1e-2 | 失敗 |
| インクリメンタルデコードの代わりに1回のteacher-forcedパス | ~4e-3 | 失敗 |
2つの帰結が実装を形作る。バッチ処理は利用できないため、シーケンスは1つずつ処理される。そして受信側は、すべてのトークンを事前に保持していても、ステゴテキストを単一のフォワードパスでスコアリングするのではなく、送信側のインクリメンタルデコードをステップごとに再生する。どちらのショートカットも約9倍高速だが、どちらも確率を保持しない。
ロジット以降はすべてfloat64であり、CPUとGPUでビット単位で同一の境界を生成するため、フォワードパスがステゴテキストをハードウェアに結びつける唯一のステップである。
精度はメッセージ長から ceil(bit_length * log10(2)) + margin として導出され、他の何かが実行される前に設定される。これはメッセージに応じてスケールする: 2**256 は78桁、2**8192 は2466桁を必要とする。
その閾値を下回る精度は、きれいに失敗しない。秘密値の下位桁が丸められ、区間は数トークン以内に崩壊し、終了テストが早期に発火するため、実行は完了し、算術的に到達不可能な数値を報告することがある。メッセージ長に関係なく精度を50桁に固定すると:
| bits | 50桁での利用率 | 完了したサンプル | 導出精度の場合 |
|---|---|---|---|
| 256 | 157.05% | 8/20 | 100.17% |
| 512 | 299.67% | 9/20 | 102.48% |
| 1024 | 660.91% | 7/10 | 102.58% |
| 2048 | 1046.40% | 1/5 | 98.18% |
| 4096 | 2483.92% | 3/3, エラーなし | 100.87% |
| 8192 | 4766.49% | 1/2 | 99.07% |
--precision_margin は導出された最小値に対する余裕を調整する; 最小値自体は強制される。
アルゴリズム 3 は (L + R) / 2 - d_s が (-0.5, 0.5] に入ったときに停止する。このテストは、区間が1より狭いときに発火することがあり、その場合ウィンドウ [mid - 0.5, mid + 0.5) はそれを運ぶ区間より広くなる。逆回転は区間幅を法とする等長変換であるため、切断部をまたぐウィンドウは受信側を d_s から丸々1区間離れた位置に残す。
したがって送信側は、停止を確定する前に受信側の逆回転を再生し、再生が秘密を返さない場合は別のトークンを出力する。再生は純粋なDecimal演算 (フォワードパスの約20 msに対して約0.05 ms) であるため、ガードは測定可能なコストを要しない。--no_verify はこれを無効にする。
Llama-2-7b での測定:
| bits | ガードなしで復元 | ガードあり |
|---|---|---|
| 128 | 291/300 (97.0%) | 300/300 |
| 512 | 64/65 (98.5%) | 40/40 |
| 1024 | 20/20 | 20/20 |
| 2048 | 10/10 | 10/10 |
128ビットでは、ガードは追加生成トークンの0.095%のコストであった。1024ビットと2048ビットのサンプルはレートを制限するには少なすぎる — 3%のレートでは0/20がそうでない場合よりも起こりやすい結果である — ため、これらは長いメッセージが異なる挙動をする証拠にはならない。ガードありでは、370/370が復元された。
ガードなしでは、送信側は違いを判別できない: 成功を報告し、無関係なビットにデコードされるステゴテキストを出力する。レートは抽選によって変動する; ある99プロンプトの実行では皆無であり、これは3%のレートで約5%の確率で生じ、再実行すると正確に再現される。同じサンプルは45桁から200桁までのあらゆる実用精度で失敗するため、この挙動は算術ではなく終了テストに起因する。
ガードは、それを無効にして埋め込み、実際の抽出器を実行することでグラウンドトゥルースと照合された: それがフラグを立てるサンプルは、受信側が失敗するサンプルと正確に一致し、偽陽性も見逃しもなかった。
LMのフォワードパスは、Llama-2-7b での生成ステップの95–97%を占め、7B fp16モデルのバッチ1デコードはメモリ帯域幅のルーフライン上にある (測定値約18.7 ms/トークンに対して約13.5 ms/トークン)。したがって埋め込み速度はモデルのデコード速度に追随し、符号化自体はそれにほとんど影響しない。
より小さなモデルではフォワードパスがもはや支配的ではなくなり、ステップごとのCPU作業が可視化される。GPT-2 の128ビット、共有マシン上でセルあたり100メッセージのエンドツーエンド測定:
| torch CPUスレッド | bits/s |
|---|---|
| 1 | 1320, 1205 |
| 16 | 1232, 756 |
| 20 (torchデフォルト) | 352 |
スループットは、符号化パス内の何かではなく、CPUスレッドの競合によって左右され、マルチスレッドの数値は実行ごとに再現しない。したがってすべてのエントリポイントは torch.set_num_threads(1) を呼び出す: ここでの各CPU演算は単一の語彙サイズのパスであり、スレッドプールから利益を得るには小さすぎ、プールが他の作業と競合すると大きく損をするほど大きい。これは予測可能性をもたらすのであって、アイドルマシンでの高速化ではない。
残りのステップごとの選択は、単独では安価であり、1スレッドでのエンドツーエンドスループットを測定可能なほど変えない; コストがかからないために採用されている:
min(config.vocab_size, len(tokenizer.vocab)) にスライスされ、トークナイザと整合される。これにより埋め込み行列の再割り当てを避け、モデルオブジェクトをそのままに保つ。これは同じモデルが実行間で共有される場合に重要である。128ビットメッセージ、全語彙、temperature 1.0、RTX 6000 Ada、1 CPUスレッド、それ以外はアイドル状態のGPU。モデルあたり100メッセージ (Llama-2-7b は60)、すべてがラウンドトリップされチェックされた:
| model | embed | extract | tokens/msg | utilisation |
|---|---|---|---|---|
| GPT-2 | 1386 bits/s | 1385 bits/s | 22.3 | 99.4% |
| OPT-1.3b | 754 bits/s | 753 bits/s | 30.4 | 98.7% |
| Llama-2-7b | 183 bits/s | 183 bits/s | 45.4 | 98.9% |
抽出は埋め込みと同じ速度で実行される。どちらもトークンごとに1回のフォワードパスを実行し、逆回転チェーンはそれらに比べて無視できるからである。
スループットはGPU上の他のジョブに影響されない — 完全に負荷のかかったカードでも同じ数値が数%以内で返る — なぜならワークロードはデバイスを飽和させるものではなく、レイテンシに縛られた単一トークンのフォワードパスの連鎖だからである。実行間の約6%の変動は、抽選されたプロンプトに由来する: bits/s は bit_length / time であり、時間はメッセージが必要とするトークン数に比例するため、低エントロピーのプロンプトのサンプルはより遅く読める。
Pythonの Decimal の % は切り捨て除算を使用し、負の剰余を返すことがある (Decimal('-19672') % Decimal('65536') は 45864 ではなく -19672 を返す)。逆回転は真の剰余を必要とするため、decimal_mod は床除算のセマンティクスを使用する:
def decimal_mod(a, m):
return a - m * (a / m).to_integral_value(rounding=ROUND_FLOOR)