Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

x509 certificates for validation #26

Merged
merged 5 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
29 changes: 29 additions & 0 deletions javatest/src/test/java/me/uma/javatest/UmaTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ public class UmaTest {
private static final String PUBKEY_HEX = "04f2998ab056897ddb91b5e6fad1e4bb6c4b7dda427409f667d0f4694b553e4feeeb08936c2993f7b931f6a3fa7e846f11165fae222de5e4a55c12def21a7c9fcf";
private static final String PRIVKEY_HEX = "10fbbee8f689b207bb22df2dfa27827ae9ae02e265980ea09ef5101ed5fb508f";

private static final String CERT = "-----BEGIN CERTIFICATE-----\n" +
"MIIB1zCCAXygAwIBAgIUGN3ihBj1RnKoeTM/auDFnNoThR4wCgYIKoZIzj0EAwIw\n" +
"QjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCmNhbGlmb3JuaWExDjAMBgNVBAcMBWxv\n" +
"cyBhMQ4wDAYDVQQKDAVsaWdodDAeFw0yNDAzMDUyMTAzMTJaFw0yNDAzMTkyMTAz\n" +
"MTJaMEIxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApjYWxpZm9ybmlhMQ4wDAYDVQQH\n" +
"DAVsb3MgYTEOMAwGA1UECgwFbGlnaHQwVjAQBgcqhkjOPQIBBgUrgQQACgNCAARB\n" +
"nFRn6lY/ABD9YU+F6IWsmcIbjo1BYkEXX91e/SJE/pB+Lm+j3WYxsbF80oeY2o2I\n" +
"KjTEd21EzECQeBx6reobo1MwUTAdBgNVHQ4EFgQUU87LnQdiP6XIE6LoKU1PZnbt\n" +
"bMwwHwYDVR0jBBgwFoAUU87LnQdiP6XIE6LoKU1PZnbtbMwwDwYDVR0TAQH/BAUw\n" +
"AwEB/zAKBggqhkjOPQQDAgNJADBGAiEAvsrvoeo3rbgZdTHxEUIgP0ArLyiO34oz\n" +
"NlwL4gk5GpgCIQCvRx4PAyXNV9T6RRE+3wFlqwluOc/pPOjgdRw/wpoNPQ==\n" +
"-----END CERTIFICATE-----";

@Test
public void testFetchPublicKeySync() throws Exception {
Expand Down Expand Up @@ -252,6 +264,23 @@ callback, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()),
new InMemoryNonceCache(1L)));
}

@Test
public void serializeAndDeserializePubKeyResponse() {
PubKeyResponse keysOnlyResponse =
new PubKeyResponse(UmaTest.hexToBytes("02d5fe"), UmaTest.hexToBytes("123456"));
String json = keysOnlyResponse.toJson();
PubKeyResponse parsedResponse = umaProtocolHelper.parseAsPubKeyResponse(json);
assertNotNull(parsedResponse);
assertEquals(keysOnlyResponse, parsedResponse);

PubKeyResponse certsOnlyResponse =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also test a response that has all 4 for the backwards compat case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussed on slack! will handle this alongside backwards compatibility for the other data models

new PubKeyResponse(CERT, CERT);
json = certsOnlyResponse.toJson();
parsedResponse = umaProtocolHelper.parseAsPubKeyResponse(json);
assertNotNull(parsedResponse);
assertEquals(certsOnlyResponse, parsedResponse);
}

