<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Xor on Tarragon</title><link>https://tarrragon.github.io/blog/tags/xor/</link><description>Recent content in Xor on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 27 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/xor/index.xml" rel="self" type="application/rss+xml"/><item><title>XOR 可逆編碼的適用邊界：金鑰隨程式發佈時、擋的是無動機的旁觀者</title><link>https://tarrragon.github.io/blog/work-log/xor_reversible_encoding_boundary/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/xor_reversible_encoding_boundary/</guid><description>&lt;p>需要把一組登入憑證印成 QR Code、讓工程師掃碼免打字登入測試環境——這篇釐清可逆編碼在這類場景提供的實際保護、失效條件、以及採用時要一併設下的邊界。討論的目標是「載體上不出現明文」；真正的機密性需要金鑰不在客戶端的加密機制，屬於另一組選型。&lt;/p>
&lt;h2 id="可逆編碼承擔的責任">可逆編碼承擔的責任&lt;/h2>
&lt;p>把資料印到實體載體上時，可逆編碼解決的是「肉眼與隨手拍照讀不出內容」這個問題。標籤貼在機器上、QR Code 印在紙上，經過的人都看得到；編碼過的內容讓這些不經意的暴露失去意義。&lt;/p>
&lt;p>它的保護強度取決於一件事：金鑰在誰手上。當金鑰跟著程式一起發佈，任何拿得到程式的人都能還原內容 —— 這時它提供的是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/obfuscation/" data-link-title="Obfuscation" data-link-desc="把內容變成不可直接閱讀的形式時，用來判斷這個處理擋得住誰、以及它與加密的界線在哪">混淆&lt;/a>（obfuscation），讓內容不以明文出現，而非&lt;strong>機密性&lt;/strong>（confidentiality）。&lt;/p>
&lt;p>把這兩者分開命名，設計時就不會誤用。混淆對付的是「不特定的旁觀者」，機密性對付的是「能取得程式的攻擊者」。分界只看金鑰位置，不看攻擊者有沒有動機 —— 動機決定這條路徑實際會不會被走，不決定它成不成立。&lt;/p>
&lt;h2 id="xor-的運作方式">XOR 的運作方式&lt;/h2>
&lt;p>XOR 對同一個值做兩次會回到原點，編碼與解碼因此是同一個函式，金鑰長度不足時循環使用：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">明文 XOR 金鑰 = 密文
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">密文 XOR 金鑰 = 明文&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">static&lt;/span> &lt;span class="n">Uint8List&lt;/span> &lt;span class="n">_xor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">input&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">keyBytes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_key&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Uint8List&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="n">index&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">index&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">index&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">output&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">input&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">^&lt;/span> &lt;span class="n">keyBytes&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">index&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="n">keyBytes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實作上外面還要再包兩層。XOR 的結果是任意位元組，其中包含控制字元與不可列印字元，直接塞進 QR Code 或指令字串會出問題，所以外面套一層 Base64 轉成安全字元集。再加一個固定前綴當識別標記，解碼端可以先確認格式再嘗試解碼：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">static&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">marker&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;EMP_QR:&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">static&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">encode&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">storeCode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">account&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">password&lt;/span>&lt;span class="p">})&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">payload&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jsonEncode&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s1">&amp;#39;s&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">storeCode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;a&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">account&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;p&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">password&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">marker&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">base64Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_xor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="p">)))&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>標記的選擇有實務考量：避開雙引號之類會被指令語法截斷的字元。標籤機的指令格式（TSPL、ZPL 等）對引號敏感，內容裡出現引號可能讓指令提前結束。&lt;/p>
&lt;h2 id="保護在哪些條件下失效">保護在哪些條件下失效&lt;/h2>
&lt;p>金鑰隨程式發佈時，還原內容的路徑有四條，需要的條件由多到少，都不需要特殊技術。&lt;/p>
&lt;p>門檻最高的一條是取得程式本身：行動應用與桌面應用的產出物都能被反編譯或用字串工具掃描，硬編碼的金鑰以明文形式存在其中，拿到之後解碼與正常使用沒有差別。同樣要取得程式、但連反編譯都省下的是&lt;strong>已知明文推導&lt;/strong> —— 攻擊者若知道編碼前的部分內容（例如 JSON 的固定欄位名），把它與密文對應位置做 XOR 就直接得到該段金鑰，JSON 這類有固定結構的格式讓這條路徑格外容易。&lt;/p>
&lt;p>另外兩條連程式都不需要，手上有密文就能走。&lt;strong>金鑰重複使用&lt;/strong>時，兩份密文互相 XOR 會消掉金鑰，留下兩份明文的 XOR 結果，配合語言統計特性可以還原；前提是兩份密文從同一個金鑰偏移起算，上面的實作每次都從索引 0 開始，這個前提成立。條件最寬鬆的是&lt;strong>單一密文的統計還原&lt;/strong>：金鑰循環使用時只要密文夠長，先用重合指數（把密文與自身位移若干位元組後比對，重複字元的比率在位移等於金鑰長度時出現峰值）推出金鑰長度，再依長度分組、對每組做頻率分析。這條路徑針對的正是「金鑰短、內容長」這個可逆編碼最常見的形態。&lt;/p>
&lt;p>這四條路徑共同的前提是「攻擊者有動機」。混淆的價值在於它讓&lt;strong>沒有動機的人&lt;/strong>看不到內容，這個目標本身是成立的。&lt;/p>
&lt;h2 id="採用時該設下的邊界">採用時該設下的邊界&lt;/h2>
&lt;p>判斷可逆編碼是否適用，關鍵在於「內容洩漏的後果」與「誰能接觸到載體」。以下四條是採用的必要條件 —— 缺一條就不該用，但四條到齊也還不夠，後面的否決條件要一併看過。&lt;/p>
&lt;p>&lt;strong>憑證本身的權限有限&lt;/strong>。編進去的是測試環境帳號、或權限受限的功能帳號，洩漏後的影響範圍可控。正式環境的高權限憑證需要的是不同層級的機制。驗收方式是把該帳號的權限清單列出來逐項看，而非以「它只是測試帳號」這個標籤代替檢查。&lt;/p>
&lt;p>&lt;strong>存在更強的後端把關&lt;/strong>。真正的授權判斷發生在服務端，客戶端持有的憑證即使洩漏，也還要通過服務端的驗證。載體上的編碼是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/defense-in-depth/" data-link-title="Defense in Depth" data-link-desc="有人主張某個弱環節「還有其他防線擋著」時，用來判斷這個說法成不成立">縱深防禦&lt;/a> 的外層，而非唯一防線。這一條的驗收方式是拿載體上的憑證直接呼叫 API，看服務端還會擋下哪些操作 —— 若它等同一組完整存取權，這個條件並不成立，宣稱「後端有把關」與實際擋得住是兩件事。&lt;/p>
&lt;p>&lt;strong>產生入口受控&lt;/strong>。能產生這種載體的功能限制在開發版本，正式版本不提供。這讓「誰手上會有這種標籤」是可預期的。要確認它成立，追的是版本旗標的實際判定點 —— 入口的顯示條件掛在哪個 build 條件上，以及正式版本的建置參數是否真的讓那個條件為假（下面的 &lt;code>isDebug&lt;/code> 在此僅為示意，各專案的旗標名稱不同）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 設定頁的列印入口只在開發版本出現
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">AppConfig&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">config&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isDebug&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">SliverToBoxAdapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">printQrCodeRow&lt;/span>&lt;span class="p">()),&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>正式流程走另一套機制&lt;/strong>。面向真實使用者的憑證由服務端核發，客戶端只負責掃描與轉送，不參與產生。這條界線劃清楚之後，客戶端就沒有理由持有能產生正式憑證的能力。列出正式憑證的所有核發來源、確認客戶端不在那份清單裡，這條就查完了。&lt;/p>
&lt;p>這四條之中有三條靠同一層防線撐著。權限有限、後端把關、正式憑證由服務端核發，失效原因都是服務端授權面被繞過 —— 一個原因同時讓三條垮掉，所以它們在稽核清單上是三項，實際的失效面只有一類。真正走不同路徑的是產生入口受控（它失效的原因是 build flag 或發佈流程出錯）。驗收重心因此壓在條件二的實測上：服務端擋不擋得住，決定另外兩條有沒有意義。&lt;/p>
&lt;p>即使四條全部成立，有三種情形仍然否決這個方案：合規對憑證儲存有明文規範、載體會流通到組織外（貼在機器上的標籤會隨機器轉賣或送修離開組織）、或同一把金鑰的載體大量產出 —— 最後這種讓前面的統計還原變得可行，一旦金鑰被還原，攻擊者能自製看起來合法的載體，「產生入口受控」這個假設就跟著失效。&lt;/p>
&lt;p>四個條件之外有一個容易被連帶收緊的設計點：產生入口受限，不必然要求解碼端也受限。掃描解碼可以在所有版本受理，工程師因此能在正式版本上用這個方式快速登入除錯，而載體的來源仍然被產生端管住。放寬解碼不擴大風險，是因為解碼本身不賦予任何信任 —— 它只把內容填進登入欄位，實際的授權判斷仍然發生在服務端。&lt;/p>
&lt;h2 id="兩種機制的分工">兩種機制的分工&lt;/h2>
&lt;p>同一個系統裡同時存在可逆編碼與 HMAC 簽章時，它們回答的是不同問題。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>可逆編碼（XOR + Base64）&lt;/th>
 &lt;th>HMAC 簽章&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>解決什麼&lt;/td>
 &lt;td>內容不以明文出現在載體上&lt;/td>
 &lt;td>證明來源身分與內容未被竄改&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金鑰位置&lt;/td>
 &lt;td>客戶端（隨程式發佈）&lt;/td>
 &lt;td>兩端各自持有，不隨程式發佈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可逆&lt;/td>
 &lt;td>是，編碼解碼同一把金鑰&lt;/td>
 &lt;td>否，簽章無法還原出原文&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失效條件&lt;/td>
 &lt;td>攻擊者取得程式&lt;/td>
 &lt;td>密鑰洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用邊界&lt;/td>
 &lt;td>低敏感內容、有後端把關&lt;/td>
 &lt;td>系統間身分驗證&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者都不提供機密性。HMAC 的訊息本身明文傳輸，可逆編碼的金鑰在客戶端。需要內容真正不可讀時，走的是傳輸層加密或服務端持有金鑰的加密機制。&lt;/p>
