Skip to content

Commit 1803556

Browse files
authored
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.
1 parent 2964e34 commit 1803556

File tree

3 files changed

+36
-13
lines changed

3 files changed

+36
-13
lines changed

src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import fr.acinq.lightning.utils.*
2626
import io.ktor.client.*
2727
import io.ktor.client.request.*
2828
import io.ktor.http.*
29+
import io.ktor.http.content.*
2930
import io.ktor.serialization.kotlinx.*
3031
import io.ktor.serialization.kotlinx.json.*
3132
import io.ktor.server.application.*
@@ -41,8 +42,9 @@ import io.ktor.server.websocket.*
4142
import kotlinx.coroutines.flow.SharedFlow
4243
import kotlinx.coroutines.launch
4344
import kotlinx.serialization.json.Json
45+
import okio.ByteString.Companion.encodeUtf8
4446

45-
class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow<ApiEvent>, private val password: String, private val webhookUrl: Url?) {
47+
class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow<ApiEvent>, private val password: String, private val webhookUrl: Url?, private val webhookSecret: String) {
4648

4749
fun Application.module() {
4850

@@ -199,6 +201,16 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va
199201
})
200202
}
201203
}
204+
client.sendPipeline.intercept(HttpSendPipeline.State) {
205+
when (val body = context.body) {
206+
is TextContent -> {
207+
val bodyBytes = body.text.encodeUtf8()
208+
val secretBytes = webhookSecret.encodeUtf8()
209+
val sig = bodyBytes.hmacSha256(secretBytes)
210+
context.headers.append("X-Phoenix-Signature", sig.hex())
211+
}
212+
}
213+
}
202214
launch {
203215
eventsFlow.collect { event ->
204216
client.post(url) {
@@ -231,5 +243,3 @@ class Api(private val nodeParams: NodeParams, private val peer: Peer, private va
231243
private fun Parameters.getOptionalLong(argName: String): Long? = this[argName]?.let { it.toLongOrNull() ?: invalidType(argName, "integer") }
232244

233245
}
234-
235-

src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,26 @@ class Phoenixd : CliktCommand() {
102102
.default(10.minutes)
103103
private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1")
104104
private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740)
105-
private val httpPassword by option("--http-password", help = "Password for the http api").defaultLazy {
106-
// the additionalValues map already contains values in phoenix.conf, so if we are here then there are no existing password
107-
terminal.print(yellow("Generating default api password..."))
108-
val value = randomBytes32().toHex()
109-
FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value\n") }
110-
terminal.println(white("done"))
111-
value
112-
}
105+
private val httpPassword by option("--http-password", help = "Password for the http api")
106+
.defaultLazy {
107+
// if we are here then no value is defined in phoenix.conf
108+
terminal.print(yellow("Generating default api password..."))
109+
val value = randomBytes32().toHex()
110+
FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value") }
111+
terminal.println(white("done"))
112+
value
113+
}
113114
private val webHookUrl by option("--webhook", help = "Webhook http endpoint for push notifications (alternative to websocket)")
114115
.convert { Url(it) }
116+
private val webHookSecret by option("--webhook-secret", help = "Secret used to authenticate webhook calls")
117+
.defaultLazy {
118+
// if we are here then no value is defined in phoenix.conf
119+
terminal.print(yellow("Generating webhook secret..."))
120+
val value = randomBytes32().toHex()
121+
FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nwebhook-secret=$value") }
122+
terminal.println(white("done"))
123+
value
124+
}
115125

116126
class LiquidityOptions : OptionGroup(name = "Liquidity Options") {
117127
val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice(
@@ -353,7 +363,7 @@ class Phoenixd : CliktCommand() {
353363
reuseAddress = true
354364
},
355365
module = {
356-
Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl).run { module() }
366+
Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl, webHookSecret).run { module() }
357367
}
358368
)
359369
val serverJob = scope.launch {

src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import fr.acinq.lightning.channel.states.ChannelStateWithCommitments
2525
import fr.acinq.lightning.db.LightningOutgoingPayment
2626
import fr.acinq.lightning.json.JsonSerializers
2727
import fr.acinq.lightning.utils.UUID
28+
import kotlinx.datetime.Clock
2829
import kotlinx.serialization.SerialName
2930
import kotlinx.serialization.Serializable
3031
import kotlinx.serialization.UseSerializers
@@ -70,7 +71,9 @@ sealed class ApiType {
7071
data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType()
7172

7273
@Serializable
73-
sealed class ApiEvent : ApiType()
74+
sealed class ApiEvent : ApiType() {
75+
val timestamp: Long = Clock.System.now().toEpochMilliseconds()
76+
}
7477

7578
@Serializable
7679
@SerialName("payment_received")

0 commit comments

Comments
 (0)