Skip to content
Merged
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
Expand Up @@ -5,6 +5,7 @@ package com.lightspark.sdk.webhooks
import com.lightspark.sdk.core.LightsparkException
import com.lightspark.sdk.model.WebhookEventType
import com.lightspark.sdk.util.serializerFormat
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlinx.datetime.Instant
Expand Down Expand Up @@ -39,18 +40,24 @@ const val SIGNATURE_HEADER = "lightspark-signature"

@OptIn(ExperimentalStdlibApi::class)
@Throws(LightsparkException::class)
fun verifyAndParseWebhook(
data: ByteArray,
hexDigest: String,
webhookSecret: String,
): WebhookEvent {
fun verifyAndParseWebhook(data: ByteArray, hexDigest: String, webhookSecret: String): WebhookEvent {
val hmac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(webhookSecret.encodeToByteArray(), "HmacSHA256")
hmac.init(secretKey)
hmac.update(data)
val signature = hmac.doFinal()
val verified = signature.contentEquals(hexDigest.hexToByteArray())
if (!verified) {

val digestBytes =
try {
hexDigest.hexToByteArray()
} catch (_ : IllegalArgumentException) {
throw LightsparkException(
"Webhook signature verification failed. Invalid message signature format.",
"webhook_signature_verification_failed",
)
}

if (!MessageDigest.isEqual(signature, digestBytes)) {
throw LightsparkException("Webhook signature verification failed", "webhook_signature_verification_failed")
}
return parseWebhook(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,85 @@ package com.lightspark.sdk.webhooks
import com.lightspark.sdk.core.LightsparkException
import com.lightspark.sdk.model.WebhookEventType
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.MissingFieldException
import kotlinx.serialization.json.jsonPrimitive

@OptIn(ExperimentalCoroutinesApi::class)
class WebhookTests {
@Test
fun `test valid verifyAndParse`() = runTest {
val eventType = WebhookEventType.NODE_STATUS
val eventId = "1615c8be5aa44e429eba700db2ed8ca5"
val entityId = "lightning_node:01882c25-157a-f96b-0000-362d42b64397"
val timestamp = Instant.parse("2023-05-17T23:56:47.874449+00:00")
val data = """{"event_type": "NODE_STATUS", "event_id": "1615c8be5aa44e429eba700db2ed8ca5", "timestamp": "2023-05-17T23:56:47.874449+00:00", "entity_id": "lightning_node:01882c25-157a-f96b-0000-362d42b64397"}"""
val hexdigest = "62a8829aeb48b4142533520b1f7f86cdb1ee7d718bf3ea15bc1c662d4c453b74"
val hexDigest = "62a8829aeb48b4142533520b1f7f86cdb1ee7d718bf3ea15bc1c662d4c453b74"
val webhookSecret = "3gZ5oQQUASYmqQNuEk0KambNMVkOADDItIJjzUlAWjX"

val event = verifyAndParseWebhook(data.encodeToByteArray(), hexdigest, webhookSecret)
val event = verifyAndParseWebhook(data.encodeToByteArray(), hexDigest, webhookSecret)
assertEquals(eventType, event.eventType)
assertEquals(eventId, event.eventId)
assertEquals(entityId, event.entityId)
assertEquals(timestamp, event.timestamp)
assertEquals("1615c8be5aa44e429eba700db2ed8ca5", event.eventId)
assertEquals("lightning_node:01882c25-157a-f96b-0000-362d42b64397", event.entityId)
assertEquals(Instant.parse("2023-05-17T23:56:47.874449+00:00"), event.timestamp)
assertNull(event.walletId)
assertNull(event.data)
}

@Test
fun `test invalid signature`() = runTest {
val data = """{"event_type": "NODE_STATUS", "event_id": "1615c8be5aa44e429eba700db2ed8ca5", "timestamp": "2023-05-17T23:56:47.874449+00:00", "entity_id": "lightning_node:01882c25-157a-f96b-0000-362d42b64397"}"""
val hexdigest = "deadbeef"
val hexDigest = "deadbeef"
val webhookSecret = "3gZ5oQQUASYmqQNuEk0KambNMVkOADDItIJjzUlAWjX"

try {
verifyAndParseWebhook(data.encodeToByteArray(), hexdigest, webhookSecret)
fail("Expected LightsparkException")
} catch (e: LightsparkException) {
assertEquals("webhook_signature_verification_failed", e.errorCode)
}
val err = assertFailsWith<LightsparkException> {
verifyAndParseWebhook(data.encodeToByteArray(), hexDigest, webhookSecret)
}
assertEquals("webhook_signature_verification_failed", err.errorCode)
}

@Test
fun `test invalid digest bytes`() = runTest {
val data = """{"event_type": "NODE_STATUS", "event_id": "1615c8be5aa44e429eba700db2ed8ca5", "timestamp": "2023-05-17T23:56:47.874449+00:00", "entity_id": "lightning_node:01882c25-157a-f96b-0000-362d42b64397"}"""
val webhookSecret = "3gZ5oQQUASYmqQNuEk0KambNMVkOADDItIJjzUlAWjX"
val hexDigest = "NotAHexDigest"

val err = assertFailsWith<LightsparkException> {
verifyAndParseWebhook(data.encodeToByteArray(), hexDigest, webhookSecret)
}
assertEquals("webhook_signature_verification_failed", err.errorCode)
}

@Test
@OptIn(ExperimentalSerializationApi::class)
fun `test invalid json structure`() = runTest {
val data = """{"event_typeeee": "NODE_STATUS", "event_id": "1615c8be5aa44e429eba700db2ed8ca5", "timestamp": "2023-05-17T23:56:47.874449+00:00", "entity_id": "lightning_node:01882c25-157a-f96b-0000-362d42b64397"}"""
val hexdigest = "4c4232ea3cccf8d40f56f873ef3a353ad8c80f2e6ea3404197d08c4d46274bf4"
val hexDigest = "4c4232ea3cccf8d40f56f873ef3a353ad8c80f2e6ea3404197d08c4d46274bf4"
val webhookSecret = "3gZ5oQQUASYmqQNuEk0KambNMVkOADDItIJjzUlAWjX"

try {
verifyAndParseWebhook(data.encodeToByteArray(), hexdigest, webhookSecret)
fail("Expected Exception")
} catch (e: Exception) {
assertTrue(e.message!!.contains("event_type"))
val err = assertFailsWith<MissingFieldException> {
verifyAndParseWebhook(data.encodeToByteArray(), hexDigest, webhookSecret)
}
assertContains(err.message!!, "event_type")
}

@Test
fun `test valid verifyAndParse with wallet`() = runTest {
val eventType = WebhookEventType.WALLET_INCOMING_PAYMENT_FINISHED
val eventId = "1615c8be5aa44e429eba700db2ed8ca5"
val entityId = "lightning_node:01882c25-157a-f96b-0000-362d42b64397"
val walletId = "wallet:01882c25-157a-f96b-0000-362d42b64397"
val timestamp = Instant.parse("2023-05-17T23:56:47.874449+00:00")
val data = """{"event_type": "WALLET_INCOMING_PAYMENT_FINISHED", "event_id": "1615c8be5aa44e429eba700db2ed8ca5", "timestamp": "2023-05-17T23:56:47.874449+00:00", "entity_id": "lightning_node:01882c25-157a-f96b-0000-362d42b64397", "wallet_id": "wallet:01882c25-157a-f96b-0000-362d42b64397" }"""
val hexdigest = "b4eeb95f18956b3c33b99e9effc61636effc4634f83604cb41de13470c42669a"
val hexDigest = "b4eeb95f18956b3c33b99e9effc61636effc4634f83604cb41de13470c42669a"
val webhookSecret = "3gZ5oQQUASYmqQNuEk0KambNMVkOADDItIJjzUlAWjX"

val event = verifyAndParseWebhook(data.encodeToByteArray(), hexdigest, webhookSecret)
val event = verifyAndParseWebhook(data.encodeToByteArray(), hexDigest, webhookSecret)
assertEquals(eventType, event.eventType)
assertEquals(eventId, event.eventId)
assertEquals(entityId, event.entityId)
assertEquals(walletId, event.walletId)
assertEquals(timestamp, event.timestamp)
assertEquals("1615c8be5aa44e429eba700db2ed8ca5", event.eventId)
assertEquals("lightning_node:01882c25-157a-f96b-0000-362d42b64397", event.entityId)
assertEquals("wallet:01882c25-157a-f96b-0000-362d42b64397", event.walletId)
assertEquals(Instant.parse("2023-05-17T23:56:47.874449+00:00"), event.timestamp)
assertNull(event.data)
}

Expand All @@ -87,7 +90,6 @@ class WebhookTests {
val data = """{"event_type": "REMOTE_SIGNING", "event_id": "8be9c360a68e420b9126b43ff6007a32", "timestamp": "2023-08-10T02:14:27.559234+00:00", "entity_id": "node_with_server_signing:0189d6bc-558d-88df-0000-502f04e71816", "data": {"sub_event_type": "GET_PER_COMMITMENT_POINT", "bitcoin_network": "TESTNET", "derivation_path": "m/3/2104864975", "per_commitment_point_idx": 281474976710654}}"""
val event = parseWebhook(data.encodeToByteArray())
assertEquals(WebhookEventType.REMOTE_SIGNING, event.eventType)
assertNotNull(event.data)
assertEquals("GET_PER_COMMITMENT_POINT", event.data!!["sub_event_type"]?.jsonPrimitive?.content)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ suspend fun handleWebhookRequest(
val webhookEvent = try {
val bodyBytes = call.receiveText().toByteArray()
verifyAndParseWebhook(bodyBytes, signature, config.webhookSecret)
} catch (e: Exception) {
} catch (_: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid webhook request.")
return "Invalid webhook request or bad signature."
}

val response = when (webhookEvent.eventType) {
WebhookEventType.REMOTE_SIGNING -> try {
handleRemoteSigningEvent(client, webhookEvent, config.masterSeed)
} catch (e: Exception) {
} catch (_: Exception) {
call.respond(HttpStatusCode.InternalServerError, "Error handling remote signing event.")
return "Error handling remote signing event."
}
Expand Down
Loading