From 18035566f8b7b89bac2bfbb40071506e0fc69c1c Mon Sep 17 00:00:00 2001 From: Pierre-Marie Padiou Date: Tue, 23 Apr 2024 16:57:03 +0200 Subject: [PATCH] Add authentication to webhook calls (#34) A new `X-Phoenix-Signature` header is added to webhook calls, which contains the HMAC-SHA256 signature of the whole json body, encoded in utf-8, using the `webhook-secret` configuration parameter, also encoded in utf-8. For example: - webhook request body: ``` { "type": "payment_received", "timestamp": 1712785550079, "amountSat": 8, "paymentHash": "e628f8a516e9d3ee5e212a675f8d0c9dc5e7a5d500c5f4f91c62e9e921492653", "externalId": null } ``` - `webhook-secret` in `phoenix.conf`:`ef72d3b96324106dfbf83f2a4efeff7dddb4ce923e9664cb56baf34cc52936b6` Will produce the header `X-Phoenix-Signature: 77ffc40401024fb417e45fdd002de06bdbf3b48b90d09d05cccd06462920aed7` A `timestamp` has been added to the events, to provide protection against replay attacks. Users should check that the timestamp is not too old. Stripe uses a [5 min default tolerance](https://docs.stripe.com/webhooks#replay-attacks). Suggested by @danielcharrua in #33. --- .../kotlin/fr/acinq/lightning/bin/Api.kt | 16 +++++++++-- .../kotlin/fr/acinq/lightning/bin/Main.kt | 28 +++++++++++++------ .../lightning/bin/json/JsonSerializers.kt | 5 +++- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt index 1a1e510..243c93c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -26,6 +26,7 @@ import fr.acinq.lightning.utils.* import io.ktor.client.* import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.serialization.kotlinx.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* @@ -41,8 +42,9 @@ import io.ktor.server.websocket.* import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import okio.ByteString.Companion.encodeUtf8 -class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow, private val password: String, private val webhookUrl: Url?) { +class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow, private val password: String, private val webhookUrl: Url?, private val webhookSecret: String) { fun Application.module() { @@ -199,6 +201,16 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va }) } } + client.sendPipeline.intercept(HttpSendPipeline.State) { + when (val body = context.body) { + is TextContent -> { + val bodyBytes = body.text.encodeUtf8() + val secretBytes = webhookSecret.encodeUtf8() + val sig = bodyBytes.hmacSha256(secretBytes) + context.headers.append("X-Phoenix-Signature", sig.hex()) + } + } + } launch { eventsFlow.collect { event -> client.post(url) { @@ -231,5 +243,3 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va private fun Parameters.getOptionalLong(argName: String): Long? = this[argName]?.let { it.toLongOrNull() ?: invalidType(argName, "integer") } } - - diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt index 4716e5b..544614f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -102,16 +102,26 @@ class Phoenixd : CliktCommand() { .default(10.minutes) private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1") private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740) - private val httpPassword by option("--http-password", help = "Password for the http api").defaultLazy { - // the additionalValues map already contains values in phoenix.conf, so if we are here then there are no existing password - terminal.print(yellow("Generating default api password...")) - val value = randomBytes32().toHex() - FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value\n") } - terminal.println(white("done")) - value - } + private val httpPassword by option("--http-password", help = "Password for the http api") + .defaultLazy { + // if we are here then no value is defined in phoenix.conf + terminal.print(yellow("Generating default api password...")) + val value = randomBytes32().toHex() + FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value") } + terminal.println(white("done")) + value + } private val webHookUrl by option("--webhook", help = "Webhook http endpoint for push notifications (alternative to websocket)") .convert { Url(it) } + private val webHookSecret by option("--webhook-secret", help = "Secret used to authenticate webhook calls") + .defaultLazy { + // if we are here then no value is defined in phoenix.conf + terminal.print(yellow("Generating webhook secret...")) + val value = randomBytes32().toHex() + FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nwebhook-secret=$value") } + terminal.println(white("done")) + value + } class LiquidityOptions : OptionGroup(name = "Liquidity Options") { val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice( @@ -353,7 +363,7 @@ class Phoenixd : CliktCommand() { reuseAddress = true }, module = { - Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl).run { module() } + Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl, webHookSecret).run { module() } } ) val serverJob = scope.launch { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt index 4e187ab..628cfe6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -25,6 +25,7 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.json.JsonSerializers import fr.acinq.lightning.utils.UUID +import kotlinx.datetime.Clock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -70,7 +71,9 @@ sealed class ApiType { data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType() @Serializable - sealed class ApiEvent : ApiType() + sealed class ApiEvent : ApiType() { + val timestamp: Long = Clock.System.now().toEpochMilliseconds() + } @Serializable @SerialName("payment_received")