시작하기
월렛이란?
Wallet은 앱이 상호작용하는 단 하나의 객체입니다. 크리덴셜 저장소를 소유하며, 소수의 서비스를 통해 SDK의
모든 기능을 노출합니다:
wallet.credentials // 목록 / 조회 / 삭제 / DCQL 매칭 / 상태 / 변경 이벤트
wallet.issuance // OpenID4VCI 발급 세션
wallet.presentation // OpenID4VP 원격 + Digital Credentials API
wallet.proximity // ISO 18013-5 대면
wallet.transactions // 감사 이력
월렛은 한 번 조립합니다 — WalletConfig(트러스트 앵커와 발급 기본값)와 WalletPorts(순수한 코어가
필요로 하는 얇은 플랫폼 어댑터 묶음)를 전달한 뒤, 앱 수명 동안 그 인스턴스를 유지합니다. 코어는 Kotlin으로
완전히 구현되어 있고 Swift로 미러링되어 있습니다. 두 구현은 동일한 API 계약을 공유하므로, 아래의 형태는
플랫폼과 무관하게 동일합니다.
월렛 조립하기
- Kotlin
- Swift
val wallet = Wallet.create(
config = WalletConfig(
trust = TrustConfig(
issuerAnchorsDer = issuerCaDerList, // verifies issuer certs & Token Status Lists
readerAnchorsDer = readerCaDerList, // verifies signed VP requests / mdoc reader auth
),
issuance = IssuanceConfig(
clientId = "wallet-dev",
redirectUri = "eudi-wallet://authorize",
),
),
ports = WalletPorts(
secureAreas = listOf(androidKeystoreSecureArea), // hardware-backed keys
storage = encryptedStorageDriver, // your persistence adapter
http = okHttpTransport, // your HTTP adapter
// walletAttestation, clock, rng, logger, transactionLogStore all have defaults
),
)
let wallet = Wallet.create(
config: WalletConfig(
trust: TrustConfig(
issuerAnchorsDer: issuerCAs, // verifies issuer certs & Token Status Lists
readerAnchorsDer: readerCAs // verifies signed VP requests / mdoc reader auth
),
issuance: IssuanceConfig(
clientId: "wallet-dev",
redirectUri: "eudi-wallet://authorize"
)
),
ports: WalletPorts(
secureAreas: [secureEnclaveSecureArea], // hardware-backed keys
storage: encryptedStorageDriver, // your persistence adapter
http: urlSessionTransport // your HTTP adapter
// walletAttestation, clock, rng, logger, transactionLogStore all have defaults
)
)
WalletConfig
두 필드 모두 기본값이 있으므로, 트러스트 앵커를 요구하지 않는 이슈어를 상대로 처음 실행할 때는 WalletConfig()
만으로도 유효합니다.
| 필드 | 타입 | 용도 |
|---|---|---|
trust | TrustConfig | SDK 전반에서 사용하는 X.509 트러스트 앵커입니다. issuerAnchorsDer는 이슈어 서명 인증서와 상태 리스트 서명자를 검증하고, readerAnchorsDer는 서명된 제시 요청(OpenID4VP JAR)과 mdoc 리더 인증을 검증합니다. 앵커는 DER 인코딩된 인증서입니다(List<ByteArray> / [[UInt8]]). |
issuance | IssuanceConfig | 발급 플로우에 적용되는 기본값입니다: clientId(기본값 "wallet-dev")와 redirectUri(기본값 "eudi-wallet://authorize"). 이슈어에 등록한 앱의 값으로 설정하세요. |
WalletPorts
포트는 순수하고 플랫폼 비의존적인 코어와 호스트 사이의 경계입니다. 셋은 필수이고, 나머지는 합리적인 기본값을 가집니다.
| 포트 | 필수? | 설명 |
|---|---|---|
secureAreas | 예 | 하나 이상의 SecureArea 키 저장소입니다. 프로덕션에서는 Android에서 Android Keystore, iOS에서 Secure Enclave여야 합니다 — 키는 하드웨어에 보관되며 기기를 절대 벗어나지 않습니다. |
storage | 예 | 크리덴셜과 SDK 상태를 암호화하여 영속화하는 StorageDriver입니다. |
http | 예 | 모든 네트워크 호출에 사용되는 HttpTransport(예: OkHttp / URLSession)입니다. 반드시 request.followRedirects를 준수해야 합니다. |
walletAttestation | 아니오 | 월렛 키 어테스테이션용 WalletAttestationProvider입니다. 기본값은 없음입니다. |
clock | 아니오 | WalletClock입니다. 기본값은 시스템 클록입니다. |
rng | 아니오 | Rng입니다. 기본값은 플랫폼 보안 RNG입니다. |
logger | 아니오 | WalletLogger입니다. 기본값은 없음입니다. |
transactionLogStore | 아니오 | 감사 로그의 백킹 저장소입니다. 기본값은 인메모리 저장소입니다. 재시작 후에도 이력을 유지하려면 영속 저장소를 주입하세요. |
:::warning 프로덕션 키
SoftwareSecureArea와 InMemoryStorageDriver는 테스트 전용입니다(테스트 킷에 포함됩니다).
릴리스 빌드에서는 절대 사용하지 마세요 — 키 자료와 크리덴셜을 프로세스 메모리에 보관합니다.
:::
단위 테스트에서는 소프트웨어 어댑터를 연결하고 트러스트 앵커를 완전히 생략할 수 있습니다:
- Kotlin
- Swift
// Tests only — never ship SoftwareSecureArea / InMemoryStorageDriver
val wallet = Wallet.create(
config = WalletConfig(), // all defaults
ports = WalletPorts(
secureAreas = listOf(SoftwareSecureArea()),
storage = InMemoryStorageDriver(),
http = fakeHttpTransport,
),
)
// Tests only — never ship SoftwareSecureArea / InMemoryStorageDriver
let wallet = Wallet.create(
config: WalletConfig(), // all defaults
ports: WalletPorts(
secureAreas: [SoftwareSecureArea()],
storage: InMemoryStorageDriver(),
http: fakeHttpTransport
)
)
직접 어댑터 구현하기
대부분의 앱은 미리 만들어진 android/ 프리셋(AndroidKeystoreSecureArea, FileStorageDriver,
OkHttpTransport, …)에 의존합니다 — Android 어댑터 및 데모를 참고하세요. 다른 플랫폼을
대상으로 하거나 프리셋을 강화하려면 포트를 직접 구현하세요. 어댑터를 올바르게 만드는 두 가지 규칙이 있습니다:
- **
SecureArea**는 개인 키 보관 경계입니다:createKey,publicKey,sign(원시r||s),keyAgreement,attestation,deleteKey를 구현하고capabilities를 정직하게 선언하세요(mdocMac용으로 구성된 월렛은capabilities.keyAgreement를 확인합니다). 개인 키는 절대 이 경계를 벗어나면 안 됩니다. - **
HttpTransport**는 반드시request.followRedirects를 준수해야 합니다 — OpenID4VCI/VP 플로우는 리다이렉트를 가로채므로, 항상 리다이렉트를 따라가는 트랜스포트는 발급과 제시를 깨뜨립니다.
테스트 킷의 SoftwareSecureArea와 InMemoryStorageDriver를 참조 구현으로 사용하고, 프리셋을 검증하는 것과
동일한 공유 컨트랙트 테스트 스위트로 어댑터를 검증하세요:
- Kotlin
- Swift
// 테스트 킷 — 컨트랙트 위반 시 IllegalStateException을 던집니다.
SecureAreaContract.verify(mySecureArea)
StorageDriverContract.verify(myStorageDriver)
// 테스트 킷 — 컨트랙트 위반 시 예외를 던집니다.
try await SecureAreaContract.verify(mySecureArea)
try await StorageDriverContract.verify(myStorageDriver)
스레드 안전성과 종료
Wallet은 스레드 안전하며 다중 인스턴스를 지원합니다 — 하나의 인스턴스를 여러 코루틴 / 태스크에서
공유하거나, 같은 프로세스에서 여러 개의 독립적인 월렛을 생성할 수 있습니다. 사용이 끝나면(예: 로그아웃이나
정리 시점) close()를 호출하여 진행 중인 발급, 제시, 근접 세션을 취소하고 리소스를 해제하세요.
- Kotlin
- Swift
wallet.close()
wallet.close()
크리덴셜 목록 조회
wallet.credentials.list()는 저장된 모든 크리덴셜의, 파싱된 포맷 비의존적 뷰를 반환합니다. 조회는
suspend(Kotlin) / async(Swift)입니다. 필터를 지정하지 않으면 전체를 반환합니다(CredentialFilter.All /
.all).
- Kotlin
- Swift
val credentials: List<Credential> = wallet.credentials.list() // CredentialFilter.All by default
val one: Credential? = wallet.credentials.get(credentialId)
let credentials: [Credential] = try await wallet.credentials.list() // .all by default
let one: Credential? = try await wallet.credentials.get(credentialId)
파싱된 클레임과 라이프사이클 읽기
각 Credential은 포맷 비의존적 메타데이터(id, format, issuer, display, configurationId)와
lifecycle을 담고 있습니다. 크리덴셜이 발급 완료(issued) 되면, 라이프사이클은 디코딩된 claims(각각
path + value이며, 사람이 읽을 수 있는 형태는 value.display())와 남아 있는 일회용 instances 수를
노출합니다.
- Kotlin
- Swift
val credential = wallet.credentials.get(credentialId) ?: return
// Format-agnostic type name
val typeName = when (val f = credential.format) {
is CredentialFormat.SdJwtVc -> f.vct // SD-JWT VC type
is CredentialFormat.MsoMdoc -> f.docType // mdoc docType
}
credential.issuer?.let { println("Issued by ${it.displayName} (${it.url})") }
// Parsed claims are available once the credential is issued
when (val lifecycle = credential.lifecycle) {
is Lifecycle.Issued -> {
for (claim in lifecycle.claims) {
val path = claim.path.joinToString(".") // e.g. "address.locality"
println("$path = ${claim.value.display()}")
}
println("${lifecycle.instances} unused instance(s) remaining")
}
else -> println("not issued yet")
}
guard let credential = try await wallet.credentials.get(credentialId) else { return }
// Format-agnostic type name
let typeName: String
switch credential.format {
case .sdJwtVc(let vct): typeName = vct // SD-JWT VC type
case .msoMdoc(let docType): typeName = docType // mdoc docType
}
if let issuer = credential.issuer {
print("Issued by \(issuer.displayName) (\(issuer.url))")
}
// Parsed claims are available once the credential is issued
if case let .issued(issued) = credential.lifecycle {
for claim in issued.claims {
let path = claim.path.joined(separator: ".") // e.g. "address.locality"
print("\(path) = \(claim.value.display())")
}
print("\(issued.instances) unused instance(s) remaining")
}
폐기 상태 확인
status(id)는 크리덴셜의 실시간 폐기 상태를 IETF Token Status List에 대조하여 조회합니다. Unknown은
상태 리스트에 도달할 수 없었음을 의미합니다 — "폐기됨"이 아니라 "재시도"로 취급하세요.
- Kotlin
- Swift
when (wallet.credentials.status(credentialId)) {
CredentialStatus.Valid -> allow()
CredentialStatus.Suspended -> warnTemporarilyBlocked()
CredentialStatus.Invalid -> blockRevoked()
CredentialStatus.Unknown -> retryLater() // status list unreachable
}
switch try await wallet.credentials.status(credentialId) {
case .valid: allow()
case .suspended: warnTemporarilyBlocked()
case .invalid: blockRevoked()
case .unknown: retryLater() // status list unreachable
}
DCQL 쿼리로 매칭
match(dcqlJson)은 저장된 어떤 크리덴셜이 DCQL(dcql_query)을 충족하는지 조회합니다 — 제시 플로우가
사용하는 것과 동일한 엔진이므로, 요청이 도착하기 전에 충족 가능 여부를 미리 확인할 수 있습니다. PID에 대한
다음의 작은 쿼리가 주어졌을 때:
{
"credentials": [
{
"id": "pid",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:pid:1"] },
"claims": [
{ "path": ["family_name"] },
{ "path": ["given_name"] },
{ "path": ["age_equal_or_over", "18"] }
]
}
]
}
byQuery는 DCQL 크리덴셜 id(위의 "pid")를 키로 사용합니다. 각 MatchedCredential은 어떤 저장된
크리덴셜이 이를 충족하는지, 그리고 정확히 어떤 경로가 공개되는지 알려줍니다.
- Kotlin
- Swift
val match = wallet.credentials.match(dcqlJson)
if (match.satisfiable) {
val candidates = match.byQuery.getValue("pid") // keyed by the DCQL credential id
for (m in candidates) {
println("credential ${m.credential.id} would disclose ${m.disclosedPaths}")
}
}
let match = try await wallet.credentials.match(dcqlJson)
if match.satisfiable {
let candidates = match.byQuery["pid"] ?? [] // keyed by the DCQL credential id
for m in candidates {
print("credential \(m.credential.id) would disclose \(m.disclosedPaths)")
}
}
크리덴셜 변경 관찰
저장소는 크리덴셜이 추가, 갱신, 또는 삭제될 때마다 변경 이벤트를 발행합니다 — 예를 들어 발급이 완료된 뒤,
제시가 일회용 인스턴스를 소비한 뒤, 또는 상태 갱신 이후입니다. Kotlin은 Flow를, Swift는 AsyncStream을
노출합니다. 이를 사용해 UI를 동기화 상태로 유지하세요.
- Kotlin
- Swift
wallet.credentials.changes.collect { change ->
when (change) {
is CredentialChange.Added -> onAdded(change.id)
is CredentialChange.Updated -> onUpdated(change.id)
is CredentialChange.Removed -> onRemoved(change.id)
}
}
for await change in await wallet.credentials.changes() {
switch change {
case .added(let id): onAdded(id)
case .updated(let id): onUpdated(id)
case .removed(let id): onRemoved(id)
}
}
다음 단계
월렛을 조립했으니, 이제 실제로 활용해 보세요: