Holder · 管理錢包與憑證
這頁是給保管模式整合者。你的後端用 Holder Service 當作每位 end user 的託管錢包。你不需要自己實作 OID4VCI / OID4VP,呼叫包好的 REST API 就好。
如果你跑的是非保管模式(end user 自己有錢包 App),跳過這頁,去看非保管模式。不確定要哪個的話,先看保管模式 vs 非保管模式。
Holder Service 替你做的事
Section titled “Holder Service 替你做的事”Holder Service 是錢包,但以 server 形式暴露。針對每位 end user 它會:
- 保管使用者帳號(UUID + 密碼)並簽發短期 JWT。
- 把使用者的 DID document 寫到 IOTA Tangle(或匯入既有 DID)。
- 解析 issuer 的 offer 與 verifier 的 authorization request。
- 換取 pre-authorized code、產出 proof JWT 與 key-binding JWT。
- 在出示憑證時挑要揭露哪些欄位。
- 持久化收到的憑證,提供列/取/刪 API。
你呼叫它,它做錢包該做的事。唯一不做的是持久化 holder 私鑰 — 這是你的責任(見 §5)。
整合面分成四條 lifecycle:
| Lifecycle | 端點 | 章節 |
|---|---|---|
| 使用者與 DID | /v1/user/*、/v1/did/* | §1 |
| 收憑證 | /v1/oid4vci/* | §2 |
| 管理憑證 | /v1/vc/* | §3 |
| 出示憑證 | /v1/oid4vp/* | §4 |
1 · 使用者與 DID lifecycle
Section titled “1 · 使用者與 DID lifecycle”Holder Service 的「使用者」對應你產品裡的一位 end user。每位 user 至少有一個 DID,憑證會綁到那個 DID 上。這是每位 user 一次性的設定 — 不會每張憑證都重做。
註冊 end user
Section titled “註冊 end user”curl -X POST https://holder.turingspace.co/v1/user/register \ -H "Content-Type: application/json" \ -d '{ "password": "SecureP@ss123", "name": "Alice" }'回應(201 Created):
{ "uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice", "created_at": "2026-04-01T00:00:00.000Z"}uuid 就是這位 user 你需要追蹤的唯一 ID。Holder Service 不主動儲存 email 或 PII。
| 失敗情境 | 意義 |
|---|---|
400 Validation failed | password / name 缺漏或不符政策 |
409 Conflict | 該身份已被註冊 |
讓使用者登入
Section titled “讓使用者登入”curl -X POST https://holder.turingspace.co/v1/user/login \ -H "Content-Type: application/json" \ -d '{ "uuid": "550e8400-e29b-41d4-a716-446655440000", "password": "SecureP@ss123" }'回應(200 OK):
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer", "expires_in": 86400, "user": { "uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice" }}代表這位 user 後續呼叫時,把 access_token 當作 Bearer token。Token 對你是 opaque — 視為 session token。
Token 續期。 Holder Service 不發 refresh token。expires_in 過期後(預設 24 小時),任何後續呼叫會回 401 + invalid_token。請以儲存的 (uuid, password) 重新呼叫 POST /v1/user/login,拿到新的 access_token 再重試原本的請求。請將憑證保存在你的 secret manager(以 uuid 為 key)—— Holder Service 不會再次提供。
| 失敗情境 | 意義 |
|---|---|
400 Validation failed | uuid / password 缺漏或格式錯 |
401 帳密錯誤 | uuid / password 不符任何 Holder Service 帳號 |
401 invalid_token(任何後續呼叫) | access_token 過期 —— 請重新呼叫 POST /v1/user/login 後重試 |
為使用者建立 DID
Section titled “為使用者建立 DID”DID 是憑證綁定的 user 端識別符。多數保管模式部署會用 did:iota(由 TCS 託管)做為 holder DID。
curl -X POST https://holder.turingspace.co/v1/did \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "method": "IOTA" }'回應(201 Created):
{ "did": "did:iota:testnet:0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b...", "private_key": "nwKZ3iFzL7pZJK5pPJ-8k9vXcL2vL7pZJK5pPJ-8k9vXcL2vL7pZJK..."}如果這位 user 已經在別處有 DID,想交給 Holder Service 託管,用 POST /v1/did/import,傳 DID URL 與既有私鑰即可。回傳格式同 create(少 private_key,因為由你提供);重複匯入回 409、所有權驗證失敗回 400、Tangle 連不到回 503。
| 失敗情境 | 意義(create / import) |
|---|---|
400 Bad request | method 不合法、DID 格式錯、或 import 時所有權驗證失敗 |
401 未授權 | Authorization 缺漏或無效 |
404 找不到 | (僅 import)DID 在 IOTA 網路上不存在 |
409 Conflict | (僅 import)此 DID 已被匯入 |
503 服務不可用 | IOTA 網路連不到 |
列出或刪除使用者的 DID
Section titled “列出或刪除使用者的 DID”列出:
curl https://holder.turingspace.co/v1/did \ -H "Authorization: Bearer ${ACCESS_TOKEN}"回應(200 OK):
{ "dids": [ { "did": "did:iota:testnet:0x7a8b...", "method": "IOTA", "status": "active", "created_at": "2026-01-30T10:30:00.000Z" } ]}刪除(回 204 No Content,無 body):
curl -X DELETE "https://holder.turingspace.co/v1/did/${DID_URL_ENCODED}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}"本地刪除不會撤銷 Tangle 上的 DID — 那需要另外的鏈上操作。
| 失敗情境 | 意義 |
|---|---|
401 未授權 | Authorization 缺漏或無效 |
403 無權限 | 此 DID 不屬於該 JWT 主體 |
404 找不到 DID | DID 被刪、或從未在 Holder Service 建過 |
2 · 收憑證
Section titled “2 · 收憑證”把 Issuer 離線給的 credential offer URI 連同 holder DID 與私鑰一起傳給 Holder Service。OID4VCI 其餘工作 Holder Service 在內部處理。
可選:預覽 offer
Section titled “可選:預覽 offer”如果你要在使用者同意前先顯示 offer 內容:
curl -X POST https://holder.turingspace.co/v1/oid4vci/offer-details \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.turingspace.co%2Fv1%2Foffers%2Fabc123" }'回應(200 OK):
{ "issuer": "https://issuer.turingspace.co/turing", "issuer_name": "Turing Space", "issuer_logo_uri": "https://issuer.turingspace.co/assets/logo.png", "credential_configurations": [ { "id": "IdentityCredential", "format": "dc+sd-jwt", "vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2", "display": { "name": "Identity Credential", "description": "Your verified identity", "locale": "en-US" } } ], "pre_authorized_code_grant": { "pre_authorized_code": "abc123...", "user_pin_required": false }, "expires_at": "2026-04-01T01:00:00Z"}可拿來做同意畫面。下一步會用到兩個欄位:
credential_configurations[].id— 多個 configuration 時,把它當作credential_configuration_id傳給 receive。pre_authorized_code_grant.user_pin_required—true時要請使用者輸入 PIN,再傳成user_pin。
| 失敗情境 | 意義 |
|---|---|
400 URI 格式錯 | credential_offer_uri 格式錯或非 OID4VCI offer |
401 未授權 | Authorization 缺漏或無效 |
502 Issuer 連不到 | Holder Service 無法從 issuer 取回 offer |
接受 offer
Section titled “接受 offer”curl -X POST https://holder.turingspace.co/v1/oid4vci/receive \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.turingspace.co%2Fv1%2Foffers%2Fabc123", "holder_did": "did:iota:testnet:0x7a8b...", "private_key": "${HOLDER_PRIVATE_KEY}", "credential_configuration_id": "IdentityCredential", "user_pin": "1234", "notarize_on_iota": true }'| 欄位 | 必填 | 說明 |
|---|---|---|
credential_offer_uri | yes | Issuer 離線給的完整 offer URI。 |
holder_did | yes | 憑證綁定的 DID。必須屬於 JWT 主體。 |
private_key | yes | holder_did 的 base64url 私鑰;did:iota 用 EdDSA、did:key 用 ES256(raw d)。只在記憶體用,不留存。 |
credential_configuration_id | no | offer 帶多個 configuration 時必填,從 offer-details 回應的 id 挑一個。 |
user_pin | no | 僅當 issuer 設 require_transaction_code: true(即 offer-details 的 pre_authorized_code_grant.user_pin_required 為 true)才需要。 |
notarize_on_iota | no | 是否把憑證 JWT 上 IOTA 鏈,預設 true。設 false 跳過上鏈。 |
回應(201 Created):
{ "credential_id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f", "vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2", "issuer_did": "did:iota:testnet:0xabcdef...", "holder_did": "did:iota:testnet:0x7a8b...", "raw_credential": "eyJhbGciOiJFZERTQSIs...~sd_disclosure_1~sd_disclosure_2~", "issued_at": "2026-04-01T00:00:30Z", "status": "active"}憑證進到該位 user 的錢包,以 credential_id 標記。回應另含 schema_id 作為 vct 的 deprecated 別名(向後相容用)— 新程式碼一律讀 vct。
| 失敗情境 | 意義 |
|---|---|
400 Pre-Authorized Code 無效 | Offer 過期、code 已用、或 tenant 錯 |
403 DID 不屬於該使用者 | holder_did 不是這個 JWT 主體擁有的 |
404 DID 不存在 | DID 被刪、或從未在 Holder Service 建過 |
502 Issuer 連不到 | Holder Service 連不上 issuer 端點 |
3 · 管理憑證
Section titled “3 · 管理憑證”列出使用者的憑證
Section titled “列出使用者的憑證”curl https://holder.turingspace.co/v1/vc \ -H "Authorization: Bearer ${ACCESS_TOKEN}"回應(200 OK):
{ "credentials": [ { "id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f", "vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2", "schema_id": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2", "issuer_did": "did:iota:testnet:0xabcdef...", "holder_did": "did:iota:testnet:0x5678...", "issued_at": "2026-04-01T00:00:30.000Z", "expires_at": "2027-04-01T00:00:30.000Z", "status": "active", "created_at": "2026-04-01T00:00:30.000Z" } ]}每筆的主鍵是 id(與 POST /v1/oid4vci/receive 回傳的 credential_id 同值)。status 是封閉列舉:active(可出示)、revoked(發行方撤銷,不可出示)、expired(超過 expires_at,不可出示)、deleted(持有方軟刪除,從 list / get 中隱藏)。可以拿來在你產品裡呈現錢包列表。
| 失敗情境 | 意義 |
|---|---|
401 未授權 | Authorization 缺漏或無效 |
取得單張憑證
Section titled “取得單張憑證”curl "https://holder.turingspace.co/v1/vc/${CREDENTIAL_ID}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}"回傳完整紀錄含 raw_credential(SD-JWT 字串) — 適用於把憑證帶到 TCS 之外使用,或診斷時直接顯示。
| 失敗情境 | 意義 |
|---|---|
400 ID 格式錯 | id 不是 UUID |
401 未授權 | Authorization 缺漏或無效 |
403 無權限 | 該憑證不屬於該 JWT 主體 |
404 找不到 | 憑證不存在或已被刪除 |
curl -X DELETE "https://holder.turingspace.co/v1/vc/${CREDENTIAL_ID}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}"軟刪除(回 204 No Content)。資料列仍留在 holder DB,但不再出現在 list / get;不會通知 issuer,不會改變撤銷狀態 — 只是 holder 不再呈現它。個資合規附註:軟刪除不等於實體清除。若你的法域要求實際抹除(例如個資法或 GDPR 的當事人刪除請求),請聯繫 TCS 團隊 —— 實體清除流程屬於 out-of-band 處理。
| 失敗情境 | 意義 |
|---|---|
400 ID 格式錯 | id 不是 UUID |
401 未授權 | Authorization 缺漏或無效 |
403 無權限 | 該憑證不屬於該 JWT 主體 |
404 找不到 | 憑證不存在 |
4 · 出示憑證
Section titled “4 · 出示憑證”出示分兩步。先解析 verifier 的 authorization request,讓 Holder Service 告訴你哪些憑證符合;然後挑選並提交 selective disclosure。
解析 verifier 的 request
Section titled “解析 verifier 的 request”curl -X POST https://holder.turingspace.co/v1/oid4vp/request-details \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "authorization_request_uri": "openid4vp://?client_id=did:iota:testnet:0xverifier...&request_uri=https%3A%2F%2Fverifier.turingspace.co%2Fv1%2Fverifier%2Frequest%2Fabc" }'回應含:
client_metadata— verifier 的名稱、logo、隱私政策 URI,可放進你的同意畫面。dcql_query— verifier 要求的內容(憑證類型、要哪些 claim)。matching_credentials— 該 user 錢包裡符合條件的憑證,含每張對應的query_id與可揭露的 claim。
matching_credentials 為空就表示該 user 沒東西可出示,應在提交前直接 fail。
| 失敗情境 | 意義 |
|---|---|
400 URI 格式錯 | authorization_request_uri 格式錯,或 request object JWT 驗證失敗 |
401 未授權 | Authorization 缺漏或無效 |
404 Request URI 找不到 | request_uri 不存在或已過期 |
502 Verifier 連不到 | Holder Service 抓不到 request object |
提交 presentation
Section titled “提交 presentation”curl -X POST https://holder.turingspace.co/v1/oid4vp/presentation \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "authorization_request_uri": "openid4vp://?client_id=did:iota:testnet:0xverifier...&request_uri=...", "selected_credentials": [ { "credential_id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f", "query_id": "identity_credential", "disclosed_claims": ["credentialName", "issuedTime"] } ], "holder_did": "did:iota:testnet:0x7a8b...", "private_key": "${HOLDER_PRIVATE_KEY}" }'Holder Service 會組 SD-JWT presentation(移除沒揭露的欄位)、用 holder 私鑰簽 key-binding JWT、提交給 verifier。
回應(200 OK):
{ "success": true, "redirect_uri": "https://verifier.example.com/callback?session_id=..."}如果 verifier 設了 redirect_uri,把 user deep-link 過去完成 verifier 端的後續流程。從 verifier 角度看 session 狀態變成 completed,你的 verifier 整合 poll 結果即可(見 Verifier · 驗證憑證)。
disclosed_claims 是 optional。省略代表揭露所有 claim — 選擇性揭露要主動指定。請永遠揭露 verifier 真正需要的最小集合。
| 失敗情境 | 意義 |
|---|---|
400 Bad request | 必填欄位缺漏,或 selected_credentials 中某筆不滿足 DCQL query |
401 未授權 | Authorization 缺漏或無效 |
403 無權限 | holder_did 或某 credential_id 不屬於該 JWT 主體 |
404 找不到 | 引用的 DID 或憑證不存在 |
502 Verifier 連不到 | Holder Service 無法把 VP token 提交給 verifier |
5 · 安全模型
Section titled “5 · 安全模型”Holder Service 不持久化 holder 私鑰 — 這是刻意設計。私鑰從來不在 TCS 的基礎設施內。它住在你的 secret manager / KMS 裡,跟你產品中其他每位 user 的 secret 沒兩樣(session 簽章金鑰、tenant API token、加密欄位等)。這是安全特性,不是摩擦。
你要存的東西,存哪:
| 項目 | 存哪 | 為什麼 |
|---|---|---|
(user_uuid, did, private_key) 三元組 | Secret manager、KMS、加密欄位。絕對不要明文。 | 每次代呼叫收證 / 出示都需要。遺失沒救。 |
每位 user 的 Holder Service access_token | 視為 session token,預設 86 400 秒。 | 代呼叫 Holder Service 必備。 |
| User UUID + name | 你的應用 DB。 | Holder Service 存帳號,但只看到你主動傳的資料。 |
你不需要自己組、簽、或驗任何加密 payload。Holder Service 用你呼叫時傳入的私鑰做完所有 crypto,請求結束後丟掉。結果:TCS、你的產品、end user 都只看到自己該看到的東西 — 這是設計帶來的,不是你要自己強制執行的。
敏感欄位處理。 Holder Service 在 POST /v1/oid4vci/receive 與 POST /v1/oid4vp/presentation 請求 body 中帶有 private_key。把你能控制的每一跳都視為金鑰傳輸:
- 對 TCS 端點所有呼叫請使用 TLS 1.2 或更新版本;TCS 公開端點僅接受 TLS。
- TCS 在記憶體中使用
private_key完成 OID4VCI / OID4VP 簽章後即丟棄,不會持久化。應用層 access log 與錯誤報告會遮罩此欄位。客戶端到 TCS ingress 之間更正式的傳輸 / 中間解密立場(cipher suites、中介檢查等)請於採購階段向 TCS 團隊確認 —— 見 架構與安全 · 合規與資料處理。 - 若你在 Holder Service 前置自己的閘道(CDN、WAF、API gateway),請確認該閘道對這兩個端點的請求 body logging 為關閉。多數預設會在錯誤情況記錄 body —— 對 credential 與 presentation 路徑請手動關閉。
- 切勿用非 TLS 通道傳輸
private_key,也勿放在 URL query string(僅放於 JSON body)。
更廣的取捨、以及什麼情況下會跑外部錢包,請見保管模式 vs 非保管模式 § Holder 金鑰放在哪。
- DID 私鑰只在收證請求後才存。
POST /v1/did回應是你唯一一次看到私鑰的機會 — 任何後續動作之前先存好。 - 跨 user 重用
access_token。 每個 token 綁定一位 Holder Service user。在後端混用 token 是最常見 bug — 維護「end user → token」的對應。 - 傳
disclosed_claims: []。 這是「揭露空集合」 — 多數 verifier 會以 DCQL query 不滿足拒絕。要不就省略disclosed_claims(揭露全部)、要不就明確列出。 holder_did跟憑證綁定的 DID 不一致。 憑證綁在發行時的 DID 上 — 用別的 DID 出示會被 verifier 簽章檢查擋下。- 忘記用 user 的 access token,誤用 Issuer API key。 Holder Service 是按 end user 認證,不是按組織。Issuer API key 在 Holder Service 沒有任何權限。
- Issuer · 發行憑證 — §2 那張 offer 是怎麼建出來的。
- Verifier · 驗證憑證 — §4 那個 request 是怎麼建出來的。
- 保管模式 vs 非保管模式 — 這頁背後的模式選擇邏輯。
- 非保管模式 — 換成 user 自帶錢包時會變什麼。
- 身份驗證 — JWT、API key、DPoP 細節。