From 86d96c571c53ecc0029da78afecaacf54519b022 Mon Sep 17 00:00:00 2001 From: shreyav Date: Mon, 18 Nov 2024 14:46:46 -0800 Subject: [PATCH 1/2] backing signature changes --- .../test/java/me/uma/javatest/UmaTest.java | 168 +++++++++++++++++- .../kotlin/me/uma/UmaProtocolHelper.kt | 157 ++++++++++++++++ .../me/uma/protocol/BackingSignature.kt | 21 +++ .../kotlin/me/uma/protocol/LnurlpRequest.kt | 37 ++++ .../kotlin/me/uma/protocol/LnurlpResponse.kt | 22 +++ .../kotlin/me/uma/protocol/PayReqResponse.kt | 36 ++++ .../kotlin/me/uma/protocol/PayRequest.kt | 20 +++ .../kotlin/me/uma/protocol/PayeeData.kt | 2 + .../kotlin/me/uma/protocol/PayerData.kt | 2 + 9 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt diff --git a/javatest/src/test/java/me/uma/javatest/UmaTest.java b/javatest/src/test/java/me/uma/javatest/UmaTest.java index dddb540..efe2b48 100644 --- a/javatest/src/test/java/me/uma/javatest/UmaTest.java +++ b/javatest/src/test/java/me/uma/javatest/UmaTest.java @@ -18,9 +18,11 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.*; import static me.uma.protocol.CurrencyUtils.createCurrency; +import static me.uma.utils.SerializationKt.getSerialFormat; public class UmaTest { - UmaProtocolHelper umaProtocolHelper = new UmaProtocolHelper(new InMemoryPublicKeyCache(), new TestUmaRequester()); + PublicKeyCache publicKeyCache = new InMemoryPublicKeyCache(); + UmaProtocolHelper umaProtocolHelper = new UmaProtocolHelper(publicKeyCache, new TestUmaRequester()); private static final String PUBKEY_HEX = "04419c5467ea563f0010fd614f85e885ac99c21b8e8d416241175fdd5efd2244fe907e2e6fa3dd6631b1b17cd28798da8d882a34c4776d44cc4090781c7aadea1b"; private static final String PRIVKEY_HEX = "77e891f0ecd265a3cda435eaa73792233ebd413aeb0dbb66f2940babfc9a2667"; private static final String encodedPayReqMetadata = "[[\"text/uma-invoice\",\"invoiceUUID\"],[\"text/plain\",\"otherInformations\"]]"; @@ -197,6 +199,63 @@ public void testGetLnurlpResponse_umaV0() throws Exception { new InMemoryNonceCache(1L))); } + @Test + public void testSignAndVerifyLnurlpResponseWithBackingSignature() throws Exception { + String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( + privateKeyBytes(), + "$bob@vasp2.com", + "https://vasp.com", + true); + LnurlpRequest request = umaProtocolHelper.parseLnurlpRequest(lnurlpUrl); + assertNotNull(request); + LnurlpResponse lnurlpResponse = umaProtocolHelper.getLnurlpResponse( + request, + privateKeyBytes(), + true, + "https://vasp2.com/callback", + "encoded metadata", + 1, + 10_000_000, + CounterPartyData.createCounterPartyDataOptions( + Map.of( + "name", false, + "email", false, + "identity", true, + "compliance", true + ) + ), + List.of( + createCurrency( + "USD", + "US Dollar", + "$", + 34_150, + 2, + 1, + 10_000_000, + "0.3" + ) + ), + KycStatus.VERIFIED + ); + assertNotNull(lnurlpResponse); + String backingDomain = "backingvasp.com"; + UmaLnurlpResponse umaResponse = lnurlpResponse.asUmaResponse(); + assertNotNull(umaResponse); + UmaLnurlpResponse responseWithBackingSignature = umaResponse.appendBackingSignature(privateKeyBytes(), backingDomain); + String responseJson = responseWithBackingSignature.toJson(); + LnurlpResponse parsedResponse = umaProtocolHelper.parseAsLnurlpResponse(responseJson); + assertNotNull(parsedResponse); + UmaLnurlpResponse parsedUmaResponse = parsedResponse.asUmaResponse(); + assertNotNull(parsedUmaResponse); + assertNotNull(parsedUmaResponse.getBackingSignatures()); + assertEquals(1, parsedUmaResponse.getBackingSignatures().size()); + Long publicKeyCacheExpiry = System.currentTimeMillis() / 1000 + 10000; + PubKeyResponse backingVaspPubKeyResponse = new PubKeyResponse(publicKeyBytes(), publicKeyBytes(), publicKeyCacheExpiry); + publicKeyCache.addPublicKeysForVasp(backingDomain, backingVaspPubKeyResponse); + assertTrue(umaProtocolHelper.verifyLnurlpResponseBackingSignaturesSync(parsedUmaResponse)); + } + @Test public void testGetPayRequest_umaV1() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( @@ -263,6 +322,38 @@ request, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), assertEquals(request, parsedRequest); } + @Test + public void testSignAndVerifyPayReqBackingSignatures() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), + "USD", + 1000L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "/api/lnurl/utxocallback?txid=1234" + ); + String backingDomain = "backingvasp.com"; + PayRequest requestWithBackingSignatures = request.appendBackingSignature(privateKeyBytes(), backingDomain); + String requestJson = requestWithBackingSignatures.toJson(); + PayRequest parsedRequest = umaProtocolHelper.parseAsPayRequest(requestJson); + assertNotNull(parsedRequest); + JsonObject payerData = parsedRequest.getPayerData(); + assertNotNull(payerData); + CompliancePayerData complianceData = getSerialFormat().decodeFromJsonElement( + CompliancePayerData.Companion.serializer(), + payerData.get("compliance") + ); + assertNotNull(complianceData); + assertNotNull(complianceData.getBackingSignatures()); + assertEquals(1, complianceData.getBackingSignatures().size()); + Long publicKeyCacheExpiry = System.currentTimeMillis() / 1000 + 10000; + PubKeyResponse backingVaspPubKeyResponse = new PubKeyResponse(publicKeyBytes(), publicKeyBytes(), publicKeyCacheExpiry); + publicKeyCache.addPublicKeysForVasp(backingDomain, backingVaspPubKeyResponse); + assertTrue(umaProtocolHelper.verifyPayReqBackingSignaturesSync(parsedRequest)); + } + @Test public void testGetPayReqResponseSync_umaV1() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( @@ -348,6 +439,55 @@ response, new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), assertEquals(response, parsedResponse); } + @Test + public void testSignAndVerifyPayReqResponseBackingSignatures() throws Exception { + PayRequest request = umaProtocolHelper.getPayRequest( + publicKeyBytes(), + privateKeyBytes(), + "USD", + 100L, + true, + "$alice@vasp1.com", + KycStatus.VERIFIED, + "/api/lnurl/utxocallback?txid=1234" + ); + PayReqResponse response = umaProtocolHelper.getPayReqResponseSync( + request, + new TestSyncUmaInvoiceCreator(), + encodedPayReqMetadata, + "USD", + 2, + 24150.0, + 100000L, + List.of("abcdef12345"), + null, + "/api/lnurl/utxocallback?txid=1234", + privateKeyBytes(), + PayeeData.createPayeeData(null, "$bob@vasp2.com"), + null, + null, + "1.0" + ); + String backingDomain = "backingvasp.com"; + PayReqResponse signedResponse = response.appendBackingSignature(privateKeyBytes(), backingDomain, "$alice@vasp1.com", "$bob@vasp2.com"); + String responseJson = signedResponse.toJson(); + PayReqResponse parsedResponse = umaProtocolHelper.parseAsPayReqResponse(responseJson); + assertNotNull(parsedResponse); + JsonObject payeeData = parsedResponse.payeeData(); + assertNotNull(payeeData); + CompliancePayeeData complianceData = getSerialFormat().decodeFromJsonElement( + CompliancePayeeData.Companion.serializer(), + payeeData.get("compliance") + ); + assertNotNull(complianceData); + assertNotNull(complianceData.getBackingSignatures()); + assertEquals(1, complianceData.getBackingSignatures().size()); + Long publicKeyCacheExpiry = System.currentTimeMillis() / 1000 + 10000; + PubKeyResponse backingVaspPubKeyResponse = new PubKeyResponse(publicKeyBytes(), publicKeyBytes(), publicKeyCacheExpiry); + publicKeyCache.addPublicKeysForVasp(backingDomain, backingVaspPubKeyResponse); + assertTrue(umaProtocolHelper.verifyPayReqResponseBackingSignaturesSync(parsedResponse, "$alice@vasp1.com")); + } + @Test public void testGetPayReqResponseFuture() throws Exception { PayRequest request = umaProtocolHelper.getPayRequest( @@ -440,6 +580,32 @@ public void testVerifyUmaLnurlpQuerySignature_purgeOlderNoncesAndStoreNonce() th requireNonNull(request.asUmaRequest()), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), nonceCache)); } + @Test + public void testSignAndVerifyLnurlpRequestWithBackingSignature() throws Exception { + String lnurlpUrl = umaProtocolHelper.getSignedLnurlpRequestUrl( + privateKeyBytes(), + "$bob@vasp2.com", + "https://vasp.com", + true); + LnurlpRequest request = umaProtocolHelper.parseLnurlpRequest(lnurlpUrl); + assertNotNull(request); + byte[] backingVaspPrivateKey = privateKeyBytes(); + String backingDomain = "backingvasp.com"; + UmaLnurlpRequest requestWithBackingSignature = request.asUmaRequest().appendBackingSignature(backingVaspPrivateKey, backingDomain); + String encodedUrl = requestWithBackingSignature.encodeToUrl(); + LnurlpRequest parsedRequest = umaProtocolHelper.parseLnurlpRequest(encodedUrl); + assertNotNull(parsedRequest); + assertNotNull(parsedRequest.getBackingSignatures()); + assertEquals(1, parsedRequest.getBackingSignatures().size()); + Long publicKeyCacheExpiry = System.currentTimeMillis() / 1000 + 10000; + PubKeyResponse backingVaspPubKeyResponse = new PubKeyResponse(publicKeyBytes(), publicKeyBytes(), publicKeyCacheExpiry); + publicKeyCache.addPublicKeysForVasp(backingDomain, backingVaspPubKeyResponse); + assertTrue(umaProtocolHelper.verifyUmaLnurlpQuerySignature(requireNonNull(request.asUmaRequest()), new PubKeyResponse(publicKeyBytes(), publicKeyBytes()), new InMemoryNonceCache(1L))); + assertTrue(umaProtocolHelper.verifyUmaLnurlpQueryBackingSignaturesSync( + requireNonNull(parsedRequest.asUmaRequest()) + )); + } + @Test public void testGetAndVerifyPostTransactionCallback() throws Exception { PostTransactionCallback callback = umaProtocolHelper.getPostTransactionCallback( diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index fbb7dc4..c8ffc9d 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -169,6 +169,42 @@ class UmaProtocolHelper @JvmOverloads constructor( ) } + /** + * Verifies the backing signatures on an UMA Lnurlp query. You may optionally call this function after + * verifyUmaLnurlpQuerySignature to verify signatures from backing VASPs. + * + * @param query The signed query to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @Throws(Exception::class) + fun verifyUmaLnurlpQueryBackingSignaturesSync(query: UmaLnurlpRequest): Boolean = runBlocking { + verifyUmaLnurlpQueryBackingSignatures(query) + } + + /** + * Verifies the backing signatures on an UMA Lnurlp query. You may optionally call this function after + * verifyUmaLnurlpQuerySignature to verify signatures from backing VASPs. + * + * @param query The signed query to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @JvmName("KotlinOnly-verifyUmaLnurlpQueryBackingSignaturesSuspended") + @Throws(Exception::class) + suspend fun verifyUmaLnurlpQueryBackingSignatures(query: UmaLnurlpRequest): Boolean { + val backingSignatures = query.backingSignatures ?: return true + for (backingSignature in backingSignatures) { + val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) + if (!verifySignature( + query.signablePayload(), + backingSignature.signature, + backingVaspPubKeyResponse.getSigningPublicKey() + )) { + return false + } + } + return true + } + /** * Creates a signed UMA [LnurlpResponse]. * @@ -305,6 +341,42 @@ class UmaProtocolHelper @JvmOverloads constructor( ) } + /** + * Verifies the backing signatures on an UMA Lnurlp response. You may optionally call this function after + * verifyLnurlpResponseSignature to verify signatures from backing VASPs. + * + * @param response The signed [LnurlpResponse] to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @Throws(Exception::class) + fun verifyLnurlpResponseBackingSignaturesSync(response: UmaLnurlpResponse): Boolean = runBlocking { + verifyLnurlpResponseBackingSignatures(response) + } + + /** + * Verifies the backing signatures on an UMA Lnurlp response. You may optionally call this function after + * verifyLnurlpResponseSignature to verify signatures from backing VASPs. + * + * @param response The signed [LnurlpResponse] to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @JvmName("KotlinOnly-verifyLnurlpResponseBackingSignaturesSuspended") + @Throws(Exception::class) + suspend fun verifyLnurlpResponseBackingSignatures(response: UmaLnurlpResponse): Boolean { + val backingSignatures = response.backingSignatures ?: return true + for (backingSignature in backingSignatures) { + val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) + if (!verifySignature( + response.compliance.signablePayload(), + backingSignature.signature, + backingVaspPubKeyResponse.getSigningPublicKey() + )) { + return false + } + } + return true + } + /** * Creates a signed UMA [PayRequest]. * @@ -468,6 +540,44 @@ class UmaProtocolHelper @JvmOverloads constructor( ) } + /** + * Verifies the backing signatures on a [PayRequest]. You may optionally call this function after + * verifyPayReqBackingSignatures to verify signatures from backing VASPs. + * + * @param payReq The signed [PayRequest] to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @Throws(Exception::class) + fun verifyPayReqBackingSignaturesSync(payReq: PayRequest): Boolean = runBlocking { + verifyPayReqBackingSignatures(payReq) + } + + /** + * Verifies the backing signatures on a [PayRequest]. You may optionally call this function after + * verifyPayReqBackingSignatures to verify signatures from backing VASPs. + * + * @param payReq The signed [PayRequest] to verify. + * @return true if all backing signatures are valid, false otherwise. + */ + @JvmName("KotlinOnly-verifyPayReqBackingSignaturesSuspended") + @Throws(Exception::class) + suspend fun verifyPayReqBackingSignatures(payReq: PayRequest): Boolean { + if (!payReq.isUmaRequest()) return false + val compliance = payReq.payerData?.compliance() ?: return false + val backingSignatures = compliance.backingSignatures ?: return true + for (backingSignature in backingSignatures) { + val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) + if (!verifySignature( + payReq.signablePayload(), + backingSignature.signature, + backingVaspPubKeyResponse.getSigningPublicKey() + )) { + return false + } + } + return true + } + /** * Creates an uma pay request response with an encoded invoice. * @@ -830,6 +940,53 @@ class UmaProtocolHelper @JvmOverloads constructor( ) } + /** + * Verifies the backing signatures on a [PayReqResponse]. You may optionally call this function after + * verifyPayReqResponseSignature to verify signatures from backing VASPs. + * + * @param payReqResponse The signed [PayReqResponse] to verify. + * @param payerIdentifier The identifier of the sender. For example, $alice@vasp1.com + * @return true if all backing signatures are valid, false otherwise. + */ + @Throws(Exception::class) + fun verifyPayReqResponseBackingSignaturesSync( + payReqResponse: PayReqResponse, + payerIdentifier: String, + ): Boolean = runBlocking { + verifyPayReqResponseBackingSignatures(payReqResponse, payerIdentifier) + } + + /** + * Verifies the backing signatures on a [PayReqResponse]. You may optionally call this function after + * verifyPayReqResponseSignature to verify signatures from backing VASPs. + * + * @param payReqResponse The signed [PayReqResponse] to verify. + * @param payerIdentifier The identifier of the sender. For example, $alice@vasp1.com + * @return true if all backing signatures are valid, false otherwise. + */ + @JvmName("KotlinOnly-verifyPayReqResponseBackingSignaturesSuspended") + @Throws(Exception::class) + suspend fun verifyPayReqResponseBackingSignatures( + payReqResponse: PayReqResponse, + payerIdentifier: String, + ): Boolean { + if (payReqResponse !is PayReqResponseV1) return true + if (!payReqResponse.isUmaResponse()) return false + val compliance = payReqResponse.payeeData?.payeeCompliance() ?: return false + val backingSignatures = compliance.backingSignatures ?: return true + for (backingSignature in backingSignatures) { + val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) + if (!verifySignature( + payReqResponse.signablePayload(payerIdentifier), + backingSignature.signature, + backingVaspPubKeyResponse.getSigningPublicKey() + )) { + return false + } + } + return true + } + /** * Creates a signed [PostTransactionCallback]. * diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt new file mode 100644 index 0000000..4534315 --- /dev/null +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt @@ -0,0 +1,21 @@ +package me.uma.protocol + +import kotlinx.serialization.Serializable + +/** + * A signature by a backing VASP that can attest to the authenticity of the message, + * along with its associated domain. + */ +@Serializable +data class BackingSignature( + /** + * Domain is the domain of the VASP that produced the signature. Public keys for this VASP will be fetched from + * the domain at /.well-known/lnurlpubkey and used to verify the signature. + */ + val domain: String, + + /** + * Signature is the signature of the payload. + */ + val signature: String +) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt index 6aec31f..a0f4173 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt @@ -2,9 +2,11 @@ package me.uma.protocol +import io.ktor.http.decodeURLQueryComponent import io.ktor.http.Parameters import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol +import me.uma.crypto.Secp256k1 import me.uma.UnsupportedVersionException import me.uma.isVersionSupported import me.uma.utils.isDomainLocalhost @@ -21,6 +23,7 @@ import kotlin.contracts.ExperimentalContracts * @param timestamp The unix timestamp in seconds of the moment when the request was sent. Used in the signature. * @param umaVersion The version of the UMA protocol that VASP1 prefers to use for this transaction. For the version * negotiation flow, see https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png + * @param backingSignatures A list of backing signatures from VASPs that can attest to the authenticity of the message. */ data class LnurlpRequest( val receiverAddress: String, @@ -30,6 +33,7 @@ data class LnurlpRequest( val vaspDomain: String?, val timestamp: Long?, val umaVersion: String?, + val backingSignatures: List? = null, ) { /** * Encodes the request to a URL that can be used to send the request to VASP2. @@ -54,6 +58,12 @@ data class LnurlpRequest( umaVersion?.let { append("umaVersion", it) } timestamp?.let { append("timestamp", it.toString()) } isSubjectToTravelRule?.let { append("isSubjectToTravelRule", it.toString()) } + backingSignatures?.let { signatures -> + append( + "backingSignatures", + signatures.joinToString(",") { "${it.domain}:${it.signature}" } + ) + } }, ).build() return url.toString() @@ -78,6 +88,7 @@ data class LnurlpRequest( vaspDomain, timestamp, umaVersion, + backingSignatures, ) } else { null @@ -114,6 +125,19 @@ data class LnurlpRequest( val isSubjectToTravelRule = urlBuilder.parameters["isSubjectToTravelRule"]?.toBoolean() val timestamp = urlBuilder.parameters["timestamp"]?.toLong() val umaVersion = urlBuilder.parameters["umaVersion"] + val backingSignatures = urlBuilder.parameters["backingSignatures"]?.let { serialized -> + serialized.split(",").map { pair -> + val decodedPair = pair.decodeURLQueryComponent() + val lastColonIndex = decodedPair.lastIndexOf(':') + if (lastColonIndex == -1) { + throw IllegalArgumentException("Invalid backing signature format") + } + BackingSignature( + domain = decodedPair.substring(0, lastColonIndex), + signature = decodedPair.substring(lastColonIndex + 1) + ) + } + } if (umaVersion != null && !isVersionSupported(umaVersion)) { throw UnsupportedVersionException(umaVersion) @@ -127,6 +151,7 @@ data class LnurlpRequest( vaspDomain, timestamp, umaVersion, + backingSignatures, ) } } @@ -145,6 +170,8 @@ data class LnurlpRequest( * @param timestamp The unix timestamp in seconds of the moment when the request was sent. Used in the signature. * @param umaVersion The version of the UMA protocol that VASP1 prefers to use for this transaction. For the version * negotiation flow, see https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png + * @param backingSignatures A list of backing signatures from VASPs that can attest to the authenticity of the message, + * along with their associated domains. */ data class UmaLnurlpRequest( val receiverAddress: String, @@ -154,6 +181,7 @@ data class UmaLnurlpRequest( val vaspDomain: String, val timestamp: Long, val umaVersion: String, + val backingSignatures: List?, ) { fun asLnurlpRequest() = LnurlpRequest( receiverAddress, @@ -163,6 +191,7 @@ data class UmaLnurlpRequest( vaspDomain, timestamp, umaVersion, + backingSignatures, ) /** @@ -173,4 +202,12 @@ data class UmaLnurlpRequest( fun signedWith(signature: String) = copy(signature = signature) fun signablePayload() = "$receiverAddress|$nonce|$timestamp".encodeToByteArray() + + @OptIn(kotlin.ExperimentalStdlibApi::class) + fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): UmaLnurlpRequest { + val signature = Secp256k1.signEcdsa(signablePayload(), signingPrivateKey).toHexString() + val newBackingSignatures = (backingSignatures ?: emptyList()).toMutableList() + newBackingSignatures.add(BackingSignature(domain = domain, signature = signature)) + return copy(backingSignatures = newBackingSignatures) + } } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt index e97d766..6496fe9 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpResponse.kt @@ -1,5 +1,6 @@ package me.uma.protocol +import me.uma.crypto.Secp256k1 import me.uma.utils.serialFormat import kotlinx.serialization.* @@ -22,6 +23,7 @@ import kotlinx.serialization.* * @property nostrPubkey An optional nostr pubkey used for nostr zaps (NIP-57). If set, it should be a valid * BIP-340 public key in hex format. * @property allowsNostr Should be set to true if the receiving VASP allows nostr zaps (NIP-57). + * @property backingSignatures A list of backing signatures from VASPs that can attest to the authenticity of the message. */ @OptIn(ExperimentalSerializationApi::class) @Serializable @@ -44,6 +46,7 @@ data class LnurlpResponse( val allowsNostr: Boolean? = null, @EncodeDefault val tag: String = "payRequest", + val backingSignatures: List? = null, ) { fun asUmaResponse(): UmaLnurlpResponse? = if ( currencies != null && @@ -64,6 +67,7 @@ data class LnurlpResponse( nostrPubkey, allowsNostr, tag, + backingSignatures, ) } else { null @@ -93,6 +97,24 @@ data class UmaLnurlpResponse( val allowsNostr: Boolean? = null, @EncodeDefault val tag: String = "payRequest", + val backingSignatures: List?, ) { fun toJson() = serialFormat.encodeToString(this) + + /** + * Appends a backing signature to the UmaLnurlpResponse. + * + * @param signingPrivateKey The private key to use to sign the payload + * @param domain The domain of the VASP that is signing the payload. The associated public key will be fetched from + * /.well-known/lnurlpubkey on this domain to verify the signature. + * @return response with the backing signature appended + */ + @OptIn(kotlin.ExperimentalStdlibApi::class) + @Throws(Exception::class) + fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): UmaLnurlpResponse { + val signature = Secp256k1.signEcdsa(compliance.signablePayload(), signingPrivateKey).toHexString() + val newBackingSignatures = (backingSignatures ?: emptyList()).toMutableList() + newBackingSignatures.add(BackingSignature(domain = domain, signature = signature)) + return copy(backingSignatures = newBackingSignatures) + } } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt index 7b02b3d..3b117c5 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -1,6 +1,8 @@ package me.uma.protocol +import me.uma.crypto.Secp256k1 import me.uma.utils.serialFormat +import me.uma.protocol.CompliancePayeeData import kotlinx.serialization.* import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement @@ -36,6 +38,13 @@ sealed interface PayReqResponse { fun isUmaResponse(): Boolean fun toJson(): String + + fun appendBackingSignature( + signingPrivateKey: ByteArray, + domain: String, + payerIdentifier: String, + payeeIdentifier: String, + ): PayReqResponse } @OptIn(ExperimentalSerializationApi::class) @@ -88,6 +97,26 @@ internal data class PayReqResponseV1( .encodeToByteArray() } } + + @OptIn(kotlin.ExperimentalStdlibApi::class) + @Throws(Exception::class) + override fun appendBackingSignature( + signingPrivateKey: ByteArray, + domain: String, + payerIdentifier: String, + payeeIdentifier: String, + ): PayReqResponseV1 { + val signablePayload = signablePayload(payerIdentifier) + val signature = Secp256k1.signEcdsa(signablePayload, signingPrivateKey).toHexString() + val complianceData = payeeData?.payeeCompliance() ?: throw IllegalArgumentException("Compliance payee data is missing") + val backingSignatures = (complianceData.backingSignatures ?: emptyList()).toMutableList() + backingSignatures.add(BackingSignature(domain = domain, signature = signature)) + val updatedComplianceData = complianceData.copy(backingSignatures = backingSignatures) + val updatedPayeeDataMap = payeeData.toMutableMap() + updatedPayeeDataMap["compliance"] = serialFormat.encodeToJsonElement( + CompliancePayeeData.serializer(), updatedComplianceData) + return this.copy(payeeData = PayeeData(updatedPayeeDataMap)) + } } @OptIn(ExperimentalSerializationApi::class) @@ -112,6 +141,13 @@ internal data class PayReqResponseV0 constructor( override fun isUmaResponse() = true override fun toJson() = serialFormat.encodeToString(this) + + override fun appendBackingSignature( + signingPrivateKey: ByteArray, + domain: String, + payerIdentifier: String, + payeeIdentifier: String, + ): PayReqResponseV0 = this } @Serializable diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt index 508835a..22a75c0 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -1,5 +1,6 @@ package me.uma.protocol +import me.uma.crypto.Secp256k1 import me.uma.utils.serialFormat import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer @@ -53,6 +54,8 @@ sealed interface PayRequest { fun toQueryParamMap(): Map + fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): PayRequest + companion object { fun fromQueryParamMap(queryMap: Map>): PayRequest { val receivingCurrencyCode = queryMap["convert"]?.firstOrNull() @@ -153,6 +156,21 @@ internal data class PayRequestV1( invoiceUUID?.let { map["invoiceUUID"] = it } return map } + + @OptIn(kotlin.ExperimentalStdlibApi::class) + @Throws(Exception::class) + override fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): PayRequestV1 { + val signablePayload = signablePayload() + val signature = Secp256k1.signEcdsa(signablePayload, signingPrivateKey).toHexString() + val complianceData = payerData?.compliance() ?: throw IllegalArgumentException("Compliance payer data is missing") + val backingSignatures = (complianceData.backingSignatures ?: emptyList()).toMutableList() + backingSignatures.add(BackingSignature(domain = domain, signature = signature)) + val updatedComplianceData = complianceData.copy(backingSignatures = backingSignatures) + val updatedPayerDataMap = payerData.toMutableMap() + updatedPayerDataMap["compliance"] = serialFormat.encodeToJsonElement( + CompliancePayerData.serializer(), updatedComplianceData) + return this.copy(payerData = PayerData(updatedPayerDataMap)) + } } @Serializable @@ -189,6 +207,8 @@ internal data class PayRequestV0( "convert" to currencyCode, "payerData" to serialFormat.encodeToString(payerData), ) + + override fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): PayRequestV0 = this } @OptIn(ExperimentalSerializationApi::class) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt index 857f2d5..a5b0928 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayeeData.kt @@ -51,6 +51,7 @@ fun PayeeData.payeeCompliance(): CompliancePayeeData? { * @property signature The signature of the receiver on the signable payload. * @property signatureNonce The nonce used in the signature. * @property signatureTimestamp The timestamp used in the signature. + * @property backingSignatures The list of backing signatures from VASPs that can attest to the authenticity of the message. */ @Serializable data class CompliancePayeeData( @@ -60,6 +61,7 @@ data class CompliancePayeeData( val signature: String, val signatureNonce: String, val signatureTimestamp: Long, + val backingSignatures: List? = null, ) { fun signedWith(signature: String) = copy(signature = signature) } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt index bb0ed92..b07a7e2 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayerData.kt @@ -64,6 +64,7 @@ fun PayerData.identifier(): String? = get("identifier")?.jsonPrimitive?.content * @property signatureTimestamp The timestamp used in the signature. * @property travelRuleFormat An optional standardized format of the travel rule information (e.g. IVMS). Null * indicates raw json or a custom format. + * @property backingSignatures The list of backing signatures from VASPs that can attest to the authenticity of the message. */ @Serializable data class CompliancePayerData @@ -78,6 +79,7 @@ data class CompliancePayerData val signatureNonce: String, val signatureTimestamp: Long, val travelRuleFormat: TravelRuleFormat? = null, + val backingSignatures: List? = null, ) { fun signedWith(signature: String) = copy(signature = signature) } From 1cc2f1c7c6d6fda2982a428e5dc123329601e607 Mon Sep 17 00:00:00 2001 From: shreyav Date: Mon, 18 Nov 2024 15:10:39 -0800 Subject: [PATCH 2/2] fix lint --- .../kotlin/me/uma/UmaProtocolHelper.kt | 30 ++++++++++--------- .../me/uma/protocol/BackingSignature.kt | 3 +- .../kotlin/me/uma/protocol/LnurlpRequest.kt | 4 +-- .../kotlin/me/uma/protocol/PayReqResponse.kt | 10 +++---- .../kotlin/me/uma/protocol/PayRequest.kt | 7 +++-- 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt index c8ffc9d..d3959ff 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/UmaProtocolHelper.kt @@ -194,11 +194,12 @@ class UmaProtocolHelper @JvmOverloads constructor( val backingSignatures = query.backingSignatures ?: return true for (backingSignature in backingSignatures) { val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) - if (!verifySignature( + val isSignatureValid = verifySignature( query.signablePayload(), backingSignature.signature, backingVaspPubKeyResponse.getSigningPublicKey() - )) { + ) + if (!isSignatureValid) { return false } } @@ -366,11 +367,12 @@ class UmaProtocolHelper @JvmOverloads constructor( val backingSignatures = response.backingSignatures ?: return true for (backingSignature in backingSignatures) { val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) - if (!verifySignature( + val isSignatureValid = verifySignature( response.compliance.signablePayload(), backingSignature.signature, backingVaspPubKeyResponse.getSigningPublicKey() - )) { + ) + if (!isSignatureValid) { return false } } @@ -567,11 +569,12 @@ class UmaProtocolHelper @JvmOverloads constructor( val backingSignatures = compliance.backingSignatures ?: return true for (backingSignature in backingSignatures) { val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) - if (!verifySignature( + val isSignatureValid = verifySignature( payReq.signablePayload(), backingSignature.signature, backingVaspPubKeyResponse.getSigningPublicKey() - )) { + ) + if (!isSignatureValid) { return false } } @@ -949,12 +952,10 @@ class UmaProtocolHelper @JvmOverloads constructor( * @return true if all backing signatures are valid, false otherwise. */ @Throws(Exception::class) - fun verifyPayReqResponseBackingSignaturesSync( - payReqResponse: PayReqResponse, - payerIdentifier: String, - ): Boolean = runBlocking { - verifyPayReqResponseBackingSignatures(payReqResponse, payerIdentifier) - } + fun verifyPayReqResponseBackingSignaturesSync(payReqResponse: PayReqResponse, payerIdentifier: String): Boolean = + runBlocking { + verifyPayReqResponseBackingSignatures(payReqResponse, payerIdentifier) + } /** * Verifies the backing signatures on a [PayReqResponse]. You may optionally call this function after @@ -976,11 +977,12 @@ class UmaProtocolHelper @JvmOverloads constructor( val backingSignatures = compliance.backingSignatures ?: return true for (backingSignature in backingSignatures) { val backingVaspPubKeyResponse = fetchPublicKeysForVasp(backingSignature.domain) - if (!verifySignature( + val isSignatureValid = verifySignature( payReqResponse.signablePayload(payerIdentifier), backingSignature.signature, backingVaspPubKeyResponse.getSigningPublicKey() - )) { + ) + if (!isSignatureValid) { return false } } diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt index 4534315..d784096 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/BackingSignature.kt @@ -13,9 +13,8 @@ data class BackingSignature( * the domain at /.well-known/lnurlpubkey and used to verify the signature. */ val domain: String, - /** * Signature is the signature of the payload. */ - val signature: String + val signature: String, ) diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt index a0f4173..eb6cf96 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/LnurlpRequest.kt @@ -2,12 +2,12 @@ package me.uma.protocol -import io.ktor.http.decodeURLQueryComponent import io.ktor.http.Parameters import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol -import me.uma.crypto.Secp256k1 +import io.ktor.http.decodeURLQueryComponent import me.uma.UnsupportedVersionException +import me.uma.crypto.Secp256k1 import me.uma.isVersionSupported import me.uma.utils.isDomainLocalhost import kotlin.contracts.ExperimentalContracts diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt index 3b117c5..b07189d 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayReqResponse.kt @@ -2,7 +2,6 @@ package me.uma.protocol import me.uma.crypto.Secp256k1 import me.uma.utils.serialFormat -import me.uma.protocol.CompliancePayeeData import kotlinx.serialization.* import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement @@ -108,20 +107,21 @@ internal data class PayReqResponseV1( ): PayReqResponseV1 { val signablePayload = signablePayload(payerIdentifier) val signature = Secp256k1.signEcdsa(signablePayload, signingPrivateKey).toHexString() - val complianceData = payeeData?.payeeCompliance() ?: throw IllegalArgumentException("Compliance payee data is missing") + val complianceData = payeeData?.payeeCompliance() + ?: throw IllegalArgumentException("Compliance payee data is missing") val backingSignatures = (complianceData.backingSignatures ?: emptyList()).toMutableList() backingSignatures.add(BackingSignature(domain = domain, signature = signature)) val updatedComplianceData = complianceData.copy(backingSignatures = backingSignatures) val updatedPayeeDataMap = payeeData.toMutableMap() - updatedPayeeDataMap["compliance"] = serialFormat.encodeToJsonElement( - CompliancePayeeData.serializer(), updatedComplianceData) + updatedPayeeDataMap["compliance"] = + serialFormat.encodeToJsonElement(CompliancePayeeData.serializer(), updatedComplianceData) return this.copy(payeeData = PayeeData(updatedPayeeDataMap)) } } @OptIn(ExperimentalSerializationApi::class) @Serializable -internal data class PayReqResponseV0 constructor( +internal data class PayReqResponseV0( @SerialName("pr") override val encodedInvoice: String, /** diff --git a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt index 22a75c0..d013ce0 100644 --- a/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt +++ b/uma-sdk/src/commonMain/kotlin/me/uma/protocol/PayRequest.kt @@ -162,13 +162,14 @@ internal data class PayRequestV1( override fun appendBackingSignature(signingPrivateKey: ByteArray, domain: String): PayRequestV1 { val signablePayload = signablePayload() val signature = Secp256k1.signEcdsa(signablePayload, signingPrivateKey).toHexString() - val complianceData = payerData?.compliance() ?: throw IllegalArgumentException("Compliance payer data is missing") + val complianceData = payerData?.compliance() + ?: throw IllegalArgumentException("Compliance payer data is missing") val backingSignatures = (complianceData.backingSignatures ?: emptyList()).toMutableList() backingSignatures.add(BackingSignature(domain = domain, signature = signature)) val updatedComplianceData = complianceData.copy(backingSignatures = backingSignatures) val updatedPayerDataMap = payerData.toMutableMap() - updatedPayerDataMap["compliance"] = serialFormat.encodeToJsonElement( - CompliancePayerData.serializer(), updatedComplianceData) + updatedPayerDataMap["compliance"] = + serialFormat.encodeToJsonElement(CompliancePayerData.serializer(), updatedComplianceData) return this.copy(payerData = PayerData(updatedPayerDataMap)) } }