非保管模式
非保管模式下,holder 跑自己的錢包(任何符合 OID4VCI / OID4VP 的錢包),TCS 只負責發行與驗證兩端。這個模式裡沒有 Holder Service — 整合面只有 Credential Issuer 與 Verifier Service。
還沒選好模式?先看保管模式 vs 非保管模式。
這頁包含兩件事:
- 與保管模式的 delta — 整合者要注意什麼變化。
- 對接外部錢包、自建錢包、或 debug verifier 拒絕時要看的 wallet-protocol 細節(proof JWT、key-binding JWT、sd_hash)。
與保管模式的差異
Section titled “與保管模式的差異”| 範疇 | 保管模式 | 非保管模式 |
|---|---|---|
| 錢包執行環境 | Holder Service(TCS 託管) | User 自己的錢包 App |
| 金鑰保管 | 整合者放在自己的 secret manager(見 Holder · 管理錢包與憑證 § 5) | User 保管(錢包自己的金鑰) |
| 憑證儲存 | Holder Service 資料庫 | User 裝置上 |
| 裝置遺失救援 | 透過 Holder Service 救援 | 由錢包 App 各自處理 |
| 合規範圍 | Holder Service 在範圍內 | 錢包 vendor 在範圍內 |
| 部署拓樸 | 全部七個服務 | 六個服務(沒有 Holder Service) |
- Issuance API surface。
POST /v1/offers用法跟 Issuer · 發行憑證 一樣。 - Verification API surface。
POST /v1/verifier/authorization-request與 pollGET /v1/verifier/result/{session_id}跟 Verifier · 驗證憑證 一樣。 - 治理。 Trust Registry 與 Schema Registry 兩種模式都需要。
- 標準合規。 兩種模式對 OID4VCI v1、OID4VP v1、SD-JWT VC、DPoP 的實作完全一致。
模式選擇對 issuer 與 verifier 角色不可見 — 他們不該也不需要知道對面是哪種錢包。
不需要呼叫的端點
Section titled “不需要呼叫的端點”非保管模式下你不會呼叫 Holder Service:
/v1/user/*、/v1/did/*/v1/oid4vci/*(Holder Service 收證)/v1/oid4vp/*(Holder Service 出示)/v1/vc/*
改以 QR、推播、或 universal link 把 deep_link(offer 的)與 authorization_request(verifier request 的)交付到 user 的錢包 App。
錢包必須支援 SD-JWT VC 的 dc+sd-jwt 格式、_sd selective disclosure 機制、與 kb+jwt 的 key binding。HAIP-conformant 錢包(如 EUDI Wallet)即可互通。
只支援 W3C JWT-VC(vc+jwt)的錢包不能跟 TCS 憑證互通。
如果你自己部署 TCS,可以省略 Holder Service。六個 service 就夠:
| 服務 | 非保管模式需要嗎? |
|---|---|
| Authorization Server | 是 |
| Credential Issuer | 是 |
| Verifier Service | 是 |
| Trust Registry | 是 |
| Schema Registry | 是 |
| Scheduler | 是 |
| Holder Service | 否 |
兩種模式可以在同一套部署上、針對不同 tenant 並存 — 非保管模式不需要獨立 cluster。
Wallet-protocol 細節
Section titled “Wallet-protocol 細節”以下是給對接外部錢包或自建錢包的工程師看的。如果你的錢包符合規範,這些 wire-level 細節錢包內部已經處理好。
範圍是 OID4VCI v1 / OID4VP v1。保管模式整合者不需要看 — Holder Service 在內部就把這些做完了。
收憑證(錢包視角)
Section titled “收憑證(錢包視角)”第一步 — 解析 offer
Section titled “第一步 — 解析 offer”Issuer 給的 offer URI:
openid-credential-offer://?credential_offer_uri=https://issuer.turingspace.co/v1/offers/a1b2c3d4-...去抓 credential_offer_uri 取得 offer JSON。Pre-authorized code 在 grants["urn:ietf:params:oauth:grant-type:pre-authorized_code"]["pre-authorized_code"]。
第二步 — 用 code 換 token
Section titled “第二步 — 用 code 換 token”curl -X POST https://auth.turingspace.co/v1/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code\&pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA"回應(沒有 c_nonce):
{ "access_token": "eyJhbGciOiJFZERTQSJ9...", "token_type": "Bearer", "expires_in": 86400}DPoP 模式下,token 端點會回 token_type: "DPoP" 與 DPoP-Nonce response header。
Transaction code(PIN)變體。 Issuer 建立 offer 時帶 require_transaction_code: true 時,第一步解析出來的 offer URI 會附帶 tx_code 物件,指示錢包向使用者收一組 PIN —— 典型結構:
"grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { "pre-authorized_code": "SplxlOBeZQQYbYS6WxSbIA", "tx_code": { "length": 6, "input_mode": "numeric", "description": "請輸入 Acme Bank App 顯示的 6 位數驗證碼" } }}把 description 原文呈現給使用者,收到 PIN 後加入 POST /v1/token 的 form-encoded body:tx_code=<pin>。省略或傳錯會回 400 invalid_grant,且該預授權碼隨之消耗、無法重試。
第三步 — 取新的 c_nonce
Section titled “第三步 — 取新的 c_nonce”curl -X POST https://issuer.turingspace.co/v1/nonce回應:
{ "c_nonce": "tZignsnFbp", "c_nonce_expires_in": 300}Nonce 端點在發行端(不是 authorization server)。空 body POST、不需 Authorization header — 拿一個獨立 nonce 就這樣。如果你用 DPoP-bound token,把 access token 用 Authorization: DPoP <token> 帶上、配合 DPoP proof,可以同時刷新 DPoP nonce。
c_nonce 由 issuer 用 HMAC 簽(不打 DB)、一次性、短期(300 秒)。
第四步 — 產出 proof JWT
Section titled “第四步 — 產出 proof JWT”Proof JWT 向 issuer 證明錢包控制憑證綁定的金鑰。
| Claim | 值 |
|---|---|
iss | Holder 的 DID 或 client identifier |
aud | Issuer 的 credential_issuer URL(從 issuer metadata 來) |
iat | 當前 Unix timestamp |
nonce | 第三步拿到的 c_nonce |
| Header | 值 |
|---|---|
alg | ES256 或 EdDSA |
typ | openid4vci-proof+jwt |
jwk 或 kid | 二擇一。當錢包持有獨立 keypair 而沒有可解析 DID 時用 jwk,把公鑰以 JWK 嵌入 header。當 holder DID 已在 Trust Registry 註冊、驗證方可解析時才用 kid(例如 kid: "did:iota:testnet:0x...#key-1" 或 did:key 參照)。kid 對應到無法解析的 DID 會回 400 invalid_proof。 |
第五步 — 索取憑證
Section titled “第五步 — 索取憑證”/credential 端點 URL 發行於 issuer metadata 之 credential_endpoint 欄位 —— 請呼叫 GET /.well-known/openid-credential-issuer/{tenant} 並從中讀取。此路徑刻意不帶 /v1/ 前綴,讓 issuer 可以公佈部署專屬的端點 URL,而不把錢包耦合到 TCS 內部版本控制。下方範例顯示 TCS 目前發行的值。
curl -X POST https://issuer.turingspace.co/credential \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGci..." \ -d '{ "credential_configuration_id": "TuringCerts_Standard_Credential_v2_sd_jwt", "proofs": { "jwt": ["eyJhbGciOiJFUzI1NiIs..."] } }'如果 issuer 回 invalid_nonce,代表 c_nonce 過期或已被消費。回到第三步取新的、重組 proof JWT — 原本的 proof 不能重用。
回應(200 OK):
{ "credentials": [ { "credential": "eyJhbGciOiJFZERTQSIs...~<disclosure-1>~<disclosure-2>~..." } ]}每個 credential 是完整的 SD-JWT VC 字串,形式為 <issuer-jwt>~<disclosure-1>~<disclosure-2>~...~。把它跟 holder 金鑰對一起持久化 — 出示時兩個都需要。
第六步 — 儲存憑證
Section titled “第六步 — 儲存憑證”把 SD-JWT 字串和 holder 金鑰對一起持久化。出示時兩個都需要 — 少了任何一個憑證就不能用。
出示憑證(錢包視角)
Section titled “出示憑證(錢包視角)”第一步 — 解析 request URI
Section titled “第一步 — 解析 request URI”openid4vp://?client_id=https://verifier.example.com&request_uri=https://verifier.turingspace.co/v1/verifier/request/abc123去 request_uri 抓 JAR(簽妥的 JWT),驗簽後解碼。
第二步 — 讀 DCQL query
Section titled “第二步 — 讀 DCQL query”JAR 裡的 dcql_query 描述要哪些憑證類型、哪些 claim、信任哪些 issuer。詳見 Verifier · DCQL Query Structure。
第三步 — 比對憑證、選要揭露的欄位
Section titled “第三步 — 比對憑證、選要揭露的欄位”把 request 跟錢包裡的憑證比對。對每張符合的憑證,挑出能滿足 request 的最小揭露集合 — 選擇性揭露的精神就是「只給必要的」。
第四步 — 組 SD-JWT presentation
Section titled “第四步 — 組 SD-JWT presentation”拿存著的 SD-JWT,移除使用者選擇不揭露的 disclosure 段。保留 issuer JWT 與選定的 disclosure。
第五步 — 簽 key-binding JWT
Section titled “第五步 — 簽 key-binding JWT”Key-binding JWT 證明出示者控制憑證綁定的金鑰。
| Claim | 值 |
|---|---|
aud | Verifier 的 client_id(從 request 拿) |
nonce | Verifier nonce(從 request 拿) |
iat | 當前 Unix timestamp |
sd_hash | base64url(SHA-256(utf8("<issuer-jwt>~<d1>~<d2>~...~"))) —— 對第四步建好的整段 SD-JWT presentation 做 hash,包含結尾 ~,但不包含 key-binding JWT 自身。摘要以無 padding 的 base64url 編碼。一個 byte 的差就會被驗證方拒絕、且無診斷訊息。 |
| Header | 值 |
|---|---|
alg | ES256 或 EdDSA |
typ | kb+jwt |
第六步 — 提交 response
Section titled “第六步 — 提交 response”curl -X POST https://verifier.turingspace.co/v1/verifier/presentation \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "vp_token=<sd-jwt-presentation>~<kb-jwt>" \ --data-urlencode 'presentation_submission={...}' \ --data-urlencode "state=<state-from-request-object>"state 必填 — 從 verifier request object 原樣帶回,verifier 才能把回應對到當初建立的 session。沒帶會回 400。
回應(200 OK):
{ "redirect_uri": "https://verifier.example.com/callback?session_id=b2c3d4e5-..."}redirect_uri 只有 verifier 設了才會帶回。verifier session 變 completed;verifier 端 client 用 GET /v1/verifier/result/:sessionId poll 驗證結果(見 Verifier · 驗證憑證 § Step 3)。
錢包端常見地雷
Section titled “錢包端常見地雷”- Proof JWT 的
aud必須是credential_issuerURL,不是 credential 端點。 Issuer metadata 裡寫得很清楚。 - Key-binding JWT 的
aud必須是 verifier 的client_id。 不是 verifier service URL。 - DPoP proof 一次性、與 nonce 綁定。 伺服器回新的
DPoP-Nonce就要組新的 proof — 原本那組不能重用。 c_nonce從發行端的/v1/nonce拿,不是從 token 端點。 依 OID4VCI 1.0 Final §7。c_nonce與 verifiernonce都是 300 秒到期。 簽完馬上提交。出現invalid_nonce就回到/v1/nonce取新的、重組 proof — 不要重用舊的。- 拿掉 disclosure 後,SD-JWT presentation 結尾仍然要有
~,表示「key-binding JWT 之前沒有更多 disclosure」。
- 保管模式 vs 非保管模式 — 模式選擇的心智模型。
- Issuer · 發行憑證 — 發行流程(與模式無關)。
- Verifier · 驗證憑證 — 驗證流程(與模式無關)。
- DID —
did:key是多數非保管錢包簽 key-binding JWT 用的方法。 - 架構與安全性 — 服務拓樸與安全邊界。