Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package id.walt.wallet2.server.handlers
import id.walt.crypto.keys.KeyManager
import id.walt.crypto.keys.TypedKeyGenerationRequest
import id.walt.did.dids.DidService
import id.walt.openid4vci.errors.CredentialError
import id.walt.wallet2.data.*
import id.walt.wallet2.handlers.*
import id.walt.verifier.openid.transactiondata.TransactionDataTypeRegistry
Expand Down Expand Up @@ -585,6 +586,17 @@ object Wallet2RouteHandler {
)
}

post("/request-nonce", {
summary = "Isolated: obtain a fresh credential proof nonce"
request { pathParameter<String>("walletId"); body<RequestNonceRequest>() }
response { HttpStatusCode.OK to { body<RequestNonceResult>() } }
}) {
val req = call.receive<RequestNonceRequest>()
call.respond(
WalletIssuanceHandler.requestNonce(request = req)
)
}

post("/sign-proof", {
summary = "Isolated: sign a proof-of-possession JWT"
request { pathParameter<String>("walletId"); body<SignProofRequest>() }
Expand All @@ -598,13 +610,23 @@ object Wallet2RouteHandler {
post("/fetch-credential", {
summary = "Isolated: fetch a credential from the issuer's credential endpoint"
description = "When storeInWallet is true the fetched credential(s) are automatically " +
"stored in the wallet, removing the need to call the import endpoint afterwards."
"stored in the wallet, removing the need to call the import endpoint afterwards. " +
"If the issuer returns invalid_nonce, request a fresh nonce, sign a new proof, " +
"and repeat this isolated fetch step."
request { pathParameter<String>("walletId"); body<FetchCredentialRequest>() }
response { HttpStatusCode.OK to { body<FetchCredentialResult>() } }
response {
HttpStatusCode.OK to { body<FetchCredentialResult>() }
HttpStatusCode.BadRequest to { body<CredentialError>() }
}
}) {
val wallet = call.resolveOrRespond(resolver, getAccountId) ?: return@post
val req = call.receive<FetchCredentialRequest>()
call.respond(WalletIssuanceHandler.fetchCredential(wallet, req))
try {
call.respond(WalletIssuanceHandler.fetchCredential(wallet, req))
} catch (error: CredentialEndpointException) {
if (!error.isInvalidNonce) throw error
call.respond(HttpStatusCode.BadRequest, requireNotNull(error.credentialError))
}
}

// Auth-code grant isolated steps
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package id.walt.wallet2.handlers

import id.walt.openid4vci.errors.CredentialErrorCodes
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail

class WalletIssuanceHandlerInvalidNonceTest {

@Test
fun retriesOnceWithFreshNonceAndNewProof() = runTest {
var nonceRequests = 0
var credentialRequests = 0
val proofNonces = mutableListOf<String?>()
val client = HttpClient(MockEngine) {
engine {
addHandler { request ->
when (request.url.toString()) {
NONCE_ENDPOINT -> respondJson(
"""{"c_nonce":"${if (++nonceRequests == 1) "stale-nonce" else "fresh-nonce"}"}"""
)

CREDENTIAL_ENDPOINT -> {
credentialRequests++
if (credentialRequests == 1) {
respondJson(
"""{"error":"invalid_nonce","error_description":"Proof nonce has expired"}""",
HttpStatusCode.BadRequest,
)
} else {
respondJson("""{"credentials":[{"credential":"issued-credential"}]}""")
}
}

else -> error("Unexpected request: ${request.method.value} ${request.url}")
}
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}

val response = WalletIssuanceHandler.requestCredentialWithNonceRetry(
request = fetchRequest(),
nonceEndpoint = NONCE_ENDPOINT,
httpClient = client,
buildProof = { nonce ->
proofNonces += nonce
"proof-for-$nonce"
},
)

assertEquals(listOf<String?>("stale-nonce", "fresh-nonce"), proofNonces)
assertEquals(2, nonceRequests)
assertEquals(2, credentialRequests)
assertEquals("issued-credential", response.credentials?.single()?.credential.toString().removeSurrounding("\""))
}

@Test
fun stopsAfterOneInvalidNonceRetry() = runTest {
var nonceRequests = 0
var credentialRequests = 0
val proofNonces = mutableListOf<String?>()
val client = HttpClient(MockEngine) {
engine {
addHandler { request ->
when (request.url.toString()) {
NONCE_ENDPOINT -> respondJson("""{"c_nonce":"nonce-${++nonceRequests}"}""")
CREDENTIAL_ENDPOINT -> {
credentialRequests++
respondJson("""{"error":"invalid_nonce"}""", HttpStatusCode.BadRequest)
}
else -> error("Unexpected request: ${request.method.value} ${request.url}")
}
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}

val failure = try {
WalletIssuanceHandler.requestCredentialWithNonceRetry(
request = fetchRequest(),
nonceEndpoint = NONCE_ENDPOINT,
httpClient = client,
buildProof = { nonce ->
proofNonces += nonce
"proof-for-$nonce"
},
)
fail("Expected invalid_nonce to be propagated after the retry")
} catch (error: CredentialEndpointException) {
error
}

assertEquals(true, failure.isInvalidNonce)
assertEquals(listOf<String?>("nonce-1", "nonce-2"), proofNonces)
assertEquals(2, nonceRequests)
assertEquals(2, credentialRequests)
}

@Test
fun isolatedFetchPropagatesTypedInvalidNonceWithoutRetry() = runTest {
var credentialRequests = 0
val client = HttpClient(MockEngine) {
engine {
addHandler { request ->
assertEquals(CREDENTIAL_ENDPOINT, request.url.toString())
credentialRequests++
respondJson(
"""{"error":"invalid_nonce","error_description":"Proof nonce has expired"}""",
HttpStatusCode.BadRequest,
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}

val failure = try {
WalletIssuanceHandler.fetchCredential(fetchRequest(), client)
fail("Expected invalid_nonce to be propagated")
} catch (error: CredentialEndpointException) {
error
}

assertEquals(HttpStatusCode.BadRequest.value, failure.statusCode)
assertEquals(CredentialErrorCodes.INVALID_NONCE, failure.credentialError?.error)
assertEquals("Proof nonce has expired", failure.credentialError?.description)
assertEquals(1, credentialRequests)
}

private fun fetchRequest() = FetchCredentialRequest(
credentialEndpoint = Url(CREDENTIAL_ENDPOINT),
accessToken = "access-token",
credentialConfigurationId = "pid",
)

private companion object {
const val NONCE_ENDPOINT = "https://issuer.example/nonce"
const val CREDENTIAL_ENDPOINT = "https://issuer.example/credential"
}
}

private fun io.ktor.client.engine.mock.MockRequestHandleScope.respondJson(
content: String,
status: HttpStatusCode = HttpStatusCode.OK,
) = respond(
content = content,
status = status,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package id.walt.wallet2.handlers

import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

class WalletIssuanceHandlerNonceTest {

@Test
fun tokenResultDescriptionRedactsAccessToken() {
val result = RequestTokenResult(accessToken = "private-token", expiresIn = 300)

assertFalse(result.toString().contains("private-token"))
}

@Test
fun requestsNonceOnlyFromIssuerMetadata() = runTest {
var nonceRequests = 0
val client = issuerClient(nonceEndpoint = NONCE_ENDPOINT) { request ->
nonceRequests += 1
assertEquals(HttpMethod.Post, request.method)
assertEquals(NONCE_ENDPOINT, request.url.toString())
"""{"c_nonce":"fresh-proof-nonce"}"""
}

val result = WalletIssuanceHandler.requestNonce(
RequestNonceRequest(Url(ISSUER)),
client,
)

assertEquals("fresh-proof-nonce", result.nonce)
assertEquals(1, nonceRequests)
assertFalse(result.toString().contains("fresh-proof-nonce"))
}

@Test
fun proofRequiredWithoutNonceEndpointReturnsNoNonce() = runTest {
val client = issuerClient(nonceEndpoint = null) {
error("Nonce endpoint must not be called")
}

val result = WalletIssuanceHandler.requestNonce(
RequestNonceRequest(Url(ISSUER)),
client,
)

assertEquals(null, result.nonce)
}

private fun issuerClient(
nonceEndpoint: String?,
nonceResponse: (io.ktor.client.request.HttpRequestData) -> String,
): HttpClient = HttpClient(MockEngine) {
engine {
addHandler { request ->
when (request.url.toString()) {
METADATA_ENDPOINT -> respond(
content = issuerMetadata(nonceEndpoint),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)

NONCE_ENDPOINT -> respond(
content = nonceResponse(request),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)

else -> error("Unexpected request: ${request.url}")
}
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}

private fun issuerMetadata(nonceEndpoint: String?): String = """
{
"credential_issuer": "$ISSUER",
"credential_endpoint": "$ISSUER/credential",
${nonceEndpoint?.let { "\"nonce_endpoint\": \"$it\"," } ?: ""}
"credential_configurations_supported": {
"test": {
"format": "jwt_vc_json",
"credential_definition": {
"type": ["VerifiableCredential", "TestCredential"]
}
}
}
}
""".trimIndent()

private companion object {
const val ISSUER = "https://issuer.example"
const val METADATA_ENDPOINT = "$ISSUER/.well-known/openid-credential-issuer"
const val NONCE_ENDPOINT = "$ISSUER/nonce"
}
}
13 changes: 10 additions & 3 deletions waltid-libraries/protocols/waltid-openid4vci-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import id.waltid.openid4vci.wallet.*
import id.waltid.openid4vci.wallet.oauth.ClientConfiguration
import id.waltid.openid4vci.wallet.offer.*
import id.waltid.openid4vci.wallet.metadata.*
import id.waltid.openid4vci.wallet.nonce.NonceRequestBuilder
import id.waltid.openid4vci.wallet.token.*
import id.waltid.openid4vci.wallet.proof.*
import io.ktor.client.*
Expand Down Expand Up @@ -126,16 +127,21 @@ if (preAuthGrant != null) {
txCode = null // or user-provided PIN
)

// 7. Generate proof of possession
// 7. Obtain a fresh proof nonce when the issuer advertises a Nonce Endpoint
val nonce = issuerMetadata.nonceEndpoint?.let { nonceEndpoint ->
NonceRequestBuilder(httpClient).requestNonce(nonceEndpoint).cNonce
}

// 8. Generate proof of possession, omitting the nonce claim when none was obtained
val key = KeyManager.loadKey("my-key-id")
val proofBuilder = JwtProofBuilder()
val proof = proofBuilder.buildProof(
key = key,
audience = offer.credentialIssuer,
nonce = tokenResponse.c_nonce!!
nonce = nonce
)

// 8. Request credential (Implementation coming soon)
// 9. Request credential (Implementation coming soon)
// ...
}
```
Expand All @@ -145,6 +151,7 @@ if (preAuthGrant != null) {
- `oauth/` - OAuth 2.0 client components (PKCE, State management)
- `offer/` - Credential offer parsing and resolution
- `metadata/` - Issuer and authorization server metadata discovery
- `nonce/` - OpenID4VCI 1.0 Nonce Endpoint requests
- `authorization/` - Authorization request building and response parsing
- `token/` - Token exchange logic for both grant types
- `proof/` - Proof of possession building (JWT-based)
Expand Down
Loading
Loading