Initial Tor support (#3043)

* Initial Tor support

* cleanup
This commit is contained in:
rorp 2021-05-07 04:43:39 -07:00 committed by GitHub
parent 6bc0943a62
commit 02c4505948
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1063 additions and 10 deletions

View File

@ -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

View File

@ -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

View File

@ -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: _*)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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,

View File

@ -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)
}
}

0
tor/README.md Normal file
View File

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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))
}
}

5
tor/tor.sbt Normal file
View File

@ -0,0 +1,5 @@
name := "bitcoin-s-tor"
libraryDependencies ++= Deps.tor.value
CommonSettings.prodSettings