mashbean 黃豆泥
mashbean.eth
豆泥的以太坊地址:mashbean.eth
狀態:未簽名
0xbf8ab17a6aaf936e2edc3944e49431b357b78a28f2d0c62b9fbd7ca22c0d7961
已簽名表示這篇文章已建立獨特的身分證字號(內容雜湊,contentHash)並且由豆泥簽署認證,簽署是採用以太坊區塊鏈的豆泥專用地址(signer.mashbean.eth)。只要內容一經修改,就會需要重新驗證換發新的身分證字號。但豆泥不是每天都在公所上班,所以偶爾會慢一點認證。
數位發展部的數位憑證皮夾把整套系統開源了,MIT 授權,發行端、驗證端、Android 與 iOS 的持有端 App、Flutter SDK 都在 GitHub 上。denkeni 另外寫了一份《TWDIW: The Missing Manual》,把「有哪些環境、走哪些流程」整理得很清楚,我做的時候幾乎全靠它。
那份手冊裡有一節標題叫「If You’re Building a Third-Party Issuer or Verifier Software」,內容是空的。第三方皮夾連標題都沒有。
我這幾天在做一個離線優先的皮夾,順手把官方那套接起來,撞到一批文件上找不到、而且不知道就一定會做錯的事。這篇是那批東西的清單。
底下的內容可以整份貼進 Claude Code 或 Codex 當工作說明書,或存成 repo 的 AGENTS.md。我寫的時候刻意假設讀的是一個 agent:規格給到位元組,關卡給到可以自己跑一次確認對錯。
不過有幾步不該讓 agent 自己決定,我在最後一節列出來。那幾步的共同點是:做錯了不會有 exception,會有一個真人在櫃檯前面被拒絕。
範圍只到持有端。第三方發行端與驗證端要申請沙盒帳號、自架、跟團隊做 UAT,那是另一件事,這篇不教。
所有數字的量測日期是 2026 年 8 月 16 日,對象是正式環境的公開端點,全部唯讀。
大部分 did:key 實作對 P-256 產生的是 did:key:zDn…,multicodec 是 p256-pub(0x1200),payload 是 33 bytes 的壓縮點。數位憑證皮夾用的不是這個。
它用 jwk_jcs-pub,multicodec 0xEB51,而 payload 是整份 JWK 的 JSON 文字。把一個真實的機關 DID base58 解開來逐位元組看:
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 不對——沒有任何地方會提到。這是我卡最久的一關。
jwk_jcs-pub 這個 multicodec 的定義就是 JWK 經過 RFC 8785 的 JCS 正規化,鍵序必須 crv < kty < x < y。
我把正式環境信任清單裡 43 個發行者 DID 全部解開比對過,43 個全部合規,自簽章也 43 個全部驗得過。發行者那一側是乾淨的。
倒是官方皮夾自己產生的 holder DID,我手上那一個是 crv, x, y, kty——kty 掉到最後,不合規。這一條我只有一個樣本、而且來自文件不是我自己跑出來的,所以我沒有拿它去回報,但它足以決定解析器要怎麼寫:
自己產生的一律 JCS 正規;解析別人的一律不要求正規。
不對稱是刻意的。正規化是你對自己的義務,拿它去否決別人只會讓你無法跟真實世界唯一存在的那個皮夾互通。
寬鬆是有代價的。下游一律拿 DID 當字串比對——JWS 的 kid 對 issuer、出示者的 DID 對 credentialSubject.id——兩種拼法能指同一把金鑰,字串不等就不再蘊含不同持有人。付這個代價的方式是:比較正規形式,不要比較原字串。兩個 DID 指同一把金鑰,恰好等價於它們的正規形式相同,而正規形式是從金鑰算出來的,沒有任何拼法躲得掉。
這兩個是真的 DID,可以直接當測試向量。第一個必須逐字元 round-trip 回它自己,第二個必須解得開、而且不可以被你的正規化檢查拒絕:
# 行政院-數位發展部(正規)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrzifm9txeerMVc9oLUg2nBJJnUtgYcAYd35rw1rCLq8y3bLDDBUPH5yTYB7ocY7oPESPBXqubuwMcRzw9evbeHHyFkwsmDc43myibDChGhDk8zrgZDB4KNyXPiQvkktUwn
# 一個皮夾的 holder DID(非正規,但必須解得開)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPodrZSqMbCy9Ndu4UgUGy3RNkhH479eLPpbfAhVSNu7B4oJvUwLzyxiP4Jt5k9cqqmChanxAazTGxJMvGxYDApNkXeDW5MPZgZRkjRgD1yaig5KCEgAaVbg8zrvYjMTi1BzqdDpPpkeSFmJwiej9YNY
這是整件事裡最貴的一個坑,因為寫錯的那個版本會通過你想得到的每一個測試。
.well-known/openid-credential-issuer 那份 metadata,我抓下來是 701,000 bytes、882 組 credential configuration。format 的分布是:
{'jwt_vc_json': 882}
882 組全部宣告 jwt_vc_json。而整份 701 KB 的文件裡,sd-jwt 這個字串出現 0 次,_sd 出現 0 次,selective 與 disclosure 各 0 次。
實際拿到的憑證是 typ: vc+sd-jwt,後面接一串用 ~ 分隔的揭露。一個照規範讀 metadata 的客戶端,沒有任何欄位可以讓它知道這件事。~ 是唯一的訊號。
SD-JWT 的 _sd 是揭露的摘要。摘要算在哪一串位元組上,有兩個都說得通的讀法:base64url 字串本身,或是解碼後的 JSON 陣列。選錯的後果是你的皮夾對每一份誠實的憑證都找不到匹配,然後把每一張真卡回報成偽造。
我沒有照規範推,直接拿正式資料驗。這是一張真的駕照電子卡的其中一段揭露,以及它 _sd 裡公開的一個值:
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 ← 對得上
print(b64u_e(hashlib.sha256(base64.urlsafe_b64decode(D + '==')).digest()))
# UQVsnphmjwdTgQaLXRZtznZngyaAvuIuCVG35pp5OF0 ← 對不上
所以是前者:base64url(SHA-256(揭露的 base64url 字串,ASCII))。對解碼後的位元組做雜湊一個都對不上。
發行端與後端驗證端用的是 Authlete 的 sd-jwt 函式庫,語意是對的——只是那份 metadata 從頭到尾不承認這個格式存在。
TWDIW 的 SD-JWT 不是標準的 SD-JWT VC。_sd 與 _sd_alg 放在 vc.credentialSubject 底下,型別是 vc.type[1] 而不是 vct。也就是 SD-JWT 裝在一份 VCDM 1.1 憑證裡面。
照 SD-JWT VC 寫的讀取器會在錯的地方找,照 VCDM 寫的則根本不會把揭露切出來。兩種都不會拋錯,兩種都是錯的。
還有一個小的:statusListIndex 是字串,"35" 不是 35。只當數字讀的話整個 credentialStatus 物件會被丟掉,而一張沒有 status 的憑證就是一張永遠不會被查撤銷的憑證。往安全的方向靜默失敗,最難發現。
正式環境的信任清單一共 43 筆——orgType=1 有 20 筆、orgType=2 有 23 筆,3 與 4 是空的。我用 size=20 與 size=5 兩種頁大小各自逐頁抓到空頁,兩次都是同樣的總數、id 全部不重複。
GET https://frontend.wallet.gov.tw/api/did?size=20&page=0&orgType=1&status=1
size 這個參數要小心:頁大小被夾在 20,但 offset 看起來是照你送的 size 算的。所以 size=100&page=1 什麼都不會回。一頁一頁抓到空為止,不要自己用 size 算 offset。
43 筆的意思是它裝得進 App。對一個離線優先的皮夾來說,這件事改變了可以做什麼。
orgGroupDetail.name 不可以顯示給使用者43 筆的 orgGroupDetail.name 全部是「政府部門」。這 43 筆裡面包括全家便利商店、統一超商、中華電信、台灣大哥大、遠傳電信,以及一批民間公司。
任何把這個欄位照字面畫在卡片上的介面,都會告訴使用者全家便利商店是政府部門。
真正區分角色的看起來是 orgType:公路局兩邊都有,超商只出現在 2——而超商正是電信憑證那個「超商取貨」情境的驗證方。
信任清單有錨定在 Arbitrum 上。我去看數發部那一筆交易,calldata 有 2,596 bytes,裡面是完整的 DID 字串與完整的自簽 DID 文件 JWS 的明文,不是 sha256(DID)。
這比「刻在石碑上」那句宣傳更強:資料本身在鏈上,所以客戶端原則上可以完全不信任那個 API。
不過我只驗了一筆交易,而且那筆的 tx hash 是 API 給我的。要證明這是一條真的可用的無信任路徑,得示範「只給合約位址就能列舉全部 43 筆」——我沒有做。
StatusList2021。撤銷清單是一個 JWT,nbf 到 exp 剛好 86,400 秒。
我本來以為驗它的金鑰就在發行者的 DID 裡,結果驗不過。抓 jku 那組來看:
key-1 x=dnQ2W9ZTsILYac3XdcvxrYNgIgjSkGJUMecMXVJk7XM ← 與 iss 的 DID 內嵌相同,簽憑證
key-2 x=9CNEmxkQimYxZtsoLuHyu2w_dHrVWrXapZzpYE0qm78 ← 簽撤銷清單,DID 裡沒有
而那份 DID 文件只有一個 verificationMethod。
所以簽撤銷清單的那把金鑰,不在任何 DID 文件裡、不在信任清單裡、不在任何鏈上紀錄裡,只存在於一個 URL。查一張憑證有沒有被撤銷,信任基礎就只有 TLS。把清單快取起來也沒用,離線一樣驗不了它的簽章。
還有一個容易寫錯的地方:nbf 是這份清單上一次被簽的時間,不是你抓下來的時間。所以一份剛下載好的清單,剩餘效期介於 24 小時與趨近於零之間。介面上要顯示的是清單自己帶的 exp,不是「24 小時內有效」。
好消息是它很小:壓縮後 76 字元、解壓 16 KB、容得下 131,072 張憑證。常常抓是可行的,問題只在離線的那一段。
摘要算在解碼後的 JSON 上。單元測試裡你自己產生揭露、自己算摘要、自己比對,永遠是綠的。接上真的憑證才會發現每一張都對不上。
跟隨 jku。header 給你一個 URL 說去那裡拿驗它的金鑰,聽起來很方便。正式環境 43 個發行者的 DID 全部內嵌了可用的金鑰,所以跟隨 jku 拿不到任何多的東西,只是多一個由被驗文件自己指定的網路來源。要 jku 與 kid 就解出來存著當診斷資訊,不要讓它參與判定。
用 allIDs().first 挑要出示的卡。我自己中了這一個:自發行卡的 id 是 national-id,而數位憑證皮夾的識別碼來自 credential configuration、以數字開頭,數字排在字母前面。第一張官方卡存進來的那一刻,出示流程會靜默改指它。要用「這是哪一種卡」來挑,不要用排序。
client_id 送什麼。官方 App 送的是 moda_dw,而那個值不是任何名單上的東西——發行端自己簽的 ID Token 把 aud 寫死成它,OID4VCI handler 再從那個 aud 讀出 client_id,最後拿它跟自己剛存的那筆比對。整條鏈驗的是「你送的字串等於你送的字串」。
技術上你送 moda_dw 一定會通。但那是冒用另一個 App 的識別字串,而且如果所有第三方都這樣做,這個欄位永遠不會有機會變成有意義的東西。這是一個要人決定的事,不是 agent 該替你決定的。
送出任何表單。demo 站領測試卡要在網頁上填表,那一步涉及同意服務條款。讓 agent 幫你讀、幫你準備,不要讓它幫你按。
決定要不要回報缺陷、以及怎麼措辭。我在自己的紀錄裡列了七件要回報上游的事,其中兩件標著「暫緩」——一件是因為我一開始寫的因果機制根本是錯的(我說 JSON-LD 展開會靜默丟掉主張,但那套驗證端根本沒做 JSON-LD 展開),一件是因為只有一個樣本而且沒記錄 App 版本。一條經不起「你們實測過嗎」的主張,會連累其他站得住的。
領卡我從來沒有真的跑完過。上面關於發卡流程的每一句都是讀原始碼讀出來的,而唯一需要送表單的那一步,正是我沒有走的那一步。
讀原始碼也不等於知道部署上去的 build 在做什麼。那個 repo 最後一次 push 是 2026 年 7 月底,我讀的是 HEAD。
statusListIndex 是字串、六個欄位都是字串——這些都是同一張駕照電子卡的觀察。882 組設定裡的電信、超商取貨那些卡,完全可能帶非字串的值。這件事的失敗形態不好:嚴格要求三元素字串陣列的讀取器會拒收整張卡,於是一張真卡被判成壞卡。
完整的量測紀錄、可重跑的指令、以及要回報上游的清單,我另外寫成一頁英文的 TWDIW Field Notes,那一頁是拿來補 denkeni 手冊裡空著的那一節的。
參考資料: