제시
원격 제시와 브라우저 제시는 하나의 세션 형태를 공유합니다 — 해석(resolve) → 동의(consent) → 제출(submit).
SDK는 검증자(verifier)의 요청을 해석하고, 저장된 크리덴셜과 매칭한 뒤, 사용자가 선택을 승인할 때까지
기다렸다가 선택적으로 공개되고 소지자에 바인딩된 응답을 구성해 전송합니다. Kotlin은 세션을
StateFlow<PresentationState>로, Swift는 AsyncStream<PresentationState>로 노출합니다.
세션 라이프사이클
모든 제시는 동일한 상태들을 거칩니다. resolve와 submit 단계는 SDK가 주도하고, 그 사이의 consent
단계는 여러분의 몫입니다 — 해석된 요청을 살펴보고, 사용자가 선택하게 한 뒤, respond(...) 또는
decline()을 호출합니다.
| 단계 | Kotlin PresentationState | Swift PresentationState |
|---|---|---|
| resolve | ResolvingRequest → RequestResolved(request) | .resolvingRequest → .requestResolved(request) |
| consent | (여러분의 UI — respond / decline) | (여러분의 UI — respond / decline) |
| submit | Submitting | .submitting |
| terminal | Completed(redirectUri, dcApiResponse) · Declined(redirectUri) · Failed(error) | .completed(redirectUri:, dcApiResponse:) · .declined(redirectUri:) · .failed(error) |
state.isTerminal은 Completed, Declined, Failed에서 true입니다.
원격 (URL / QR)
원격 플로우는 openid4vp://… 딥링크나 QR 코드에서 캡처한 request_uri에서 시작합니다. 세션을 시작한
뒤 RequestResolved를 기다립니다.
- Kotlin
- Swift
import kotlinx.coroutines.flow.first
val session = wallet.presentation.start(requestUri) // openid4vp://… or a request_uri
val resolved = session.state.first {
it is PresentationState.RequestResolved || it is PresentationState.Failed
}
val request = (resolved as PresentationState.RequestResolved).request
let session = wallet.presentation.start(requestUri) // openid4vp://… or a request_uri
var request: PresentationRequest?
for await state in session.states {
if case let .requestResolved(resolved) = state { request = resolved; break }
if state.isTerminal { break } // .failed before it ever resolved
}
guard let request else { return }
누가 무엇을 요청하는지 살펴보기
request.verifier는 요청의 출처를 알려주고, request.queries는 무엇을 요구하는지 나열합니다 — 각
쿼리는 해당 쿼리를 만족할 수 있는 저장된 크리덴셜을 candidates로 담고 있습니다.
- Kotlin
- Swift
val v = request.verifier
// v.clientId, v.clientIdScheme, v.commonName (from the certificate), v.trusted
println("${v.commonName ?: v.clientId} · trusted=${v.trusted}")
for (query in request.queries) {
// query.queryId, query.required
for (candidate in query.candidates) {
// candidate.credentialId — a stored credential that satisfies this query
// candidate.disclosedPaths — the claims that would be revealed if chosen
}
}
request.satisfiable // true if every required query has at least one candidate
request.transactionData // optional transaction data to confirm (e.g. payment), or null
let v = request.verifier
// v.clientId, v.clientIdScheme, v.commonName (from the certificate), v.trusted
print("\(v.commonName ?? v.clientId) · trusted=\(v.trusted)")
for query in request.queries {
// query.queryId, query.required
for candidate in query.candidates {
// candidate.credentialId — a stored credential that satisfies this query
// candidate.disclosedPaths — the claims that would be revealed if chosen
}
}
request.satisfiable // true if every required query has at least one candidate
request.transactionData // optional transaction data to confirm (e.g. payment), or nil
여기서 v.commonName은 인증서에서 추출된 표시용 이름이며, request.satisfiable은 모든 필수 쿼리에
후보가 하나 이상 있을 때 true가 됩니다. request.transactionData는 확인이 필요한 트랜잭션 데이터(예:
결제)를 담으며 없을 수도 있습니다.
응답하고 결과 기다리기
사용자가 승인하면 SDK에 선택을 전달하고 종료 상태를 기다립니다. Completed.redirectUri는 검증자가
제공하는 선택적 사후 리다이렉트입니다.
- Kotlin
- Swift
session.respond(PresentationSelection.auto(request)) // or a user-chosen selection
// session.decline() instead, if the user refuses
val done = session.state.first { it.isTerminal }
when (done) {
is PresentationState.Completed -> done.redirectUri // optional redirect after success
is PresentationState.Declined -> done.redirectUri // verifier에 `access_denied` 통지됨; 리다이렉트 따라가기
is PresentationState.Failed -> done.error
else -> {}
}
session.respond(PresentationSelection.auto(request)) // or a user-chosen selection
// session.decline() instead, if the user refuses
for await state in session.states {
switch state {
case let .completed(redirectUri, _): _ = redirectUri // optional redirect after success
case let .declined(redirectUri): _ = redirectUri // verifier에 `access_denied` 통지됨; 리다이렉트 따라가기
case let .failed(error): _ = error
default: break
}
if state.isTerminal { break }
}
선택적 공개와 소지자 바인딩
요청이 다루는 클레임만 공개되고, 크리덴셜의 나머지 내용은 모두 숨겨진 채로 유지됩니다. 응답은 **소지자에
바인딩(holder-bound)**됩니다 — SecureArea를 절대 벗어나지 않는 크리덴셜의 소지자 키로 서명됩니다.
- SD-JWT VC — 제시 대상을 오디언스와 nonce에 바인딩하는 KB-JWT(Key Binding JWT)를 공개된 클레임 위에 서명합니다.
- mdoc — 디바이스 서명이 OpenID4VP
SessionTranscript위에서 계산되는DeviceResponse를 생성해, 응답을 바로 이 요청에 바인딩합니다.
verifier 주도의 비바인딩 제시 (require_cryptographic_holder_binding)
소지자 바인딩은 기본적으로 켜져 있습니다. verifier가 require_cryptographic_holder_binding: false
(OpenID4VP §6.1)를 설정하면, SDK는 SD-JWT VC를 KB-JWT 없이 제시합니다 — 비바인딩 제시입니다. 이는
verifier 주도이며 자동으로 처리되므로 여러분이 할 일은 없습니다. KB-JWT를 빼면 그 재생(replay) 보호도 함께
사라지며, 요청에 transaction_data가 있으면 그와 무관하게 바인딩이 다시 강제됩니다.
트랜잭션 데이터 (§8.4)
verifier가 transaction_data를 포함하면, SDK는 각 항목을 그것이 참조하는 크리덴셜 중 정확히 하나에
바인딩합니다.
- SD-JWT VC — 항목의
sha-256해시가 KB-JWT에 자동으로 들어갑니다. 호스트가 할 일은 없습니다. - mdoc —
WalletConfig.presentation.mdocTransactionDataBinder를 제공해야 합니다. 이는TransactionData항목을 디바이스 서명 데이터 엘리먼트(namespace, elementId, value)로 매핑하는 함수이며(이 매핑은 트랜잭션 데이터 타입별로 다릅니다), 없으면 mdoc에 바인딩되는transaction_data는invalid_transaction_data로 거부됩니다.
또한 SDK는 형식이 잘못된 항목, 알 수 없는 credential_ids, 그리고 (mdoc의 경우) MSO keyAuthorizations가
허가하지 않은 엘리먼트를 거부합니다.
선택: 자동 또는 명시적
PresentationSelection.auto(request)는 multiple 쿼리에 대해서는 매칭되는 모든 후보를, 그 외의 쿼리에
대해서는 첫 번째 후보를 선택합니다 — 사용자가 명백한 매칭을 이미 승인한 경우에 적합합니다. 실제 동의
UI에서는 각 queryId를 사용자가 선택한 credentialId 목록에 매핑해 선택을 명시적으로 구성합니다.
- Kotlin
- Swift
// Automatic: all candidates for a `multiple` query, first otherwise
session.respond(PresentationSelection.auto(request))
// Explicit: map each query to the chosen credential(s)
val chosen: Map<String, List<CredentialId>> = request.queries.associate { query ->
query.queryId to listOf(query.candidates.first().credentialId)
}
session.respond(PresentationSelection(chosen = chosen))
// Automatic: all candidates for a `multiple` query, first otherwise
session.respond(PresentationSelection.auto(request))
// Explicit: map each query to the chosen credential(s)
var chosen: [String: [CredentialId]] = [:]
for query in request.queries {
chosen[query.queryId] = [query.candidates.first!.credentialId]
}
session.respond(PresentationSelection(chosen: chosen))
값이 목록인 이유는 OpenID4VP DCQL의 multiple: true(§6.1)가 하나의 쿼리로 여러 크리덴셜을 반환할 수
있게 해주기 때문입니다. multiple: false(기본값) 쿼리는 여전히 정확히 하나만 받습니다. QueryPresentation은
multiple: Boolean을 노출하므로, 동의 UI는 multiple 쿼리에 대해 사용자가 여러 개를 고르도록 허용할 수
있습니다.
KeyUse.OneTime으로 발급된 크리덴셜을 제시하면 그 인스턴스 하나가 소모됩니다(연결 불가능성 확보를
위함). Rotate 크리덴셜은 여러 제시에 걸쳐 재사용됩니다.
Digital Credentials API (브라우저)
요청이 플랫폼의 Digital Credentials API를 통해 도착하면, 브라우저가 요청 객체와 호출자의 origin을
직접 전달합니다. 이 경우 HTTP가 수행되지 않습니다 — 완성된 응답 객체가 여러분에게 반환되므로 앱이
이를 navigator.credentials.get()으로 다시 전달할 수 있습니다.
- Kotlin
- Swift
val session = wallet.presentation.startDcApi(requestObject, origin)
// resolve → inspect → respond, exactly as above
val completed = session.state.first { it.isTerminal } as PresentationState.Completed
val response = completed.dcApiResponse // hand back to navigator.credentials.get()
let session = wallet.presentation.startDcApi(requestObject, origin: origin)
// resolve → inspect → respond, exactly as above
var response: String?
for await state in session.states {
if case let .completed(_, dcApiResponse) = state { response = dcApiResponse; break }
if state.isTerminal { break }
}
mdoc DC API 응답은 **origin에 바인딩(origin-bound)**됩니다 — 호출자 origin이 디바이스 서명이 보호하는
DC API 핸드오버에 포함되므로, 응답을 다른 사이트에서 재생(replay)할 수 없습니다.
리더 신뢰 (Reader trust)
TrustConfig에 readerAnchorsDer를 구성하면, 서명된 요청 객체(JAR)가 여러분에게 노출되기 전에 먼저
검증됩니다. 검증은 다음을 모두 요구합니다.
- 리더 인증서 체인이 구성된 앵커 중 하나로 검증되고,
- 요청 객체의 JWS 서명이 검증되며,
client_id스킴(x509_san_dns또는x509_hash)이 서명 인증서와 일치해야 합니다.
이 세 가지가 모두 충족될 때에만 request.verifier.trusted == true가 됩니다. 이 중 하나라도 실패한 서명된
요청은 해석되지 않으며, 세션은 VerifierNotTrusted 오류와 함께 Failed로 종료됩니다. 리더 앵커를
구성하지 않은 경우에도 요청은 여전히 해석되지만 신뢰되지 않은 상태(trusted == false)로 도착하며, 이를
사용자에게 반영하는 것은 여러분 UI의 책임입니다.
서명된 요청 객체는 OpenID4VP §5 규칙으로도 검사됩니다: JOSE typ는 반드시 oauth-authz-req+jwt여야 하고,
요청 객체의 client_id는 인가 요청의 것과 prefix까지 동일해야 합니다. request_uri_method=post에서는 SDK가
새 wallet_nonce를 보내고, verifier가 이를 그대로 되돌려주지 않으면 요청 처리를 중단합니다.
거절을 verifier에게 알리기 (§8.5)
사용자가 원격 제시를 거절하면, SDK는 verifier의 response_uri로 인가 오류 응답을 POST 합니다 —
vp_token이 갔을 바로 그 엔드포인트입니다:
error=access_denied&error_description=…&state=…
이게 없으면 verifier 페이지는 타임아웃까지 그냥 기다립니다. verifier는 redirect_uri로 답할 수 있고, SDK는
이를 Declined(redirectUri)로 노출합니다. 값이 있으면 사용자 에이전트를 그곳으로 반드시 보내야 합니다.
access_denied는 *"사용자가 거절함"*과 *"지갑에 해당 크리덴셜이 없음"*을 의도적으로 같이 덮습니다. 그래서
verifier는 둘을 구분할 수 없습니다. 다른 상황을 직접 알리려면 VpErrorCode taxonomy와 함께
Openid4VpClient.reportError(request, code)를 쓰세요.
:::note DC API에서는 아무것도 보내지 않습니다
Digital Credentials API 요청에는 response_uri가 없고, OpenID4VP §15.9.2는 거기서 프로토콜 오류를 돌려주는
것 자체가 사용자가 해당 크리덴셜을 보유했음을 드러낼 수 있다고 경고합니다 — 플랫폼이 요청을 만족하는 지갑만
선택지로 띄우기 때문입니다. 거절은 대신 플랫폼으로 전달됩니다.
:::