diff --git a/.github/workflows/Linux_2.12_RPC_Tests.yml b/.github/workflows/Linux_2.12_RPC_Tests.yml index a5c29fa762..5d38ee4593 100644 --- a/.github/workflows/Linux_2.12_RPC_Tests.yml +++ b/.github/workflows/Linux_2.12_RPC_Tests.yml @@ -1,4 +1,4 @@ -name: Linux 2.12 bitcoind, eclair, and lnd rpc tests +name: Linux 2.12 bitcoind, eclair, tor, and lnd rpc tests env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} @@ -28,4 +28,4 @@ jobs: ~/.bitcoin-s/binaries key: ${{ runner.os }}-cache - name: run tests - run: sbt ++2.12.13 downloadBitcoind downloadEclair downloadLnd coverage bitcoindRpcTest/test bitcoindRpc/coverageReport bitcoindRpc/coverageAggregate bitcoindRpc/coveralls eclairRpcTest/test eclairRpc/coverageReport eclairRpc/coverageAggregate eclairRpc/coveralls lndRpcTest/test + run: sbt ++2.12.13 downloadBitcoind downloadEclair downloadLnd coverage bitcoindRpcTest/test bitcoindRpc/coverageReport bitcoindRpc/coverageAggregate bitcoindRpc/coveralls eclairRpcTest/test eclairRpc/coverageReport eclairRpc/coverageAggregate eclairRpc/coveralls lndRpcTest/test torTest/test diff --git a/.github/workflows/Linux_2.13_RPC_Tests.yml b/.github/workflows/Linux_2.13_RPC_Tests.yml index b6dfdee5b8..765c8d7088 100644 --- a/.github/workflows/Linux_2.13_RPC_Tests.yml +++ b/.github/workflows/Linux_2.13_RPC_Tests.yml @@ -1,4 +1,4 @@ -name: Linux 2.13 bitcoind, eclair, and lnd rpc tests +name: Linux 2.13 bitcoind, eclair, tor, and lnd rpc tests env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} @@ -28,4 +28,4 @@ jobs: ~/.bitcoin-s/binaries key: ${{ runner.os }}-cache - name: run tests - run: sbt ++2.13.5 downloadBitcoind downloadEclair downloadLnd coverage bitcoindRpcTest/test bitcoindRpc/coverageReport bitcoindRpc/coverageAggregate bitcoindRpc/coveralls eclairRpcTest/test eclairRpc/coverageReport eclairRpc/coverageAggregate eclairRpc/coveralls lndRpcTest/test + run: sbt ++2.13.5 downloadBitcoind downloadEclair downloadLnd coverage bitcoindRpcTest/test bitcoindRpc/coverageReport bitcoindRpc/coverageAggregate bitcoindRpc/coveralls eclairRpcTest/test eclairRpc/coverageReport eclairRpc/coverageAggregate eclairRpc/coveralls lndRpcTest/test torTest/test diff --git a/build.sbt b/build.sbt index 1aff82c695..860b6e9ed4 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,6 @@ Test / flywayClean / aggregate := true lazy val Benchmark = config("bench") extend Test - lazy val benchSettings: Seq[Def.SettingsDefinition] = { //for scalameter //https://scalameter.github.io/home/download/ @@ -148,6 +147,14 @@ lazy val lndRpc = project .in(file("lnd-rpc")) .dependsOn(asyncUtilsJVM, bitcoindRpc) +lazy val tor = project + .in(file("tor")) + .dependsOn(cryptoJVM) + +lazy val torTest = project + .in(file("tor-test")) + .dependsOn(tor, testkit) + lazy val jsProjects: Vector[ProjectReference] = Vector(asyncUtilsJS, asyncUtilsTestJS, @@ -209,6 +216,8 @@ lazy val `bitcoin-s` = project serverRoutes, lndRpc, lndRpcTest, + tor, + torTest, scripts ) .dependsOn( @@ -256,6 +265,8 @@ lazy val `bitcoin-s` = project serverRoutes, lndRpc, lndRpcTest, + tor, + torTest, scripts ) .settings(CommonSettings.settings: _*) diff --git a/core/src/main/scala/org/bitcoins/core/p2p/InetAddress.scala b/core/src/main/scala/org/bitcoins/core/p2p/InetAddress.scala index b6a6c179ab..73efba6966 100644 --- a/core/src/main/scala/org/bitcoins/core/p2p/InetAddress.scala +++ b/core/src/main/scala/org/bitcoins/core/p2p/InetAddress.scala @@ -33,3 +33,32 @@ object InetAddress { InetAddressImpl(bytes) } } + +trait TorAddress extends InetAddress { + + override def ipv4Bytes: ByteVector = throw new IllegalArgumentException( + "Tor address cannot be an IPv4 address") +} + +object TorAddress { + val TOR_V2_ADDR_LENGTH = 10 + val TOR_V3_ADDR_LENGTH = 32 +} + +trait Tor2Address extends TorAddress + +object Tor2Address { + + private case class Tor2AddressImpl(bytes: ByteVector) extends Tor2Address { + require(bytes.size == TorAddress.TOR_V2_ADDR_LENGTH) + } +} + +trait Tor3Address extends TorAddress + +object Tor3Address { + + private case class Tor3AddressImpl(bytes: ByteVector) extends Tor2Address { + require(bytes.size == TorAddress.TOR_V3_ADDR_LENGTH) + } +} diff --git a/core/src/main/scala/org/bitcoins/core/p2p/NetworkPayload.scala b/core/src/main/scala/org/bitcoins/core/p2p/NetworkPayload.scala index c635ffb8dc..78e1e59236 100644 --- a/core/src/main/scala/org/bitcoins/core/p2p/NetworkPayload.scala +++ b/core/src/main/scala/org/bitcoins/core/p2p/NetworkPayload.scala @@ -623,10 +623,10 @@ object AddrV2Message extends Factory[AddrV2Message] { final val IPV6_ADDR_LENGTH: Int = 16 final val TOR_V2_NETWORK_BYTE: Byte = 0x03 - final val TOR_V2_ADDR_LENGTH: Int = 10 + final val TOR_V2_ADDR_LENGTH: Int = TorAddress.TOR_V2_ADDR_LENGTH final val TOR_V3_NETWORK_BYTE: Byte = 0x04 - final val TOR_V3_ADDR_LENGTH: Int = 32 + final val TOR_V3_ADDR_LENGTH: Int = TorAddress.TOR_V3_ADDR_LENGTH final val I2P_NETWORK_BYTE: Byte = 0x05 final val I2P_ADDR_LENGTH: Int = 32 diff --git a/project/Deps.scala b/project/Deps.scala index f3955672a9..3ef0b719d7 100644 --- a/project/Deps.scala +++ b/project/Deps.scala @@ -101,10 +101,11 @@ object Deps { "org.scalafx" %% "scalafx" % V.scalaFxV withSources () withJavadoc () lazy val arch = System.getProperty("os.arch") + lazy val osName = System.getProperty("os.name") match { - case n if n.startsWith("Linux") => "linux" - case n if n.startsWith("Mac") => - if (arch == "aarch64" ) { + case n if n.startsWith("Linux") => "linux" + case n if n.startsWith("Mac") => + if (arch == "aarch64") { //needed to accommodate the different chip //arch for M1 s"mac-${arch}" @@ -114,6 +115,7 @@ object Deps { case n if n.startsWith("Windows") => "win" case x => throw new Exception(s"Unknown platform $x!") } + // Not sure if all of these are needed, some might be possible to remove lazy val javaFxBase = "org.openjfx" % s"javafx-base" % V.javaFxV classifier osName withSources () withJavadoc () @@ -426,6 +428,13 @@ object Deps { Compile.grizzledSlf4j ) + val tor: Def.Initialize[List[ModuleID]] = Def.setting { + List( + Compile.akkaActor, + Compile.scodec.value + ) + } + val lndRpc = List( Compile.akkaHttp, Compile.akkaHttp2, diff --git a/tor-test/src/test/scala/org/bitcoins/tor/TorProtocolHandlerSpec.scala b/tor-test/src/test/scala/org/bitcoins/tor/TorProtocolHandlerSpec.scala new file mode 100644 index 0000000000..fc12e109fb --- /dev/null +++ b/tor-test/src/test/scala/org/bitcoins/tor/TorProtocolHandlerSpec.scala @@ -0,0 +1,337 @@ +package org.bitcoins.tor + +import akka.actor.ActorSystem +import akka.io.Tcp.Connected +import akka.testkit.{ImplicitSender, TestActorRef, TestKit} +import akka.util.ByteString +import org.bitcoins.crypto.CryptoUtil +import org.scalatest._ +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} + +class TorProtocolHandlerSpec + extends TestKit(ActorSystem("test")) + with TestSuite + with BeforeAndAfterAll + with AnyFunSuiteLike + with ImplicitSender { + + import TorProtocolHandler._ + + override def afterAll(): Unit = { + TestKit.shutdownActorSystem(system) + } + + val LocalHost = new InetSocketAddress("localhost", 8888) + val PASSWORD = "foobar" + + val ClientNonce = + hex"8969A7F3C03CD21BFD1CC49DBBD8F398345261B5B66319DF76BB2FDD8D96BCCA" + val tempDir = Files.createTempDirectory("tor-test-").toAbsolutePath.toString + val PkFilePath = Paths.get(tempDir, "testtorpk.dat") + val CookieFilePath = Paths.get(tempDir, "testtorcookie.dat") + + val AuthCookie = + hex"AA8593C52DF9713CC5FF6A1D0A045B3FADCAE57745B1348A62A6F5F88D940485" + + override def withFixture(test: NoArgTest) = { + PkFilePath.toFile.delete() + super.withFixture(test) // Invoke the test function + } + + ignore("connect to real tor daemon") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + val protocolHandlerProps = + TorProtocolHandler.props(version = OnionServiceVersion("v2"), + authentication = Password(PASSWORD), + privateKeyPath = PkFilePath, + virtualPort = 9999, + onionAdded = Some(promiseOnionAddress)) + + val controller = + TestActorRef(TorController.props(new InetSocketAddress("localhost", 9051), + protocolHandlerProps), + "tor") + + val address = Await.result(promiseOnionAddress.future, 30.seconds) + println(address) + } + + test("happy path v2") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + val protocolHandler = TestActorRef( + props(version = OnionServiceVersion("v2"), + 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" + + "250-AUTH METHODS=HASHEDPASSWORD\r\n" + + "250-VERSION Tor=\"0.3.3.5\"\r\n" + + "250 OK\r\n" + ) + + expectMsg(ByteString(s"""AUTHENTICATE "$PASSWORD"\r\n""")) + protocolHandler ! ByteString( + "250 OK\r\n" + ) + + expectMsg(ByteString("ADD_ONION NEW:RSA1024 Port=9999,9999\r\n")) + protocolHandler ! ByteString( + "250-ServiceID=z4zif3fy7fe7bpg3\r\n" + + "250-PrivateKey=RSA1024:private-key\r\n" + + "250 OK\r\n" + ) + protocolHandler ! GetOnionAddress + val addr = expectMsgType[Option[InetSocketAddress]] + + assert(addr.nonEmpty) + + assertAddressesEqual( + addr.get, + InetSocketAddress.createUnresolved("z4zif3fy7fe7bpg3.onion", 9999)) + + val address = Await.result(promiseOnionAddress.future, 3.seconds) + assertAddressesEqual( + address, + InetSocketAddress.createUnresolved("z4zif3fy7fe7bpg3.onion", 9999)) + + assert(readString(PkFilePath) === "RSA1024:private-key") + } + + test("happy path v3") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + val protocolHandler = TestActorRef( + props(version = OnionServiceVersion("v3"), + 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" + + "250-AUTH METHODS=HASHEDPASSWORD\r\n" + + "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 + val addr = expectMsgType[Option[InetSocketAddress]] + + assert(addr.nonEmpty) + + assertAddressesEqual( + addr.get, + InetSocketAddress.createUnresolved( + "mrl2d3ilhctt2vw4qzvmz3etzjvpnc6dczliq5chrxetthgbuczuggyd.onion", + 9999)) + + val address = Await.result(promiseOnionAddress.future, 3.seconds) + assertAddressesEqual( + address, + InetSocketAddress.createUnresolved( + "mrl2d3ilhctt2vw4qzvmz3etzjvpnc6dczliq5chrxetthgbuczuggyd.onion", + 9999)) + + assert(readString(PkFilePath) === "ED25519-V3:private-key") + } + + test("v2/v3 compatibility check against tor version") { + assert(OnionServiceVersion.isCompatible(V3, "0.3.3.6")) + assert(!OnionServiceVersion.isCompatible(V3, "0.3.3.5")) + assert(OnionServiceVersion.isCompatible(V3, "0.3.3.6-devel")) + assert(OnionServiceVersion.isCompatible(V3, "0.4")) + assert(!OnionServiceVersion.isCompatible(V3, "0.2")) + assert(OnionServiceVersion.isCompatible(V3, "0.5.1.2.3.4")) + + } + + test("authentication method errors") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + val protocolHandler = TestActorRef( + props(version = OnionServiceVersion("v2"), + 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" + + "250-AUTH METHODS=COOKIE,SAFECOOKIE COOKIEFILE=\"" + CookieFilePath + "\"\r\n" + + "250-VERSION Tor=\"0.3.3.5\"\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'")) + } + + test("invalid server hash") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + Files.write(CookieFilePath, CryptoUtil.randomBytes(32).toArray) + + val protocolHandler = TestActorRef( + props( + version = OnionServiceVersion("v2"), + 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-AUTH METHODS=COOKIE,SAFECOOKIE COOKIEFILE=\"" + CookieFilePath + "\"\r\n" + + "250-VERSION Tor=\"0.3.3.5\"\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[InetSocketAddress]() + + Files.write(CookieFilePath, AuthCookie.toArray) + + val protocolHandler = TestActorRef( + props( + version = OnionServiceVersion("v2"), + 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-AUTH METHODS=COOKIE,SAFECOOKIE COOKIEFILE=\"" + CookieFilePath + "\"\r\n" + + "250-VERSION Tor=\"0.3.3.5\"\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.")) + } + + test("ADD_ONION failure") { + val promiseOnionAddress = Promise[InetSocketAddress]() + + Files.write(CookieFilePath, AuthCookie.toArray) + + val protocolHandler = TestActorRef( + props( + version = OnionServiceVersion("v2"), + 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-AUTH METHODS=COOKIE,SAFECOOKIE COOKIEFILE=\"" + CookieFilePath + "\"\r\n" + + "250-VERSION Tor=\"0.3.3.5\"\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:RSA1024 Port=9999,9999\r\n")) + protocolHandler ! ByteString( + "513 Invalid argument\r\n" + ) + + val t = intercept[TorException] { + Await.result(promiseOnionAddress.future, 3.seconds) + } + + assert(intercept[TorException] { + Await.result(promiseOnionAddress.future, 3.seconds) + } === TorException("server returned error: 513 Invalid argument")) + } + + private def assertAddressesEqual( + actual: InetSocketAddress, + expected: InetSocketAddress) = { + assert( + actual.getHostString == expected.getHostString && actual.getPort == expected.getPort) + } + +} diff --git a/tor/README.md b/tor/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tor/src/main/scala/org/bitcoins/tor/Socks5Connection.scala b/tor/src/main/scala/org/bitcoins/tor/Socks5Connection.scala new file mode 100644 index 0000000000..d01a2f8353 --- /dev/null +++ b/tor/src/main/scala/org/bitcoins/tor/Socks5Connection.scala @@ -0,0 +1,238 @@ +package org.bitcoins.tor + +import java.net.{Inet4Address, Inet6Address, InetAddress, InetSocketAddress} +import akka.actor.{Actor, ActorLogging, ActorRef, Props, Terminated} +import akka.io.Tcp +import akka.util.ByteString +import Socks5Connection.{Credentials, Socks5Connect} +import org.bitcoins.crypto.CryptoUtil + +/** Simple socks 5 client. It should be given a new connection, and will + * + * Created by rorp + * + * @param connection underlying TcpConnection + * @param credentials_opt optional username/password for authentication + */ +class Socks5Connection( + connection: ActorRef, + credentials_opt: Option[Credentials], + command: Socks5Connect) + extends Actor + with ActorLogging { + + import Socks5Connection._ + + context watch connection + + val passwordAuth: Boolean = credentials_opt.isDefined + + var isConnected: Boolean = false + + connection ! Tcp.Register(self) + connection ! Tcp.ResumeReading + connection ! Tcp.Write(socks5Greeting(passwordAuth)) + + override def receive: Receive = greetings + + def greetings: Receive = { case Tcp.Received(data) => + if (data(0) != 0x05) { + throw Socks5Error("Invalid SOCKS5 proxy response") + } else if ( + (!passwordAuth && data(1) != NoAuth) || (passwordAuth && data( + 1) != PasswordAuth) + ) { + throw Socks5Error("Unrecognized SOCKS5 auth method") + } else { + if (data(1) == PasswordAuth) { + context become authenticate + val credentials = credentials_opt.getOrElse( + throw Socks5Error("credentials are not defined")) + connection ! Tcp.Write( + socks5PasswordAuthenticationRequest(credentials.username, + credentials.password)) + connection ! Tcp.ResumeReading + } else { + context become connectionRequest + connection ! Tcp.Write(socks5ConnectionRequest(command.address)) + connection ! Tcp.ResumeReading + } + } + } + + def authenticate: Receive = { case Tcp.Received(data) => + if (data(0) != 0x01) { + throw Socks5Error("Invalid SOCKS5 proxy response") + } else if (data(1) != 0) { + throw Socks5Error("SOCKS5 authentication failed") + } + context become connectionRequest + connection ! Tcp.Write(socks5ConnectionRequest(command.address)) + connection ! Tcp.ResumeReading + } + + def connectionRequest: Receive = { case Tcp.Received(data) => + if (data(0) != 0x05) { + throw Socks5Error("Invalid SOCKS5 proxy response") + } else { + val status = data(1) + if (status != 0) { + throw Socks5Error( + connectErrors.getOrElse(status, s"Unknown SOCKS5 error $status")) + } + val connectedAddress = data(3) match { + case 0x01 => + val ip = Array(data(4), data(5), data(6), data(7)) + val port = data(8).toInt << 8 | data(9) + new InetSocketAddress(InetAddress.getByAddress(ip), port) + case 0x03 => + val len = data(4) + val start = 5 + val end = start + len + val domain = data.slice(start, end).utf8String + val port = data(end).toInt << 8 | data(end + 1) + new InetSocketAddress(domain, port) + case 0x04 => + val ip = Array.ofDim[Byte](16) + data.copyToArray(ip, 4, 4 + ip.length) + val port = data(4 + ip.length).toInt << 8 | data(4 + ip.length + 1) + new InetSocketAddress(InetAddress.getByAddress(ip), port) + case _ => throw Socks5Error(s"Unrecognized address type") + } + context become connected + context.parent ! Socks5Connected(connectedAddress) + isConnected = true + } + } + + def connected: Receive = { case Tcp.Register(handler, _, _) => + context become registered(handler) + } + + def registered(handler: ActorRef): Receive = { + case c: Tcp.Command => connection ! c + case e: Tcp.Event => handler ! e + } + + override def unhandled(message: Any): Unit = message match { + case Terminated(actor) if actor == connection => context stop self + case _: Tcp.ConnectionClosed => context stop self + case _ => log.warning(s"unhandled message=$message") + } + + override def postStop(): Unit = { + super.postStop() + connection ! Tcp.Close + if (!isConnected) { + context.parent ! command.failureMessage + } + } + +} + +object Socks5Connection { + + def props( + tcpConnection: ActorRef, + credentials_opt: Option[Credentials], + command: Socks5Connect): Props = Props( + new Socks5Connection(tcpConnection, credentials_opt, command)) + + case class Socks5Connect(address: InetSocketAddress) extends Tcp.Command + + case class Socks5Connected(address: InetSocketAddress) extends Tcp.Event + + case class Socks5Error(message: String) extends RuntimeException(message) + + case class Credentials(username: String, password: String) { + require(username.length < 256, "username is too long") + require(password.length < 256, "password is too long") + } + + val NoAuth: Byte = 0x00 + val PasswordAuth: Byte = 0x02 + + val connectErrors: Map[Byte, String] = Map[Byte, String]( + (0x00, "Request granted"), + (0x01, "General failure"), + (0x02, "Connection not allowed by ruleset"), + (0x03, "Network unreachable"), + (0x04, "Host unreachable"), + (0x05, "Connection refused by destination host"), + (0x06, "TTL expired"), + (0x07, "Command not supported / protocol error"), + (0x08, "Address type not supported") + ) + + def socks5Greeting(passwordAuth: Boolean) = ByteString( + 0x05, // SOCKS version + 0x01, // number of authentication methods supported + if (passwordAuth) PasswordAuth else NoAuth + ) // auth method + + def socks5PasswordAuthenticationRequest( + username: String, + password: String): ByteString = + ByteString(0x01, // version of username/password authentication + username.length.toByte) ++ + ByteString(username) ++ + ByteString(password.length.toByte) ++ + ByteString(password) + + def socks5ConnectionRequest(address: InetSocketAddress): ByteString = { + ByteString(0x05, // SOCKS version + 0x01, // establish a TCP/IP stream connection + 0x00) ++ // reserved + addressToByteString(address) ++ + portToByteString(address.getPort) + } + + def inetAddressToByteString(inet: InetAddress): ByteString = inet match { + case a: Inet4Address => + ByteString( + 0x01 // IPv4 address + ) ++ ByteString(a.getAddress) + case a: Inet6Address => + ByteString( + 0x04 // IPv6 address + ) ++ ByteString(a.getAddress) + case _ => throw Socks5Error("Unknown InetAddress") + } + + def addressToByteString(address: InetSocketAddress): ByteString = Option( + address.getAddress) match { + case None => + // unresolved address, use SOCKS5 resolver + val host = address.getHostString + ByteString(0x03, // Domain name + host.length.toByte) ++ + ByteString(host) + case Some(inetAddress) => + inetAddressToByteString(inetAddress) + } + + def portToByteString(port: Int): ByteString = + ByteString((port & 0x0000ff00) >> 8, port & 0x000000ff) +} + +case class Socks5ProxyParams( + address: InetSocketAddress, + credentials_opt: Option[Credentials], + randomizeCredentials: Boolean, + useForIPv4: Boolean, + useForIPv6: Boolean, + useForTor: Boolean) + +object Socks5ProxyParams { + + def proxyCredentials( + proxyParams: Socks5ProxyParams): Option[Socks5Connection.Credentials] = + if (proxyParams.randomizeCredentials) { + // randomize credentials for every proxy connection to enable Tor stream isolation + Some( + Socks5Connection.Credentials(CryptoUtil.randomBytes(16).toHex, + CryptoUtil.randomBytes(16).toHex)) + } else { + proxyParams.credentials_opt + } +} diff --git a/tor/src/main/scala/org/bitcoins/tor/TorController.scala b/tor/src/main/scala/org/bitcoins/tor/TorController.scala new file mode 100644 index 0000000000..ab8e7227aa --- /dev/null +++ b/tor/src/main/scala/org/bitcoins/tor/TorController.scala @@ -0,0 +1,75 @@ +package org.bitcoins.tor + +import akka.actor.{ + Actor, + ActorLogging, + OneForOneStrategy, + Props, + SupervisorStrategy, + Terminated +} +import akka.io.{IO, Tcp} +import akka.util.ByteString + +import java.net.InetSocketAddress + +/** Created by rorp + * + * @param address Tor control address + * @param protocolHandlerProps Tor protocol handler props + * @param ec execution context + */ +class TorController(address: InetSocketAddress, protocolHandlerProps: Props) + extends Actor + with ActorLogging { + + import TorController._ + import Tcp._ + import context.system + + IO(Tcp) ! Connect(address) + + def receive = { + case e @ CommandFailed(_: Connect) => + e.cause match { + case Some(ex) => log.error(ex, "Cannot connect") + case _ => log.error("Cannot connect") + } + context stop self + case c: Connected => + val protocolHandler = context actorOf protocolHandlerProps + protocolHandler ! c + val connection = sender() + connection ! Register(self) + context watch connection + context become { + case data: ByteString => + connection ! Write(data) + case CommandFailed(_: Write) => + // O/S buffer was full + protocolHandler ! SendFailed + log.error("Tor command failed") + case Received(data) => + protocolHandler ! data + case _: ConnectionClosed => + context stop self + case Terminated(actor) if actor == connection => + context stop self + } + } + + // we should not restart a failing tor session + override val supervisorStrategy = OneForOneStrategy(loggingEnabled = true) { + case _ => SupervisorStrategy.Escalate + } + +} + +object TorController { + + def props(address: InetSocketAddress, protocolHandlerProps: Props) = + Props(new TorController(address, protocolHandlerProps)) + + case object SendFailed + +} diff --git a/tor/src/main/scala/org/bitcoins/tor/TorProtocolHandler.scala b/tor/src/main/scala/org/bitcoins/tor/TorProtocolHandler.scala new file mode 100644 index 0000000000..1a20c2ff2c --- /dev/null +++ b/tor/src/main/scala/org/bitcoins/tor/TorProtocolHandler.scala @@ -0,0 +1,349 @@ +package org.bitcoins.tor + +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.{Files, Path, Paths} +import java.util +import akka.actor.{Actor, ActorLogging, ActorRef, Props, Stash} +import akka.io.Tcp.Connected +import akka.util.ByteString +import TorProtocolHandler.{Authentication, OnionServiceVersion} +import org.bitcoins.crypto.CryptoUtil +import scodec.bits.ByteVector + +import java.net.InetSocketAddress +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import scala.concurrent.Promise +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 onionServiceVersion v2 or v3 + * @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( + onionServiceVersion: OnionServiceVersion, + authentication: Authentication, + privateKeyPath: Path, + virtualPort: Int, + targets: Seq[String], + onionAdded: Option[Promise[InetSocketAddress]]) + extends Actor + with Stash + with ActorLogging { + + import TorProtocolHandler._ + + private var receiver: ActorRef = _ + + private var address: Option[InetSocketAddress] = None + + override def receive: Receive = { case Connected(_, _) => + receiver = sender() + sendCommand("PROTOCOLINFO 1") + context become protocolInfo + } + + def protocolInfo: Receive = { case data: ByteString => + val res = parseResponse(readResponse(data)) + val methods: String = + res.getOrElse("METHODS", throw TorException("auth methods not found")) + val torVersion = unquote( + res.getOrElse("Tor", throw TorException("version not found"))) + log.info(s"Tor version $torVersion") + if (!OnionServiceVersion.isCompatible(onionServiceVersion, torVersion)) { + throw TorException( + s"version $torVersion does not support onion service $onionServiceVersion") + } + if (!Authentication.isCompatible(authentication, methods)) { + throw TorException( + s"cannot use authentication '$authentication', supported methods are '$methods'") + } + authentication match { + case Password(password) => + sendCommand(s"""AUTHENTICATE "$password"""") + context become authenticate + case SafeCookie(nonce) => + val cookieFile = Paths.get( + unquote( + res.getOrElse("COOKIEFILE", + throw TorException("cookie file not found")))) + sendCommand(s"AUTHCHALLENGE SAFECOOKIE ${nonce.toHex}") + context become cookieChallenge(cookieFile, nonce) + } + } + + def cookieChallenge(cookieFile: Path, clientNonce: ByteVector): Receive = { + case data: ByteString => + val res = parseResponse(readResponse(data)) + val clientHash = computeClientHash( + ByteVector.fromValidHex(res + .getOrElse("SERVERHASH", throw TorException("server hash not found")) + .toLowerCase), + ByteVector.fromValidHex( + res + .getOrElse("SERVERNONCE", + throw TorException("server nonce not found")) + .toLowerCase), + clientNonce, + cookieFile + ) + sendCommand(s"AUTHENTICATE ${clientHash.toHex}") + context become authenticate + } + + def authenticate: Receive = { case data: ByteString => + readResponse(data) + sendCommand(s"ADD_ONION $computeKey $computePort") + context become addOnion + } + + def addOnion: Receive = { case data: ByteString => + val res = readResponse(data) + if (ok(res)) { + val serviceId = processOnionResponse(parseResponse(res)) + address = Some( + InetSocketAddress.createUnresolved(s"$serviceId.onion", virtualPort)) + onionAdded.foreach(_.success(address.get)) + log.debug("Onion address: {}", address.get) + } + } + + override def aroundReceive(receive: Receive, msg: Any): Unit = try { + super.aroundReceive(receive, msg) + } catch { + case t: Throwable => + onionAdded.map(_.tryFailure(t)) + () + } + + override def unhandled(message: Any): Unit = message match { + case GetOnionAddress => + sender() ! address + } + + 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-------") + } + serviceId + } + + private def computeKey: String = { + if (privateKeyPath.toFile.exists()) { + readString(privateKeyPath) + } else { + onionServiceVersion match { + case V2 => "NEW:RSA1024" + case V3 => "NEW:ED25519-V3" + } + } + } + + private def computePort: String = { + if (targets.isEmpty) { + s"Port=$virtualPort,$virtualPort" + } else { + targets.map(p => s"Port=$virtualPort,$p").mkString(" ") + } + } + + private def computeClientHash( + serverHash: ByteVector, + serverNonce: ByteVector, + clientNonce: ByteVector, + cookieFile: Path): ByteVector = { + if (serverHash.length != 32) + throw TorException("invalid server hash length") + if (serverNonce.length != 32) + throw TorException("invalid server nonce length") + + val cookie = ByteVector.view(Files.readAllBytes(cookieFile)) + + val message = cookie ++ clientNonce ++ serverNonce + + val computedServerHash = hmacSHA256(ServerKey, message) + if (computedServerHash != serverHash) { + throw TorException("unexpected server hash") + } + + hmacSHA256(ClientKey, message) + } + + private def sendCommand(cmd: String): Unit = { + receiver ! ByteString(s"$cmd\r\n") + } +} + +object TorProtocolHandler { + + def props( + version: OnionServiceVersion, + authentication: Authentication, + privateKeyPath: Path, + virtualPort: Int, + targets: Seq[String] = Seq(), + onionAdded: Option[Promise[InetSocketAddress]] = None): Props = + Props( + new TorProtocolHandler(version, + authentication, + privateKeyPath, + virtualPort, + targets, + onionAdded)) + + // those are defined in the spec + private val ServerKey = ByteVector.view( + "Tor safe cookie authentication server-to-controller hash".getBytes()) + + private val ClientKey = ByteVector.view( + "Tor safe cookie authentication controller-to-server hash".getBytes()) + + // @formatter:off + sealed trait OnionServiceVersion + case object V2 extends OnionServiceVersion + case object V3 extends OnionServiceVersion + // @formatter:on + + object OnionServiceVersion { + + def apply(s: String): OnionServiceVersion = s match { + case "v2" | "V2" => V2 + case "v3" | "V3" => V3 + case _ => throw TorException(s"unknown protocol version `$s`") + } + + def isCompatible( + onionServiceVersion: OnionServiceVersion, + torVersion: String): Boolean = + onionServiceVersion match { + case V2 => true + case V3 => + torVersion + .split("\\.") + .map( + _.split('-').head + ) // remove non-numeric symbols at the end of the last number (rc, beta, alpha, etc.) + .map(d => Try(d.toInt).getOrElse(0)) + .zipAll(List(0, 3, 3, 6), 0, 0) // min version for v3 is 0.3.3.6 + .foldLeft(Option.empty[Boolean]) { // compare subversion by subversion starting from the left + case (Some(res), _) => + Some( + res + ) // we stop the comparison as soon as there is a difference + case (None, (v, vref)) => + if (v > vref) Some(true) + else if (v < vref) Some(false) + else None + } + .getOrElse(true) // if version == 0.3.3.6 then result will be None + + } + } + + sealed trait Authentication + + case class Password(password: String) extends Authentication { + override def toString = "password" + } + + case class SafeCookie(nonce: ByteVector = CryptoUtil.randomBytes(32)) + extends Authentication { + override def toString = "safecookie" + } + + object Authentication { + + def isCompatible(authentication: Authentication, methods: String): Boolean = + authentication match { + case _: Password => methods.contains("HASHEDPASSWORD") + case _: SafeCookie => methods.contains("SAFECOOKIE") + } + } + + case object GetOnionAddress + + def readString(path: Path): String = Files.readAllLines(path).get(0) + + 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("\"") + .replace("""\\""", """\""") + .replace("""\"""", "\"") + + private val r1 = """(\d+)-(.*)""".r + private val r2 = """(\d+) (.*)""".r + + def readResponse(bstr: ByteString): Seq[(Int, String)] = { + val lines = bstr.utf8String + .split('\n') + .map(_.stripSuffix("\r")) + .filterNot(_.isEmpty) + .map { + case r1(c, msg) => (c.toInt, msg) + case r2(c, msg) => (c.toInt, msg) + case x @ _ => throw TorException(s"unknown response line format: `$x`") + } + .toSeq + if (!ok(lines)) { + throw TorException( + s"server returned error: ${status(lines)} ${reason(lines)}") + } + lines + } + + def ok(res: Seq[(Int, String)]): Boolean = status(res) == 250 + + def status(res: Seq[(Int, String)]): Int = + res.lastOption.map(_._1).getOrElse(-1) + + def reason(res: Seq[(Int, String)]): String = + res.lastOption.map(_._2).getOrElse("Unknown error") + + private val r = """([^=]+)=(.+)""".r + + def parseResponse(lines: Seq[(Int, String)]): Map[String, String] = { + lines.flatMap { case (_, message) => + message + .split(" ") + .collect { case r(k, v) => + (k, v) + } + }.toMap + } + + def hmacSHA256(key: ByteVector, message: ByteVector): ByteVector = { + val mac = Mac.getInstance("HmacSHA256") + val secretKey = new SecretKeySpec(key.toArray, "HmacSHA256") + mac.init(secretKey) + ByteVector.view(mac.doFinal(message.toArray)) + } +} diff --git a/tor/tor.sbt b/tor/tor.sbt new file mode 100644 index 0000000000..ceae6c3c4e --- /dev/null +++ b/tor/tor.sbt @@ -0,0 +1,5 @@ +name := "bitcoin-s-tor" + +libraryDependencies ++= Deps.tor.value + +CommonSettings.prodSettings