第三方皮夾:把台灣數位憑證皮夾(TWDIW)整合進來
豆泥 · 相依 M1 · 2026-08-16 重新定義範圍
原本只寫「資料模型留好接口」。範圍已擴大成真的做一個第三方皮夾:官方認證的發行者與驗證者都能跑完整流程,皮夾裡除了我們自己的身分證,還有駕照電子卡等官方憑證。目的不是相容性打勾,是證明最理想的皮夾長什麼樣子——把三種信任模型並排,讓它們的差異第一次變得可見。完整規劃在 docs/twdiw-integration-plan.md。
-
已完成
規劃與事實查證(2026-08-16)
八項事實全部自己量過、可複現。三個決定性的:
①「第三方皮夾」在 TWDIW 裡不存在。 發行端與驗證端都要申請沙盒帳號、自架、做 UAT;皮夾沒有申請表、沒有註冊、沒有 attestation。denkeni 手冊裡標題叫「If You're Building a Third-Party Issuer or Verifier Software」的那一節是空的,而第三方皮夾連標題都沒有。
② client_id 不是名單,是回音。 證據鏈全在 MIT 授權的官方原始碼裡:發行端自己簽的 ID Token 把 aud 寫死成 "moda_dw"(CredentialService.java:1715)→ OID4VCI handler 從那個 aud 讀出 client_id(CredentialIssuer.java:225)→ /token 拿它比對(:1196)→ proof 的 iss 只跟它比(:1741)。整條鏈驗的是「你送的字串等於你送的字串」。 對我們是入場沒有技術門檻;對 TWDIW 是一個要回報的缺口——憑證與「哪個皮夾拿走它」之間沒有任何綁定。真正被驗的是金鑰:proof 簽章會用 DID 取出的公鑰驗過,那把公鑰寫進 cnf.jwk。裝置綁定是真的,皮夾身分是假的。
③ 我們現在的 DID 會被官方拒絕。 TWDIW 用 multicodec 0xEB51(jwk_jcs-pub,varint D1 D6 03),payload 是整份 JWK 的 JSON;我逐位元組解過兩個真實 DID(129 bytes,前 3 是 D1D603,其餘 126 是 JWK 文字)。官方後端的解法是 did.substring(1) → Base58 → substring(6)「去掉prefix D1D603」——寫死三個位元組、不檢查 multibase、不檢查 multicodec。我們的 DIDKey 用 p256-pub(0x1200)產 did:key:zDn…,送過去必然解析失敗。
-
已完成
M5.1 did:key 第二種拼法+解析寬容規則
新增
jwk_jcs-pub 編解碼,與現有 p256-pub 並存(我們自己那條離線路徑的 DID 不改)。
而且官方皮夾自己的 DID 不符合它宣稱的編碼:jwk_jcs-pub 的定義就是 JWK 經過 JCS(RFC 8785),鍵序必須 crv < kty < x < y。機關 DID 是 crv,kty,x,y ✅;官方皮夾的 holder DID 是 crv,x,y,kty ❌。我們的 DIDKey 有一個 nonCanonicalDID 錯誤案例,存在的理由正是拒絕這種東西——所以嚴格照規範實作的解析器,會拒絕真實世界唯一存在的那個台灣皮夾。
由此定一條不對稱規則:我們自己產生的一律 JCS 正規;解析別人的一律不要求正規。 正規化是我們對自己的義務,拿它去否決別人只會讓我們無法互通。
閘門:用官方那個非正規 holder DID 當測試向量,必須解得開。不碰網路,可離線做完。
-
卡住
M5.2 OID4VCI 領卡(沙盒)——卡在兩個必須先決定的事
五步實測順序:
credential-offer-object → .well-known/openid-credential-issuer → /token(pre-authorized_code + tx_code)→ /credential(proof JWT,typ: openid4vci-proof+jwt,aud 結尾有斜線)→ 抓卡面圖。CredentialStore 本來就是一檔一憑證、有 allIDs(),結構上已經是多卡的,不必改。
⚠️ client_id 要送什麼是「先量再決定」,不是先決定。 送 moda_dw 一定會通,但不該長期冒用官方 App 的識別字串。做法是先送 tw.bonds.backupTW 量它會不會被拒:通過就用我們自己的;被拒的話,那件事本身就是要回報的發現——TWDIW 沒有辦法讓第三方皮夾正當地表明身分,於是唯一能跑的方式是冒充。
前置:沙盒帳號申請的對象是發行/驗證端,但 demo 站的「訪客領卡」不需要帳號,可以先不申請就跑通。要不要申請、要不要按同意服務條款,由使用者決定。
⛔ 2026-08-16 對抗式查證擋下兩件事。② 已完成,① 卡在一個必須先做的量測:
(進行中:卡片層已完成——StoredCardSource,順手抓到「出示會靜默改指第一張官方卡」這個活著的缺陷。發行者授權閘門已完成——兩道閘門、主機當主機比不用前綴、無法正規化的一律拒絕、比中之後改用清單自己的字串。① 一卡一金鑰卡在紅線:整套 Keychain 列舉壓在一條從來沒量過的假設上(SecItemCopyMatching + kSecMatchLimitAll 看不看得到 Secure Enclave 金鑰、tag 與建立時間回不回得來),而漏看一把的後果是靜默的——掃描刪 0 把、驗證通過、使用者被告知已清除。本專案的規矩是先量再決定,所以停在真機量測前面。)
①「這個發行者是誰」沒有人檢查。 領卡流程的 URL 全部來自一張 QR,而其中一步會用持有人金鑰簽一個 proof JWT、aud 直接填對方給的值。任何人印一張 QR,就能讓皮夾對他指定的 URL 產生一個由裝置長期金鑰簽出來、內容由他選的 JWT。 本專案對這類輸入已經有立場(MOICACallbackRouter 檔頭:inbound URL 是 untrusted input,只能當「再去查一次」的訊號)。而那 43 筆信任清單正好可以拿來比對 issuer DID。
② 要用哪一把持有人金鑰。 若沿用現有那把唯一的 DeviceKey,TWDIW 憑證的 cnf.jwk 會被寫進政府發行端的資料庫,而那串位元組正是任何一個離線查驗方看到的識別碼。我們刻意讓自然人憑證那條路拿不到統一編號,結果會從另一扇門交出一個更好用的索引——而且這條汙染不需要任何人上網才生效,離線出示的那張卡上就帶著這個公鑰。→ 至少一卡一金鑰;而一旦有第二把,IdentityReset 只吃單一 keyTag 就不夠用了,「重設身分」會漏掉 TWDIW 那幾把。
✅ 2026-08-16 實機量測:① 的紅線清掉了。 iPhone 14 實機,五題全部是好消息——列舉看得到 Secure Enclave 金鑰(tokenID=com.apple.setoken)、kSecAttrApplicationTag 帶得回、kSecAttrCreationDate 帶得回、用 kSecValueRef 刪得掉、掃完剩 0 把。所以「列舉當真相來源不必維護清單」「用建立時間當護欄避免刪到領卡中途那把」都成立。
⚠️ 模擬器不能拿來回答這一題:同一支測試在模擬器上五題也全部「通過」,因為它會回報 com.apple.setoken 而那裡根本沒有 SEP。測試裡因此多一支專門在模擬器上印警告的,免得一次綠燈被當成答案。
② 已完成:兩道閘門、主機當主機比不用前綴、無法正規化的一律拒絕、比中之後改用清單自己的字串。
-
已完成
M5.3 SD-JWT VC 讀取與選擇性揭露
TWDIW 把
_sd 放在 vc.credentialSubject 底下,不是 SD-JWT VC 標準的頂層 claims(沒有 vct,用 vc.type)——是 VCDM 1.1 與 SD-JWT 的混血。我們的 SelectiveDisclosure 已經是同一套加鹽承諾機制,接得上,但要另寫 adapter,不要把我們的格式改成它的。
做完了,而且比預期簡單——因為密碼學早就是共用的。 SD-JWT 的摘要是對「揭露的 base64url 字串本身」做 SHA-256,不是對解碼後的 JSON,而本專案的 Disclosure.digest 早就是這個算法。而且不是照規範推的,是拿正式資料驗的:用 2025-10-07 一張真實駕照電子卡的六段揭露,重算出它 _sd 公開的那兩個值(id_number 與 roc_birthday),兩個都對得上;對解碼後的位元組做雜湊則一個都對不上。
順帶抓到自己抄錯的三個值:姓名是陳筱玲不是陳籍玲、身分證是 A234567890 十碼不是十一碼、管轄編號中間沒有空格。三個都是因為從渲染後的頁面上讀而不是把 base64 解開。渲染過的內容不可以當資料來源——一個十一碼的「身分證統一編號」留在文件裡,後面每個讀它的人都會被誤導。
釘住的:拒收未被承諾的揭露(紅線)、少給揭露是正常的、alg 不是 ES256 一律拒、_sd_alg 不認識就拒不要假設、statusListIndex 在正式環境是字串(只當數字讀會靜默丟掉整個 status 條目,而沒有 status 的憑證就是永遠不會被查撤銷的憑證)。1,060 個測試通過。
-
待辦
M5.4 OID4VP 出示
POST /api/oidvp/authorization-response,帶 state/vp_token/presentation_submission。vp_token 的 aud 形狀是 redirect_uri:https://…(前面真的有那個字首)。
⚠️ 官方的 vp_token 裡是 "vp":{"context":[…]}——沒有 @(我從真實 vp_token 的 base64 解出來確認過)。JSON-LD 處理會把整個 VP 的主張丟掉而簽章照樣驗得過,正是 M1 咬過我們那個 bug 的同一個家族。要不要複製這個錯字?要——出示要被官方驗證端收下就得照它實際吃的格式;但必須在程式碼註明哪一天、為什麼、上游修好後拿掉。一個沒有註解的相容性 hack,三個月後會被當成我們自己的 bug 修掉。
-
半完成
M5.5 三種卡並排與能力對照
這一項才是整個 M5 的論點。 「最理想的皮夾」的區別不是裝得下很多張卡——官方 App 也裝得下——而是它把每一張卡證明得了什麼、證明不了什麼,寫在卡的旁邊。
我們有硬證據說明為什麼非做不可:官方那一套分不出 IAL。 denkeni 自己問「是不是用 kid: key-1 標示 IAL?」自己回答「nope」;實測 IAL-1(自己在網頁填的 email)與 IAL-3 的 VC 結構上一模一樣——同一個 jku、同一個 kid、同一個發行者 DID。皮夾看不出「櫃檯驗過證件」與「使用者打字」的差別。 加上撤銷是每天過期的線上清單(實測 nbf→exp 正好 86,400 秒)——離線超過 24 小時,TWDIW 憑證的撤銷狀態不可知。
要新增兩條既有 caveat 型別沒涵蓋的:「這張卡看不出身分確信度」與「離線超過 24 小時撤銷狀態不可知」。⚠️ LocalizationCoverageTests 目前不涵蓋 PresentationScenario(UX 盤點 b-1 的同一個洞),新增之前要先補覆蓋,否則新的英文字串會照樣全綠。
✅ 2026-08-16:論點做出來了,畫面也畫上了。 CardCapability 三種卡並排,接在能力頁的場景之後——上面問的是「這個 App 證明得了 X 嗎」,下面問的是「我的每一張卡值多少」,同一個問題的兩端。
一條紅線用測試釘住:任何一張卡都不准說「與你其他的卡不可連結」。一卡一金鑰切斷的只有卡與卡之間;TWDIW 的 cnf.jwk 由發行端固定、每個查驗方永遠看到同一把,statusListIndex 逐張唯一又在揭露信封之外,而在這些之前——駕照上的姓名早就把兩張卡接起來了。測試同時檢查英文鍵與出貨的中文,因為一句只在翻譯後才出現的承諾仍然是承諾。
順序也是斷言:我們的排第一。把政府那張放前面,底下的限制會讀成在抱怨別人。
b-1 的教訓有照做:翻譯與覆蓋測試跟字串同一批進來。畫面 render 成 PNG 逐頁看過(3,655pt 高、五頁),三張卡的「可以倚賴的」與「建立不了的」是同樣的視覺重量——那正是論點。
✅ 同日補上首頁的卡片列。 在此之前首頁只有一列,寫死自發行的那一張——TWDIW 卡收下來、存進去,然後在首頁上不存在。這跟「MyData 收完首頁沒反應」是同一類缺陷,後果也一樣:一個沒有任何證據顯示卡到手的人,會再去領一次,而政府核發的卡可能意味著再跑一趟櫃檯。
規則寫在 CardInventory(純函式、可測),不寫在畫面裡,因為其中三條錯起來在截圖上完全看不出來:①讀不出來的卡照樣有一列(誘人的寫法是 compactMap——清單很乾淨,而卡憑空消失);②順序是我們定的,不是 store 的目錄順序(那種順序「穩定到看起來像刻意的、又不穩定到會在無關的寫入之後重排」,是最糟的組合);③欄位值一律不上首頁,卡的種類才上——一個測試拿 TWDIW 官方範例人物的姓名、身分證字號、生日去比對整列文字。
做的過程撞到兩個真的會出事的東西:(a) Item 的識別是「標題+副標」,而兩張同月核發的同型政府卡兩者都一樣——NSDiffableDataSourceSnapshot 遇到重複識別是直接終止 App,不是畫錯。Item 因此多了 identifier。(b) StoredCredentialViewController 綁死在單一識別碼上,所以「點政府卡就 push 詳細頁」會在你點的那一列底下顯示另一張卡的內容。現在點下去直說這個版本列得出、打不開,並給出它唯一真的答得了的問題(這種卡證明得了什麼)。沒有畫面的功能寧可直說,不要假裝。
沒有 exp 的卡也另外處理:reader 會代入 .distantFuture,直接印出來會變成「有效到 4001 年」——把發行者從來沒做過的宣稱變成螢幕上的一個日期,正是這個 App 反對的那件事。
-
研究
兩張卡的事實剛好相反,而這是產品的核心對比
官方駕照電子卡實測帶
id_number(可選擇性揭露)。對照我們自己量過的:自然人憑證的 Subject DN 裡沒有統一編號(只有 C/CN/serialNumber 16 位數字)。所以我們那條路扣不住姓名,也拿不到號碼;TWDIW 兩個都有。
皮夾裡會同時放著兩種相反的卡:我們的離線可驗但無法不可連結,官方的欄位齊全但要信任發行者且離線一天後撤銷不可知。並排本身就是論證,統一格式會殺掉論證——所以明確不做「把自發行憑證改成 TWDIW 格式」。
資料風險隨之而來:把官方駕照放進來,等於把一個我們自己刻意拿不到的識別碼帶進裝置。LocalDataEraser 必須一體涵蓋,出示預設全關對 TWDIW 卡一樣成立,而且卡片說明要直說「這張卡裡有你的身分證字號」。
-
研究
政策風險:現在不需要許可,不表示以後不會
TWDIW 目前沒有辦法分辨皮夾,但歐盟 ARF 走的是 wallet attestation。若 TWDIW 補上,第三方皮夾會在一夜之間從「不需要許可」變成「需要許可」。所以排程上要早做,而且「我們用了什麼身分字串」要集中在一處,方便將來換。
順帶:這件整合會產出四份可以回報上游的發現——client_id 不具鑑別力、官方皮夾的 did:key 不符 JCS、issuer metadata 的 format 與實際回傳不符(宣稱 jwt_vc_json、實際是 vc+sd-jwt 加 ~ 揭露串,照 format 解析的客戶端永遠看不到揭露)、VP 用 context 而非 @context。後兩者都屬於「簽章驗得過、語意卻是錯的」,最難被自己的測試抓到。
-
半完成
VP 呈現與 Schema 標準化
選擇性揭露的底層機制完成(SD-JWT 式加鹽承諾)。這曾是「滿 18 歲」真正的擋路點——不是電路,是資料模型:當時出示會交出整個
credentialSubject,等於為了證明年齡而連身分證字號與戶籍地址一起給出去。那一點已經解掉了。
三個安全性質都以突變反向確認過會轉紅:每條揭露各加 128 bits 鹽(否則 nationality 十來種、birthdate 約四萬種可能值都能枚舉還原)、承諾摘要排序(否則位置洩漏哪個摘要對應哪個欄位)、拒收未被承諾的揭露(否則持證方能宣稱任何事)。已接進出示流程與 UI:出示前每個欄位一個開關、預設全部關閉,按鈕寫「出示 N 個欄位」,查驗方會看到「另外保留了 N 個欄位」。credentialSubject 現在只剩 id,其餘全進 _sd——留在明文裡的欄位就是持有人永遠無法扣住的欄位。
但有一個欄位扣不住,而畫面一度承諾扣得住:姓名寫在替憑證簽章的 X.509 憑證的 Subject CN 裡,而查驗方需要那張憑證才能驗簽。X.509 沒有選擇性揭露,所以這是結構性的。該開關已經拿掉,改成一行「一定會被看到」的說明。還沒對齊 OID4VP 的線上格式。
-
已完成
TW-DIW 相容性評估
2026-08-16 做完,結論被上面的 M5.1–M5.5 取代。原本寫的是「若官方走 SD-JWT,需同步建立去連結化路徑,否則授權後的資料包會變成新的蜜罐」——官方確實走 SD-JWT,而那個顧慮的形狀跟預期不同:蜜罐不在「授權後的資料包」,在憑證本身(駕照電子卡直接帶
id_number),而且它是選擇性揭露的,所以出示時扣得住、儲存時扣不住。去連結化路徑對這一點沒有幫助,能幫上忙的是儲存與抹除的一體涵蓋,以及卡片上直說裡面有什麼。
明確不做的四件事也在這次定案:不做第三方發行端/驗證端(要自架、要 UAT,我們的價值不在那裡);不把自發行憑證改成 TWDIW 格式(會把離線保證稀釋成它的等級,並殺掉並排的論點);不把 TWDIW 信任清單當成我們的 TrustList(它是 HTTPS API 加 Arbitrum 錨點 0x84172caf…,跟我們的離線承諾模型是兩件事);不做 ZK 與 TWDIW 的接合(會產生一個看起來像零知識、實際上要信任發行者的東西,比不做更糟)。