본문으로 건너뛰기

제시

원격 제시와 브라우저 제시는 하나의 세션 형태를 공유합니다 — 해석(resolve) → 동의(consent) → 제출(submit). SDK는 검증자(verifier)의 요청을 해석하고, 저장된 크리덴셜과 매칭한 뒤, 사용자가 선택을 승인할 때까지 기다렸다가 선택적으로 공개되고 소지자에 바인딩된 응답을 구성해 전송합니다. Kotlin은 세션을 StateFlow<PresentationState>로, Swift는 AsyncStream<PresentationState>로 노출합니다.

세션 라이프사이클​

모든 제시는 동일한 상태들을 거칩니다. resolve와 submit 단계는 SDK가 주도하고, 그 사이의 consent 단계는 여러분의 몫입니다 — 해석된 요청을 살펴보고, 사용자가 선택하게 한 뒤, respond(...) 또는 decline()을 호출합니다.

단계Kotlin PresentationStateSwift PresentationState
resolveResolvingRequest → RequestResolved(request).resolvingRequest → .requestResolved(request)
consent(여러분의 UI — respond / decline)(여러분의 UI — respond / decline)
submitSubmitting.submitting
terminalCompleted(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를 기다립니다.

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

누가 무엇을 요청하는지 살펴보기​

request.verifier는 요청의 출처를 알려주고, request.queries는 무엇을 요구하는지 나열합니다 — 각 쿼리는 해당 쿼리를 만족할 수 있는 저장된 크리덴셜을 candidates로 담고 있습니다.

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

여기서 v.commonName은 인증서에서 추출된 표시용 이름이며, request.satisfiable은 모든 필수 쿼리에 후보가 하나 이상 있을 때 true가 됩니다. request.transactionData는 확인이 필요한 트랜잭션 데이터(예: 결제)를 담으며 없을 수도 있습니다.

응답하고 결과 기다리기​

사용자가 승인하면 SDK에 선택을 전달하고 종료 상태를 기다립니다. Completed.redirectUri는 검증자가 제공하는 선택적 사후 리다이렉트입니다.

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 -> {}
}

선택적 공개와 소지자 바인딩​

요청이 다루는 클레임만 공개되고, 크리덴셜의 나머지 내용은 모두 숨겨진 채로 유지됩니다. 응답은 **소지자에 바인딩(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 목록에 매핑해 선택을 명시적으로 구성합니다.

// 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))

값이 목록인 이유는 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()으로 다시 전달할 수 있습니다.

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()

mdoc DC API 응답은 **origin에 바인딩(origin-bound)**됩니다 — 호출자 origin이 디바이스 서명이 보호하는 DC API 핸드오버에 포함되므로, 응답을 다른 사이트에서 재생(replay)할 수 없습니다.

리더 신뢰 (Reader trust)​

TrustConfig에 readerAnchorsDer를 구성하면, 서명된 요청 객체(JAR)가 여러분에게 노출되기 전에 먼저 검증됩니다. 검증은 다음을 모두 요구합니다.

  1. 리더 인증서 체인이 구성된 앵커 중 하나로 검증되고,
  2. 요청 객체의 JWS 서명이 검증되며,
  3. 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는 거기서 프로토콜 오류를 돌려주는 것 자체가 사용자가 해당 크리덴셜을 보유했음을 드러낼 수 있다고 경고합니다 — 플랫폼이 요청을 만족하는 지갑만 선택지로 띄우기 때문입니다. 거절은 대신 플랫폼으로 전달됩니다. :::