근접 제시
근접 제시는 대면·오프라인 상황에서 mdoc(예: 모바일 운전면허증, mDL)을 근거리 전송 수단(BLE / NFC)을 통해 가까이 있는 리더에게 제시하는 것입니다. 네트워크는 관여하지 않습니다. 월렛과 리더가 기기 대 기기로 직접 크리덴셜을 주고받습니다.
SDK가 ISO/IEC 18013-5 device retrieval 교환 전체와 양쪽 역할을 담당합니다 — mdoc을 제시하는
홀더(wallet.proximity)와 검증하는 리더(wallet.reader):
- 디바이스 인게이지먼트 — QR 코드 또는 NFC handover — static 또는 negotiated(HCE로 Handover Select 서빙). 어느 쪽이든 BLE carrier를 peripheral server 또는 central client 모드로 담습니다.
- ECDH 세션 키 —
SKDevice/SKReader도출(SessionTranscript로 salt한 HKDF) 및 양방향SessionData프레임을 AES-256-GCM으로 암호화. DeviceRequest/DeviceResponse— 리더 요청 디코딩 + 선택적 공개·홀더 인증된 응답 생성.- 리더 인증 — 리더의
readerAuth서명 + 인증서 체인 검증. - 디바이스 인증 — 서명 키는
deviceSignature(COSE_Sign1), 키합의 키는deviceMac(COSE_Mac0, EReaderKey↔DeviceKey ECDH의 EMacKey).
여러분은 전송 수단(transport) — 라디오 바이트 입출력 — 만 제공합니다. 암호화·프로토콜 계층은 모두 SDK 내부에 있고, 표준 준수 ISO 18013-5 기기와 상호운용됩니다.
흐름
ProximityTransport를 인자로 present(transport)를 호출한 뒤 세션 상태를 관찰하세요. 상태 머신은
GeneratingEngagement → EngagementReady(리더가 스캔할 QR 표시) → RequestReceived(검사 후 응답) →
Completed 순으로 진행합니다.
- Kotlin
- Swift
val session = wallet.proximity.present(transport) // QR engagement
// val session = wallet.proximity.present(transport, nfc = true) // NFC static handover
session.state.collect { s ->
when (s) {
is ProximityState.EngagementReady ->
if (s.handoverNdef != null) serveOverNfc(s.handoverNdef) // NFC: serve the Handover Select via HCE
else showQr(s.deviceEngagement) // QR: render "mdoc:" + base64url
is ProximityState.RequestReceived -> {
val request = s.request
// request.documents — requested doctype(s) + elements
// request.reader.trusted — reader authentication result
// request.reader.commonName
session.respond(ProximitySelection.auto(request)) // or a user-chosen selection
}
is ProximityState.Completed -> onDone()
is ProximityState.Declined -> onDeclined()
is ProximityState.Failed -> onError(s.error)
else -> { /* GeneratingEngagement, Submitting */ }
}
}
let session = wallet.proximity.present(transport) // your ProximityTransport
for await state in session.states {
switch state {
case let .engagementReady(deviceEngagement, handoverNdef):
if let handoverNdef { serveOverNfc(handoverNdef) } // NFC: serve the Handover Select via HCE
else { showQr(deviceEngagement) } // QR: render "mdoc:" + base64url
case .requestReceived(let request):
// request.documents — requested doctype(s) + elements
// request.reader.trusted — reader authentication result
// request.reader.commonName
session.respond(.auto(request)) // or a user-chosen selection
case .completed:
onDone()
case .declined:
onDeclined()
case .failed(let error):
onError(error)
default:
break // generatingEngagement, submitting
}
}
EngagementReady는 deviceEngagement 바이트(QR 문자열 mdoc: + base64url로 렌더링)를 담고, NFC
모드에서는 HCE로 서빙할 handoverNdef Handover Select 메시지도 담습니다. BLE carrier(서비스 UUID +
모드)는 둘 중 어느 쪽에든 들어 있습니다. 리더가 연결해 DeviceRequest를 보내면 SDK가 복호화·파싱하고
RequestReceived를 방출합니다.
종료 상태는 Completed, Declined, Failed이며, 모든 상태는 .isTerminal을 제공합니다.
요청 검사
RequestReceived는 ProximityRequest를 제공합니다. documents를 검사해 리더가 무엇을 요청했는지 확인하고,
reader에서 인증 결과를 확인하며, satisfiable로 응답이 가능한지 판단하세요. 각 문서의 candidates는 해당
doctype에 답할 수 있는 저장 크리덴셜 목록이라 사용자가 하나를 고르게 할 수 있고, ProximitySelection.auto(request)는
각 문서에서 첫 번째를 선택합니다. decline()은 거부합니다.
- Kotlin
- Swift
is ProximityState.RequestReceived -> {
val request = s.request
for (doc in request.documents) {
doc.docType // e.g. "org.iso.18013.5.1.mDL"
doc.requestedElements // Map<namespace, List<elementId>>
doc.candidates // CredentialIds that answer it — let the User pick when there's >1
}
val reader = request.reader
if (reader.trusted) {
reader.commonName // verified reader identity from readerAuth
}
if (request.satisfiable) {
session.respond(ProximitySelection.auto(request))
} else {
session.decline()
}
}
case .requestReceived(let request):
for doc in request.documents {
doc.docType // e.g. "org.iso.18013.5.1.mDL"
doc.requestedElements // [namespace: [elementId]]
doc.candidates // CredentialIds that answer it — let the User pick when there's >1
}
let reader = request.reader
if reader.trusted {
reader.commonName // verified reader identity from readerAuth
}
if request.satisfiable {
session.respond(.auto(request))
} else {
session.decline()
}
전송 포트
SDK는 프레임 단위의 암호화된 메시지 교환을 구동하지만, 라디오는 절대 직접 다루지 않습니다. 여러분은
ProximityTransport 포트를 구현합니다 — 각 방향에 하나씩 두 개의 메서드와 종료 메서드까지 세 개입니다:
- Kotlin
- Swift
interface ProximityTransport {
suspend fun send(message: ByteArray) // framed SessionData → peer
suspend fun receive(): ByteArray // framed SessionData ← peer
suspend fun close()
// 리더에게 연결 방법을 알림 (기본값: 빈 리스트 / null):
fun retrievalMethods(): List<ByteArray> = emptyList() // QR 인게이지먼트용 BLE DeviceRetrievalMethod
fun nfcCarrier(): NfcCarrier? = null // NFC static handover용 BLE carrier (UUID + 모드)
}
protocol ProximityTransport: Sendable {
func send(_ message: [UInt8]) async throws // framed SessionData → peer
func receive() async throws -> [UInt8] // framed SessionData ← peer
func close() async
// 리더에게 연결 방법을 알림 (기본값: 빈 리스트 / nil):
func retrievalMethods() -> [[UInt8]] // QR 인게이지먼트용 BLE DeviceRetrievalMethod
func nfcCarrier() -> NfcCarrier? // NFC static handover용 BLE carrier (UUID + 모드)
}
send / receive를 오가는 바이트는 이미 ISO 18013-5 세션 메시지입니다. 여러분의 역할은 이를 통신 경로로
옮기는 것뿐입니다.
BLE 구현
BLE는 ISO 18013-5 §8.3.3.1.1의 두 모드 중 하나로 동작하며, SDK는 어느 쪽이든 무관합니다:
- Peripheral server mode — 홀더가 GATT 서버(서비스 UUID 광고), 리더가 GATT 클라이언트. 특성
00000001/2/3-a123-48ce-896b-4c76973373e6. - Central client mode — 역할 반대(홀더=클라이언트, 리더=서버). 특성
00000005/6/7-….
여러분의 transport는 retrievalMethods()(QR)와 nfcCarrier()(NFC)로 UUID·모드를 SDK에 알리고, SDK가 그
위에 인게이지먼트를 만듭니다. transport는 state / client2server / server2client 특성을 노출하고, 들어오는
쓰기/알림을 receive()로 전달하며, 각 send()를 청크 쓰기/알림(0x01=more, 0x00=last)으로 변환합니다.
SDK는 소켓을 열거나 광고하거나 특성을 읽지 않고 오직 포트만 호출합니다. 데모 앱은 두 모드를 각각 한 클래스로
처리하는 BleGattServerTransport + BleGattClientTransport를 제공합니다.
- Kotlin
- Swift
// A BLE peripheral that carries ISO 18013-5 mdoc device retrieval.
// The SDK calls send()/receive(); you bridge them to GATT.
class BleProximityTransport(
private val serviceUuid: UUID, // from the DeviceEngagement
) : ProximityTransport {
private val inbound = Channel<ByteArray>(Channel.UNLIMITED)
// Advertise `serviceUuid`; expose the state / client2server / server2device
// characteristics (ISO 18013-5 §8). Route inbound notifications into `inbound`.
override suspend fun send(message: ByteArray) {
// write `message` to the outbound characteristic (chunked to the MTU)
}
override suspend fun receive(): ByteArray =
inbound.receive() // fed by notifications from the reader
override suspend fun close() {
// stop advertising, tear down the GATT server
inbound.close()
}
}
// A BLE peripheral that carries ISO 18013-5 mdoc device retrieval.
// The SDK calls send()/receive(); you bridge them to CoreBluetooth.
final class BleProximityTransport: ProximityTransport {
let serviceUUID: CBUUID // from the DeviceEngagement
// Advertise `serviceUUID`; expose the state / client2server / server2device
// characteristics (ISO 18013-5 §8). Route inbound notifications into a buffer.
func send(_ message: [UInt8]) async throws {
// write `message` to the outbound characteristic (chunked to the MTU)
}
func receive() async throws -> [UInt8] {
// await the next notification from the reader
}
func close() async {
// stop advertising, tear down the peripheral manager
}
}
NFC handover
present(transport, nfc = true)이면 EngagementReady.handoverNdef는 ISO 18013-5 §8.3.3.1.2 Handover
Select NDEF 메시지(Hs 레코드 + DeviceEngagement 레코드 + BLE carrier 레코드)이며, SDK가 transport의
nfcCarrier()로 만듭니다. 이를 Android Host Card Emulation Type 4 태그 서비스로 서빙하면, 리더가 탭해서
읽고 인게이지먼트 + BLE UUID를 추출하며, 세션은 QR과 똑같이 BLE로 이어집니다. 전체 Handover Select 메시지가
SessionTranscript에 해시되어 양쪽이 같은 키를 도출합니다. SDK는 MdocNfcEngagement(빌드/파싱)와 Ndef를
제공하고, 데모가 HCE 서비스 + NFC 리더를 제공합니다.
여기까지가 static handover — mdoc이 내놓은 Select를 리더가 그대로 가져가는 방식입니다. §8.2.2.1은
negotiated handover도 정의합니다. mdoc이 TNEP으로 Connection Handover 서비스를 광고하면, 리더가
자기가 지원하는 캐리어를 담은 Handover Request를 쓰고, mdoc이 그중 정확히 하나를 골라 Select로
답합니다. 그 Request 바이트를 present(transport, nfc = true, handoverRequestNdef = hr)로 넘기면 SDK가
SessionTranscript에 [Hs, Hr]을 바인딩합니다(static은 [Hs, null]). 홀더 측 APDU 상태머신은
NfcEngagementProcessor 하나가 두 방식을 모두 처리하며 플랫폼 타입에 의존하지 않아, 플랫폼 계층은 얇은
HCE 브리지로 끝납니다.
BLE 모드는 누가 정하나
negotiated handover에서는 리더가 제안하고 mdoc이 고릅니다. 어느 쪽도 일방적으로 정하지 못합니다. 각 BLE
캐리어 레코드는 Bluetooth LE Role(0x1C)을 싣는데, 이 값은 언제나 보내는 쪽 자신의 역할입니다. ISO
모드 이름은 mdoc 기준으로 붙어 있어서, 리더가 보낼 때는 뒤집혀 읽힙니다:
| LE Role | 보내는 쪽의 진술 | Handover Request에서 (보낸 쪽 = 리더) |
|---|---|---|
0x00 | Peripheral만 | 리더가 GATT 서버 ⇒ mdoc central client 모드만 |
0x01 | Central만 | 리더가 접속하는 쪽 ⇒ mdoc peripheral server 모드만 |
0x02 | 둘 다, Peripheral 선호 | 어느 쪽이든 가능; 리더가 peripheral을 하고 싶다 ⇒ mdoc central client 선호 |
0x03 | 둘 다, Central 선호 | 어느 쪽이든 가능; mdoc peripheral server 선호 |
"둘 다" 두 값은 예외적인 케이스가 아닙니다 — §8.3.3.1.1.1이 mdoc과 리더는 "Central 역할, Peripheral 역할,
또는 둘 다"를 지원한다고 표시한다고 규정하고, 실제 리더들이 그 값을 씁니다. 그래서 LE Role을 boolean으로
읽으면 두 가지를 동시에 놓칩니다. "둘 다"라는 답을 표현하지 못하고, UUID와 모드의 짝이 어긋납니다.
§8.3.3.1.1.2가 Request의 UUID를 mdoc-central-client 주소로, Select의 UUID를 mdoc-peripheral-server
주소로 못박기 때문에, "둘 다"라고 답한 mdoc은 자기 Select의 UUID가 아니라 리더의 UUID로 연결해야 합니다.
MdocNfcEngagement는 네 값을 모두 모델링하고(NfcBleCarrier.supportsPeripheral / supportsCentral),
parseHandover(select, request)가 리더 측에서 그 짝을 풀어줍니다.
홀더 측 정책은 NfcHandoverRequest.selectCarrier()입니다. 리더가 나열한 순서대로 훑고, "둘 다" 캐리어면
LE Role 선호 비트를 존중하며, 선호 표시가 없으면 mdoc central client 모드를 택합니다 — §8.3.3.1.1.1이
권고하고, Google Wallet이 항상 그걸 고르며, 지갑이 자기 서비스 UUID를 방송하지 않아도 되기 때문입니다. SDK와
데모의 기본값이 전부 이 모드입니다: 리더가 광고하고, 지갑은 스캔만 합니다.
// 홀더: 리더가 실제로 제안한 것 중에서 고르고(§8.2.2.1), 그 transport로 제시한다.
val choice = MdocNfcEngagement.parseHandoverRequest(hr)?.selectCarrier()
val transport = if (choice?.peripheralServerMode == false && choice.serviceUuid != null) {
BleGattClientTransport(context, Ble.bytesToUuid(choice.serviceUuid), Ble.CENTRAL_CLIENT) // 리더에 접속
} else {
gattServer // 우리가 광고
}
wallet.proximity.present(transport, nfc = true, handoverRequestNdef = hr)
리더 자신의 Request는 MdocNfcEngagement.buildHandoverRequest가 만듭니다. BLE 캐리어 하나를 LE Role
0x02로 보냅니다 — §8.2.2.1이 서술하는 형태로, alternative carrier의 단위는 BLE 역할이 아니라 전송 기술
입니다. 한 모드당 레코드 하나씩 보내는 대체 인코딩이 필요한 상대를 위해 alsoOfferMdocPeripheralServer가
남아 있습니다.
실기기 검증
위 동작은 Multipaz 테스트앱과 Google Wallet을 상대로 양쪽 역할 모두 폰-대-폰으로 검증했습니다. 전체
매트릭스(QR + 두 handover 방식 × 두 BLE 모드, 15행)와 날짜별 결과는
demo/PROXIMITY-MATRIX.md에
있습니다. 연동 전에 알아둘 negotiated handover 관련 사실:
| 상대 | 그쪽 역할 | 우리 Request에 대한 반응 |
|---|---|---|
| Google Wallet | 홀더 | LE Role 선호 비트를 읽고 mdoc central client 모드를 택함 |
| Multipaz | 홀더 | 선호 비트를 무시하고 mdoc peripheral server로 답함(0x02·0x03 둘 다 측정). 캐리어 순서는 따름 |
| Multipaz | 리더 | LE Role 0x03 캐리어 하나를 제안 — 자기가 Central을 원하므로 우리 홀더는 peripheral server로 답함 |
둘 다 정상 완료됩니다. 선호 모드를 거절한 홀더는 그냥 다른 모드를 택할 뿐이고, 그래서 이건 요구사항이 아니라
선호입니다. 데모는 제시 중 negotiated 메시지 두 개를 원문 그대로 로그에 남기고, tools/ndef-dump.py가 그걸
캐리어·LE Role·UUID로 디코딩합니다 — 상대가 실제로 무엇을 제안했는지 볼 수 있는 유일한 방법입니다.
다른 월렛 읽기 (리더 측)
같은 SDK가 리더이기도 합니다. wallet.reader.read(transport, engagement, documents)가 여러분의
transport로 검증자 측 절반을 구동합니다 — DeviceRequest 생성, 암호화 세션 수립, 반환된 DeviceResponse
검증(issuer trust, digest, deviceSignature/deviceMac 홀더 바인딩). engagement은 월렛 QR(또는 NFC로
읽은 것)이고, NFC의 경우 SessionTranscript가 맞도록 handoverNdef를 넘깁니다.
- Kotlin
- Swift
val engagement = decodeQr(scanned) // "mdoc:" + base64url → DeviceEngagement bytes
val request = listOf(
RequestedDocument("org.iso.18013.5.1.mDL", mapOf("org.iso.18013.5.1" to listOf("family_name", "given_name"))),
)
val verified = wallet.reader.read(transport, engagement, request) // BLE central transport
for (doc in verified) {
doc.docType // "org.iso.18013.5.1.mDL"
doc.elements // Map<namespace, Map<elementId, value>>
doc.deviceAuthenticated // 홀더 바인딩이 검증되면 true
}
let engagement = decodeQr(scanned) // "mdoc:" + base64url → DeviceEngagement bytes
let request = [
RequestedDocument(docType: "org.iso.18013.5.1.mDL", elements: [("org.iso.18013.5.1", ["family_name", "given_name"])]),
]
let verified = try await wallet.reader.read(transport: transport, engagement: engagement, documents: request)
for doc in verified {
doc.docType // "org.iso.18013.5.1.mDL"
doc.elements // [namespace: [elementId: value]]
doc.deviceAuthenticated // 홀더 바인딩이 검증되면 true
}
issuer trust anchor가 설정되어 있으면 deviceAuthenticated는 issuer + 홀더 바인딩 검증 결과를 반영합니다.
없으면 리더는 공개된 엘리먼트를 그대로 반환하되 미검증으로 표시합니다. MSO value-digest 검증은 SHA-256,
SHA-384, SHA-512를 지원하며(§9.1.2.5에 따라 리더는 반드시 지원해야 함), MSO digestAlgorithm을 자동으로
따릅니다.
리더 인증
DeviceRequest가 readerAuth를 담고 있고 그 인증서가 readerAnchorsDer에 설정된 트러스트 앵커까지
체인 검증되면, request.reader.trusted가 true가 되고, 리더의 CommonName과 전체 인증서 체인이 감사
로그에 기록됩니다. 리더 서명이 없거나, 위조되었거나, 검증 불가능하면 trusted = false가 됩니다.
이는 원격 서명 요청(OpenID4VP)과 동일한 트러스트 모델입니다. SDK는 검증하고, 결과를 기록하며, 이를 표면화하지만 그 결과로 차단하지는 않습니다. 월렛은 리더의 신뢰 여부를 사용자에게 보여줄 뿐, 공개 여부는 여전히 사용자가 결정합니다.
- Kotlin
- Swift
// Configure the reader (verifier) trust anchors when assembling the wallet.
val config = WalletConfig(
trust = TrustConfig(
issuerAnchorsDer = issuerAnchors,
readerAnchorsDer = readerAnchors, // trusted reader roots (DER-encoded)
),
)
// Later, on each request:
val reader = request.reader
reader.trusted // chained to a configured anchor + valid readerAuth
reader.commonName // reader CN, when present
reader.certificateChainDer // full presented chain (also written to the audit log)
// Configure the reader (verifier) trust anchors when assembling the wallet.
let config = WalletConfig(
trust: TrustConfig(
issuerAnchorsDer: issuerAnchors,
readerAnchorsDer: readerAnchors // trusted reader roots (DER-encoded)
)
)
// Later, on each request:
let reader = request.reader
reader.trusted // chained to a configured anchor + valid readerAuth
reader.commonName // reader CN, when present
reader.certificateChainDer // full presented chain (also written to the audit log)
완료된 모든 교환은 wallet.transactions에 기록되며, 여기에는 상대방(리더), 그 신뢰 판정과 체인, 그리고
정확히 공개된 클레임이 담깁니다 — **트러스트 및 감사**를 참고하세요.
테스트 용이성
인게이지먼트, 세션 암호화, DeviceResponse 서명이 모두 순수(pure)하기 때문에, 교환 전체를 인메모리
전송으로 단위 테스트할 수 있습니다 — 블루투스도, NFC도, 실기기도 필요 없습니다. 라디오 대신 인프로세스
큐로 동일한 ProximityTransport 포트를 구현하고, 반대편은 목(mock) 리더로 구동하면 됩니다. 실기기에서는
그 클래스 하나만 BLE/NFC 구현으로 교체하면 되고, 나머지 코드는 그대로입니다.
- Kotlin
- Swift
// In-memory transport: two queues wired to a mock reader — no radio.
class InMemoryTransport : ProximityTransport {
val toReader = Channel<ByteArray>(Channel.UNLIMITED) // SDK.send() lands here
val toWallet = Channel<ByteArray>(Channel.UNLIMITED) // SDK.receive() reads here
override suspend fun send(message: ByteArray) = toReader.send(message)
override suspend fun receive(): ByteArray = toWallet.receive()
override suspend fun close() { toReader.close(); toWallet.close() }
}
@Test
fun `reader retrieves the requested elements`() = runTest {
val transport = InMemoryTransport()
val session = wallet.proximity.present(transport)
// A mock reader reads `transport.toReader`, replies into `transport.toWallet`,
// and the SDK runs engagement, session encryption and DeviceResponse signing
// fully in-process.
val done = session.state.first { it.isTerminal }
assertTrue(done is ProximityState.Completed)
}
// In-memory transport: two queues wired to a mock reader — no radio.
final class InMemoryTransport: ProximityTransport, @unchecked Sendable {
let toReader: AsyncStream<[UInt8]> // SDK.send() lands here
private let toReaderIn: AsyncStream<[UInt8]>.Continuation
private var toWallet: AsyncStream<[UInt8]>.Iterator // SDK.receive() reads here
init(fromReader: AsyncStream<[UInt8]>) {
var cont: AsyncStream<[UInt8]>.Continuation!
self.toReader = AsyncStream { cont = $0 }
self.toReaderIn = cont
self.toWallet = fromReader.makeAsyncIterator()
}
func send(_ message: [UInt8]) async throws { toReaderIn.yield(message) }
func receive() async throws -> [UInt8] { await toWallet.next() ?? [] }
func close() async { toReaderIn.finish() }
}
func testReaderRetrievesRequestedElements() async {
let transport = InMemoryTransport(fromReader: mockReaderReplies)
let session = wallet.proximity.present(transport)
// The mock reader consumes `transport.toReader` and feeds `mockReaderReplies`;
// the SDK runs engagement, session encryption and DeviceResponse signing
// fully in-process.
for await state in session.states where state.isTerminal {
if case .completed = state { /* pass */ }
break
}
}
인메모리 전송 덕분에 근접 제시 로직을 일반 CI 러너(Linux 포함)에서 테스트할 수 있고, 라디오 어댑터만 실기기를 필요로 합니다.
기기 인증 (Device authentication)
ISO 18013-5 §9.1.3.5는 mdoc이 DeviceResponse를 인증하는 방식을 두 가지로 정의합니다. SDK는 둘 다
생성·검증하며, 홀더 측 선택은 설정 한 줄입니다:
- Kotlin
- Swift
WalletConfig(presentation = PresentationConfig(mdocDeviceAuth = MdocDeviceAuthMode.Mac))
WalletConfig(presentation: PresentationConfig(mdocDeviceAuth: .mac))
mdocDeviceAuth는 proximity와 OpenID4VP mdoc 제시 둘 다의 디바이스 인증 방식을 결정하며, proximity
전용이 아닙니다.
deviceSignature (기본) | deviceMac | |
|---|---|---|
| 구조 | COSE_Sign1, ES256 | COSE_Mac0, HMAC 256/256 |
| 키 | 서명용 DeviceKey | 키합의용 DeviceKey |
| 검증 가능한 주체 | 누구나 | 그 세션의 리더만 |
deviceMac의 키는 EMacKey입니다:
HKDF(ECDH(DeviceKey, EReaderKey), salt = SHA-256(SessionTranscriptBytes), info = "EMacKey").
홀더는 그 ECDH를 SecureArea 안에서 계산하고(개인키는 밖으로 안 나감), 리더는 자기 임시키로 대칭인 ECDH를
계산합니다. 오직 그 두 당사자만 키를 유도할 수 있으므로 MAC은 양도 불가합니다 — 리더가 제3자에게 "이
지갑이 응답했다"를 증명할 수 없어요. 서명은 증명할 수 있고요. 어떤 배포는 전자를, 어떤 배포는 후자를 원합니다.
:::warning DeviceKey가 ECDH를 지원해야 합니다
MdocDeviceAuthMode.Mac은 키합의 DeviceKey를 요구합니다. Android Keystore와 Secure Enclave는 키 용도를
생성 시점에 고정하므로, 서명 전용 크리덴셜을 든 지갑은 재발급해야 전환됩니다. SDK는
SecureArea.capabilities.keyAgreement를 확인해, 조용히 서명으로 폴백하지 않고 명확한 오류로 세션을 실패시킵니다.
:::
:::caution 한 키에 한 방식 (§9.1.3.4)
A single mdoc authentication key shall not be used to produce both MACs and signatures during its lifetime.
OpenID4VP에서는 mdoc이 오직 deviceauth_alg_values가 MAC을 요구하는 암호화된 응답에 대해서만 MAC을 할
수 있습니다(ISO 18013-7 B.4.5) — verifier의 응답 암호화 키가 ECDH의 EReaderKey 역할을 겸하기 때문입니다.
org-iso-mdoc HPKE DC API 경로에는 그런 키가 없어 여전히 항상 서명합니다. 기본 정책인 KeyUse.Rotate에서는
같은 DeviceKey가 재사용되므로, Mac으로 설정한 지갑이 다른 채널로도 바인딩·서명 제시를 하면 한 키가 두 방식을
모두 쓰게 됩니다 — 조항이 금지하는 상황이고, Mac을 고른 이유였던 부인 가능성도 조용히 무효화됩니다.
CredentialPolicy(batchSize = n, use = KeyUse.OneTime)로 배치 발급하면 각 DeviceKey가 제시 한 번에
소모되므로 이 조항을 구조적으로 충족합니다. HAIP가 unlinkability를 위해 권하는 방식이기도 합니다.
:::
:::note 세션 키 곡선
proximity 세션의 임시 키(EDeviceKey/EReaderKey)는 기본적으로 EcCurve.P256을 사용합니다.
WalletConfig.presentation.proximitySessionCurve를 .P384 또는 .P521(Swift .p256/.p384/.p521)로
설정할 수 있으며, 이는 ISO 18013-5 §9.1.5.2 Table 22를 따릅니다. 리더로 동작할 때 SDK는 mdoc이 제시하는
곡선에 자동으로 맞춥니다.
:::