&lt;h2 id="判讀與邊界">判讀與邊界&lt;/h2>
&lt;p>&lt;strong>適合採用可逆編碼的訊號&lt;/strong>：載體會被不特定人看到、內容洩漏的後果可控、產生入口能限制在受控環境、服務端另有授權把關。後三項在「採用時該設下的邊界」各有對應的檢查動作，會議上被追問時直接跑那幾個動作，比複述訊號有說服力。&lt;/p>
&lt;p>&lt;strong>應該改用其他機制的訊號&lt;/strong>：憑證具備正式環境權限、載體會流通到組織外、缺少服務端的二次驗證、合規要求對憑證儲存有明確規範。&lt;/p>
&lt;p>&lt;strong>判定不適用時往哪走&lt;/strong>：改由服務端核發短期憑證，客戶端只負責掃描與轉送、不參與產生，載體上帶的是一次性的兌換碼而非憑證本身。金鑰必須留在裝置上時，交給作業系統的受保護儲存區（iOS Keychain、Android Keystore）而非程式碼常數 —— 這把金鑰移出了產出物，反編譯的路徑就不成立。落地資料本身要不可讀時走 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/at-rest-encryption/" data-link-title="At-Rest Encryption" data-link-desc="說明資料落到儲存媒介前的加密層，以及它對應的威脅模型">At-Rest Encryption&lt;/a>，傳輸過程走 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">TLS / mTLS&lt;/a>；三者的責任分界，以及金鑰位置如何決定能對抗誰，見 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/cryptographic-primitive-selection/" data-link-title="7.28 密碼學原語選型：金鑰位置決定威脅模型" data-link-desc="決定用加密、簽章、編碼還是單向轉換保護一段資料時，用來判斷各原語的保護範圍與失效條件">7.28 密碼學原語選型&lt;/a>。&lt;/p>
&lt;p>這裡最容易走錯的一步是換演算法。把 XOR 換成 AES，金鑰仍然在客戶端時威脅模型完全沒有改變 —— 反編譯取得金鑰這條路徑照樣成立，變的是取得金鑰要多花的時間，不是能不能取得。有效的動作是移動金鑰的位置，不是提高演算法強度。&lt;/p>
&lt;p>命名是這個決定留在程式碼裡的最後一道說明。編解碼工具的類別名帶上 &lt;code>Debug&lt;/code>、文件註解寫明它提供的是混淆而非加密並指出金鑰在客戶端，呼叫端讀到名字時就知道邊界，不必回頭查文件。這比註解本身更耐久：註解會被略過，名字每次呼叫都會被讀到。&lt;/p></description><content:encoded><![CDATA[<p>需要把一組登入憑證印成 QR Code、讓工程師掃碼免打字登入測試環境——這篇釐清可逆編碼在這類場景提供的實際保護、失效條件、以及採用時要一併設下的邊界。討論的目標是「載體上不出現明文」；真正的機密性需要金鑰不在客戶端的加密機制，屬於另一組選型。</p>
<h2 id="可逆編碼承擔的責任">可逆編碼承擔的責任</h2>
<p>把資料印到實體載體上時，可逆編碼解決的是「肉眼與隨手拍照讀不出內容」這個問題。標籤貼在機器上、QR Code 印在紙上，經過的人都看得到；編碼過的內容讓這些不經意的暴露失去意義。</p>
<p>它的保護強度取決於一件事：金鑰在誰手上。當金鑰跟著程式一起發佈，任何拿得到程式的人都能還原內容 —— 這時它提供的是 <a href="/blog/backend/knowledge-cards/obfuscation/" data-link-title="Obfuscation" data-link-desc="把內容變成不可直接閱讀的形式時，用來判斷這個處理擋得住誰、以及它與加密的界線在哪">混淆</a>（obfuscation），讓內容不以明文出現，而非<strong>機密性</strong>（confidentiality）。</p>
<p>把這兩者分開命名，設計時就不會誤用。混淆對付的是「不特定的旁觀者」，機密性對付的是「能取得程式的攻擊者」。分界只看金鑰位置，不看攻擊者有沒有動機 —— 動機決定這條路徑實際會不會被走，不決定它成不成立。</p>
<h2 id="xor-的運作方式">XOR 的運作方式</h2>
<p>XOR 對同一個值做兩次會回到原點，編碼與解碼因此是同一個函式，金鑰長度不足時循環使用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">明文 XOR 金鑰 = 密文
</span></span><span class="line"><span class="ln">2</span><span class="cl">密文 XOR 金鑰 = 明文</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">static</span> <span class="n">Uint8List</span> <span class="n">_xor</span><span class="p">(</span><span class="n">List</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="n">input</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">final</span> <span class="n">keyBytes</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">_key</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">final</span> <span class="n">output</span> <span class="o">=</span> <span class="n">Uint8List</span><span class="p">(</span><span class="n">input</span><span class="p">.</span><span class="n">length</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="n">index</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">input</span><span class="p">.</span><span class="n">length</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">output</span><span class="p">[</span><span class="n">index</span><span class="p">]</span> <span class="o">=</span> <span class="n">input</span><span class="p">[</span><span class="n">index</span><span class="p">]</span> <span class="o">^</span> <span class="n">keyBytes</span><span class="p">[</span><span class="n">index</span> <span class="o">%</span> <span class="n">keyBytes</span><span class="p">.</span><span class="n">length</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="k">return</span> <span class="n">output</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>實作上外面還要再包兩層。XOR 的結果是任意位元組，其中包含控制字元與不可列印字元，直接塞進 QR Code 或指令字串會出問題，所以外面套一層 Base64 轉成安全字元集。再加一個固定前綴當識別標記，解碼端可以先確認格式再嘗試解碼：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">static</span> <span class="kd">const</span> <span class="n">marker</span> <span class="o">=</span> <span class="s1">&#39;EMP_QR:&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">static</span> <span class="kt">String</span> <span class="n">encode</span><span class="p">({</span><span class="kd">required</span> <span class="kt">String</span> <span class="n">storeCode</span><span class="p">,</span> <span class="kd">required</span> <span class="kt">String</span> <span class="n">account</span><span class="p">,</span> <span class="kd">required</span> <span class="kt">String</span> <span class="n">password</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">final</span> <span class="n">payload</span> <span class="o">=</span> <span class="n">jsonEncode</span><span class="p">({</span><span class="s1">&#39;s&#39;</span><span class="o">:</span> <span class="n">storeCode</span><span class="p">,</span> <span class="s1">&#39;a&#39;</span><span class="o">:</span> <span class="n">account</span><span class="p">,</span> <span class="s1">&#39;p&#39;</span><span class="o">:</span> <span class="n">password</span><span class="p">});</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">return</span> <span class="s1">&#39;</span><span class="si">$</span><span class="n">marker</span><span class="si">${</span><span class="n">base64Encode</span><span class="p">(</span><span class="n">_xor</span><span class="p">(</span><span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">payload</span><span class="p">)))</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>標記的選擇有實務考量：避開雙引號之類會被指令語法截斷的字元。標籤機的指令格式（TSPL、ZPL 等）對引號敏感，內容裡出現引號可能讓指令提前結束。</p>
<h2 id="保護在哪些條件下失效">保護在哪些條件下失效</h2>
<p>金鑰隨程式發佈時，還原內容的路徑有四條，需要的條件由多到少，都不需要特殊技術。</p>
<p>門檻最高的一條是取得程式本身：行動應用與桌面應用的產出物都能被反編譯或用字串工具掃描，硬編碼的金鑰以明文形式存在其中，拿到之後解碼與正常使用沒有差別。同樣要取得程式、但連反編譯都省下的是<strong>已知明文推導</strong> —— 攻擊者若知道編碼前的部分內容（例如 JSON 的固定欄位名），把它與密文對應位置做 XOR 就直接得到該段金鑰，JSON 這類有固定結構的格式讓這條路徑格外容易。</p>
<p>另外兩條連程式都不需要，手上有密文就能走。<strong>金鑰重複使用</strong>時，兩份密文互相 XOR 會消掉金鑰，留下兩份明文的 XOR 結果，配合語言統計特性可以還原；前提是兩份密文從同一個金鑰偏移起算，上面的實作每次都從索引 0 開始，這個前提成立。條件最寬鬆的是<strong>單一密文的統計還原</strong>：金鑰循環使用時只要密文夠長，先用重合指數（把密文與自身位移若干位元組後比對，重複字元的比率在位移等於金鑰長度時出現峰值）推出金鑰長度，再依長度分組、對每組做頻率分析。這條路徑針對的正是「金鑰短、內容長」這個可逆編碼最常見的形態。</p>
<p>這四條路徑共同的前提是「攻擊者有動機」。混淆的價值在於它讓<strong>沒有動機的人</strong>看不到內容，這個目標本身是成立的。</p>
<h2 id="採用時該設下的邊界">採用時該設下的邊界</h2>
<p>判斷可逆編碼是否適用，關鍵在於「內容洩漏的後果」與「誰能接觸到載體」。以下四條是採用的必要條件 —— 缺一條就不該用，但四條到齊也還不夠，後面的否決條件要一併看過。</p>
<p><strong>憑證本身的權限有限</strong>。編進去的是測試環境帳號、或權限受限的功能帳號，洩漏後的影響範圍可控。正式環境的高權限憑證需要的是不同層級的機制。驗收方式是把該帳號的權限清單列出來逐項看，而非以「它只是測試帳號」這個標籤代替檢查。</p>
<p><strong>存在更強的後端把關</strong>。真正的授權判斷發生在服務端，客戶端持有的憑證即使洩漏，也還要通過服務端的驗證。載體上的編碼是 <a href="/blog/backend/knowledge-cards/defense-in-depth/" data-link-title="Defense in Depth" data-link-desc="有人主張某個弱環節「還有其他防線擋著」時，用來判斷這個說法成不成立">縱深防禦</a> 的外層，而非唯一防線。這一條的驗收方式是拿載體上的憑證直接呼叫 API，看服務端還會擋下哪些操作 —— 若它等同一組完整存取權，這個條件並不成立，宣稱「後端有把關」與實際擋得住是兩件事。</p>
<p><strong>產生入口受控</strong>。能產生這種載體的功能限制在開發版本，正式版本不提供。這讓「誰手上會有這種標籤」是可預期的。要確認它成立，追的是版本旗標的實際判定點 —— 入口的顯示條件掛在哪個 build 條件上，以及正式版本的建置參數是否真的讓那個條件為假（下面的 <code>isDebug</code> 在此僅為示意，各專案的旗標名稱不同）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 設定頁的列印入口只在開發版本出現
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="n">AppConfig</span><span class="p">.</span><span class="n">config</span><span class="p">.</span><span class="n">isDebug</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">SliverToBoxAdapter</span><span class="p">(</span><span class="nl">child:</span> <span class="n">printQrCodeRow</span><span class="p">()),</span></span></span></code></pre></div><p><strong>正式流程走另一套機制</strong>。面向真實使用者的憑證由服務端核發，客戶端只負責掃描與轉送，不參與產生。這條界線劃清楚之後，客戶端就沒有理由持有能產生正式憑證的能力。列出正式憑證的所有核發來源、確認客戶端不在那份清單裡，這條就查完了。</p>
<p>這四條之中有三條靠同一層防線撐著。權限有限、後端把關、正式憑證由服務端核發，失效原因都是服務端授權面被繞過 —— 一個原因同時讓三條垮掉，所以它們在稽核清單上是三項，實際的失效面只有一類。真正走不同路徑的是產生入口受控（它失效的原因是 build flag 或發佈流程出錯）。驗收重心因此壓在條件二的實測上：服務端擋不擋得住，決定另外兩條有沒有意義。</p>
<p>即使四條全部成立，有三種情形仍然否決這個方案：合規對憑證儲存有明文規範、載體會流通到組織外（貼在機器上的標籤會隨機器轉賣或送修離開組織）、或同一把金鑰的載體大量產出 —— 最後這種讓前面的統計還原變得可行，一旦金鑰被還原，攻擊者能自製看起來合法的載體，「產生入口受控」這個假設就跟著失效。</p>
<p>四個條件之外有一個容易被連帶收緊的設計點：產生入口受限，不必然要求解碼端也受限。掃描解碼可以在所有版本受理，工程師因此能在正式版本上用這個方式快速登入除錯，而載體的來源仍然被產生端管住。放寬解碼不擴大風險，是因為解碼本身不賦予任何信任 —— 它只把內容填進登入欄位，實際的授權判斷仍然發生在服務端。</p>
<h2 id="兩種機制的分工">兩種機制的分工</h2>
<p>同一個系統裡同時存在可逆編碼與 HMAC 簽章時，它們回答的是不同問題。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>可逆編碼（XOR + Base64）</th>
          <th>HMAC 簽章</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>解決什麼</td>
          <td>內容不以明文出現在載體上</td>
          <td>證明來源身分與內容未被竄改</td>
      </tr>
      <tr>
          <td>金鑰位置</td>
          <td>客戶端（隨程式發佈）</td>
          <td>兩端各自持有，不隨程式發佈</td>
      </tr>
      <tr>
          <td>可逆</td>
          <td>是，編碼解碼同一把金鑰</td>
          <td>否，簽章無法還原出原文</td>
      </tr>
      <tr>
          <td>失效條件</td>
          <td>攻擊者取得程式</td>
          <td>密鑰洩漏</td>
      </tr>
      <tr>
          <td>適用邊界</td>
          <td>低敏感內容、有後端把關</td>
          <td>系統間身分驗證</td>
      </tr>
  </tbody>
</table>
<p>兩者都不提供機密性。HMAC 的訊息本身明文傳輸，可逆編碼的金鑰在客戶端。需要內容真正不可讀時，走的是傳輸層加密或服務端持有金鑰的加密機制。</p>
<h2 id="判讀與邊界">判讀與邊界</h2>
<p><strong>適合採用可逆編碼的訊號</strong>：載體會被不特定人看到、內容洩漏的後果可控、產生入口能限制在受控環境、服務端另有授權把關。後三項在「採用時該設下的邊界」各有對應的檢查動作，會議上被追問時直接跑那幾個動作，比複述訊號有說服力。</p>
<p><strong>應該改用其他機制的訊號</strong>：憑證具備正式環境權限、載體會流通到組織外、缺少服務端的二次驗證、合規要求對憑證儲存有明確規範。</p>
<p><strong>判定不適用時往哪走</strong>：改由服務端核發短期憑證，客戶端只負責掃描與轉送、不參與產生，載體上帶的是一次性的兌換碼而非憑證本身。金鑰必須留在裝置上時，交給作業系統的受保護儲存區（iOS Keychain、Android Keystore）而非程式碼常數 —— 這把金鑰移出了產出物，反編譯的路徑就不成立。落地資料本身要不可讀時走 <a href="/blog/backend/knowledge-cards/at-rest-encryption/" data-link-title="At-Rest Encryption" data-link-desc="說明資料落到儲存媒介前的加密層，以及它對應的威脅模型">At-Rest Encryption</a>，傳輸過程走 <a href="/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">TLS / mTLS</a>；三者的責任分界，以及金鑰位置如何決定能對抗誰，見 <a href="/blog/backend/07-security-data-protection/cryptographic-primitive-selection/" data-link-title="7.28 密碼學原語選型：金鑰位置決定威脅模型" data-link-desc="決定用加密、簽章、編碼還是單向轉換保護一段資料時，用來判斷各原語的保護範圍與失效條件">7.28 密碼學原語選型</a>。</p>
<p>這裡最容易走錯的一步是換演算法。把 XOR 換成 AES，金鑰仍然在客戶端時威脅模型完全沒有改變 —— 反編譯取得金鑰這條路徑照樣成立，變的是取得金鑰要多花的時間，不是能不能取得。有效的動作是移動金鑰的位置，不是提高演算法強度。</p>
<p>命名是這個決定留在程式碼裡的最後一道說明。編解碼工具的類別名帶上 <code>Debug</code>、文件註解寫明它提供的是混淆而非加密並指出金鑰在客戶端，呼叫端讀到名字時就知道邊界，不必回頭查文件。這比註解本身更耐久：註解會被略過，名字每次呼叫都會被讀到。</p>
]]></content:encoded></item></channel></rss>