做一個第三方持有端的時候,量到與撞到的東西。它補的是 denkeni 那份《Missing Manual》—— 那份講的是有哪些環境、走哪些流程,這一頁講的是文件與實際部署的差別、 規範被折彎的地方,以及哪些錯誤會通過你所有的測試然後在現場壞掉。
完整中文版,不是摘要——與 English 一起維護、同一個 commit 更新。 所有量測日期 2026-08-16,對象為正式環境公開端點,全部唯讀。底下每一條主張都掛了證據等級。這件事在這裡比平常重要,因為其中幾條是對一個政府系統的缺陷回報, 而一條經不起「你們實測過嗎」的主張,會連累其他站得住的。
moda-gov-tw/TWDIW-official-app,2026-08-16 讀 HEAD(最後 push 2026-07-27)。⚠️ 讀公開的原始碼不等於知道部署上去的 build 在做什麼。 凡是主張講的是線上行為、而證據只到原始碼的,我都會註明。
這一頁沒有的東西:我們從來沒有真的領過一張卡。 底下沒有一件事需要發出一張憑證才做得到,所以也不要把它讀成「整條流程我們跑過而且會通」。 唯一需要送出表單的那一步,正是我們沒有走的那一步。
不是「很難」。是文件、沙盒、資料模型裡都沒有這個角色。
| 角色 | 要做什麼才能上場 |
|---|---|
| 發行端 | 申請沙盒帳號 → 自架 → 跟團隊做 UAT → 正式 |
| 驗證端 | 同上,換成驗證端那一套 |
| 皮夾(持有端) | 文件沒有寫。沒有申請表、沒有註冊、沒有 attestation。 |
沙盒帳號申請頁的對象是「產品、服務或程式之串接測試」,指向
issuer-sandbox 與 verifier-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_id
(twdiw-oid4vci-handler/…/CredentialIssuer.java),存進 pre-authorized code。
到了 /token,皮夾送的 client_id 拿去跟那筆比對;
proof JWT 的 iss 再跟同一個值比對一次。
client_secret、沒有 client assertion、沒有 mTLS、
沒有 Play Integrity、沒有 App Attest、沒有 DeviceCheck。
AccessTokenFilter 的驗證邏輯整段被註解掉,旁邊留著
「尚未啟用 AccessToken 驗證」,而 /api/** 是 permitAll。
tx_code。站得住而且更有力的說法是:
皮夾最先死在這裡,而且是以一個離真正原因兩層遠的解析錯誤死掉的。
數位憑證皮夾用的是 multicodec 0xEB51(jwk_jcs-pub),
payload 是整份 JWK 的 JSON 文字。不是 p256-pub(0x1200)
——後者帶的是 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 不對——沒有任何地方會提到。
0xEB51 ×43、JWK 鍵序
crv,kty,x,y ×43、自簽章驗得過 ×43、內嵌 DID 文件的 id 相符 ×43、
header 的 jwk 與 DID 內嵌金鑰相符 ×43。
發行者那一側是乾淨的。
jwk_jcs-pub 的定義就是 JWK 經過 RFC 8785 的 JCS,鍵序必須
crv < kty < x < y。
我們手上那一個 holder DID 是 crv, x, y, kty——kty 掉到最後。
kid 對 issuer、出示者的 DID 對 credentialSubject.id。
關掉它的方式是:比較正規形式,也就是從金鑰算出來的那一份,
而不是比較收到的字串。兩個 DID 指同一把金鑰,恰好等價於它們的正規形式相同,
沒有任何拼法躲得掉。
測試向量。兩個都是真的,來自正式環境:
# 行政院-數位發展部(正規——必須逐字元 round-trip 回它自己)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrzifm9txeerMVc9oLUg2nBJJnUtgYcAYd35rw1rCLq8y3bLDDBUPH5yTYB7ocY7oPESPBXqubuwMcRzw9evbeHHyFkwsmDc43myibDChGhDk8zrgZDB4KNyXPiQvkktUwn
# 一個皮夾的 holder DID(非正規——但必須解得開)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPodrZSqMbCy9Ndu4UgUGy3RNkhH479eLPpbfAhVSNu7B4oJvUwLzyxiP4Jt5k9cqqmChanxAazTGxJMvGxYDApNkXeDW5MPZgZRkjRgD1yaig5KCEgAaVbg8zrvYjMTi1BzqdDpPpkeSFmJwiej9YNY
這是最貴的一個坑,因為寫錯的那個版本會通過你想得到的每一個測試。
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 次、
selective 與 disclosure 各 0 次。
typ: vc+sd-jwt 加一串 ~ 分隔的揭露。
一個照規範讀 metadata 的 OID4VCI 客戶端,沒有任何欄位可以讓它發現這件事。
~ 是唯一的訊號。
_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
vc 裡
_sd 與 _sd_alg 在 vc.credentialSubject 底下,不在頂層。
型別是 vc.type[1],不是 vct。所以照 SD-JWT VC 寫的讀取器
會在錯的地方找,照 VCDM 寫的則根本不會把揭露切出來。兩種都不拋錯,兩種都是錯的。
com.authlete:sd-jwt,所以摘要語意是對的
——只是那份 metadata 從頭到尾不承認這個格式存在。
statusListIndex 是字串
"statusListIndex": "35",不是 35。只接受數字的讀取器會把整個
credentialStatus 物件丟掉——而一張沒有 status 的憑證,
就是一張永遠不會被查撤銷的憑證。靜默,而且往看起來安全的方向失敗。
比想像中小,錨定得比想像中好,而它的分類標籤絕對不能顯示給使用者。
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)各自逐頁抓到空頁,兩次總數相同,識別碼全部不重複。
size 算的,
所以 size=100&page=1 什麼都不會回。一頁一頁抓到空為止,
不要自己用 size 算 offset。
orgGroupDetail.name
43 筆全部標成「政府部門」。這 43 筆裡面包括全家便利商店、統一超商、中華電信、
台灣大哥大、遠傳電信,以及一批民間公司。
orgType 1/2——公路局兩邊都有,超商只在 2,
而超商正是電信憑證那個「超商取貨」情境的驗證方。
0x84172caf8dd126c76f1fa8a2733ca3233264d31f。
數發部那一筆錨定交易的 calldata 有 2,596 bytes,內容是完整的 DID 字串與
完整的自簽 DID 文件 JWS 的明文。sha256(DID) 不在裡面。
frontend.wallet.gov.tw。
orgType 各一),
onChainHistory 是空的、x509_type 是 XCA,
而且雖然登記在發行端那個 orgType 底下,
issuerMetadataBaseURL 卻是 null。
憑證那條路錨在公鏈上。撤銷這條路只有一個 URL。
iss 的 did:key 內嵌金鑰 → 驗不過
jku 那組的 key-2 → 驗得過
GET https://issuer-vc.wallet.gov.tw/api/keys 回兩把。
key-1 就是發行者 DID 內嵌的那把,簽憑證;
key-2 簽撤銷清單,而它不在任何 DID 文件裡、不在信任清單裡、
不在任何鏈上紀錄裡。那份 DID 文件只有一個 verification method。
nbf 到 exp 剛好 86,400 秒。但 nbf 是
這份清單上一次被簽的時間,不是你抓下來的時間。
exp,永遠不要跟使用者說「24 小時內有效」。
exp 是絕對時間。
兩張完整的駕照電子卡,端到端驗過。分隔「已滿 18」與「未滿 18」的只有一個揭露 ——而沒有任何辦法在不交出生日的情況下送出它。
_sd 摘要
(兩張各 6/6),兩張的 ES256 簽章都用線上抓下來的 key-1 驗得過。
name、id_number、type、
controlnumber、gDate 五項的值在兩張裡一樣;
只有 roc_birthday 不同——1040605(民國 104 → 2015-06-05,未成年)
對 0570605(民國 057 → 1968-06-05,成年)。鹽不同所以摘要不同,
其餘就是同一份文件。
age_over_18 這種布林欄位、沒有任何衍生值。
用這張憑證回答「你滿 18 了嗎」的唯一辦法,是把 roc_birthday
完整揭露,精確到日。
_sd 剛好六個摘要,而定義那六個欄位的 credential configuration
公開在 issuer metadata 裡誰都讀得到。所以一個收到四個揭露的查驗方,
知道有兩個被扣住,也知道它們是從哪一份六項的清單裡挑的。
credentialStatus.statusListIndex 是逐張憑證的:這兩張一張是
#20、一張是 #19。它不可選擇性揭露
——它在簽過的 payload 裡、在 _sd 之外,所以每一次、對每一個查驗方都會送出去。
把卡片列做出來、render 出來、看了一眼才發現的。那一列長這樣:
00000000_demo_drivinglicense_202504251418。
vc.type[1] 是皮夾唯一叫得出它名字的欄位,而它是發行系統的內部識別碼:
四十一個字元,在 390pt 寬的螢幕上折成兩行。人看得懂的名稱在發行者 metadata 的
credential_configurations_supported[…].display[].name——
那是一個 HTTPS 端點(實測 701 KB、882 組設定)。
CardInventory.readableType 拿掉兩段對持有人毫無意義的東西——
開頭全是數字的發行者代碼,結尾 8–14 位數字的核發時間戳——其餘原樣輸出。
上面那一列因此變成 demo_drivinglicense。
demo_drivinglicense
依然是發行系統的字,不是持有人的話。真正的解法在發行端,所以它是下面的第 8 件。
依使用者受害程度排,不是依技術嚴重度。第 6、7 件因為證據不足而暫緩, 列在這裡是為了讓暫緩的理由看得見,而不是默默不提。
String decoded = utf8.decode(base64.decode(base64Url)); if (decoded.contains(field)) { … }
id_number 與 controlnumber,兩個都含子字串
number。值裡若出現另一個欄位的名稱也會誤中;隨機的鹽理論上也可能命中。
另外那個 continue 在內層迴圈,所以一個揭露同時命中兩個欄位會被附加兩次。
setAllowedHostnames(null)。而在 HttpUtils 裡那不是
「不設清單、走系統預設」——自訂的 HostnameVerifier
無條件回傳 true。那四條連線上,
TLS 憑證與主機名之間的繫結消失了。
jku 必須限制在受信任 URL 的允許清單。
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 組全部。見第三節。
context 而不是 @context
皮夾 SDK 裡三個產生 VP 的函式全部寫 'context',少了 at 符號。
搜尋 VCDM 的 context URL 只找到那三處,也就是說
系統裡沒有任何一條路徑寫對。
vc.type[1] 是內部識別碼;人類可讀名稱只存在於線上 metadata,
而且不在任何簽章的覆蓋範圍內。
vc.name,或 SD-JWT VC 的 vct 搭配一份有簽章覆蓋的型別
metadata——讓離線皮夾能顯示持有人認得的名稱。
did:key——金鑰就是身分。
所以輪替簽章金鑰等於變成另一個發行者,而所有已經發出去的憑證的
iss 從此不在信任清單裡。皮夾將無法區分
「這個發行者被撤下了」與「它換了金鑰、舊卡其實還是真的」。
列出來是因為一頁只有發現而沒有這一節,等於在邀請別人過度解讀。
moda_dw 的 client_id 會不會被拒」。