Skip to content

Commit 4194b5a

Browse files
committed
[VCI] Support credential response encryption
1 parent 04aed8f commit 4194b5a

4 files changed

Lines changed: 140 additions & 14 deletions

File tree

app/src/main/java/com/credman/cmwallet/Utils.kt

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ fun ecJwkThumbprintSha256(jwk: JSONObject): ByteArray {
7777
put("y", jwk.get("y"))
7878
}
7979
val md = MessageDigest.getInstance("SHA-256")
80-
Log.d("helenqinn", jwkWithRequired.toString())
8180
return md.digest(jwkWithRequired.toString().toByteArray())
8281
}
8382

@@ -188,15 +187,11 @@ fun jwsDeserialization(jws: String): Pair<JSONObject, JSONObject> {
188187
return Pair(header, JSONObject(payload))
189188
}
190189

191-
/** ECDH-ES key agreement, A128GCM encryption, JWE Compact Serialization */
192-
fun jweSerialization(recipientKeyJwk: JSONObject, plainText: String): String {
193-
val kid = recipientKeyJwk.optString("kid")
194-
val x = recipientKeyJwk.getString("x")
195-
val y = recipientKeyJwk.getString("y")
190+
fun toEcPublicKey(x: String, y: String): PublicKey {
196191
val kf = KeyFactory.getInstance("EC")
197192
val parameters = AlgorithmParameters.getInstance("EC")
198193
parameters.init(ECGenParameterSpec("secp256r1"))
199-
val publicKey = kf.generatePublic(
194+
return kf.generatePublic(
200195
ECPublicKeySpec(
201196
ECPoint(
202197
BigInteger(1, x.decodeBase64UrlNoPadding()),
@@ -205,6 +200,12 @@ fun jweSerialization(recipientKeyJwk: JSONObject, plainText: String): String {
205200
parameters.getParameterSpec(ECParameterSpec::class.java)
206201
)
207202
)
203+
}
204+
205+
/** ECDH-ES key agreement, A128GCM encryption, JWE Compact Serialization */
206+
fun jweSerialization(recipientKeyJwk: JSONObject, plainText: String): String {
207+
val kid = recipientKeyJwk.optString("kid")
208+
val publicKey = toEcPublicKey(recipientKeyJwk.getString("x"), recipientKeyJwk.getString("y"))
208209
val kpg = KeyPairGenerator.getInstance("EC")
209210
kpg.initialize(ECGenParameterSpec("secp256r1"))
210211
val kp = kpg.genKeyPair()
@@ -251,3 +252,63 @@ fun jweSerialization(recipientKeyJwk: JSONObject, plainText: String): String {
251252
val tagEncoded = tag.toBase64UrlNoPadding()
252253
return "${headerEncoded}..${ivEncoded}.${ctEncoded}.${tagEncoded}"
253254
}
255+
256+
fun jweDecrypt(jwe: String, privateKey: PrivateKey): String {
257+
val parts = jwe.split(".")
258+
259+
val headerB64 = parts[0]
260+
val ivB64 = parts[2]
261+
val ciphertextB64 = parts[3]
262+
val tagB64 = parts[4]
263+
264+
val headerJsonStr = String(headerB64.decodeBase64UrlNoPadding(), Charsets.UTF_8)
265+
val header = JSONObject(headerJsonStr)
266+
267+
val alg = header.optString("alg")
268+
val enc = header.optString("enc")
269+
require(alg == "ECDH-ES" && enc == "A128GCM") { "Unsupported algorithms: alg=$alg, enc=$enc" }
270+
271+
val epk = header.getJSONObject("epk")
272+
require(epk.getString("crv") == "P-256") { "Only P-256 curve is supported" }
273+
274+
val publicKey = toEcPublicKey(epk.getString("x"), epk.getString("y"))
275+
276+
val keyAgreement = KeyAgreement.getInstance("ECDH")
277+
keyAgreement.init(privateKey)
278+
keyAgreement.doPhase(publicKey, true)
279+
val sharedSecret = keyAgreement.generateSecret()
280+
val concatKdf = ConcatKeyDerivationFunction("SHA-256")
281+
282+
val algOctets = "A128GCM".toByteArray()
283+
val keydatalen = 128
284+
285+
val apu = if (header.has("apu")) header.getString("apu").decodeBase64UrlNoPadding() else ByteArray(0)
286+
val apv = if (header.has("apv")) header.getString("apv").decodeBase64UrlNoPadding() else ByteArray(0)
287+
288+
val derivedKey = concatKdf.kdf(
289+
sharedSecret,
290+
keydatalen,
291+
intToBigEndianByteArray(algOctets.size) + algOctets,
292+
intToBigEndianByteArray(apu.size) + apu,
293+
intToBigEndianByteArray(apv.size) + apv,
294+
intToBigEndianByteArray(keydatalen),
295+
ByteArray(0)
296+
)
297+
298+
val iv = ivB64.decodeBase64UrlNoPadding()
299+
val ciphertext = ciphertextB64.decodeBase64UrlNoPadding()
300+
val tag = tagB64.decodeBase64UrlNoPadding()
301+
302+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
303+
val secretKey = SecretKeySpec(derivedKey, "AES")
304+
305+
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv))
306+
307+
cipher.updateAAD(headerB64.toByteArray())
308+
309+
// In Java/Android, the Cipher expects the ciphertext and authentication tag to be concatenated
310+
val combinedCiphertextAndTag = ciphertext + tag
311+
val plaintextBytes = cipher.doFinal(combinedCiphertextAndTag)
312+
313+
return String(plaintextBytes, Charsets.UTF_8)
314+
}

app/src/main/java/com/credman/cmwallet/openid4vci/OpenId4VCI.kt

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import android.util.Log
77
import androidx.compose.ui.input.key.Key
88
import com.credman.cmwallet.CmWalletApplication.Companion.TAG
99
import com.credman.cmwallet.createJWTES256
10+
import com.credman.cmwallet.jweDecrypt
1011
import com.credman.cmwallet.jweSerialization
1112
import com.credman.cmwallet.loadECPrivateKey
1213
import com.credman.cmwallet.openid4vci.data.CredentialOffer
1314
import com.credman.cmwallet.openid4vci.data.CredentialRequest
1415
import com.credman.cmwallet.openid4vci.data.CredentialResponse
16+
import com.credman.cmwallet.openid4vci.data.CredentialResponseEncryptionInReuqest
17+
import com.credman.cmwallet.openid4vci.data.JwkKey
1518
import com.credman.cmwallet.openid4vci.data.NonceResponse
1619
import com.credman.cmwallet.openid4vci.data.OauthAuthorizationServer
1720
import com.credman.cmwallet.openid4vci.data.ParResponse
@@ -294,7 +297,7 @@ class OpenId4VCI(val credentialOfferJson: String) {
294297
val key = keys.firstOrNull{
295298
it.alg == "ECDH-ES"
296299
} ?: throw java.lang.UnsupportedOperationException("No supported encryption key")
297-
return JSONObject(Json.encodeToString(key))
300+
return JSONObject(json.encodeToString(key))
298301
}
299302
fun requireCredentialResponseEncryption(): Boolean = credentialOffer.issuerMetadata.credentialResponseEncryption?.encryptionRequired ?: false
300303

@@ -304,7 +307,26 @@ class OpenId4VCI(val credentialOfferJson: String) {
304307
credentialRequest: CredentialRequest,
305308
nonce: String? = null,
306309
): CredentialResponse {
307-
Log.d(TAG, "Credential request: $credentialRequest")
310+
var responseEncryptionKey: KeyPair? = null
311+
val request: CredentialRequest = if (requireCredentialResponseEncryption()) {
312+
Log.d(TAG, "Credential response encryption requested")
313+
if (credentialOffer.issuerMetadata.credentialResponseEncryption!!.encValuesSupported.contains("A128GCM") &&
314+
credentialOffer.issuerMetadata.credentialResponseEncryption.algValuesSupported.contains("ECDH-ES")) {
315+
val kpg = KeyPairGenerator.getInstance("EC")
316+
kpg.initialize(ECGenParameterSpec("secp256r1"))
317+
responseEncryptionKey = kpg.genKeyPair()
318+
credentialRequest.copy(
319+
credentialResponseEncryption = CredentialResponseEncryptionInReuqest(
320+
enc = "A128GCM",
321+
jwk = JwkKey.fromPublicKey(responseEncryptionKey.public, alg = "ECDH-ES")
322+
)
323+
)
324+
} else {
325+
throw UnsupportedOperationException("Unsupported Credential Response encryption")
326+
}
327+
} else { credentialRequest }
328+
329+
Log.d(TAG, "Credential request: $request")
308330
val endpoint = credentialOffer.issuerMetadata.credentialEndpoint
309331
val md = MessageDigest.getInstance("SHA256")
310332
val accessTokenHash = md.digest(accessToken.toByteArray()).toBase64UrlNoPadding()
@@ -318,12 +340,12 @@ class OpenId4VCI(val credentialOfferJson: String) {
318340
contentType(ContentType("application", "jwt"))
319341
setBody(jweSerialization(
320342
recipientKeyJwk = getCredentialRequestEncryptionKey(),
321-
plainText = json.encodeToJsonElement(credentialRequest).toString()
343+
plainText = json.encodeToJsonElement(request).toString()
322344
))
323345
} else {
324346
contentType(ContentType.Application.Json)
325347
setBody(
326-
json.encodeToJsonElement(credentialRequest)
348+
json.encodeToJsonElement(request)
327349
)
328350
}
329351
}
@@ -338,7 +360,12 @@ class OpenId4VCI(val credentialOfferJson: String) {
338360
Log.d(TAG, "Credential response status: ${result.status}")
339361
Log.d(TAG, "Credential response: ${result.bodyAsText()}")
340362

341-
return result.body()
363+
return if (responseEncryptionKey != null) {
364+
val encryptedCredentialResponse = result.bodyAsText()
365+
json.decodeFromString<CredentialResponse>(jweDecrypt(encryptedCredentialResponse, responseEncryptionKey.private))
366+
} else {
367+
result.body()
368+
}
342369
}
343370

344371
suspend fun createJwt(publicKey: PublicKey, privateKey: PrivateKey): String {

app/src/main/java/com/credman/cmwallet/openid4vci/data/CredentialEndpoint.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ data class Proofs(
1717
data class CredentialRequest(
1818
@SerialName("credential_identifier") val credentialIdentifier: String? = null,
1919
@SerialName("credential_configuration_id") val credentialConfigurationId: String? = null,
20-
@SerialName("proofs") val proofs: Proofs? = null
20+
@SerialName("proofs") val proofs: Proofs? = null,
21+
@SerialName("credential_response_encryption") val credentialResponseEncryption: CredentialResponseEncryptionInReuqest? = null
22+
)
23+
24+
@Serializable
25+
data class CredentialResponseEncryptionInReuqest(
26+
@SerialName("jwk") val jwk: JwkKey,
27+
@SerialName("enc") val enc: String,
2128
)
2229

2330
@Serializable

app/src/main/java/com/credman/cmwallet/openid4vci/data/CredentialOfferEndpoint.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package com.credman.cmwallet.openid4vci.data
22

3+
import com.credman.cmwallet.toBase64UrlNoPadding
4+
import com.credman.cmwallet.toFixedByteArray
35
import kotlinx.serialization.SerialName
46
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.json.Json
58
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
69
import kotlinx.serialization.json.JsonElement
10+
import kotlinx.serialization.json.encodeToJsonElement
711
import kotlinx.serialization.json.jsonObject
812
import kotlinx.serialization.json.jsonPrimitive
13+
import java.security.PublicKey
14+
import java.security.interfaces.ECPublicKey
915

1016
@Serializable
1117
data class GrantPreAuthorizedCode(
@@ -31,9 +37,34 @@ data class JwkKey(
3137
@SerialName("use") val use: String?,
3238
@SerialName("alg") val alg: String?,
3339
@SerialName("kid") val kid: String?,
40+
@SerialName("crv") val crv: String?,
3441
@SerialName("x") val x: String?,
3542
@SerialName("y") val y: String?,
36-
)
43+
) {
44+
companion object {
45+
fun fromPublicKey(publicKey: PublicKey, alg: String?): JwkKey {
46+
when (publicKey) {
47+
is ECPublicKey -> {
48+
val x = publicKey.w.affineX.toFixedByteArray(32).toBase64UrlNoPadding()
49+
val y = publicKey.w.affineY.toFixedByteArray(32).toBase64UrlNoPadding()
50+
return JwkKey(
51+
kty = "EC",
52+
crv = "P-256",
53+
x = x,
54+
y = y,
55+
kid = null,
56+
use = null,
57+
alg = alg
58+
)
59+
}
60+
61+
else -> {
62+
throw IllegalArgumentException("Only support EC Keys for now")
63+
}
64+
}
65+
}
66+
}
67+
}
3768

3869
@Serializable
3970
data class Jwks(

0 commit comments

Comments
 (0)