跳到內容

非保管模式

非保管模式下,holder 跑自己的錢包(任何符合 OID4VCI / OID4VP 的錢包),TCS 只負責發行與驗證兩端。這個模式裡沒有 Holder Service — 整合面只有 Credential Issuer 與 Verifier Service。

還沒選好模式?先看保管模式 vs 非保管模式

這頁包含兩件事:

  1. 與保管模式的 delta — 整合者要注意什麼變化。
  2. 對接外部錢包、自建錢包、或 debug verifier 拒絕時要看的 wallet-protocol 細節(proof JWT、key-binding JWT、sd_hash)。

範疇保管模式非保管模式
錢包執行環境Holder Service(TCS 託管)User 自己的錢包 App
金鑰保管整合者放在自己的 secret manager(見 Holder · 管理錢包與憑證 § 5User 保管(錢包自己的金鑰)
憑證儲存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 與 poll GET /v1/verifier/result/{session_id}Verifier · 驗證憑證 一樣。
  • 治理。 Trust Registry 與 Schema Registry 兩種模式都需要。
  • 標準合規。 兩種模式對 OID4VCI v1、OID4VP v1、SD-JWT VC、DPoP 的實作完全一致。

模式選擇對 issuer 與 verifier 角色不可見 — 他們不該也不需要知道對面是哪種錢包。

非保管模式下你不會呼叫 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 VCdc+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。


以下是給對接外部錢包自建錢包的工程師看的。如果你的錢包符合規範,這些 wire-level 細節錢包內部已經處理好。

範圍是 OID4VCI v1 / OID4VP v1。保管模式整合者不需要看 — Holder Service 在內部就把這些做完了。

Rendering diagram...

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"]

Terminal window
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,且該預授權碼隨之消耗、無法重試。

Terminal window
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 向 issuer 證明錢包控制憑證綁定的金鑰。

Claim
issHolder 的 DID 或 client identifier
audIssuer 的 credential_issuer URL(從 issuer metadata 來)
iat當前 Unix timestamp
nonce第三步拿到的 c_nonce
Header
algES256EdDSA
typopenid4vci-proof+jwt
jwkkid二擇一。當錢包持有獨立 keypair 而沒有可解析 DID 時用 jwk,把公鑰以 JWK 嵌入 header。當 holder DID 已在 Trust Registry 註冊、驗證方可解析時才用 kid(例如 kid: "did:iota:testnet:0x...#key-1" 或 did:key 參照)。kid 對應到無法解析的 DID 會回 400 invalid_proof

/credential 端點 URL 發行於 issuer metadata 之 credential_endpoint 欄位 —— 請呼叫 GET /.well-known/openid-credential-issuer/{tenant} 並從中讀取。此路徑刻意不帶 /v1/ 前綴,讓 issuer 可以公佈部署專屬的端點 URL,而不把錢包耦合到 TCS 內部版本控制。下方範例顯示 TCS 目前發行的值。

Terminal window
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 金鑰對一起持久化 — 出示時兩個都需要。

把 SD-JWT 字串和 holder 金鑰對一起持久化。出示時兩個都需要 — 少了任何一個憑證就不能用。

Rendering diagram...
openid4vp://?client_id=https://verifier.example.com&request_uri=https://verifier.turingspace.co/v1/verifier/request/abc123

request_uri 抓 JAR(簽妥的 JWT),驗簽後解碼。

JAR 裡的 dcql_query 描述要哪些憑證類型、哪些 claim、信任哪些 issuer。詳見 Verifier · DCQL Query Structure

第三步 — 比對憑證、選要揭露的欄位

Section titled “第三步 — 比對憑證、選要揭露的欄位”

把 request 跟錢包裡的憑證比對。對每張符合的憑證,挑出能滿足 request 的最小揭露集合 — 選擇性揭露的精神就是「只給必要的」。

拿存著的 SD-JWT,移除使用者選擇不揭露的 disclosure 段。保留 issuer JWT 與選定的 disclosure。

Key-binding JWT 證明出示者控制憑證綁定的金鑰。

Claim
audVerifier 的 client_id(從 request 拿)
nonceVerifier nonce(從 request 拿)
iat當前 Unix timestamp
sd_hashbase64url(SHA-256(utf8("<issuer-jwt>~<d1>~<d2>~...~"))) —— 對第四步建好的整段 SD-JWT presentation 做 hash,包含結尾 ~,但不包含 key-binding JWT 自身。摘要以無 padding 的 base64url 編碼。一個 byte 的差就會被驗證方拒絕、且無診斷訊息。
Header
algES256EdDSA
typkb+jwt
Terminal window
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)。

  • Proof JWT 的 aud 必須是 credential_issuer URL,不是 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 與 verifier nonce 都是 300 秒到期。 簽完馬上提交。出現 invalid_nonce 就回到 /v1/nonce 取新的、重組 proof — 不要重用舊的。
  • 拿掉 disclosure 後,SD-JWT presentation 結尾仍然要有 ~,表示「key-binding JWT 之前沒有更多 disclosure」。