Skip to content
Open
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
@@ -0,0 +1,39 @@
import Foundation

public enum X509RequestObjectFixture {
/// SHA-256 Base64URL hash of the leaf certificate in `signedRequestObject`.
public static let clientID = "x509_hash:IT2EMOaIMUTx1zj0yteLZyOgkXF6Siw2t_K42tsKT_I"

/// Test-only CA for the leaf in `signedRequestObject`, valid until July 2036.
public static let trustAnchorPEM = """
-----BEGIN CERTIFICATE-----
MIIB6zCCAZGgAwIBAgIUSFgRXRcRirEjuPvPM38K5ZGrPpcwCgYIKoZIzj0EAwIw
QzEvMC0GA1UEAwwmd2FsdC5pZCBBcHAtSG9zdGVkIFg1MDkgSGFzaCBUZXN0IFJv
b3QxEDAOBgNVBAoMB3dhbHQuaWQwHhcNMjYwNzI3MDkyMDIwWhcNMzYwNzI0MDky
MDIwWjBDMS8wLQYDVQQDDCZ3YWx0LmlkIEFwcC1Ib3N0ZWQgWDUwOSBIYXNoIFRl
c3QgUm9vdDEQMA4GA1UECgwHd2FsdC5pZDBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABFqSCpC466BkSl98zsxgt63FTIjBltCCxhDCHcTZ1+T8ljrbYGHxEVWAiq/T
XFvAE971gsSm/3lZSgE0lk5pfrijYzBhMB0GA1UdDgQWBBTk0sEWqbKG1u+e8nSa
+01kr6XitDAfBgNVHSMEGDAWgBTk0sEWqbKG1u+e8nSa+01kr6XitDAPBgNVHRMB
Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNIADBFAiBPP37V
E9U0V2Uax6E9uI00qAtm+feze/eCEtuMs3fRlwIhAP96ce+J+6kuWRvUSZPYISuf
2JhW6xQPYQbAK6L6nIeR
-----END CERTIFICATE-----
"""

/// Fixed ES256 `x509_hash` Request Object with a matching `x5c` leaf.
public static let signedRequestObject = [
"eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QiLCJ4NWMiOlsiTUlJQjdUQ0NBWktnQXdJQkFnSVVMQ3QwbGllc2tseVpnMGxsLyt2M0NVWXAwQ2N3Q2dZSUtvWkl6ajBFQXdJd1F6RXZNQzBHQTFVRUF3d21kMkZzZEM1cFpDQkJjSEF0U0c5emRHVmtJRmcxTURrZ1NHRnphQ0JVWlhOMElGSnZiM1F4RURBT0JnTlZCQW9NQjNkaGJIUXVhV1F3SGhjTk1qWXdOekkzTURreU1ESXdXaGNOTXpZd056STBNRGt5TURJd1dqQkhNVE13TVFZRFZRUUREQ3AzWVd4MExtbGtJRUZ3Y0MxSWIzTjBaV1FnV0RVd09TQklZWE5vSUZSbGMzUWdWbVZ5YVdacFpYSXhFREFPQmdOVkJBb01CM2RoYkhRdWFXUXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBVEk1c2VTWGRSRUZnS1lLWnl5SVU4RStqajRjaDdoQUlyanA2YUl6eXBhQ2FvUC9XNmM3TGMwR04rY3o4RTF1YU1POFZjdmNGN1ZQUE9lUDkxWDAzWGlvMkF3WGpBTUJnTlZIUk1CQWY4RUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJSGdEQWRCZ05WSFE0RUZnUVVTZUlNb05saUdQY29aUG1nT0Z6MlhTYUJqM3N3SHdZRFZSMGpCQmd3Rm9BVTVOTEJGcW15aHRidm52SjBtdnROWksrbDRyUXdDZ1lJS29aSXpqMEVBd0lEU1FBd1JnSWhBTjBUVGhlZW1aa1pYUk5jSFhUdENjaVJOc0o5czBWckpQR1J2V2I2UnlGU0FpRUEwTHhla09hK2pxRjhKTHArbzNXNzBMaDlCY253ZDRzRkFuQWJ5WFRNUHFFPSJdfQ",
".eyJhdWQiOiJodHRwczovL3NlbGYtaXNzdWVkLm1lL3YyIiwiZXhwIjoyMDgyNzU4NDAwLCJjbGllbnRfaWQiOiJ4NTA5X2hhc2g6SVQyRU1PYUlNVVR4MXpqMHl0ZUxaeU9na1hGNlNpdzJ0X0s0MnRzS1RfSSIsInJlc3BvbnNlX3R5cGUiOiJ2cF90b2tlbiIsInJlc3BvbnNlX21vZGUiOiJkaXJlY3RfcG9zdCIsInJlc3BvbnNlX3VyaSI6Imh0dHBzOi8vdmVyaWZpZXIuZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJub25jZSI6ImFwcC1ob3N0ZWQteDUwOS1oYXNoLXRlc3QiLCJzdGF0ZSI6ImFwcC1ob3N0ZWQteDUwOS1oYXNoLXRlc3QiLCJkY3FsX3F1ZXJ5Ijp7ImNyZWRlbnRpYWxzIjpbeyJpZCI6InRlc3RfY3JlZGVudGlhbCIsImZvcm1hdCI6ImRjK3NkLWp3dCIsIm1ldGEiOnsidmN0X3ZhbHVlcyI6WyJ1cm46ZXhhbXBsZTp0ZXN0Il19fV0sImNyZWRlbnRpYWxfc2V0cyI6W3sib3B0aW9ucyI6W1sidGVzdF9jcmVkZW50aWFsIl1dLCJyZXF1aXJlZCI6ZmFsc2V9XX0sImNsaWVudF9tZXRhZGF0YSI6e319",
".2qQUOrCDfL2SVBHa4z-KbkhjrzRFUft-jto17Mq3p4XhSyUOhlcl_FQlGNGJLHrLri06MGnVoO8dM8mCUofRPg",
].joined()

public static let authorizationRequestURL: URL = {
var components = URLComponents(string: "openid4vp://authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "request", value: signedRequestObject),
]
return components.url!
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,46 @@ final class MobileWalletIntegrationTests: XCTestCase {

// MARK: - Tests (mirror Android MobileWalletIntegrationTest.kt)

func testAppHostedWalletValidatesSignedRequestObjectCertificateChain() async throws {
let wallet = try await Wallet(
configuration: WalletConfiguration(
walletID: testWalletId,
clientIDTrustConfiguration: WalletClientIDTrustConfiguration(
x509TrustAnchorsPEM: [X509RequestObjectFixture.trustAnchorPEM]
)
)
)
_ = try await wallet.bootstrap()

let result = try await wallet.previewPresentation(request: X509RequestObjectFixture.authorizationRequestURL)
guard case let .ready(preview) = result else {
XCTFail("The pinned X.509 Request Object should authenticate and produce a preview: \(result)")
return
}
XCTAssertTrue(preview.credentialOptions.isEmpty)
XCTAssertTrue(preview.credentialRequirements.isEmpty)
}

func testAppHostedWalletRejectsUntrustedSignedRequestObjectCertificateChain() async throws {
let wallet = try await Wallet(
configuration: WalletConfiguration(
walletID: testWalletId,
clientIDTrustConfiguration: WalletClientIDTrustConfiguration(
x509TrustAnchorsPEM: [EudiTestBackend.verifierTrustAnchorPEM]
)
)
)

do {
_ = try await wallet.previewPresentation(request: X509RequestObjectFixture.authorizationRequestURL)
XCTFail("Expected the signed Request Object to be rejected without the pinned trust anchor")
} catch {
let description = String(describing: error)
XCTAssertFalse(description.contains("No matches found for required credential queries"), description)
XCTAssertTrue(description.contains("InvalidSignature"), description)
}
}

func testBootstrapCreatesKeyAndDid() async throws {
let wallet = try await makeWallet()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ data class DcqlDisclosure(
val name: String?,
val value: JsonElement,

/** Full SD-JWT Claim Path locating this disclosure within the credential. */
val location: List<JsonElement>? = null,

// A place to pass a reference to the original (using which this class was created)
//val original: Any? = null // no longer needed?
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
package id.walt.dcql

// Custom exception for match failures
class DcqlMatchException(message: String) : Exception(message)
open class DcqlMatchException(message: String) : Exception(message)

/** A well-formed request asks for a credential that is not available locally. */
class RequiredCredentialUnavailableException(
val queryIds: List<String>,
message: String,
) : DcqlMatchException(message)

/** The request contains a constraint that this matcher cannot evaluate safely. */
class UnsupportedDcqlConstraintException(message: String) : DcqlMatchException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ object DcqlMatcher {
* @param trustedAuthoritiesChecker Optional callback invoked when a credential query has
* `trusted_authorities` constraints. Receives the credential and the list of authority
* queries; should return `true` if the credential satisfies at least one authority.
* When null (default), trusted_authorities constraints are not enforced.
* When null (default), credentials cannot match a trusted_authorities constraint.
* @return A Result containing a map where keys are CredentialQuery IDs and
* values are lists of matching Credentials, or a failure with an exception.
*/
Expand All @@ -48,13 +48,20 @@ object DcqlMatcher {
// Find matches for each individual CredentialQuery
for (credentialQuery in query.credentials) {
log.trace { "Processing CredentialQuery: ${credentialQuery.id} (format: ${credentialQuery.format})" }
if (!credentialQuery.trustedAuthorities.isNullOrEmpty() && trustedAuthoritiesChecker == null) {
return Result.failure(
UnsupportedDcqlConstraintException(
"trusted_authorities matching requires a trusted-authorities checker",
)
)
}
val potentialMatchesByFormat = availableCredentials.filter { it.format in credentialQuery.format.id }
log.trace { "Potential matches for ${credentialQuery.id} based on format: ${potentialMatchesByFormat.map { it.id + "(${it.format})" }}" }

val successfullyMatchedCredentialsForThisQuery = mutableListOf<DcqlMatchResult>()

for (credential in potentialMatchesByFormat) {
val metaCheck = matchesMeta(credential, credentialQuery.meta ?: NoMeta, credentialQuery.format)
val metaCheck = matchesMeta(credential, credentialQuery.meta, credentialQuery.format)
if (!metaCheck) {
log.trace { "Credential ${credential.id} failed meta check for query ${credentialQuery.id}" }
continue
Expand Down Expand Up @@ -104,7 +111,15 @@ object DcqlMatcher {
if (!satisfied) {
val errorMsg = "Required credential set constraints not met."
log.warn { errorMsg }
return Result.failure(DcqlMatchException(errorMsg))
return Result.failure(RequiredCredentialUnavailableException(emptyList(), errorMsg))
}
} ?: run {
val missingRequired = query.credentials.map { it.id }
.filterNot(finalIndividualMatches::containsKey)
if (missingRequired.isNotEmpty()) {
val errorMsg = "No matches found for required credential queries: $missingRequired"
log.warn { errorMsg }
return Result.failure(RequiredCredentialUnavailableException(missingRequired, errorMsg))
}
}

Expand All @@ -131,6 +146,13 @@ object DcqlMatcher {
// 1. Find matches for each individual CredentialQuery
for (credQuery in query.credentials) {
log.trace { "Processing CredentialQuery: ${credQuery.id} (format: ${credQuery.format})" }
if (!credQuery.trustedAuthorities.isNullOrEmpty() && trustedAuthoritiesChecker == null) {
return Result.failure(
UnsupportedDcqlConstraintException(
"trusted_authorities matching requires a trusted-authorities checker",
)
)
}
val potentialMatches = availableCredentials.filter { it.format in credQuery.format.id }
log.trace { "Potential matches for ${credQuery.id} based on format: ${potentialMatches.map { it.id }}" }

Expand Down Expand Up @@ -166,7 +188,12 @@ object DcqlMatcher {
query.credentialSets?.let { sets ->
val satisfied = checkCredentialSets(sets, individualMatches.keys)
if (!satisfied) {
return Result.failure(DcqlMatchException("Required credential set constraints not met."))
return Result.failure(
RequiredCredentialUnavailableException(
emptyList(),
"Required credential set constraints not met.",
)
)
}
}

Expand All @@ -184,10 +211,7 @@ object DcqlMatcher {
if (query.credentialSets == null && missingRequired.isNotEmpty()) {
val errorMsg = "No matches found for required credential queries: $missingRequired"
log.warn { errorMsg }
// Decide if this is a failure. Often, returning an empty map or partial map is desired.
// Let's return what we found, assuming partial fulfillment might be acceptable.
// If strict fulfillment is needed, uncomment the failure below.
// return Result.failure(DcqlMatchException(errorMsg))
return Result.failure(RequiredCredentialUnavailableException(missingRequired, errorMsg))
}


Expand Down Expand Up @@ -298,48 +322,61 @@ object DcqlMatcher {

// If the credential has disclosures and is JWT-based (where SD mechanism applies)
if (isCredentialPotentiallySD && credential.disclosures != null) {
// Match on the claim name (path.last()). The disclosure's full Claim Path is available
// via it.location (SD-JWT VC §4.6.1) for stricter matching if needed in the future.
val targetClaimName = claimQuery.path.lastOrNull {
it is JsonPrimitive && it.isString
}?.jsonPrimitive?.content
val matchingDisclosure =
credential.disclosures?.find { it.name == targetClaimName }
val matchingDisclosure = credential.disclosures?.find { disclosure ->
disclosure.matchesPath(claimQuery.path)
}

if (matchingDisclosure != null) {
if (!claimQuery.values.isNullOrEmpty()) {
val disclosureValueJson = matchingDisclosure.value
val matchesValue = claimQuery.values.any { queryValue -> queryValue == disclosureValueJson }
if (!matchesValue) {
log.trace { "SD Disclosure '${targetClaimName}' value '${disclosureValueJson}' does not match required values ${claimQuery.values} in ${credential.id}" }
return Result.failure(DcqlMatchException("SD Disclosure value mismatch for $targetClaimName"))
log.trace { "SD Disclosure at '${matchingDisclosure.location}' value '${disclosureValueJson}' does not match required values ${claimQuery.values} in ${credential.id}" }
return Result.failure(DcqlMatchException("SD Disclosure value mismatch for ${claimQuery.path}"))
}
}
log.trace { "SD Disclosure '${targetClaimName}' found and matches criteria in ${credential.id}" }
log.trace { "SD Disclosure at '${matchingDisclosure.location}' found and matches criteria in ${credential.id}" }
return Result.success(matchingDisclosure) // Return the disclosure object
} else {
// If path not found as a disclosure, it might be an always-visible claim in the SD-JWT core.
// Fall through to generic path resolution for such cases.
log.trace { "Claim path ${claimQuery.path} not found among SD disclosures for ${credential.id}. Checking core JWT." }
log.trace { "Claim path ${claimQuery.path.joinToString(".")} not found among SD disclosures for ${credential.id}. Checking core JWT." }
}
}

// Generic path resolution for non-SD claims or core claims of an SD-JWT
val claimJsonElement = resolveClaimPath(credential.data, claimQuery.path)
?: return Result.failure(DcqlMatchException("Claim path ${claimQuery.path} not found in ${credential.id}"))
?: return Result.failure(DcqlMatchException("Claim path ${claimQuery.path.joinToString(".")} not found in ${credential.id}"))

if (!claimQuery.values.isNullOrEmpty()) {
val matchesValue = claimQuery.values.any { queryValue -> queryValue == claimJsonElement }
if (!matchesValue) {
log.trace { "claimExistsAndMatchesValue: Claim path ${claimQuery.path} value '$claimJsonElement' does not match required values ${claimQuery.values} in ${credential.id} " }
return Result.failure(DcqlMatchException("Claim value mismatch for ${claimQuery.path}"))
log.trace { "claimExistsAndMatchesValue: Claim path ${claimQuery.path.joinToString(".")} value '$claimJsonElement' does not match required values ${claimQuery.values} in ${credential.id} " }
return Result.failure(DcqlMatchException("Claim value mismatch for ${claimQuery.path.joinToString(".")}"))
}
}
log.trace { "Claim path ${claimQuery.path} exists and matches criteria in ${credential.id}" }
log.trace { "Claim path ${claimQuery.path.joinToString(".")} exists and matches criteria in ${credential.id}" }
// If values were specified and matched, or if no values were specified (existence check), return the element.
return Result.success(claimJsonElement)
}

/** Match a Claims Path Pointer against the disclosure's complete resolved location. */
Comment thread
philpotisk marked this conversation as resolved.
fun DcqlDisclosure.matchesPath(queryPath: List<JsonElement>): Boolean {
val disclosurePath = location ?: name
?.takeIf { queryPath.size == 1 }
?.let { listOf(JsonPrimitive(it)) }
?: return false
if (queryPath.size != disclosurePath.size) return false
return queryPath.zip(disclosurePath).all { (querySegment, actualSegment) ->
when (querySegment) {
is JsonNull -> actualSegment is JsonPrimitive &&
!actualSegment.isString &&
actualSegment.intOrNull?.let { it >= 0 } == true
else -> querySegment == actualSegment
}
}
}

private fun matchesMeta(
credential: DcqlCredential,
metaQuery: CredentialQueryMeta?,
Expand Down Expand Up @@ -444,8 +481,8 @@ object DcqlMatcher {
): Boolean {
if (authoritiesQuery.isNullOrEmpty()) return true
if (trustedAuthoritiesChecker == null) {
log.trace { "trusted_authorities query present for credential ${credential.id} but no checker provided — skipping (not enforced)" }
return true
log.warn { "trusted_authorities query present for credential ${credential.id} but no checker was provided; failing closed" }
return false
}
return trustedAuthoritiesChecker(credential, authoritiesQuery)
}
Expand Down Expand Up @@ -586,4 +623,3 @@ object DcqlMatcher {
return true // All required sets were satisfied
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ data class ClaimsQuery(
/** Required if claim_sets is present in parent CredentialQuery */
val id: String? = null,

/** Path to the claim (format-specific interpretation) */
/** Required non-empty path to the claim (format-specific interpretation). */
val path: List<JsonElement>,

/** Optional specific values to match */
Expand All @@ -24,6 +24,9 @@ data class ClaimsQuery(
@SerialName("intent_to_retain")
val intentToRetain: Boolean? = null
) {
init {
require(path.isNotEmpty()) { "Claims Query path must not be empty" }
}
constructor(
id: String? = null,
pathStrings: List<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import id.walt.dcql.models.meta.MsoMdocMeta
import id.walt.dcql.models.meta.SdJwtVcMeta
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonPrimitive

/**
* Represents the top-level DCQL query structure.
Expand Down Expand Up @@ -33,6 +34,17 @@ data class DcqlQuery(
if (credentials.any { it.claims != null && it.claims.isEmpty() }) {
throw IllegalArgumentException("Requested dcql query: claims was set, but has no elements")
}

credentials.filter { it.format == CredentialFormat.MSO_MDOC }
.flatMap { it.claims.orEmpty() }
.forEach { claim ->
require(claim.path.size == 2) {
"mso_mdoc Claims Query path must contain exactly namespace and data element identifier"
}
require(claim.path.all { it is JsonPrimitive && it.isString }) {
"mso_mdoc Claims Query path elements must be strings"
}
}
}

object DcqlQueryExamples {
Expand Down
Loading
Loading