Bonds.tw 有備而來 · Bond for the Future 2026-08-16 EN · 中文

數位憑證皮夾實作筆記

做一個第三方持有端的時候,量到與撞到的東西。它補的是 denkeni 那份《Missing Manual》—— 那份講的是有哪些環境、走哪些流程,這一頁講的是文件與實際部署的差別、 規範被折彎的地方,以及哪些錯誤會通過你所有的測試然後在現場壞掉。

完整中文版,不是摘要——與 English 一起維護、同一個 commit 更新。 所有量測日期 2026-08-16,對象為正式環境公開端點,全部唯讀。

零、這一頁怎麼讀

底下每一條主張都掛了證據等級。這件事在這裡比平常重要,因為其中幾條是對一個政府系統的缺陷回報, 而一條經不起「你們實測過嗎」的主張,會連累其他站得住的。

這一頁沒有的東西:我們從來沒有真的領過一張卡。 底下沒有一件事需要發出一張憑證才做得到,所以也不要把它讀成「整條流程我們跑過而且會通」。 唯一需要送出表單的那一步,正是我們沒有走的那一步。

一、第三方皮夾這個角色不存在

不是「很難」。是文件、沙盒、資料模型裡都沒有這個角色。

角色要做什麼才能上場
發行端申請沙盒帳號 → 自架 → 跟團隊做 UAT → 正式
驗證端同上,換成驗證端那一套
皮夾(持有端)文件沒有寫。沒有申請表、沒有註冊、沒有 attestation。