static byte[] hexToBytes(String hex) {
byte[] bytes = new byte[hex.length() / 2];
for (int i = 0; i < hex.length(); i += 2) {
Expand Down
16 changes: 14 additions & 2 deletions uma-sdk/src/commonMain/kotlin/me/uma/PublicKeyCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ class InMemoryPublicKeyCache : PublicKeyCache {
private val cache = mutableMapOf<String, PubKeyResponse>()

override fun getPublicKeysForVasp(vaspDomain: String): PubKeyResponse? {
return cache[vaspDomain]
val pubKeyResponse = cache[vaspDomain]
return if (pubKeyResponse?.expirationTimestamp == null ||
pubKeyResponse.expirationTimestamp < System.currentTimeMillis() / 1000
) {
cache.remove(vaspDomain)
null
} else {
pubKeyResponse
}
}

override fun addPublicKeysForVasp(vaspDomain: String, pubKeyResponse: PubKeyResponse) {
cache[vaspDomain] = pubKeyResponse
if (pubKeyResponse.expirationTimestamp != null &&
pubKeyResponse.expirationTimestamp > System.currentTimeMillis() / 1000
) {
cache[vaspDomain] = pubKeyResponse
}
}

override fun removePublicKeysForVasp(vaspDomain: String) {
Expand Down
16 changes: 10 additions & 6 deletions uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,15 @@ class UmaProtocolHelper @JvmOverloads constructor(

val scheme = if (isDomainLocalhost(vaspDomain)) "http" else "https"
val response = umaRequester.makeGetRequest("$scheme://$vaspDomain/.well-known/lnurlpubkey")
val pubKeyResponse = Json.decodeFromString<PubKeyResponse>(response)
val pubKeyResponse = parseAsPubKeyResponse(response)
publicKeyCache.addPublicKeysForVasp(vaspDomain, pubKeyResponse)
return pubKeyResponse
}

fun parseAsPubKeyResponse(response: String): PubKeyResponse {
return Json.decodeFromString(response)
}

private fun generateNonce(): String {
return Random.nextULong().toString()
}
Expand Down Expand Up @@ -156,7 +160,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
nonceCache: NonceCache,
): Boolean {
nonceCache.checkAndSaveNonce(query.nonce, query.timestamp)
return verifySignature(query.signablePayload(), query.signature, pubKeyResponse.signingPubKey)
return verifySignature(query.signablePayload(), query.signature, pubKeyResponse.getSigningPubKey())
}

/**
Expand Down Expand Up @@ -247,7 +251,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
return verifySignature(
response.compliance.signablePayload(),
response.compliance.signature,
pubKeyResponse.signingPubKey,
pubKeyResponse.getSigningPubKey(),
)
}

Expand Down Expand Up @@ -387,7 +391,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
return verifySignature(
payReq.signablePayload(),
compliance.signature,
pubKeyResponse.signingPubKey,
pubKeyResponse.getSigningPubKey(),
)
}

Expand Down Expand Up @@ -649,7 +653,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
return verifySignature(
payReqResponse.signablePayload(payerIdentifier),
compliance.signature,
pubKeyResponse.signingPubKey,
pubKeyResponse.getSigningPubKey(),
)
}

Expand Down Expand Up @@ -698,7 +702,7 @@ class UmaProtocolHelper @JvmOverloads constructor(
return verifySignature(
postTransactionCallback.signablePayload(),
postTransactionCallback.signature,
pubKeyResponse.signingPubKey,
pubKeyResponse.getSigningPubKey(),
)
}

Expand Down
75 changes: 71 additions & 4 deletions uma-sdk/src/commonMain/kotlin/me/uma/protocol/PubKeyResponse.kt
Original file line number Diff line number Diff line change
@@ -1,26 +1,72 @@
package me.uma.protocol

import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.uma.utils.ByteArrayAsHexSerializer
import me.uma.utils.X509CertificateSerializer

/**
* Response from another VASP when requesting public keys.
*
* @property signingCertificate PEM encoded X.509 certificate string.
* Used to verify signatures from the VASP.
* @property encryptionCertificate PEM encoded X.509 certificate string.
* Used to encrypt TR info sent to the VASP.
* @property signingPubKey The public key used to verify signatures from the VASP.
* @property encryptionPubKey The public key used to encrypt TR info sent to the VASP.
* @property expirationTimestamp Seconds since epoch at which these pub keys must be refreshed.
* They can be safely cached until this expiration (or forever if null).
*/
@Serializable
data class PubKeyResponse @JvmOverloads constructor(
data class PubKeyResponse internal constructor(
@Serializable(with = X509CertificateSerializer::class)
val signingCertificate: X509Certificate?,
@Serializable(with = X509CertificateSerializer::class)
val encryptionCertificate: X509Certificate?,
@Serializable(with = ByteArrayAsHexSerializer::class)
val signingPubKey: ByteArray,
private val signingPubKey: ByteArray?,
@Serializable(with = ByteArrayAsHexSerializer::class)
val encryptionPubKey: ByteArray,
private val encryptionPubKey: ByteArray?,
val expirationTimestamp: Long? = null,
) {
@JvmOverloads
constructor(signingKey: ByteArray, encryptionKey: ByteArray, expirationTs: Long? = null) : this(
signingCertificate = null,
encryptionCertificate = null,
signingPubKey = signingKey,
encryptionPubKey = encryptionKey,
expirationTimestamp = expirationTs,
)

@JvmOverloads
constructor(signingCert: String, encryptionCert: String, expirationTs: Long? = null) : this(
signingCertificate = signingCert.toX509Certificate(),
encryptionCertificate = encryptionCert.toX509Certificate(),
signingPubKey = signingCert.toX509Certificate().getPubKeyBytes(),
encryptionPubKey = encryptionCert.toX509Certificate().getPubKeyBytes(),
expirationTimestamp = expirationTs,
)

fun getSigningPubKey(): ByteArray {
return if (signingCertificate != null) {
signingCertificate.getPubKeyBytes()
} else {
signingPubKey ?: throw IllegalStateException("No signing public key")
}
}

fun getEncryptionPubKey(): ByteArray {
return if (encryptionCertificate != null) {
encryptionCertificate.getPubKeyBytes()
} else {
encryptionPubKey ?: throw IllegalStateException("No encryption public key")
}
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
Expand All @@ -30,16 +76,37 @@ data class PubKeyResponse @JvmOverloads constructor(
if (!signingPubKey.contentEquals(other.signingPubKey)) return false
if (!encryptionPubKey.contentEquals(other.encryptionPubKey)) return false
if (expirationTimestamp != other.expirationTimestamp) return false
if (signingCertificate != other.signingCertificate) return false
if (encryptionCertificate != other.encryptionCertificate) return false

return true
}

override fun hashCode(): Int {
var result = signingPubKey.contentHashCode()
result = 31 * result + encryptionPubKey.contentHashCode()
result = 31 * result + (expirationTimestamp?.hashCode() ?: 0)
result = 31 * result + expirationTimestamp.hashCode()
result = 31 * result + signingCertificate.hashCode()
result = 31 * result + encryptionCertificate.hashCode()
return result
}

fun toJson() = Json.encodeToString(this)
}

private fun String.toX509Certificate(): X509Certificate {
return CertificateFactory.getInstance("X.509")
.generateCertificate(byteInputStream()) as? X509Certificate
?: throw IllegalStateException("Could not be parsed as X.509 certificate")
}

private fun X509Certificate.getPubKeyBytes(): ByteArray {
if (publicKey !is ECPublicKey ||
!(publicKey as ECPublicKey).params.toString().contains("secp256k1")
) {
throw IllegalStateException("Public key extracted from certificate is not EC/secp256k1")
}
// encryptionPubKey.publicKey is an ASN.1/DER encoded X.509/SPKI key, the last 65
// bytes are the uncompressed public key
return publicKey.encoded.takeLast(65).toByteArray()
Comment on lines +109 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh cool :-). This starts with 04 and doesn't include the ---- END BLAH ---- tags, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package me.uma.utils

import java.io.ByteArrayInputStream
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

@OptIn(ExperimentalStdlibApi::class)
class X509CertificateSerializer : KSerializer<X509Certificate> {
override val descriptor = buildClassSerialDescriptor("X509Certificate")

override fun serialize(encoder: Encoder, value: X509Certificate) {
encoder.encodeString(value.encoded.toHexString())
}

override fun deserialize(decoder: Decoder): X509Certificate {
val bytes = decoder.decodeString().hexToByteArray()
return CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(bytes)) as X509Certificate
}
}
Loading