秘密とカバーを分けて考える
SnapSealは画像、ファイル、テキストの元のバイト列とメタデータを暗号化し、できたエンベロープをカバー画像の画素に埋め込む。受け取る側に必要なのは保護されたファイルとパスワードで、元のカバー画像やサーバーは要らない。
内容の機密性を守るのが暗号技術で、画像を運び手にするのが画像ステガノグラフィだ。どちらも以前からある技術であり、ここではその役割の境界が重要になる。デモの絵柄とテスト用パスワードは、意図して公開されている。
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"下位2ビットを使う
RGBの各画素には、8ビットのチャンネルが3つある。この例ではチャンネルを左から右へ読み、アルファを飛ばし、上位6ビットを残して下位2ビットを置き換える。4チャンネルで1バイトを格納できる。
スライダーを動かすと、強調されたビットの変化を確認できる。この下位ビットへの書き込み単体なら、各チャンネルの値の変化は最大3だ。ただし、追加のJPEG復元用データやその他の装飾によって、カバーの変化はそれより大きくなり得る。
R₀10101011170 → 171 Δ +1G₀10110101181 → 181 Δ 0B₀0101110094 → 92 Δ -2R₁11001011203 → 203 Δ 0def embed_byte(channels: bytes, value: int) -> bytes:
if len(channels) != 4 or not 0 <= value <= 255:
raise ValueError("Expected four channels and one byte")
pairs = [(value >> shift) & 3 for shift in (6, 4, 2, 0)]
return bytes((c & 0xFC) | pair
for c, pair in zip(channels, pairs, strict=True))
def read_byte(channels: bytes) -> int:
value = 0
for channel in channels:
value = (value << 2) | (channel & 3)
return value
cover = bytes([170, 181, 94, 203])
stored = embed_byte(cover, 0xD3)
assert stored == bytes([171, 181, 92, 203])
assert read_byte(stored) == 0xD3格納できるバイト数を数える
3チャンネルに各2ビットを使うので、1画素に6ビットを格納できる。1024×1024のカバーでは、16バイトの画素パケットヘッダーを引く前が786,432バイト、引いた後は786,416バイトだ。絵柄に使う画素が予約されると、その分だけ容量は減る。
固定マーカーがパケットの配置を示し、長さの情報でエンベロープの終端を判定する。画素パケットと暗号エンベロープのバージョンは別々の体系で管理されるため、PNG2パケットにv3の暗号エンベロープを格納できる。
C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
鍵を導出し、エンベロープを認証する
SnapSealのv3プロファイルは、UTF-8のパスワードとランダムな16バイトのソルトから、Argon2id v1.3で32バイトの鍵を導出する。設定はメモリ64 MiB、3パス、1レーンだ。リーダーはKDFを実行する前に、処理量の設定が対応する固定値かどうかを検証しなければならない。
AES-256-GCMは元のバイト列とメタデータを暗号化する。16バイトのタグは暗号文と公開された48バイトのヘッダーを認証する。認証に失敗した場合、平文を決して出力してはならない。
[0:8] | SNAPSEAL |
|---|---|
[8:12] | version=3 · KDF=1 · Argon2=0x13 (v1.3) · lanes=1 |
[12:16] | memory_KiB=65536 |
[16:20] | passes=3 |
[20:36] | salt · 16 bytes |
[36:48] | nonce · 12 bytes |
from argon2.low_level import Type, hash_secret_raw
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_v3(envelope: bytes, password: str) -> bytes:
if not 70 <= len(envelope) <= 50 * 1024 * 1024 + 4096:
raise ValueError("Invalid envelope size")
header = envelope[:48]
if header[:12] != b"SNAPSEAL\x03\x01\x13\x01":
raise ValueError("Expected the v3 Argon2id profile")
memory = int.from_bytes(header[12:16], "big")
passes = int.from_bytes(header[16:20], "big")
if (memory, passes) != (65536, 3):
raise ValueError("Unsupported work factors")
secret = password.encode("utf-8")
if not 1 <= len(password) <= 256 or len(secret) > 1024:
raise ValueError("Invalid password length")
key = hash_secret_raw(
secret, header[20:36], time_cost=passes,
memory_cost=memory, parallelism=1, hash_len=32,
type=Type.ID, version=19,
)
return AESGCM(key).decrypt(
header[36:48], envelope[48:], header,
)plaintext = uint32_be(metadata_length) + metadata_json + original_bytes
envelope = header + AESGCM(key).encrypt(nonce, plaintext, header)メッセージは画素にある
データが入っているのはPNGのメタデータではなく、デコード後のRGB値だ。PNGファイルのバイト列が異なっていても、デコード後のRGB値が変わらなければ、下位ビットのメッセージは保たれる。一方、スクリーンショットの撮影や画素の編集、サイズ変更では、絵を見分けられるままデータだけが壊れることがある。
元データの圧縮を明示的に選ばなければ、元のバイト列とメタデータは正確に復元される。ファイル名は暗号化されるが、パディングがなければエンベロープからおおよそのサイズは分かる。
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)耐圧縮:JPEGから復元するもう一つの経路
耐圧縮とは、寸法が変わっていないJPEGからの復元を可能な範囲で試みる機能だ。同じ暗号化エンベロープを、PNGのRGB下位ビットに収め、必要に応じて画像の周波数係数にも復元用コピーを収める。
圧縮を防ぐ仕組みではなく、復元の成功も保証されない。また、形式を検出不能にするものでもない。
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
画素から周波数へ
JPEGは8×8の輝度ブロックにDCT(離散コサイン変換)をかけて周波数係数に変換し、その係数を量子化する。SnapSealは選んだ係数を、意図的に間隔を空けた値に調整してデータを載せる。これにより、JPEG符号化で係数が多少変わっても読み取れる余地を設ける。
誤り訂正を施した転送用ヘッダーが復元データの配置を示す。読み取りに元のカバー画像は必要ない。
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerシンボルの間に余裕を設ける
QIM(量子化インデックス変調)は、間隔を空けた位置にシンボルを割り当てる。1ビットの説明用モデルでは、24の倍数が交互に0と1を表す。1を保存するには、70を72に移す。72から+11の変化なら1と読めるが、+13では境界を越えてビットが反転する。スライダーを動かして、その境界を確かめてほしい。
実際のチャンネルはグレイ符号を使い、選択した係数ごとに1〜3ビットを格納する。PythonのQIMコードは原理を示すためのもので、完全なJPEGデコーダーではない。
from math import floor
def put_bit(coefficient: float, bit: int, step: int = 24) -> int:
cell = floor((coefficient / step - bit) / 2 + 0.5)
return step * (2 * cell + bit)
def read_bit(coefficient: float, step: int = 24) -> int:
return floor(coefficient / step + 0.5) % 2
stored = put_bit(70, 1) # 72
assert read_bit(stored + 11) == 1 # 83: same cell
assert read_bit(stored + 13) == 0 # 85: crossed the boundary修復してから認証する
Reed–Solomon (255,223)は223バイトのデータに32バイトのパリティを加え、符号語ごとに最大16バイトの誤りを訂正できる。インターリーブと画像全体へのブロックの分散配置により、損傷が少数の符号語に集中するのを抑える。
リーダーは転送データを修復し、CRCを検査してから、AES-GCMでエンベロープを認証する。CRCは転送データの検証であって、暗号学的な認証ではない。修復に成功しても、GCMの検査に失敗した平文は出力できない。
223 + 32 = 255 bytes
t = (255 − 223) / 2 = 16 byte errors
A₀ B₀ C₀ A₁ B₁ C₁ … → A, B, C
repair → CRC → AES-GCM → original_bytes二つのコピーを載せた状態で試す
二つのコピーは同じ画素を使うため、下位ビットへの書き込みもDCT係数を変化させる。そこで自己テストには、両方を組み込んだ最終カバーを使う。復元可能と示すのは、ローカルのブラウザーでJPEG品質0.60にした場合と、0.85→0.60と連続して符号化した場合の両方でエンベロープを完全に復元でき、かつ符号語ごとの修復が最大12バイトに収まったときだけだ。12バイトは訂正限界の16バイトを下回る。これらの品質設定はローカル試験の条件であり、あらゆるJPEGに対する保証ではない。
自動試行の上限は、オーバーヘッドを含めて暗号化後280,000バイトだ。冗長性や強い信号はカバーの画質と容量を消費するが、非公開の元データを再圧縮するわけではない。試験に失敗した場合は、可逆なPNGまたはZIPでの提供に切り替える。切り抜き、サイズ変更、回転や別のエンコーダーによって復元できなくなる場合もある。確実な方法は、元のPNGまたはZIPをファイルとして送ることだ。
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000形式から分かることと、その限界
固定マーカーがあるため、この形式は検出できる。カバーの絵柄は公開されており、絵柄として認証されるわけではない。上位ビットを変更しても、埋め込まれたメッセージが残る場合がある。誤り訂正とCRCは転送中の損傷を扱い、保護された内容を認証するのはAES-GCMだ。
攻撃者はオフラインでパスワードを推測できる。Argon2idは1回ごとの試行コストを高めるが、弱いパスワードを救うことはできない。パスワードの復旧手段はなく、独立したセキュリティ監査を受けたと主張するものでもない。
アプリケーションなしで読み取る
この形式では既存の暗号技術を使い、データの構造と制限を明示している。独立したリーダーで読み取れることも重要だ。Python 3.12+のリーダーは、JPEG復元用コピーを含むものも含めた元のPNG、サイズなどの制限を設けた単一PNG入りSnapSeal ZIP、そして生のv3エンベロープに対応する。変換後のJPEGファイルを読むにはTypeScriptコーデックが必要だ。
ここで扱うのはオープンソースのv3実装だ。すでに配布されている古いリーダーは、その出力を受け付けない場合がある。指定された依存関係は仮想環境にインストールする。完全なソースと任意のダウンロードは、コード例の後に掲載している。
python -m pip install -r requirements.txtfrom getpass import getpass
from pathlib import Path
from decode_snapseal import decode_path
result = decode_path(
Path("protected.png"),
getpass("Password: "),
)
output = Path("restored")
output.mkdir(exist_ok=True)
with (output / result.filename).open("xb") as target:
target.write(result.data)

