跳到內容

Holder · 管理錢包與憑證

這頁是給保管模式整合者。你的後端用 Holder Service 當作每位 end user 的託管錢包。你不需要自己實作 OID4VCI / OID4VP,呼叫包好的 REST API 就好。

如果你跑的是非保管模式(end user 自己有錢包 App),跳過這頁,去看非保管模式。不確定要哪個的話,先看保管模式 vs 非保管模式


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

Holder Service 的「使用者」對應你產品裡的一位 end user。每位 user 至少有一個 DID,憑證會綁到那個 DID 上。這是每位 user 一次性的設定 — 不會每張憑證都重做。

Terminal window
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 failedpassword / name 缺漏或不符政策
409 Conflict該身份已被註冊
Terminal window
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 faileduuid / password 缺漏或格式錯
401 帳密錯誤uuid / password 不符任何 Holder Service 帳號
401 invalid_token(任何後續呼叫)access_token 過期 —— 請重新呼叫 POST /v1/user/login 後重試

DID 是憑證綁定的 user 端識別符。多數保管模式部署會用 did:iota(由 TCS 託管)做為 holder DID。

Terminal window
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 requestmethod 不合法、DID 格式錯、或 import 時所有權驗證失敗
401 未授權Authorization 缺漏或無效
404 找不到(僅 import)DID 在 IOTA 網路上不存在
409 Conflict(僅 import)此 DID 已被匯入
503 服務不可用IOTA 網路連不到

列出:

Terminal window
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):

Terminal window
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 找不到 DIDDID 被刪、或從未在 Holder Service 建過

把 Issuer 離線給的 credential offer URI 連同 holder DID 與私鑰一起傳給 Holder Service。OID4VCI 其餘工作 Holder Service 在內部處理。

如果你要在使用者同意前先顯示 offer 內容:

Terminal window
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_requiredtrue 時要請使用者輸入 PIN,再傳成 user_pin
失敗情境意義
400 URI 格式錯credential_offer_uri 格式錯或非 OID4VCI offer
401 未授權Authorization 缺漏或無效
502 Issuer 連不到Holder Service 無法從 issuer 取回 offer
Terminal window
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_uriyesIssuer 離線給的完整 offer URI。
holder_didyes憑證綁定的 DID。必須屬於 JWT 主體。
private_keyyesholder_did 的 base64url 私鑰;did:iota 用 EdDSA、did:key 用 ES256(raw d)。只在記憶體用,不留存。
credential_configuration_idnooffer 帶多個 configuration 時必填,從 offer-details 回應的 id 挑一個。
user_pinno僅當 issuer 設 require_transaction_code: true(即 offer-details 的 pre_authorized_code_grant.user_pin_requiredtrue)才需要。
notarize_on_iotano是否把憑證 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 端點

Terminal window
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 缺漏或無效
Terminal window
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 找不到憑證不存在或已被刪除
Terminal window
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 找不到憑證不存在

出示分兩步。先解析 verifier 的 authorization request,讓 Holder Service 告訴你哪些憑證符合;然後挑選並提交 selective disclosure。

Terminal window
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
Terminal window
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

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/receivePOST /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 沒有任何權限。