From f2bf66994c467085a23ebbc5ffbd1c798641a129 Mon Sep 17 00:00:00 2001 From: Suzanna Jiwani Date: Fri, 15 Nov 2024 14:22:22 -0500 Subject: [PATCH] Fix Expiration Date Checking in OpenID4VPPresentationActivity Fix expiration date checking in OpenID4VPPresentationActivity and allow issuance time to be the same as now. Also added tests to OpenID4VPTest to confirm expected behavior for expired, not yet issued, and not yet valid JWTs. Tested-by: Adding examples to OpenID4VPTest.kt Signed-off-by: Suzanna Jiwani --- .../OpenID4VPPresentationActivity.kt | 8 +- .../wallet/OpenID4VPTest.kt | 247 ++++++++++++++++++ 2 files changed, 251 insertions(+), 4 deletions(-) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt index ec70e8505..92c7d298e 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt @@ -874,8 +874,8 @@ private class TimeChecks : JWTClaimsSetVerifier { var exp: Instant? = null if (expiration != null) { exp = Instant.fromEpochMilliseconds(expiration.time) - if (exp >= now) { - throw BadJWTException("Expired JWT ($exp >= $now)") + if (exp <= now) { + throw BadJWTException("Expired JWT ($exp <= $now)") } } @@ -883,8 +883,8 @@ private class TimeChecks : JWTClaimsSetVerifier { var iat: Instant? = null if (issuance != null) { iat = Instant.fromEpochMilliseconds(issuance.time) - if (now <= iat) { - throw BadJWTException("JWT issued in the future ($now <= $iat)") + if (now < iat) { + throw BadJWTException("JWT issued in the future ($now < $iat)") } if (exp != null) { diff --git a/wallet/src/test/java/com/android/identity_credential/wallet/OpenID4VPTest.kt b/wallet/src/test/java/com/android/identity_credential/wallet/OpenID4VPTest.kt index 17257d1f9..ce9850345 100644 --- a/wallet/src/test/java/com/android/identity_credential/wallet/OpenID4VPTest.kt +++ b/wallet/src/test/java/com/android/identity_credential/wallet/OpenID4VPTest.kt @@ -1,18 +1,53 @@ package com.android.identity_credential.wallet +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.EcPrivateKey +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.crypto.X509CertificateCreateOption +import com.android.identity.crypto.X509CertificateExtension +import com.android.identity.crypto.create +import com.android.identity.crypto.javaPrivateKey +import com.android.identity.crypto.javaPublicKey +import com.android.identity.crypto.javaX509Certificate import com.android.identity.document.DocumentRequest import com.android.identity.issuance.CredentialFormat +import com.android.identity.util.toBase64Url import com.android.identity_credential.wallet.presentation.DescriptorMap import com.android.identity_credential.wallet.presentation.createPresentationSubmission import com.android.identity_credential.wallet.presentation.formatAsDocumentRequest import com.android.identity_credential.wallet.presentation.getAuthRequestFromJwt import com.android.identity_credential.wallet.presentation.parsePathItem +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.util.Base64 +import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.serialization.json.Json.Default.parseToJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.x509.ExtendedKeyUsage +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.KeyPurposeId +import org.bouncycastle.asn1.x509.KeyUsage import org.junit.Assert import org.junit.Test +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import kotlin.time.Duration class OpenID4VPTest { @@ -46,6 +81,173 @@ class OpenID4VPTest { "RlIjoiZGlyZWN0X3Bvc3Quand0In0.6VvDuIZ6QhLbzVyncJ-3mEkykYAadSqmgpjxd72j6zxeivNxGpnPh0" + "c7YxCHbGNHY47ZZ7STu6LxbJY6EOWHHw" + private fun createSingleUseReaderKey(): Pair { + val now = Clock.System.now() + val validFrom = now.plus(DateTimePeriod(minutes = -10), TimeZone.currentSystemDefault()) + val validUntil = now.plus(DateTimePeriod(minutes = 10), TimeZone.currentSystemDefault()) + val readerKey = Crypto.createEcPrivateKey(EcCurve.P256) + + val extensions = mutableListOf() + extensions.add( + X509CertificateExtension( + Extension.keyUsage.toString(), + true, + KeyUsage(KeyUsage.digitalSignature).encoded + ) + ) + extensions.add( + X509CertificateExtension( + Extension.extendedKeyUsage.toString(), + true, + ExtendedKeyUsage( + KeyPurposeId.getInstance(ASN1ObjectIdentifier("1.0.18013.5.1.2")) + ).encoded + ) + ) + val readerKeySubject = "CN=OWF IC Online Verifier Single-Use Reader Key" + + // TODO: for now, instead of using the per-site Reader Root generated at first run, use the + // well-know OWF IC Reader root checked into Git. + val owfIcReaderCert = X509Cert.fromPem(""" +-----BEGIN CERTIFICATE----- +MIICCTCCAY+gAwIBAgIQZc/0rhdjZ9n3XoZYzpt2GjAKBggqhkjOPQQDAzA+MS8wLQYDVQQDDCZP +V0YgSWRlbnRpdHkgQ3JlZGVudGlhbCBURVNUIFJlYWRlciBDQTELMAkGA1UEBhMCWlowHhcNMjQw +OTE3MTY1NjA5WhcNMjkwOTE3MTY1NjA5WjA+MS8wLQYDVQQDDCZPV0YgSWRlbnRpdHkgQ3JlZGVu +dGlhbCBURVNUIFJlYWRlciBDQTELMAkGA1UEBhMCWlowdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATM +1ZVDQ7E4A+ujJl0J7Op8qvy/BSgg/UCTw+WrwYI32/jV9pk8Qu5BSTbUDZE2PQheqy4s3j8y1gMu ++Q5pemhYn/c4OMYXZY8uD+t4Wo9UFoSDkFbvlumZ/cuO5TTAI76jUjBQMB0GA1UdDgQWBBTgtILK +HJ50qO/Nc33zshz2aX4+4TAfBgNVHSMEGDAWgBTgtILKHJ50qO/Nc33zshz2aX4+4TAOBgNVHQ8B +Af8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIxALmOcU+Ggax3wHbD8tcd8umuDxzimf9PSICjvlh5 +kwR0/1SZZF7bqMAOQXsrwNYFLgIwLVirmU4WvRlUktR2Ty5kxgDG0iy+g00ur9JXCF+wAUQjKHbg +VvIQ6NRr06GwpPJR +-----END CERTIFICATE----- + """.trimIndent()) + + val owfIcReaderRoot = EcPrivateKey.fromPem(""" +-----BEGIN PRIVATE KEY----- +MFcCAQAwEAYHKoZIzj0CAQYFK4EEACIEQDA+AgEBBDDxgrZBXnoO54/hZM2DAGrByoWRatjH9hGs +lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= +-----END PRIVATE KEY----- + """.trimIndent(), + owfIcReaderCert.ecPublicKey) + val owfIcReaderRootSignatureAlgorithm = Algorithm.ES384 + val owfIcReaderRootIssuer = owfIcReaderCert.javaX509Certificate.issuerX500Principal.name + + val readerKeyCertificate = X509Cert.create( + readerKey.publicKey, + owfIcReaderRoot, + owfIcReaderCert, + owfIcReaderRootSignatureAlgorithm, + "1", + readerKeySubject, + owfIcReaderRootIssuer, + validFrom, + validUntil, + setOf( + X509CertificateCreateOption.INCLUDE_SUBJECT_KEY_IDENTIFIER, + X509CertificateCreateOption.INCLUDE_AUTHORITY_KEY_IDENTIFIER_FROM_SIGNING_KEY_CERTIFICATE + ), + extensions + ) + return Pair( + readerKey, + X509CertChain(listOf(readerKeyCertificate) + owfIcReaderCert) + ) + } + + private fun generateSignedJWT(claimsSet: JWTClaimsSet) : SignedJWT { + val (singleUseReaderKeyPriv, singleUseReaderKeyCertChain) = createSingleUseReaderKey() + val readerPub = singleUseReaderKeyPriv.publicKey.javaPublicKey as ECPublicKey + val readerPriv = singleUseReaderKeyPriv.javaPrivateKey as ECPrivateKey + val readerKey = ECKey( + Curve.P_256, + readerPub, + readerPriv, + null, + null, + null, + null, + null, + null, + null, + null, + null + ) + + val readerX5c = singleUseReaderKeyCertChain.certificates.map { cert -> + Base64.from(cert.encodedCertificate.toBase64Url()) + } + + val signedJWT = SignedJWT( + JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(readerKey.keyID) + .x509CertChain(readerX5c) + .type(JOSEObjectType("oauth-authz-req+jwt")) + .build(), + claimsSet + ) + + val signer: JWSSigner = ECDSASigner(readerKey) + signedJWT.sign(signer) + return signedJWT + } + + private fun generateClaimSet( + issuance: Instant, + expiration: Instant, + notBefore: Instant + ): JWTClaimsSet = + JWTClaimsSet.parse("{\n" + + " \"response_type\": \"vp_token\",\n" + + " \"client_id\": \"example.id\",\n" + + " \"response_uri\": \"https://example.com\",\n" + + " \"response_mode\": \"direct_post.jwt\",\n" + + " \"presentation_definition\": {\n" + + " \"id\": \"4db74328-9e94-49bb-97b7-bbfcb2d11a06\",\n" + + " \"input_descriptors\": [\n" + + " {\n" + + " \"id\": \"8f76f19e-b161-4baf-a57a-fd129322a48c\",\n" + + " \"format\": {\n" + + " \"vc+sd-jwt\": {\n" + + " \"sd-jwt_alg_values\": [\n" + + " \"ES256\"\n" + + " ],\n" + + " \"kb-jwt_alg_values\": [\n" + + " \"ES256\"\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"constraints\": {\n" + + " \"limit_disclosure\": \"required\",\n" + + " \"fields\": [\n" + + " {\n" + + " \"path\": [\n" + + " \"\$.age_equal_or_over.21\"\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"path\": [\n" + + " \"\$.vct\"\n" + + " ],\n" + + " \"filter\": {\n" + + " \"type\": \"string\",\n" + + " \"enum\": [\n" + + " \"https://example.bmi.bund.de/credential/pid/1.0\",\n" + + " \"urn:eu.europa.ec.eudi:pid:1\"\n" + + " ]\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"exp\": ${expiration.epochSeconds},\n" + + " \"nbf\": ${notBefore.epochSeconds},\n" + + " \"iat\": ${issuance.epochSeconds},\n" + + " \"jti\": \"39543149-470b-47ae-be96-213f8f4bc9fd\"\n" + + "}") + @Test fun testParsePathBracketed() { Assert.assertEquals(Pair("namespace", "dataElem"), parsePathItem("\"\$['namespace']['dataElem']\"")) @@ -56,6 +258,51 @@ class OpenID4VPTest { Assert.assertEquals(Pair("credentialSubject", "dataElem"), parsePathItem("\"\$.dataElem\"")) } + @Test + fun testExpiredJWT() { + val now = Clock.System.now() + + val expiredClaimSet = generateClaimSet( + now.minus(Duration.parse("1m")), + now.minus(Duration.parse("1d")), + now.minus(Duration.parse("1m")) + ) + + Assert.assertThrows(RuntimeException::class.java) { + getAuthRequestFromJwt(generateSignedJWT(expiredClaimSet), "example.id") + } + } + + @Test + fun testIssuedInFutureJWT() { + val now = Clock.System.now() + + val issuedInFutureClaimSet = generateClaimSet( + now.plus(Duration.parse("1m")), + now.plus(Duration.parse("1d")), + now.plus(Duration.parse("1m")) + ) + + Assert.assertThrows(RuntimeException::class.java) { + getAuthRequestFromJwt(generateSignedJWT(issuedInFutureClaimSet), "example.id") + } + } + + @Test + fun testNotYetValidJWT() { + val now = Clock.System.now() + + val notYetValidClaimSet = generateClaimSet( + now.minus(Duration.parse("1m")), + now.plus(Duration.parse("1d")), + now.plus(Duration.parse("5m")) + ) + + Assert.assertThrows(RuntimeException::class.java) { + getAuthRequestFromJwt(generateSignedJWT(notYetValidClaimSet), "example.id") + } + } + @Test fun eudiwJwtToPresentationSubmission() { val authRequest = getAuthRequestFromJwt(SignedJWT.parse(eudiAgeOver18RequestObject), "verifier-backend.eudiw.dev")