From b32d1dd4a866cff5329760a644ff67c3408ac90d Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 13 Aug 2026 16:16:00 +0200 Subject: [PATCH 1/3] Verify that Tor cookie is 32-bytes In order to be valid, a Tor cookie must be exactly 32 bytes. We verify that and fail otherwise. --- .../acinq/eclair/tor/TorProtocolHandler.scala | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala index b775280649..bc6024c0b8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala @@ -35,22 +35,21 @@ import scala.util.Try case class TorException(private val msg: String) extends RuntimeException(s"Tor error: $msg") /** - * Created by rorp - * - * Specification: https://gitweb.torproject.org/torspec.git/tree/control-spec.txt - * - * @param authentication Tor controller auth mechanism (password or safecookie) - * @param privateKeyPath path to a file that contains a Tor private key - * @param virtualPort port for the public hidden service (typically 9735) - * @param targets address of our protected server (format [host:]port), 127.0.0.1:[[virtualPort]] if empty - * @param onionAdded a Promise to track creation of the endpoint - */ + * Created by rorp + * + * Specification: https://gitweb.torproject.org/torspec.git/tree/control-spec.txt + * + * @param authentication Tor controller auth mechanism (password or safecookie) + * @param privateKeyPath path to a file that contains a Tor private key + * @param virtualPort port for the public hidden service (typically 9735) + * @param targets address of our protected server (format [host:]port), 127.0.0.1:[[virtualPort]] if empty + * @param onionAdded a Promise to track creation of the endpoint + */ class TorProtocolHandler(authentication: Authentication, privateKeyPath: Path, virtualPort: Int, targets: Seq[String], - onionAdded: Option[Promise[NodeAddress]] - ) extends Actor with Stash with ActorLogging { + onionAdded: Option[Promise[NodeAddress]]) extends Actor with Stash with ActorLogging { import TorProtocolHandler._ @@ -158,15 +157,19 @@ class TorProtocolHandler(authentication: Authentication, } private def computeClientHash(serverHash: ByteVector, serverNonce: ByteVector, clientNonce: ByteVector, cookieFile: Path): ByteVector = { - if (serverHash.length != 32) + if (serverHash.length != 32) { throw TorException("invalid server hash length") - if (serverNonce.length != 32) + } + if (serverNonce.length != 32) { throw TorException("invalid server nonce length") + } val cookie = ByteVector.view(Files.readAllBytes(cookieFile)) + if (cookie.length != 32) { + throw TorException("invalid server cookie length") + } val message = cookie ++ clientNonce ++ serverNonce - val computedServerHash = hmacSHA256(ServerKey, message) if (computedServerHash != serverHash) { throw TorException("unexpected server hash") From 5665d087a5ed98e06624362906dfd8d626df515d Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 13 Aug 2026 16:44:21 +0200 Subject: [PATCH 2/3] Use Tor safe-cookie mode by default And fail if password mode is used but the host is remote. --- docs/Tor.md | 42 +++++---- docs/release-notes/eclair-vnext.md | 13 +++ eclair-core/src/main/resources/reference.conf | 6 +- .../acinq/eclair/tor/TorProtocolHandler.scala | 19 ++++- .../eclair/tor/TorProtocolHandlerSpec.scala | 85 ++++++++----------- 5 files changed, 95 insertions(+), 70 deletions(-) diff --git a/docs/Tor.md b/docs/Tor.md index 377e43a610..80168e6053 100644 --- a/docs/Tor.md +++ b/docs/Tor.md @@ -4,25 +4,25 @@ Current supported version of Tor is 0.3.3.6 or higher. ### Installing Tor on your node -#### Linux: +#### Linux ```shell sudo apt install tor ``` -#### Mac OS X: +#### Mac OS X ```shell brew install tor ``` -#### Windows: +#### Windows [Download the "Expert Bundle"](https://www.torproject.org/download/tor/) from Tor's website and extract it to `C:\tor`. ### Configuring Tor -#### Linux and Max OS X: +#### Linux and Max OS X Eclair requires safe cookie authentication as well as SOCKS5 and control connections to be enabled. @@ -37,7 +37,7 @@ ExitPolicy reject *:* # don't change this unless you really know what you are do Make sure eclair is allowed to read Tor's cookie file (typically `/var/run/tor/control.authcookie`). -#### Windows: +#### Windows On Windows, it is easier to use the password authentication mechanism. @@ -60,19 +60,19 @@ ExitPolicy reject *:* # don't change this unless you really know what you are do ### Start Tor -#### Linux: +#### Linux ```shell sudo systemctl start tor ``` -#### Mac OS X: +#### Mac OS X ```shell brew services start tor ``` -#### Windows: +#### Windows Open a CMD with administrator access @@ -84,6 +84,7 @@ tor --service install -options -f "c:\tor\Conf\torrc" ### Configure Tor hidden service To create a Tor hidden service endpoint simply set the `eclair.tor.enabled` parameter in `eclair.conf` to true. + ``` eclair.tor.enabled = true ``` @@ -94,16 +95,25 @@ eclair.tor.auth = safecookie # eclair.tor.password = "" # Needed if you set auth to password ``` +:warning: The Tor control port is a trusted local interface and must not be exposed to other machines. Eclair sends its +onion private key to the control port on every startup, so anything that can read that connection can impersonate your +node's onion address. With `password` authentication, the Tor control protocol also gives eclair no way of verifying +that it is really talking to Tor: the password is sent in cleartext to whatever process is listening on the control +port. Eclair therefore refuses to use `password` authentication unless `eclair.tor.host` is a local address. If your Tor +daemon runs on another host or in a separate container, use `safecookie`, which authenticates the Tor server. + Eclair will automatically set up a hidden service endpoint and add its onion address to the `server.public-ips` list. You can see what onion address is assigned using `eclair-cli`: ```shell eclair-cli getinfo ``` -Eclair saves the Tor endpoint's private key in `~/.eclair/tor.dat`, so that it can recreate the endpoint address after -a restart. If you remove the private key Eclair will regenerate the endpoint address. - + +Eclair saves the Tor endpoint's private key in `~/.eclair/tor.dat`, so that it can recreate the endpoint address after +a restart. If you remove the private key Eclair will regenerate the endpoint address. + For increased privacy do not advertise your IP address in the `server.public-ips` list, and set your binding IP to `localhost`: + ``` eclair.server.binding-ip = "127.0.0.1" ``` @@ -118,11 +128,13 @@ You can always see your node's onion address using `getinfo` CLI command. ### Configure SOCKS5 proxy -By default, all incoming connections will be established via Tor network, but all outgoing will be created via the +By default, all incoming connections will be established via Tor network, but all outgoing will be created via the clearnet. To route them through Tor you can use Tor's SOCKS5 proxy. Add this line in your `eclair.conf`: + ``` eclair.socks5.enabled = true ``` + You can use SOCKS5 proxy only for specific types of addresses. Use `eclair.socks5.use-for-ipv4`, `eclair.socks5.use-for-ipv6` or `eclair.socks5.use-for-tor` for fine-tuning. @@ -132,14 +144,14 @@ To create a new Tor circuit for every connection, use `randomize-credentials` pa eclair.socks5.randomize-credentials = true ``` -:warning: Tor hidden service and SOCKS5 are independent options. You can use just one of them, but if you want to get most privacy +:warning: Tor hidden service and SOCKS5 are independent options. You can use just one of them, but if you want to get most privacy features from using Tor, use both. Note, that bitcoind should be configured to use Tor as well (https://en.bitcoin.it/wiki/Setting_up_a_Tor_hidden_service). ### Blockchain watchdogs -Eclair version 0.5.0 introduced blockchain watchdogs, that fetch bitcoin headers from various sources in +Eclair version 0.5.0 introduced blockchain watchdogs, that fetch bitcoin headers from various sources in order to detect whether the node is being eclipsed. Eclair supports four sources at the moment: * blockchainheaders.net @@ -166,6 +178,6 @@ eclair.blockchain-watchdog.sources = [ Also, you can disable Tor for all watchdog sources altogether using: -```s +``` eclair.socks5.use-for-watchdogs = false ``` \ No newline at end of file diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index d68e79d3eb..c3528467ed 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -8,6 +8,8 @@ ### Configuration changes +#### Gossip queries + A `query_channel_range` message is a few dozen bytes to send, but answering one requires scanning our whole routing table and sending back megabytes of data. We now limit how many of them we accept from a given peer, which can be configured with: @@ -29,6 +31,17 @@ eclair.router.sync.max-queries-per-sync = 2000 At the default `channel-query-chunk-size` this covers 200 000 channels, which is several times the current size of the network. +#### Tor configuration + +`eclair.tor.auth` now defaults to `safecookie` instead of `password`. + +Password authentication sends our tor control password in cleartext to whatever process is listening on the control +port, without any way of verifying that it is really tor. It is now rejected when `eclair.tor.host` isn't a local +address: if you run tor on another host or in a separate container, switch to `eclair.tor.auth = safecookie`, which +authenticates the tor server, or move the control port to the host running eclair. Note that the onion private key is +sent to the control port on every startup, so a remote control port exposes it to the network regardless of the +authentication method used. + ### API changes diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 39a4f31ffa..7fc6f1d4eb 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -547,9 +547,9 @@ eclair { tor { enabled = false - auth = "password" // safecookie, password - password = "foobar" // used when auth=password - host = "127.0.0.1" + auth = "safecookie" // safecookie, password + password = "foobar" // used when auth=password, which requires host to be a local address + host = "127.0.0.1" // must be a local address unless auth=safecookie port = 9051 private-key-file = "tor.dat" targets = [] // a list of address:port, for advanced use (e.g. to send traffic to front servers). See the tor man page for syntax details. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala index bc6024c0b8..e4842b4a8d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala @@ -24,6 +24,7 @@ import fr.acinq.eclair.wire.protocol.{NodeAddress, Tor3} import scodec.bits.Bases.Alphabets import scodec.bits.ByteVector +import java.net.InetSocketAddress import java.nio.file.attribute.PosixFilePermissions import java.nio.file.{Files, Path, Paths} import java.util @@ -58,12 +59,28 @@ class TorProtocolHandler(authentication: Authentication, private var address: Option[NodeAddress] = None override def receive: Receive = { - case Connected(_, _) => + case Connected(remoteAddress, _) => + checkControlAddress(remoteAddress) receiver = sender() sendCommand("PROTOCOLINFO 1") context become protocolInfo } + /** + * The tor control protocol doesn't let us authenticate the server when using password authentication: we would send + * our password in cleartext to whatever process is listening on the control port. And since ADD_ONION also sends our + * onion private key in cleartext, a remote control port exposes both secrets to the network. We thus only allow + * password authentication on a local control port, and require safecookie otherwise, which authenticates the server. + */ + private def checkControlAddress(remoteAddress: InetSocketAddress): Unit = { + val isLocal = Option(remoteAddress.getAddress).exists(_.isLoopbackAddress) + authentication match { + case _: Password if !isLocal => throw TorException(s"cannot use password authentication with a remote control port ($remoteAddress): use safecookie instead") + case _ if !isLocal => log.warning("tor control port {} is not local: our onion private key will be sent in cleartext over the network", remoteAddress) + case _ => () + } + } + def protocolInfo: Receive = { case data: ByteString => val res = parseResponse(readResponse(data)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala index 995c529b11..9e19f926bf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala @@ -16,19 +16,17 @@ package fr.acinq.eclair.tor -import java.net.InetSocketAddress -import java.nio.file.{Files, Paths} - -import akka.actor.ActorNotFound import akka.io.Tcp.Connected import akka.testkit.{ImplicitSender, TestActorRef} import akka.util.ByteString -import fr.acinq.eclair.wire.protocol.{NodeAddress, Tor2, Tor3} +import fr.acinq.eclair.wire.protocol.{NodeAddress, Tor3} import fr.acinq.eclair.{TestKitBaseClass, TestUtils} -import org.scalatest._ +import org.scalatest.Outcome import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits._ +import java.net.InetSocketAddress +import java.nio.file.{Files, Paths} import scala.concurrent.duration._ import scala.concurrent.{Await, Promise} @@ -39,43 +37,38 @@ class TorProtocolHandlerSpec extends TestKitBaseClass import TorProtocolHandler._ val LocalHost = new InetSocketAddress("localhost", 8888) + val RemoteHost = new InetSocketAddress("1.2.3.4", 8888) val PASSWORD = "foobar" val ClientNonce = hex"8969A7F3C03CD21BFD1CC49DBBD8F398345261B5B66319DF76BB2FDD8D96BCCA" val PkFilePath = Paths.get(TestUtils.BUILD_DIRECTORY, "testtorpk.dat") val CookieFilePath = Paths.get(TestUtils.BUILD_DIRECTORY, "testtorcookie.dat") val AuthCookie = hex"AA8593C52DF9713CC5FF6A1D0A045B3FADCAE57745B1348A62A6F5F88D940485" - override def withFixture(test: NoArgTest) = { + override def withFixture(test: NoArgTest): Outcome = { PkFilePath.toFile.delete() super.withFixture(test) // Invoke the test function } ignore("connect to real tor daemon") { val promiseOnionAddress = Promise[NodeAddress]() - val protocolHandlerProps = TorProtocolHandler.props( authentication = Password(PASSWORD), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress)) - val controller = TestActorRef(Controller.props(new InetSocketAddress("localhost", 9051), protocolHandlerProps), "tor") - val address = Await.result(promiseOnionAddress.future, 30 seconds) println(address) } test("happy path v2") { val promiseOnionAddress = Promise[NodeAddress]() - val protocolHandler = TestActorRef(props( authentication = Password(PASSWORD), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -83,23 +76,18 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.3.5\"\r\n" + "250 OK\r\n" ) - awaitCond(promiseOnionAddress.isCompleted) - assertThrows[TorException](Await.result(promiseOnionAddress.future, Duration.Inf)) } test("happy path v3") { val promiseOnionAddress = Promise[NodeAddress]() - val protocolHandler = TestActorRef(props( authentication = Password(PASSWORD), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -107,28 +95,50 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.4.8\"\r\n" + "250 OK\r\n" ) - expectMsg(ByteString(s"""AUTHENTICATE "$PASSWORD"\r\n""")) protocolHandler ! ByteString( "250 OK\r\n" ) - expectMsg(ByteString("ADD_ONION NEW:ED25519-V3 Port=9999,9999\r\n")) protocolHandler ! ByteString( "250-ServiceID=mrl2d3ilhctt2vw4qzvmz3etzjvpnc6dczliq5chrxetthgbuczuggyd\r\n" + "250-PrivateKey=ED25519-V3:private-key\r\n" + "250 OK\r\n" ) - protocolHandler ! GetOnionAddress expectMsg(Some(Tor3("mrl2d3ilhctt2vw4qzvmz3etzjvpnc6dczliq5chrxetthgbuczuggyd", 9999))) - val address = Await.result(promiseOnionAddress.future, 3 seconds) assert(address == Tor3("mrl2d3ilhctt2vw4qzvmz3etzjvpnc6dczliq5chrxetthgbuczuggyd", 9999)) - assert(readString(PkFilePath) == "ED25519-V3:private-key") } + test("reject password authentication on a remote control port") { + val promiseOnionAddress = Promise[NodeAddress]() + val protocolHandler = TestActorRef(props( + authentication = Password(PASSWORD), + privateKeyPath = PkFilePath, + virtualPort = 9999, + onionAdded = Some(promiseOnionAddress))) + protocolHandler ! Connected(RemoteHost, LocalHost) + // we don't send anything to a control port that we cannot authenticate + expectNoMessage(100 millis) + assert(intercept[TorException] { + Await.result(promiseOnionAddress.future, 3 seconds) + } == TorException(s"cannot use password authentication with a remote control port ($RemoteHost): use safecookie instead")) + } + + test("allow safecookie authentication on a remote control port") { + val promiseOnionAddress = Promise[NodeAddress]() + val protocolHandler = TestActorRef(props( + authentication = SafeCookie(ClientNonce), + privateKeyPath = PkFilePath, + virtualPort = 9999, + onionAdded = Some(promiseOnionAddress))) + protocolHandler ! Connected(RemoteHost, LocalHost) + // safecookie lets us verify that we're talking to the real tor server, so we allow a remote control port + expectMsg(ByteString("PROTOCOLINFO 1\r\n")) + } + test("v2/v3 compatibility check against tor version") { assert(isCompatible("0.3.3.6")) assert(!isCompatible("0.3.3.5")) @@ -140,15 +150,12 @@ class TorProtocolHandlerSpec extends TestKitBaseClass test("authentication method errors") { val promiseOnionAddress = Promise[NodeAddress]() - val protocolHandler = TestActorRef(props( authentication = Password(PASSWORD), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -156,7 +163,6 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.3.6\"\r\n" + "250 OK\r\n" ) - assert(intercept[TorException] { Await.result(promiseOnionAddress.future, 3 seconds) } == TorException("cannot use authentication 'password', supported methods are 'COOKIE,SAFECOOKIE'")) @@ -164,17 +170,13 @@ class TorProtocolHandlerSpec extends TestKitBaseClass test("invalid server hash") { val promiseOnionAddress = Promise[NodeAddress]() - Files.write(CookieFilePath, fr.acinq.eclair.randomBytes32().toArray) - val protocolHandler = TestActorRef(props( authentication = SafeCookie(ClientNonce), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -182,31 +184,24 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.3.6\"\r\n" + "250 OK\r\n" ) - expectMsg(ByteString("AUTHCHALLENGE SAFECOOKIE 8969a7f3c03cd21bfd1cc49dbbd8f398345261b5b66319df76bb2fdd8d96bcca\r\n")) protocolHandler ! ByteString( "250 AUTHCHALLENGE SERVERHASH=6828e74049924f37cbc61f2aad4dd78d8dc09bef1b4c3bf6ff454016ed9d50df SERVERNONCE=b4aa04b6e7e2df60dcb0f62c264903346e05d1675e77795529e22ca90918dee7\r\n" ) - assert(intercept[TorException] { Await.result(promiseOnionAddress.future, 3 seconds) } == TorException("unexpected server hash")) } - test("AUTHENTICATE failure") { val promiseOnionAddress = Promise[NodeAddress]() - Files.write(CookieFilePath, AuthCookie.toArray) - val protocolHandler = TestActorRef(props( authentication = SafeCookie(ClientNonce), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -214,17 +209,14 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.3.6\"\r\n" + "250 OK\r\n" ) - expectMsg(ByteString("AUTHCHALLENGE SAFECOOKIE 8969a7f3c03cd21bfd1cc49dbbd8f398345261b5b66319df76bb2fdd8d96bcca\r\n")) protocolHandler ! ByteString( "250 AUTHCHALLENGE SERVERHASH=6828e74049924f37cbc61f2aad4dd78d8dc09bef1b4c3bf6ff454016ed9d50df SERVERNONCE=b4aa04b6e7e2df60dcb0f62c264903346e05d1675e77795529e22ca90918dee7\r\n" ) - expectMsg(ByteString("AUTHENTICATE 0ddcab5deb39876cdef7af7860a1c738953395349f43b99f4e5e0f131b0515df\r\n")) protocolHandler ! ByteString( "515 Authentication failed: Safe cookie response did not match expected value.\r\n" ) - assert(intercept[TorException] { Await.result(promiseOnionAddress.future, 3 seconds) } == TorException("server returned error: 515 Authentication failed: Safe cookie response did not match expected value.")) @@ -232,17 +224,13 @@ class TorProtocolHandlerSpec extends TestKitBaseClass test("ADD_ONION failure") { val promiseOnionAddress = Promise[NodeAddress]() - Files.write(CookieFilePath, AuthCookie.toArray) - val protocolHandler = TestActorRef(props( authentication = SafeCookie(ClientNonce), privateKeyPath = PkFilePath, virtualPort = 9999, onionAdded = Some(promiseOnionAddress))) - protocolHandler ! Connected(LocalHost, LocalHost) - expectMsg(ByteString("PROTOCOLINFO 1\r\n")) protocolHandler ! ByteString( "250-PROTOCOLINFO 1\r\n" + @@ -250,26 +238,21 @@ class TorProtocolHandlerSpec extends TestKitBaseClass "250-VERSION Tor=\"0.3.3.6\"\r\n" + "250 OK\r\n" ) - expectMsg(ByteString("AUTHCHALLENGE SAFECOOKIE 8969a7f3c03cd21bfd1cc49dbbd8f398345261b5b66319df76bb2fdd8d96bcca\r\n")) protocolHandler ! ByteString( "250 AUTHCHALLENGE SERVERHASH=6828e74049924f37cbc61f2aad4dd78d8dc09bef1b4c3bf6ff454016ed9d50df SERVERNONCE=b4aa04b6e7e2df60dcb0f62c264903346e05d1675e77795529e22ca90918dee7\r\n" ) - expectMsg(ByteString("AUTHENTICATE 0ddcab5deb39876cdef7af7860a1c738953395349f43b99f4e5e0f131b0515df\r\n")) protocolHandler ! ByteString( "250 OK\r\n" ) - expectMsg(ByteString("ADD_ONION NEW:ED25519-V3 Port=9999,9999\r\n")) protocolHandler ! ByteString( "513 Invalid argument\r\n" ) - - val t = intercept[TorException] { + intercept[TorException] { Await.result(promiseOnionAddress.future, 3 seconds) } - assert(intercept[TorException] { Await.result(promiseOnionAddress.future, 3 seconds) } == TorException("server returned error: 513 Invalid argument")) From d99108abab47045a3a8128eb80cd4ba7a378c1c8 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 13 Aug 2026 17:33:15 +0200 Subject: [PATCH 3/3] Fix race condition in file permissions There was a race condition in file permissions that allowed attackers on the machine to read secrets, because we started by creating the secret and only after that we set the file permissions. We now reverse that flow: we create an empty file with the right permissions and then write the secret to it. This applies to our seed files and to tor private keys. --- .../scala/fr/acinq/eclair/NodeParams.scala | 9 ++--- .../main/scala/fr/acinq/eclair/package.scala | 36 +++++++++++++++++-- .../acinq/eclair/tor/TorProtocolHandler.scala | 14 ++------ .../eclair/tor/TorProtocolHandlerSpec.scala | 24 +++++++++++-- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 46202eb46a..8531fe4ac9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -211,17 +211,14 @@ object NodeParams extends Logging { } private def writeSeedToFile(path: File, seed: ByteVector): Unit = { - Files.write(path.toPath, seed.toArray) - // Seed files derive the node identity key and all channel keys: they must never be readable by other local users. - setOwnerPermissions(path.toPath, "rw-------") + writeSecret(path.toPath, seed.toArray) logger.info(s"create new seed file: ${path.getCanonicalPath}") } private def migrateSeedFile(source: File, destination: File): Unit = { if (source.exists() && !destination.exists()) { - Files.copy(source.toPath, destination.toPath) - // The copied file inherits default umask permissions, so we restrict it to its owner like a freshly created seed. - setOwnerPermissions(destination.toPath, "rw-------") + val seed = readSeedFromFile(source) + writeSecret(destination.toPath, seed.toArray) logger.info(s"migrate seed file: ${source.getCanonicalPath} → ${destination.getCanonicalPath}") } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala index c3dc09dc8a..4a9299445a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala @@ -24,24 +24,54 @@ import fr.acinq.eclair.payment.relay.Relayer.RelayFees import scodec.Attempt import scodec.bits.{BitVector, ByteVector} -import java.nio.file.Path import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.{Files, Path} package object eclair { val randomGen = new StrongRandom() + /** + * Create an empty file that only the current user can read, replacing it if it already exists. The permissions must + * be set when the file is created: if we set them after writing to the file, there is a window during which other + * users can read its content. + */ + def createSecretFile(path: Path): Unit = { + // File attributes are ignored when the file already exists, so we start from a clean slate. + Files.deleteIfExists(path) + try { + // The permissions are passed to the underlying open(2) call, so the file never exists with broader permissions. + // This also fails if someone else concurrently created that file, instead of storing our secret in their file. + Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + } catch { + case _: UnsupportedOperationException => Files.createFile(path) // non-POSIX file system (e.g. Windows) + } + } + + /** Write a secret to a file that only the current user can read. */ + def writeSecret(path: Path, secret: String): Unit = { + createSecretFile(path) + Files.writeString(path, secret) // the file already exists, so this doesn't change its permissions + } + + /** Write a secret to a file that only the current user can read. */ + def writeSecret(path: Path, secret: Array[Byte]): Unit = { + createSecretFile(path) + Files.write(path, secret) // the file already exists, so this doesn't change its permissions + } + /** * Restrict access to a file or directory to its owner, e.g. "rw-------" for a secret file or "rwx------" for a * directory containing secrets. This is a no-op on file systems that don't support POSIX permissions (e.g. Windows), * where access control is handled differently. */ - def setOwnerPermissions(path: Path, permissions: String): Unit = + def setOwnerPermissions(path: Path, permissions: String): Unit = { try { - java.nio.file.Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(permissions)) + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(permissions)) } catch { case _: UnsupportedOperationException => () // non-POSIX file system (e.g. Windows) } + } def randomBytes(length: Int): ByteVector = { val buffer = new Array[Byte](length) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala index e4842b4a8d..a2d09481ed 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/tor/TorProtocolHandler.scala @@ -21,11 +21,11 @@ import akka.io.Tcp.Connected import akka.util.ByteString import fr.acinq.eclair.tor.TorProtocolHandler.Authentication import fr.acinq.eclair.wire.protocol.{NodeAddress, Tor3} +import fr.acinq.eclair.writeSecret import scodec.bits.Bases.Alphabets import scodec.bits.ByteVector import java.net.InetSocketAddress -import java.nio.file.attribute.PosixFilePermissions import java.nio.file.{Files, Path, Paths} import java.util import javax.crypto.Mac @@ -150,10 +150,7 @@ class TorProtocolHandler(authentication: Authentication, private def processOnionResponse(res: Map[String, String]): String = { val serviceId = res.getOrElse("ServiceID", throw TorException("service ID not found")) val privateKey = res.get("PrivateKey") - privateKey.foreach { pk => - writeString(privateKeyPath, pk) - setPermissions(privateKeyPath, "rw-------") - } + privateKey.foreach(pk => writeSecret(privateKeyPath, pk)) serviceId } @@ -246,13 +243,6 @@ object TorProtocolHandler { def writeString(path: Path, string: String): Unit = Files.write(path, util.Arrays.asList(string)) - def setPermissions(path: Path, permissionString: String): Unit = - try { - Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(permissionString)) - } catch { - case _: UnsupportedOperationException => () // we are on windows - } - def unquote(s: String): String = s .stripSuffix("\"") .stripPrefix("\"") diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala index 9e19f926bf..6ae897adc2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/tor/TorProtocolHandlerSpec.scala @@ -20,13 +20,14 @@ import akka.io.Tcp.Connected import akka.testkit.{ImplicitSender, TestActorRef} import akka.util.ByteString import fr.acinq.eclair.wire.protocol.{NodeAddress, Tor3} -import fr.acinq.eclair.{TestKitBaseClass, TestUtils} +import fr.acinq.eclair.{TestKitBaseClass, TestUtils, createSecretFile, writeSecret} import org.scalatest.Outcome import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits._ import java.net.InetSocketAddress -import java.nio.file.{Files, Paths} +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.{FileSystems, Files, Paths} import scala.concurrent.duration._ import scala.concurrent.{Await, Promise} @@ -139,6 +140,25 @@ class TorProtocolHandlerSpec extends TestKitBaseClass expectMsg(ByteString("PROTOCOLINFO 1\r\n")) } + test("create the private key file with restricted permissions") { + assume(FileSystems.getDefault.supportedFileAttributeViews().contains("posix"), "this test requires a posix file system") + // the private key file may already exist with permissions that are too broad, for example if it was created by a + // previous version of eclair or restored from a backup + writeString(PkFilePath, "ED25519-V3:previous-key") + Files.setPosixFilePermissions(PkFilePath, PosixFilePermissions.fromString("rw-rw-rw-")) + createSecretFile(PkFilePath) + // the permissions are already restricted while the file is still empty: our private key is never readable by others + assert(Files.size(PkFilePath) == 0) + assert(PosixFilePermissions.toString(Files.getPosixFilePermissions(PkFilePath)) == "rw-------") + } + + test("write private key to a file that only we can read") { + assume(FileSystems.getDefault.supportedFileAttributeViews().contains("posix"), "this test requires a posix file system") + writeSecret(PkFilePath, "ED25519-V3:private-key") + assert(readString(PkFilePath) == "ED25519-V3:private-key") + assert(PosixFilePermissions.toString(Files.getPosixFilePermissions(PkFilePath)) == "rw-------") + } + test("v2/v3 compatibility check against tor version") { assert(isCompatible("0.3.3.6")) assert(!isCompatible("0.3.3.5"))