友人に秘密のメッセージを送信したいが、使用したいチャネルが侵害され、監視されていると想像してください。暗号化を使用することもできますが、会話を監視している人々の疑いを招くため、別の方法を使用する必要があります。
現在、ステガノグラフィーとは、秘密のメッセージを別の非秘密ファイル (猫の写真など) に隠す方法であり、そのファイルを送信しても検出されません。ステガノグラフィーは、画像にテキストを隠すことに限定されず、一般的には「秘密情報を別の非秘密メッセージまたは物理的オブジェクトに隠す」ことを意味します。たとえば、列転置を使用して、オーディオ、ビデオ、またはその他のテキストにメッセージを隠すことができます。
ステガノグラフィーは、他の多くのケースでも非常に役立ちます。たとえば、機密文書の漏洩を防ぐための透かしの優れた代替手段となります。
画像内の情報を隠す方法は、ファイルの末尾にテキストを追加するだけのものからメタデータに隠すものまで、数多くあります。この記事では、バイナリ レベルまで下げて画像自体の境界内にメッセージを隠す、より高度なステガノグラフィの手法について説明します。
ステガノグラフィの例では、ブラウザで実行できる強力なプログラミング言語である JavaScript を使用することにしました。
ユーザーが画像をアップロードしてその中に隠されたメッセージを読んだり、自分で画像内のメッセージをエンコードしたりできるシンプルなインターフェースを考案しました。
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Steganography</title> <style> body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; } textarea { width: 300px; height: 100px; } button { margin: 10px; } #outputImage { margin-top: 20px; max-width: 100%; } </style> </head> <body> <h1>Steganography Example</h1> <input type="file" id="upload" accept="image/*"><br> <canvas id="canvas" style="display:none;"></canvas><br> <textarea id="text" placeholder="Enter text to encode"></textarea><br> <button id="encode">Encode Text</button> <button id="decode">Decode Text</button> <p id="decodedText"></p> <img id="outputImage" alt="Output Image"> <script src="./script.js"></script> </body> </html>
これを使用するには、ユーザーは操作したい画像を選択し、そこからテキストをデコードするか、エンコードして後で画像をダウンロードするだけです。
JavaScript で画像を操作するには、 Canvas APIを使用します。Canvas API は、画像、アニメーション、さらにはゲームのグラフィックやビデオを操作および描画するためのさまざまな機能を提供します。
Canvas API は主に 2D グラフィックスに使用されます。3D のハードウェア アクセラレーション グラフィックスを使用する場合は、 WebGL APIを使用できます (ちなみに、WebGL API も <canvas> 要素を使用します)。
const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const image = new Image();
ファイル システムから画像ファイルを読み取り、それをキャンバス コンテキストに追加するには、 FileReader APIを使用します。これにより、カスタム ライブラリを必要とせずに、ユーザーのコンピューターに保存されているファイルの内容を簡単に読み取ることができます。
function handleFileUpload(event) { const reader = new FileReader(); reader.onload = function (e) { image.src = e.target.result; image.onload = function () { canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); }; }; reader.readAsDataURL(event.target.files[0]); }
ファイルを読み取り、そのファイル内の画像を事前に定義した 2D キャンバス コンテキストに描画します。その後、その画像にテキストをエンコードするか、画像からテキストを読み取ろうとします。
画像はピクセルで構成され、各ピクセルには色に関する情報が含まれています。たとえば、RGBA モデルを使用して画像がエンコードされている場合、各ピクセルには、赤、緑、青、アルファ (不透明度) を表す量に関する 4 バイトの情報が含まれます。
画像内のテキストをエンコードするには、これらのチャネルの 1 つ (たとえば、アルファ チャネル) を使用できます。この情報はバイナリ システム (01001100 など) で表されているため、最後のビットを必要なビットに切り替えることができます。これは最下位ビット (LSB) と呼ばれ、これを変更しても画像自体に最小限の変化しか生じず、人間と区別がつかなくなります。
さて、「Hello」のようなテキストがあり、それを画像にエンコードしたいとします。これを行うアルゴリズムは次のようになります。
「Hello」テキストをバイナリに変換します。
画像データのバイトを反復処理し、それらのバイトの LSB をバイナリ テキストのビットに置き換えます (各ピクセルには色ごとに 4 バイトのデータが含まれます。私の例では、画像の不透明度チャネルを変更したいので、4 バイトごとに反復処理します)。
デコード時にいつ停止するかがわかるように、メッセージの最後にnull バイトを追加します。
変更された画像バイトを画像自体に適用します。
まず、エンコードしたいテキストをユーザーから取得し、それに対していくつかの基本的な検証を実行する必要があります。
const text = document.getElementById("text").value; if (!text) { alert("Please enter some text to encode."); return; }
次に、テキストをバイナリに変換し、このテキストをエンコードする画像のキャンバスを作成する必要があります。
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; for (let i = 0; i < text.length; i++) { let binaryChar = text.charCodeAt(i).toString(2).padStart(8, "0"); binaryText += binaryChar; }
これを行うには、各文字を反復処理し、 charCodeAt
関数を使用して Unicode インデックスを取得します。次に、この Unicode はバイナリに変換され、他の文字と同じ長さになるようにパディングされます。
たとえば、文字「H」は Unicode では 72 として表されます。次に、この数値を 2 進数 (1001000) に変換し、先頭に 0 を追加して (01001000)、すべての文字が同じ長さ (8 ビット) になるようにします。
次に、メッセージを復号化するときに実際のテキストと画像のランダムなピクセル データを区別できるように、メッセージの最後に null バイトを追加する必要があります。
binaryText += "00000000";
次に、オーバーフローしないようにメッセージをエンコードするのに十分なピクセルが画像にあることを確認するために、基本的な検証を行う必要があります。
if (binaryText.length > data.length / 4) { alert("Text is too long to encode in this image."); return; }
そして、最も興味深い部分、つまりメッセージのエンコードが続きます。先ほど定義したデータ配列には、画像内の各ピクセルの RGBA 値の形式でピクセル情報が含まれています。したがって、画像が RGBA エンコードされている場合、画像内の各ピクセルはデータ配列の 4 つの値で表され、各値はそのピクセルの赤、緑、青の量を表します。
for (let i = 0; i < binaryText.length; i++) { data[i * 4] = (data[i * 4] & 0b11111110) | parseInt(binaryText[i]); } ctx.putImageData(imgData, 0, 0); const outputImage = document.getElementById("outputImage"); outputImage.src = canvas.toDataURL();
上記のコードでは、バイナリエンコードされたテキストを反復処理します。data data[i * 4]
変更する必要があるバイトを見つけます。特定のチャネルのバイトのみを変更したいので、変数i
に4
を掛けてアクセスします。
data[i * 4] & 0b11111110
演算は最下位ビットを0
に設定します。たとえば、 data[i * 4]
がバイナリで10101101
の場合、 10101101 & 11111110
演算の結果は10101100
になります。これにより、LSB が0
に設定されてから、それ以上の操作が行われることが保証されます。
parseInt(binaryText[i])
バイナリエンコードされた文字列の現在のビットで、 1
または0
いずれかです。次に、ビット OR ( |
) 演算を使用して、このビットを LSB に設定できます。たとえば、ビット OR の左側が10101100
で、 binaryText[i]
が1
場合、 10101100 | 00000001
10101101
になります。現在のビットが0
の場合、OR は10101100
になります。これが、最初に LSB を削除する必要があった理由です。
メッセージがエンコードされると、それを現在のキャンバスに配置し、 canvas.toDataURL
メソッドを使用して HTML でレンダリングできます。
イメージをデコードするプロセスは、実際にはエンコードするプロセスよりもはるかに簡単です。アルファ チャネルのみをエンコードしたことがわかっているので、4 バイトごとに繰り返し処理し、最後のビットを読み取り、それを最終的な文字列に連結し、このデータをバイナリから Unicode 文字列に変換するだけです。
まず、変数を初期化する必要がありますimgData
にはすでに画像情報が設定されているため (ファイル システムからファイルを読み取るたびにctx.drawImage
を呼び出します)、それをデータ変数に抽出するだけです。
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; let decodedText = "";
次に、画像の 4 バイトごとに反復処理し、最後のビットを読み取って、それをbinaryText
変数に連結する必要があります。
for (let i = 0; i < data.length; i += 4) { binaryText += (data[i] & 1).toString(); }
data[i]
エンコードされたバイトであり、LSB を抽出するにはビット単位の AND ( &
) 演算子を使用できます。この演算子は 2 つの値を受け取り、対応するビットの各ペアに対して AND 演算を実行します。 data[i]
を1
と比較することで、基本的にピクセル情報から最下位ビットを分離し、LSB が1
の場合、このような演算の結果は1
になります。LSB が0
の場合、結果も0
になります。
すべての LSB を読み取ってbinaryText
変数に格納したら、それをバイナリからプレーン テキストに変換する必要があります。各文字は 8 ビットで構成されていることがわかっているので (各文字を同じ長さにするためにpadStart(8, "0")
を使用したことを覚えていますか?)、 binaryText
の 8 文字ごとに反復処理を行うことができます。
次に、 .slice()
操作を使用して、反復処理に基づいてbinaryText
から現在のバイトを抽出します。バイナリ文字列はparseInt(byte, 2)
を使用して数値に変換できます。次に、結果が0
(null バイト) かどうかを確認し、変換を停止して結果を照会します。それ以外の場合は、どの文字が Unicode 番号に対応するかを見つけて、結果の文字列に追加できます。
for (let i = 0; i < binaryText.length; i += 8) { let byte = binaryText.slice(i, i + 8); if (byte.length < 8) break; // Stop if the byte is incomplete let charCode = parseInt(byte, 2); if (charCode === 0) break; // Stop if we hit a null character decodedText += String.fromCharCode(charCode); }
デコードされたテキストは、ユーザーに安全に表示できます。
document.getElementById("decodedText").textContent = decodedText;
この記事で使用した完全なコードはGitHub リポジトリに残してありますので、自由に試してみてください。改善できる点はたくさんあります :)
ステガノグラフィーは非常に強力な技術であり、文書の検証、漏洩防止、画像 AI 検証、音楽ファイルの DRM 管理など、さまざまなユースケースに適用できます。この技術は、ビデオ、ゲーム、さらには生のテキストにも適用できるため、大きな可能性を秘めていると思います。
NFT とブロックチェーンの時代において、それがどのように使用例を見つけ、この技術がどのように進化していくのかを見るのはさらに興味深いことです。