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.
			
			
This commit is contained in:
		
							parent
							
								
									2964e34213
								
							
						
					
					
						commit
						18035566f8
					
				@ -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<ApiEvent>, private val password: String, private val webhookUrl: Url?) {
 | 
			
		||||
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) {
 | 
			
		||||
 | 
			
		||||
    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") }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user