XOR 可逆編碼的適用邊界:金鑰隨程式發佈時、擋的是無動機的旁觀者
需要把一組登入憑證印成 QR Code、讓工程師掃碼免打字登入測試環境——這篇釐清可逆編碼在這類場景提供的實際保護、失效條件、以及採用時要一併設下的邊界。討論的目標是「載體上不出現明文」;真正的機密性需要金鑰不在客戶端的加密機制,屬於另一組選型。
可逆編碼承擔的責任
把資料印到實體載體上時,可逆編碼解決的是「肉眼與隨手拍照讀不出內容」這個問題。標籤貼在機器上、QR Code 印在紙上,經過的人都看得到;編碼過的內容讓這些不經意的暴露失去意義。
它的保護強度取決於一件事:金鑰在誰手上。當金鑰跟著程式一起發佈,任何拿得到程式的人都能還原內容 —— 這時它提供的是 混淆(obfuscation),讓內容不以明文出現,而非機密性(confidentiality)。
把這兩者分開命名,設計時就不會誤用。混淆對付的是「不特定的旁觀者」,機密性對付的是「能取得程式的攻擊者」。分界只看金鑰位置,不看攻擊者有沒有動機 —— 動機決定這條路徑實際會不會被走,不決定它成不成立。
XOR 的運作方式
XOR 對同一個值做兩次會回到原點,編碼與解碼因此是同一個函式,金鑰長度不足時循環使用:
1明文 XOR 金鑰 = 密文
2密文 XOR 金鑰 = 明文1static Uint8List _xor(List<int> input) {
2 final keyBytes = utf8.encode(_key);
3 final output = Uint8List(input.length);
4 for (var index = 0; index < input.length; index++) {
5 output[index] = input[index] ^ keyBytes[index % keyBytes.length];
6 }
7 return output;
8}實作上外面還要再包兩層。XOR 的結果是任意位元組,其中包含控制字元與不可列印字元,直接塞進 QR Code 或指令字串會出問題,所以外面套一層 Base64 轉成安全字元集。再加一個固定前綴當識別標記,解碼端可以先確認格式再嘗試解碼:
1static const marker = 'EMP_QR:';
2
3static String encode({required String storeCode, required String account, required String password}) {
4 final payload = jsonEncode({'s': storeCode, 'a': account, 'p': password});
5 return '$marker${base64Encode(_xor(utf8.encode(payload)))}';
6}標記的選擇有實務考量:避開雙引號之類會被指令語法截斷的字元。標籤機的指令格式(TSPL、ZPL 等)對引號敏感,內容裡出現引號可能讓指令提前結束。
保護在哪些條件下失效
金鑰隨程式發佈時,還原內容的路徑有四條,需要的條件由多到少,都不需要特殊技術。
門檻最高的一條是取得程式本身:行動應用與桌面應用的產出物都能被反編譯或用字串工具掃描,硬編碼的金鑰以明文形式存在其中,拿到之後解碼與正常使用沒有差別。同樣要取得程式、但連反編譯都省下的是已知明文推導 —— 攻擊者若知道編碼前的部分內容(例如 JSON 的固定欄位名),把它與密文對應位置做 XOR 就直接得到該段金鑰,JSON 這類有固定結構的格式讓這條路徑格外容易。
另外兩條連程式都不需要,手上有密文就能走。金鑰重複使用時,兩份密文互相 XOR 會消掉金鑰,留下兩份明文的 XOR 結果,配合語言統計特性可以還原;前提是兩份密文從同一個金鑰偏移起算,上面的實作每次都從索引 0 開始,這個前提成立。條件最寬鬆的是單一密文的統計還原:金鑰循環使用時只要密文夠長,先用重合指數(把密文與自身位移若干位元組後比對,重複字元的比率在位移等於金鑰長度時出現峰值)推出金鑰長度,再依長度分組、對每組做頻率分析。這條路徑針對的正是「金鑰短、內容長」這個可逆編碼最常見的形態。
這四條路徑共同的前提是「攻擊者有動機」。混淆的價值在於它讓沒有動機的人看不到內容,這個目標本身是成立的。
採用時該設下的邊界
判斷可逆編碼是否適用,關鍵在於「內容洩漏的後果」與「誰能接觸到載體」。以下四條是採用的必要條件 —— 缺一條就不該用,但四條到齊也還不夠,後面的否決條件要一併看過。
憑證本身的權限有限。編進去的是測試環境帳號、或權限受限的功能帳號,洩漏後的影響範圍可控。正式環境的高權限憑證需要的是不同層級的機制。驗收方式是把該帳號的權限清單列出來逐項看,而非以「它只是測試帳號」這個標籤代替檢查。
存在更強的後端把關。真正的授權判斷發生在服務端,客戶端持有的憑證即使洩漏,也還要通過服務端的驗證。載體上的編碼是 縱深防禦 的外層,而非唯一防線。這一條的驗收方式是拿載體上的憑證直接呼叫 API,看服務端還會擋下哪些操作 —— 若它等同一組完整存取權,這個條件並不成立,宣稱「後端有把關」與實際擋得住是兩件事。
產生入口受控。能產生這種載體的功能限制在開發版本,正式版本不提供。這讓「誰手上會有這種標籤」是可預期的。要確認它成立,追的是版本旗標的實際判定點 —— 入口的顯示條件掛在哪個 build 條件上,以及正式版本的建置參數是否真的讓那個條件為假(下面的 isDebug 在此僅為示意,各專案的旗標名稱不同):
1// 設定頁的列印入口只在開發版本出現
2if (AppConfig.config.isDebug)
3 SliverToBoxAdapter(child: printQrCodeRow()),正式流程走另一套機制。面向真實使用者的憑證由服務端核發,客戶端只負責掃描與轉送,不參與產生。這條界線劃清楚之後,客戶端就沒有理由持有能產生正式憑證的能力。列出正式憑證的所有核發來源、確認客戶端不在那份清單裡,這條就查完了。
這四條之中有三條靠同一層防線撐著。權限有限、後端把關、正式憑證由服務端核發,失效原因都是服務端授權面被繞過 —— 一個原因同時讓三條垮掉,所以它們在稽核清單上是三項,實際的失效面只有一類。真正走不同路徑的是產生入口受控(它失效的原因是 build flag 或發佈流程出錯)。驗收重心因此壓在條件二的實測上:服務端擋不擋得住,決定另外兩條有沒有意義。
即使四條全部成立,有三種情形仍然否決這個方案:合規對憑證儲存有明文規範、載體會流通到組織外(貼在機器上的標籤會隨機器轉賣或送修離開組織)、或同一把金鑰的載體大量產出 —— 最後這種讓前面的統計還原變得可行,一旦金鑰被還原,攻擊者能自製看起來合法的載體,「產生入口受控」這個假設就跟著失效。
四個條件之外有一個容易被連帶收緊的設計點:產生入口受限,不必然要求解碼端也受限。掃描解碼可以在所有版本受理,工程師因此能在正式版本上用這個方式快速登入除錯,而載體的來源仍然被產生端管住。放寬解碼不擴大風險,是因為解碼本身不賦予任何信任 —— 它只把內容填進登入欄位,實際的授權判斷仍然發生在服務端。
兩種機制的分工
同一個系統裡同時存在可逆編碼與 HMAC 簽章時,它們回答的是不同問題。
| 面向 | 可逆編碼(XOR + Base64) | HMAC 簽章 |
|---|---|---|
| 解決什麼 | 內容不以明文出現在載體上 | 證明來源身分與內容未被竄改 |
| 金鑰位置 | 客戶端(隨程式發佈) | 兩端各自持有,不隨程式發佈 |
| 可逆 | 是,編碼解碼同一把金鑰 | 否,簽章無法還原出原文 |
| 失效條件 | 攻擊者取得程式 | 密鑰洩漏 |
| 適用邊界 | 低敏感內容、有後端把關 | 系統間身分驗證 |
兩者都不提供機密性。HMAC 的訊息本身明文傳輸,可逆編碼的金鑰在客戶端。需要內容真正不可讀時,走的是傳輸層加密或服務端持有金鑰的加密機制。
判讀與邊界
適合採用可逆編碼的訊號:載體會被不特定人看到、內容洩漏的後果可控、產生入口能限制在受控環境、服務端另有授權把關。後三項在「採用時該設下的邊界」各有對應的檢查動作,會議上被追問時直接跑那幾個動作,比複述訊號有說服力。
應該改用其他機制的訊號:憑證具備正式環境權限、載體會流通到組織外、缺少服務端的二次驗證、合規要求對憑證儲存有明確規範。
判定不適用時往哪走:改由服務端核發短期憑證,客戶端只負責掃描與轉送、不參與產生,載體上帶的是一次性的兌換碼而非憑證本身。金鑰必須留在裝置上時,交給作業系統的受保護儲存區(iOS Keychain、Android Keystore)而非程式碼常數 —— 這把金鑰移出了產出物,反編譯的路徑就不成立。落地資料本身要不可讀時走 At-Rest Encryption,傳輸過程走 TLS / mTLS;三者的責任分界,以及金鑰位置如何決定能對抗誰,見 7.28 密碼學原語選型。
這裡最容易走錯的一步是換演算法。把 XOR 換成 AES,金鑰仍然在客戶端時威脅模型完全沒有改變 —— 反編譯取得金鑰這條路徑照樣成立,變的是取得金鑰要多花的時間,不是能不能取得。有效的動作是移動金鑰的位置,不是提高演算法強度。
命名是這個決定留在程式碼裡的最後一道說明。編解碼工具的類別名帶上 Debug、文件註解寫明它提供的是混淆而非加密並指出金鑰在客戶端,呼叫端讀到名字時就知道邊界,不必回頭查文件。這比註解本身更耐久:註解會被略過,名字每次呼叫都會被讀到。