LINEは不可視文字の31文字目を捨てる ── Unicodeステガノグラフィの盲点と回避策
Unicodeには256個の不可視制御文字がある。異体字セレクタ(Variation Selector、以下VS)と呼ばれるこの文字群は、直前の文字の表示形を変えるための書式制御文字で、それ自体はどのエディタにも描画されない。256個がちょうど1バイトの全値域に対応するため、任意のバイト列を通常のテキストに埋め込むステガノグラフィの媒体として使える。
Slack、X、iMessage、TikTokではこの埋め込みが正常に保存される。LINEだけが壊す。しかも「削る」のではなく「破壊する」。その挙動を実機テストで特定し、回避アルゴリズムを設計するまでの記録を残す。
何を作っていたか
subtextは、VSを使って構造化データを任意のテキストに不可視に埋め込むオープンプロトコルだ。「再来週の日曜日、レンタカー借りてグランピング。準備のリマインダーと買い物リスト作って」という自然言語のメッセージに、AIが解釈したカレンダーイベントとTodoリストをそのまま埋め込んで送る。受信者のアプリはそのテキストを受け取った瞬間に構造化データを取り出し、カレンダーとリマインダーに登録する。テキストとして流通しながら、機械可読なデータを搬送するという設計だ。
プロトコルの仕様(subtext-spec)とTypeScriptコア実装(subtext-core)を書いた段階で、日本最大のメッセンジャーを経由した実機テストを行った。結果、送信側でエンコードしたペイロードが受信側で復号できないことが判明した。
LINEが何をしているか
最初の仮説は「VSをすべて除去している」だった。しかし実際は違った。短いVS列は無傷で通過する。長いVS列だけが壊れる。
11種類のテストケースを系統的に送信・受信して逆解析した結果、以下の挙動が確認された。
LINEは受信メッセージの不可視文字を累積カウントする。カウント対象はUnicode Whitespaceプロパティを持たないすべての文字、すなわちVS、ZWNJ、ZWJ、WJ、BOM、Invisible Separatorなどの制御文字全般だ。このカウントが31に達した時点でwrap処理が発動し、31番目の不可視文字が U+202F NARROW NO-BREAK SPACE 2個に置換される。
置換が行われる前後でバイト数が変化するだけなら、デコーダは検出して対処できる。問題はVS-Supplement(U+E0100–U+E01EF)がUTF-16のサロゲートペアである点だ。置換処理がペアの途中に介入すると、片割れが孤立してcodepoint全体が消滅する。1回のwrapで1バイトではなく1コードポイント、すなわちsubtextでは正確に1ペイロードバイトが失われる。
カウンタはUnicode Whitespaceプロパティを持つ可視文字(U+0020通常スペース、U+200A HAIR SPACEなど)でリセットされる。ゼロ幅文字(U+200B ZWSP、U+200C ZWNJなど)はリセットしない。不可視文字として累積カウントされる。
T04[30個のVS] → 完全保存(閾値以下)
T05[31個のVS] → 1コードポイント消失
T07[60個のVS] → 2コードポイント消失
T09[20個+X+20個] → 完全保存(可視文字Xがカウンタリセット)
T10[20個+ +20個] → 完全保存(スペースがカウンタリセット)
この挙動を再現可能な形で記録したツール群が tools/line-test/ だ。テストケースの生成スクリプトと、受信テキストを食わせて消失バイトを特定するアナライザで構成されている。
なぜゼロ幅文字はリセットしないのか
直感に反する部分がある。U+200B(ZERO WIDTH SPACE)はその名の通りスペースだが、LINEのカウンタをリセットしない。U+200A(HAIR SPACE)は極めて細い幅のスペースで視覚的にはほぼ不可視に近いが、カウンタをリセットする。
差はUnicode Whitespaceプロパティの有無だ。U+200AはWhitespaceプロパティを持ち、U+200BはWhitespaceプロパティを持たない(Zero Width Spaceはスペースという名前だがUnicodeの分類では書式制御文字であり、Whitespace扱いではない)。LINEのword-wrapエンジンはUnicode仕様のWhespaceプロパティに厳密に準拠して改行候補位置を判定しており、その実装がそのままカウンタリセットの条件になっている。
つまり「スペースっぽい文字を間に挟めばリセットされる」という直感的な回避策は機能しない。Whitespaceプロパティを持つ文字だけが有効だ。
解決:grapheme境界への分散配置
VS列を1箇所に集中させないことが解決策だ。カバーテキストをgrapheme cluster単位に分割し、各graphemeの直前にVS chunkを配置する。各graphemeはLINEのカウンタをリセットする可視文字として機能するため、各VS chunkを30文字以内に保てばwrapが発動しない。
SAFE_CHUNK = 30
[VS×n₁] G[0] [VS×n₂] G[1] [VS×n₃] G[2] ...
各チャンクのサイズはペイロードバイト数をgrapheme数で割って均等配分する。カバーテキストに絵文字(U+FE0F含むgrapheme)など偶発的なVSが含まれる場合は、最後の偶発VS含有graphemeより後ろにのみペイロードchunkを配置する。チャンクがSAFE_CHUNKを超える場合はHAIR SPACE(U+200A)を区切りとして挿入する。HAIR SPACEはWhespaceプロパティを持つため確実にカウンタをリセットするが、レンダリング幅が極小(典型的に1px程度)なので視覚的影響は最小だ。
デコード側は位置に関係なくVS文字を順序保持で全収集するため、この分散配置は透過的だ。集中配置の旧フォーマットと分散配置の新フォーマットを同一コードパスで処理できる。
クリーン容量(HAIR SPACEなしで運べるバイト数)の概算は grapheme数 × 30 バイトになる。7文字のカバーテキストなら210バイト、12文字なら360バイトが上限の目安だ。
この仕様はsubtext Protocol Specification v0.2の§3.5として形式化されている。
設計上の含意
プラットフォームを横断してデータを搬送するプロトコルを設計するとき、各プラットフォームの「沈黙した制約」を発見するコストは予想より高い。
Slack・X・iMessage・TikTokはVS列に対して何も処理しない。そのためこれらで動作確認しただけでは制約は見えない。LINEのword-wrap挙動は実装ドキュメントとして公開されておらず、実機テストで逆解析するほかない。
今回判明したのは、LINEのword-wrapエンジンがUnicode Whitespaceプロパティに厳密準拠しているという事実だ。これは実装として正確だが、そのコードパスがVS-Supplementのサロゲートペアを意識していない。修正が必要な実装バグとして報告することもできるが、プロトコル側で対処する方が現実的だ。利用者が最も多いメッセンジャーに対してプロトコルが壊れたままでは意味がない。
「技術制約をユーザーに見せず勝手に対処する」か「制約をユーザーに示してカバーテキストを調整させる」かの判断が残る。subtextは前者を選んだ。⌬フィラー(カバーが短すぎる場合にブランドマークで埋める)を使って容量不足を吸収し、ユーザーには「あと何文字」というカウンタのみを見せる設計だ。制約は内部に隠れている。
subtext Protocol Specification および tools/line-test の全スクリプトは prospectorlabs/subtext-spec(CC0)、TypeScriptコア実装は prospectorlabs/subtext-core(MIT)として公開している。
Discussion