沙盒帳號申請頁的對象是「產品、服務或程式之串接測試」,指向 issuer-sandboxverifier-sandbox。沒有 wallet-sandbox。 而在《Missing Manual》裡,標題叫 「If You're Building a Third-Party Issuer or Verifier Software」的那一節是空的, 第三方皮夾連標題都沒有。

  • 讀碼 client_id 不是名單,是回音 發行端自己簽一個 ID Token,aud 是寫死的字串 "moda_dw"twdiw-vc-handler/…/CredentialService.java)。OID4VCI handler 接著 從那個 aud 讀出 client_idtwdiw-oid4vci-handler/…/CredentialIssuer.java),存進 pre-authorized code。 到了 /token,皮夾送的 client_id 拿去跟那筆比對; proof JWT 的 iss 再跟同一個值比對一次。
    整條鏈驗的是「你送的字串等於你送的字串」。 資料庫 49 張表裡沒有任何一張是 client、wallet、app 或 device 的註冊表。 沒有 client_secret、沒有 client assertion、沒有 mTLS、 沒有 Play Integrity、沒有 App Attest、沒有 DeviceCheck。
    同一次閱讀還撞到兩件事:ID Token 的簽章被取出來之後從此沒有被使用AccessTokenFilter 的驗證邏輯整段被註解掉,旁邊留著 「尚未啟用 AccessToken 驗證」,而 /api/**permitAll
  • 小心 這件事的意思,以及不是什麼意思 表示任何人都能領到憑證。領卡真正的門檻是 pre-authorized code ——也就是那張 QR——加上可選的 tx_code。站得住而且更有力的說法是:
    拿得到那張 QR 的人,用任何自製皮夾都能走完全程; 官方 App 沒有任何密碼學上或註冊上的特權。
    還有一個讀原始碼消不掉的限制:我們沒有對正式的領卡端點送過任何一個請求, 所以無法排除網路層(WAF、API gateway、mTLS、IP 限制)另有管制, 而那些東西不會出現在 repo 裡。

二、did:key 的拼法不是你實作的那一種

皮夾最先死在這裡,而且是以一個離真正原因兩層遠的解析錯誤死掉的。

數位憑證皮夾用的是 multicodec 0xEB51jwk_jcs-pub, payload 是整份 JWK 的 JSON 文字。不是 p256-pub0x1200) ——後者帶的是 33 bytes 的壓縮點,而那是多數 did:key 實作對 P-256 產生的東西。 拿一筆真實的信任清單條目逐位元組解開:

129 bytes
前 3 bytes = D1 D6 03          # 0xEB51 的 LEB128 varint
其餘 126   = {"crv":"P-256","kty":"EC","x":"kY6ina…","y":"c5je-…"}

伺服器端把那三個位元組寫死切掉,multibase 字元不檢查、multicodec 也不檢查:

String did_hex_prefix = CryptoHelper.convertBinaryToHexString(Base58.decode(did.substring(1)));
String did_hex = did_hex_prefix.substring(6);   // 去掉prefix D1D603
String did_jwk = new String(CryptoHelper.hexStringToByteArray(did_hex));

所以你送一個 did:key:zDn… 過去,它會少掉三個位元組、然後被當成 JSON 解析。 你拿到的是一個 JSON 錯誤,而真正的原因——multicodec 不對——沒有任何地方會提到。

  • 實測 正式環境每一個發行者 DID 都是 JCS 正規的 正式信任清單 43 筆全部:multicodec 0xEB51 ×43、JWK 鍵序 crv,kty,x,y ×43、自簽章驗得過 ×43、內嵌 DID 文件的 id 相符 ×43、 header 的 jwk 與 DID 內嵌金鑰相符 ×43。 發行者那一側是乾淨的。
  • n=1 而官方皮夾自己的 holder DID 不合規 jwk_jcs-pub 的定義就是 JWK 經過 RFC 8785 的 JCS,鍵序必須 crv < kty < x < y。 我們手上那一個 holder DID 是 crv, x, y, kty——kty 掉到最後。
    ⚠️ 一個樣本,來自文件而不是我們自己跑出來的皮夾,而且沒有記錄 App 版本。 這足以決定解析器要怎麼寫,但不足以當缺陷回報。要回報得先從一個標明版本的 build 蒐集幾個 DID。
  • 規則 產生要嚴格,接受要寬鬆——而且永遠不要比較原始的 DID 字串 自己產生的一律正規化。不要要求接受的東西正規: 嚴格的解析器會拒絕真實世界唯一存在的那個台灣皮夾,而金鑰的位元組兩邊都不含糊。
    寬鬆會打開一個真的問題,因為下游一律拿 DID 當字串比對——JWS 的 kidissuer、出示者的 DID 對 credentialSubject.id。 關掉它的方式是:比較正規形式,也就是從金鑰算出來的那一份, 而不是比較收到的字串。兩個 DID 指同一把金鑰,恰好等價於它們的正規形式相同, 沒有任何拼法躲得掉。

測試向量。兩個都是真的,來自正式環境:

# 行政院-數位發展部(正規——必須逐字元 round-trip 回它自己)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrzifm9txeerMVc9oLUg2nBJJnUtgYcAYd35rw1rCLq8y3bLDDBUPH5yTYB7ocY7oPESPBXqubuwMcRzw9evbeHHyFkwsmDc43myibDChGhDk8zrgZDB4KNyXPiQvkktUwn

# 一個皮夾的 holder DID(非正規——但必須解得開)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPodrZSqMbCy9Ndu4UgUGy3RNkhH479eLPpbfAhVSNu7B4oJvUwLzyxiP4Jt5k9cqqmChanxAazTGxJMvGxYDApNkXeDW5MPZgZRkjRgD1yaig5KCEgAaVbg8zrvYjMTi1BzqdDpPpkeSFmJwiej9YNY

三、憑證是 SD-JWT,而沒有任何欄位會告訴你

這是最貴的一個坑,因為寫錯的那個版本會通過你想得到的每一個測試。

  • 實測 metadata 說 jwt_vc_json,而且是全部 GET https://issuer-oid4vci.wallet.gov.tw/api/issuer/00000000/.well-known/openid-credential-issuer → 701,000 bytes、882 組 credential configuration。
    format 分布:{jwt_vc_json: 882}。整份文件裡 sd-jwt 出現 0 次、_sd 0 次、 selectivedisclosure 各 0 次。
    實際回來的是 typ: vc+sd-jwt 加一串 ~ 分隔的揭露。 一個照規範讀 metadata 的 OID4VCI 客戶端,沒有任何欄位可以讓它發現這件事。 ~ 是唯一的訊號。
    (順帶一個營運面的數字:為了拿一組設定要下載 701 KB。抓一次就好。)
  • 實測 摘要算在 base64url 字串本身,不是解碼後的 JSON 兩個讀法都說得通,只有一個是對的。選錯的後果是你的皮夾對每一份誠實的揭露都找不到匹配, 然後把每一張真卡回報成偽造。
    沒有照規範推,直接拿正式資料驗:2025-10-07 一張真實駕照電子卡的六段揭露, 以及同一張憑證公開的兩個 _sd 值。
    base64url(SHA-256(揭露自己的 base64url 字串,ASCII)) 兩個都對得上。對解碼後的位元組做雜湊則一個都對不上。
    自己跑一次:
python3 - <<'EOF'
import hashlib, base64
b64u_e = lambda b: base64.urlsafe_b64encode(b).decode().rstrip('=')
D = "WyJwbHowWFN6LW9CSEUwZTUzTFVBeWNBIiwiaWRfbnVtYmVyIiwiQTIzNDU2Nzg5MCJd"
print(b64u_e(hashlib.sha256(D.encode('ascii')).digest()))
# ApkeYAR85EzxAHS1ojnNHhG7wnCDyTt4_iCIX2VKxaw  ← 憑證裡公開的那個 _sd 值
EOF
  • 讀碼 它是混血:SD-JWT 裝在 VCDM 1.1 的 vc _sd_sd_algvc.credentialSubject 底下,不在頂層。 型別是 vc.type[1],不是 vct。所以照 SD-JWT VC 寫的讀取器 會在錯的地方找,照 VCDM 寫的則根本不會把揭露切出來。兩種都不拋錯,兩種都是錯的。
    發行端與後端驗證端用的都是 com.authlete:sd-jwt,所以摘要語意是對的 ——只是那份 metadata 從頭到尾不承認這個格式存在。
  • 實測 statusListIndex 是字串 "statusListIndex": "35",不是 35。只接受數字的讀取器會把整個 credentialStatus 物件丟掉——而一張沒有 status 的憑證, 就是一張永遠不會被查撤銷的憑證。靜默,而且往看起來安全的方向失敗。

四、信任清單

比想像中小,錨定得比想像中好,而它的分類標籤絕對不能顯示給使用者。

  • 實測 正式環境 43 筆,而 size 不被遵守 GET https://frontend.wallet.gov.tw/api/did?size=20&page=0&orgType=1&status=1
    orgType=1 20 筆、orgType=2 23 筆、3 與 4 是 0。 用兩種頁大小(20 與 5)各自逐頁抓到空頁,兩次總數相同,識別碼全部不重複。
    ⚠️ 頁大小被夾在 20,但 offset 看起來是照你送的 size 算的, 所以 size=100&page=1 什麼都不會回。一頁一頁抓到空為止, 不要自己用 size 算 offset。
    43 筆裝得進 App。 這件事改變了一個離線皮夾可以做什麼。
  • 實測 不要顯示 orgGroupDetail.name 43 筆全部標成「政府部門」。這 43 筆裡面包括全家便利商店、統一超商、中華電信、 台灣大哥大、遠傳電信,以及一批民間公司。
    任何照字面顯示這個欄位的介面,都會告訴使用者全家便利商店是政府部門。 真正區分角色的看起來是 orgType 1/2——公路局兩邊都有,超商只在 2, 而超商正是電信憑證那個「超商取貨」情境的驗證方。
  • 實測 鏈上存的是完整文件,不是雜湊 Arbitrum One,合約 0x84172caf8dd126c76f1fa8a2733ca3233264d31f。 數發部那一筆錨定交易的 calldata 有 2,596 bytes,內容是完整的 DID 字串與 完整的自簽 DID 文件 JWS 的明文sha256(DID) 不在裡面。
    這比「刻在石碑上」那句宣傳更強:資料本身在鏈上,所以客戶端原則上可以 完全不信任 frontend.wallet.gov.tw
    ⚠️ 但我們只驗了一筆交易,而且那筆的 tx hash 是那個 API 給的。 要證明這是一條真的可用的無信任路徑,得示範「只給合約位址就能列舉全部 43 筆」。沒有做。
  • n=1 43 筆裡有 41 筆上鏈——那兩個例外值得知道 中國醫藥大學出現兩次(兩個 orgType 各一), onChainHistory 是空的、x509_typeXCA, 而且雖然登記在發行端那個 orgType 底下, issuerMetadataBaseURL 卻是 null
    ⚠️ 這是那個 API 自己的自述。我們沒有去鏈上找它們, 所以「沒上鏈」在這裡的意思是「API 說沒有」——考慮到錨定本來就是為了不必相信那個 API, 這是循環的。

五、撤銷這條路沒有離線的信任錨點

憑證那條路錨在公鏈上。撤銷這條路只有一個 URL。

  • 實測 簽撤銷清單的金鑰不在發行者的 DID 裡 抓一份撤銷清單,用兩種方式各驗一次:
    • 用它自己 issdid:key 內嵌金鑰 → 驗不過
    • 用 jku 那組的 key-2驗得過
    GET https://issuer-vc.wallet.gov.tw/api/keys 回兩把。 key-1 就是發行者 DID 內嵌的那把,簽憑證; key-2 簽撤銷清單,而它不在任何 DID 文件裡、不在信任清單裡、 不在任何鏈上紀錄裡。那份 DID 文件只有一個 verification method。
    所以:查一張憑證有沒有被撤銷,信任基礎只有 TLS。 把清單快取起來也沒用——離線一樣驗不了它的簽章。
  • 實測 有效期是「上次被簽之後 24 小時」,不是「抓下來之後」 nbfexp 剛好 86,400 秒。但 nbf這份清單上一次被簽的時間,不是你抓下來的時間。
    所以一份剛下載好的撤銷清單,剩餘效期介於 24 小時與趨近於零之間。 介面上要顯示清單自己帶的 exp,永遠不要跟使用者說「24 小時內有效」。
    清單本身很便宜:壓縮後 76 字元、解壓 16,384 bytes、容量 131,072 bits, 量測當下有 84 個位元是 1。常常抓是可行的,問題只在離線的那一段。
    ⚠️ 重簽節奏是 n=2(一個 2025 年的樣本是發卡時重簽, 一個 2026 年的落在每日邊界)。不要拿那個時刻去設計預抓排程; 關於剩餘效期的結論不受影響,因為它只需要 exp 是絕對時間。

六、證明年齡會交出生日,而卡片本身會出賣自己

兩張完整的駕照電子卡,端到端驗過。分隔「已滿 18」與「未滿 18」的只有一個揭露 ——而沒有任何辦法在不交出生日的情況下送出它。

  • 實測 兩張都驗得過,而且只差一個欄位 取得兩張完整的 SD-JWT 並逐項檢查:六個揭露全部重算得出它們的 _sd 摘要 (兩張各 6/6),兩張的 ES256 簽章都用線上抓下來的 key-1 驗得過。
    兩張結構完全相同。nameid_numbertypecontrolnumbergDate 五項的值在兩張裡一樣; 只有 roc_birthday 不同——1040605(民國 104 → 2015-06-05,未成年) 對 0570605(民國 057 → 1968-06-05,成年)。鹽不同所以摘要不同, 其餘就是同一份文件。
  • 實測 沒有年齡述詞——要證明滿 18 就得交出生日 沒有 range proof、沒有 age_over_18 這種布林欄位、沒有任何衍生值。 用這張憑證回答「你滿 18 了嗎」的唯一辦法,是把 roc_birthday 完整揭露,精確到日
    那正是超商那個最普通的情境,也正是選擇性揭露最常被拿來當賣點的情境。 機制本身是在的而且是對的——SD-JWT、加鹽、逐欄位——而它仍然沒辦法在不交出 一個把持有人縮得比問題所需窄得多的日期的前提下,回答這個問題。
  • 實測 你扣住了幾個欄位是看得見的,是哪幾個則猜得到 _sd 剛好六個摘要,而定義那六個欄位的 credential configuration 公開在 issuer metadata 裡誰都讀得到。所以一個收到四個揭露的查驗方, 知道有兩個被扣住,也知道它們是從哪一份六項的清單裡挑的。
    扣住仍然值得做——值本身是藏住的——但它不是隱形的, 而一個把它講成「他們不會知道」的皮夾是說過頭了。
  • 實測 每張卡都帶著一個唯一索引,明文,每次出示都送 credentialStatus.statusListIndex 是逐張憑證的:這兩張一張是 #20、一張是 #19。它不可選擇性揭露 ——它在簽過的 payload 裡、在 _sd 之外,所以每一次、對每一個查驗方都會送出去。
    兩個查驗方對一下就知道他們看過同一張卡,不需要任何其他欄位。 這是 StatusList2021 的本質而不是 TWDIW 的失誤——索引正是撤銷之所以查得到的原因。 會寫出來是因為一個皮夾的選擇性揭露介面,很容易讓人以為情況相反。

七、皮夾裡的卡,離線時沒有名字

把卡片列做出來、render 出來、看了一眼才發現的。那一列長這樣: 00000000_demo_drivinglicense_202504251418

  • 實測 憑證裡唯一能拿來稱呼它的欄位是一個機器識別碼 vc.type[1] 是皮夾唯一叫得出它名字的欄位,而它是發行系統的內部識別碼: 四十一個字元,在 390pt 寬的螢幕上折成兩行。人看得懂的名稱在發行者 metadata 的 credential_configurations_supported[…].display[].name—— 那是一個 HTTPS 端點(實測 701 KB、882 組設定)。
    所以一個離線的皮夾,無法告訴持有人他手上這張卡叫什麼。 站在櫃檯前,他看到的是一串底線和數字,要自己認出哪一張是駕照。
  • 實測 而且沒有任何簽章覆蓋那個名稱,所以快取解決不了 這跟撤銷不一樣。撤銷至少還有一個講得清楚的降級(「離線超過 24 小時就不可知」)。 名稱抓一次快取起來,那份快取不是憑證的一部分——沒有東西簽它—— 所以顯示快取標籤的皮夾,顯示的是它自己的筆記,不是發行者的宣稱。 離線要拿到一個可信的卡片名稱,這條路根本不存在。
  • 程式碼 我們怎麼處理,以及刻意不做什麼 CardInventory.readableType 拿掉兩段對持有人毫無意義的東西—— 開頭全是數字的發行者代碼,結尾 8–14 位數字的核發時間戳——其餘原樣輸出。 上面那一列因此變成 demo_drivinglicense
    刻意不做對照表。一張「已知型別→中文名稱」的表, 對表上沒有的型別就是一個猜測,而沒在表上的型別正是猜錯最要命的地方。 兩端的裁切都是條件式的,裁完會變空字串就原樣輸出。
    這改善了可讀性,但沒有解決問題demo_drivinglicense 依然是發行系統的字,不是持有人的話。真正的解法在發行端,所以它是下面的第 8 件。

八、八件值得回報上游的事

依使用者受害程度排,不是依技術嚴重度。第 6、7 件因為證據不足而暫緩, 列在這裡是為了讓暫緩的理由看得見,而不是默默不提。

  • 讀碼 一、選擇性揭露可能送出使用者沒有勾選的欄位 皮夾 SDK 決定保留哪些揭露的方法,是拿要求的欄位名去對整段解碼後的 JSON 做 子字串比對——而那段 JSON 包含鹽與值:
    String decoded = utf8.decode(base64.decode(base64Url)); if (decoded.contains(field)) { … }
    駕照同時有 id_numbercontrolnumber,兩個都含子字串 number。值裡若出現另一個欄位的名稱也會誤中;隨機的鹽理論上也可能命中。 另外那個 continue 在內層迴圈,所以一個揭露同時命中兩個欄位會被附加兩次。
    使用者勾選「只揭露 A」,可能送出 A 與 B。 這是這個功能的核心承諾,也是這一頁唯一一條不需要解釋為什麼有害的缺陷。
    ⚠️ 只有原始碼。沒有記錄 App 版本,也沒有在流量上觀察到。
  • 讀碼 二、四個對外抓取關閉了 TLS 主機名驗證 發行者公鑰、撤銷清單、schema、以及 frontend 的 DID 查詢,四個都傳 setAllowedHostnames(null)。而在 HttpUtils 裡那不是 「不設清單、走系統預設」——自訂的 HostnameVerifier 無條件回傳 true。那四條連線上, TLS 憑證與主機名之間的繫結消失了。
    RFC 8725 §3.8 明文要求 jku 必須限制在受信任 URL 的允許清單。
    ⚠️ 只有原始碼;而且這描述的是公開的驗證端程式,由各驗證機關自行部署, 我們無法確認任何一個實際部署跑的是什麼。
  • 讀碼 三、撤銷清單與 schema 在發行者簽章驗證完成前就被抓取 validateVC() 把六個步驟當成同時起飛的 future:抓撤銷清單(第 3 步) 與抓 schema(第 5 步)都比驗發行者簽章(第 6 步)早,而那兩個 URL 取自 尚未驗簽的 payload。配上第二條,任何能對驗證端 POST 一份 VP 的人, 都能讓它對任意 URL 發出 GET。
    另外 StatusListCheckTask 沒有 DID 優先那一段, 直接走 loadIssuerPublicKey(jku, kid)。這是 jku 這個模式在 正式環境真正活著的那個實例,而它是自我指涉的:清單自己說用哪把鑰匙驗自己。
  • 讀碼 四、client_id 不帶任何資訊,而且兩道檢查是關的 見第一節。ID Token 的簽章從未被驗;AccessTokenFilter 的驗證被註解掉。
  • 實測 五、format 沒有描述憑證 882 組全部。見第三節。
  • 讀碼 六、VP 物件用 context 而不是 @context 皮夾 SDK 裡三個產生 VP 的函式全部寫 'context',少了 at 符號。 搜尋 VCDM 的 context URL 只找到那三處,也就是說 系統裡沒有任何一條路徑寫對
    ⚠️ 我們一開始把它描述成「JSON-LD 處理會靜默丟掉主張」。那是錯的 ——TWDIW 的驗證端根本沒有做 JSON-LD 展開,所以今天沒有東西被丟掉。 準確的說法是:這是一個會咬到任何有做展開的第三方驗證端的互通性缺陷, 不是一個正在造成資料遺失的 bug。值得回報,但不值得用我們一開始那個寫法回報。
  • n=1 七、holder DID 不符合 JCS——暫緩 見第二節。一個樣本、二手、沒有 App 版本。在拿到一個標明 build 的幾個 DID 之前不回報。 列出來是為了讓這個缺口看得見。
  • 實測 八、憑證沒有攜帶任何離線可讀的名稱 見第七節。vc.type[1] 是內部識別碼;人類可讀名稱只存在於線上 metadata, 而且不在任何簽章的覆蓋範圍內。
    建議的修法:把 display name 放進 VC 本身——例如 vc.name,或 SD-JWT VC 的 vct 搭配一份有簽章覆蓋的型別 metadata——讓離線皮夾能顯示持有人認得的名稱。
    受害程度排序上它不高,沒有人因此被冒用。但它是這張清單上唯一 每一位持有人、每一次打開皮夾都會遇到的,而其他多半不會。
  • 設計 還有一件不算缺陷的:發行者換不了金鑰 發行者的身分是一個 did:key——金鑰就是身分。 所以輪替簽章金鑰等於變成另一個發行者,而所有已經發出去的憑證的 iss 從此不在信任清單裡。皮夾將無法區分 「這個發行者被撤下了」與「它換了金鑰、舊卡其實還是真的」。
    這是這一頁唯一一件會讓已經在使用者手上的卡集體變成無法評估的事。

九、我們沒有驗證過什麼

列出來是因為一頁只有發現而沒有這一節,等於在邀請別人過度解讀。