Add support for parsing "GETINFO circuit-status" message.
ADD
WalletWasabi.Tests/UnitTests/Tor/Control/Messages/GetInfoCircuitStatusReplyTests.cs
+using System.Text;
+using System.Threading.Tasks;
+using WalletWasabi.Tor.Control.Messages;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Tor.Control
+{
+ /// <summary>
+ /// Tests for <see cref="GetInfoCircuitStatusReply"/> class.
+ /// </summary>
+ public class GetInfoCircuitStatusReplyTests
+ {
+ [Fact]
+ public async Task ParseReplyAsync()
+ {
+ StringBuilder sb = new();
+ sb.Append("250+circuit-status=\r\n");
+ sb.Append("1 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$D0423D3A13C18D2ED0F3D5BFD90E13E77C9AD239~d0xkb,$3A9559477D72F71215850C97FA62A0DA7380964B~PawNetBlue BUILD_FLAGS=NEED_CAPACITY PURPOSE=GENERAL TIME_CREATED=2021-05-15T14:04:17.615384\r\n");
+ sb.Append("2 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$A0FA50A070CFB4B89737A27F3259F92C118A0AF0~pipiska,$7E77CC94D94C08609D70B517FF938CC61C9F8232~pitfall BUILD_FLAGS=NEED_CAPACITY PURPOSE=GENERAL TIME_CREATED=2021-05-15T14:04:18.628885\r\n");
+ sb.Append("3 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$706A7674A217BA905FE677E82236B7B968A23DB7~rofltor04,$4D4938B725B89561773A161215D88B7C45C43C35~TheGreenDynamo,$18CA339AD0C33EAB035F1D869518F3D2D88BABC0~FreeAssange BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=HS_CLIENT_HSDIR HS_STATE=HSCI_CONNECTING TIME_CREATED=2021-05-15T14:04:19.353271\r\n");
+ sb.Append("4 EXTENDED $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=MEASURE_TIMEOUT TIME_CREATED=2021-05-15T14:04:19.631228\r\n");
+ sb.Append("5 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$31D270A38505D4BFBBCABF717E9FB4BCA6DDF2FF~Belgium,$B411027C926A9BFFCF7DA91E3CAF1856A321EFFD~pulsetor BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=HS_CLIENT_REND HS_STATE=HSCR_JOINED REND_QUERY=wasabiukrxmkdgve5kynjztuovbg43uxcbcxn6y2okcrsg7gb6jdmbad TIME_CREATED=2021-05-15T14:04:20.634686\r\n");
+ sb.Append("6 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$573CC3D8F1C845AA33AA4593B68CA0D47156AA30~loglessRelay0,$F0F2CEF702D60AC53747CA6A7CA0C5C145F873F9~ax01 BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=GENERAL TIME_CREATED=2021-05-15T14:04:21.649363\r\n");
+ sb.Append("7 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$18ECBFC3A6834CB67414640C7F1E765E542CED2C~Unnamed,$708A968F3644F8A547156368FEA3DB664110E631~LittleFoxRahja,$81E18725397D620DA548ABBE24D9CD263DEF2FDC~rosskemp BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=CIRCUIT_PADDING REND_QUERY=wasabiukrxmkdgve5kynjztuovbg43uxcbcxn6y2okcrsg7gb6jdmbad TIME_CREATED=2021-05-15T14:04:22.311493\r\n");
+ sb.Append("8 BUILT $E9F71AC06F29B2110E3FC09016B0E50407444EE2~libertas,$2931AE89C9ED11361081D572F1174084CA8714CE~Hackhindi,$47E9EDA00A8A7BE0073501C3098EF9530771EB03~Unnamed BUILD_FLAGS=IS_INTERNAL,NEED_CAPACITY PURPOSE=GENERAL TIME_CREATED=2021-05-15T14:04:22.662664\r\n");
+ sb.Append(".\r\n");
+
+ string data = sb.ToString();
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ GetInfoCircuitStatusReply reply = GetInfoCircuitStatusReply.FromReply(rawReply);
+
+ Assert.Equal(8, reply.Circuits.Count);
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/CircuitStatus/BuildFlag.cs
+namespace WalletWasabi.Tor.Control.Messages.CircuitStatus
+{
+ public enum BuildFlag
+ {
+ /// <summary>One-hop circuit, used for tunneled directory conns</summary>
+ ONEHOP_TUNNEL,
+
+ /// <summary>Internal circuit, not to be used for exiting streams</summary>
+ IS_INTERNAL,
+
+ /// <summary>This circuit must use only high-capacity nodes</summary>
+ NEED_CAPACITY,
+
+ /// <summary>This circuit must use only high-uptime nodes</summary>
+ NEED_UPTIME,
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/CircuitStatus/CircStatus.cs
+namespace WalletWasabi.Tor.Control.Messages.CircuitStatus
+{
+ public enum CircStatus
+ {
+ /// <summary>Circuit ID assigned to new circuit</summary>
+ LAUNCHED,
+
+ /// <summary>All hops finished, can now accept streams</summary>
+ BUILT,
+
+ /// <summary>All hops finished, waiting to see if a circuit with a better guard will be usable.</summary>
+ GUARD_WAIT,
+
+ /// <summary>One more hop has been completed</summary>
+ EXTENDED,
+
+ /// <summary>Circuit closed (was not built)</summary>
+ FAILED,
+
+ /// <summary>Circuit closed (was built)</summary>
+ CLOSED,
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/CircuitStatus/HsState.cs
+namespace WalletWasabi.Tor.Control.Messages.CircuitStatus
+{
+ public enum HsState
+ {
+ // Client-side introduction-point circuit states
+
+ /// <summary>Connecting to intro point</summary>
+ HSCI_CONNECTING,
+ /// <summary>Sent INTRODUCE1; waiting for reply from IP (introduction point)</summary>
+ HSCI_INTRO_SENT,
+ /// <summary>Received reply from IP relay; closing</summary>
+ HSCI_DONE,
+
+ // Client-side rendezvous-point circuit states
+
+ /// <summary>Connecting to or waiting for reply from RP (rendezvous-point)</summary>
+ HSCR_CONNECTING,
+ /// <summary>Established RP; waiting for introduction</summary>
+ HSCR_ESTABLISHED_IDLE,
+ /// <summary>Introduction sent to HS; waiting for rend</summary>
+ HSCR_ESTABLISHED_WAITING,
+ /// <summary>Connected to HS</summary>
+ HSCR_JOINED,
+
+ // Service-side introduction-point circuit states
+
+ /// <summary>connecting to intro point</summary>
+ HSSI_CONNECTING,
+ /// <summary>established intro point</summary>
+ HSSI_ESTABLISHED,
+
+ // Service-side rendezvous-point circuit states
+
+ /// <summary>Connecting to client's rend point</summary>
+ HSSR_CONNECTING,
+ /// <summary>Connected to client's RP circuit</summary>
+ HSSR_JOINED,
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/CircuitStatus/Purpose.cs
+namespace WalletWasabi.Tor.Control.Messages.CircuitStatus
+{
+ public enum Purpose
+ {
+ /// <summary>Circuit for AP and/or directory request streams</summary>
+ GENERAL,
+
+ /// <summary>HS client-side introduction-point circuit</summary>
+ HS_CLIENT_INTRO,
+
+ /// <summary>HS client-side rendezvous circuit; carries AP streams</summary>
+ HS_CLIENT_REND,
+
+ /// <summary>HS service-side introduction-point circuit</summary>
+ HS_SERVICE_INTRO,
+
+ /// <summary>HS service-side rendezvous circuit</summary>
+ HS_SERVICE_REND,
+
+ /// <summary>Reachability-testing circuit; carries no traffic</summary>
+ TESTING,
+
+ /// <summary>Circuit built by a controller</summary>
+ CONTROLLER,
+
+ /// <summary>Circuit being kept around to see how long it takes</summary>
+ MEASURE_TIMEOUT,
+
+ /// <summary>Circuit created ahead of time when using HS vanguards, and later repurposed as needed</summary>
+ HS_VANGUARDS,
+
+ /// <summary>Circuit used to probe whether our circuits are being deliberately closed by an attacker</summary>
+ PATH_BIAS_TESTING,
+
+ /// <summary>Circuit that is being held open to disguise its true close time</summary>
+ CIRCUIT_PADDING,
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/CircuitStatus/Reason.cs
+namespace WalletWasabi.Tor.Control.Messages.CircuitStatus
+{
+ public enum Reason
+ {
+ NONE,
+ TORPROTOCOL,
+ INTERNAL,
+ REQUESTED,
+ HIBERNATING,
+ RESOURCELIMIT,
+ CONNECTFAILED,
+ OR_IDENTITY,
+ OR_CONN_CLOSED,
+ TIMEOUT,
+ FINISHED,
+ DESTROYED,
+ NOPATH,
+ NOSUCHSERVICE,
+ MEASUREMENT_EXPIRED
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/GetInfoCircuitStatusReply.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using WalletWasabi.Logging;
+using WalletWasabi.Tor.Control.Exceptions;
+using WalletWasabi.Tor.Control.Messages.CircuitStatus;
+using WalletWasabi.Tor.Control.Utils;
+
+namespace WalletWasabi.Tor.Control.Messages
+{
+ public class GetInfoCircuitStatusReply
+ {
+ public GetInfoCircuitStatusReply(IList<CircuitInfo> circuits)
+ {
+ Circuits = circuits;
+ }
+
+ public IList<CircuitInfo> Circuits { get; }
+
+ /// <exception cref="TorControlReplyParseException"/>
+ public static GetInfoCircuitStatusReply FromReply(TorControlReply reply)
+ {
+ if (!reply.Success)
+ {
+ throw new TorControlReplyParseException("GETINFO[circuit-status]: Expected reply with OK status.");
+ }
+
+ if (reply.ResponseLines.First() != "circuit-status=")
+ {
+ throw new TorControlReplyParseException("GETINFO[circuit-status]: First line is invalid.");
+ }
+
+ if (reply.ResponseLines.Last() != ".")
+ {
+ throw new TorControlReplyParseException("GETINFO[circuit-status]: Last line must be equal to dot ('.').");
+ }
+
+ IList<CircuitInfo> circuits = new List<CircuitInfo>();
+
+ foreach (string line in reply.ResponseLines.Skip(1).SkipLast(1))
+ {
+ (string circuitId, string remainder1) = Tokenizer.ReadUntilSeparator(line);
+ (string circuitStatus, string remainder2) = Tokenizer.ReadUntilSeparator(remainder1);
+
+ if (!Enum.TryParse(circuitStatus, out CircStatus circStatus))
+ {
+ throw new TorControlReplyParseException("GETINFO[circuit-status]: Circuit status is not defined.");
+ }
+
+ string remainder = remainder2;
+
+ BuildFlag? buildFlag = null;
+ Purpose? purpose = null;
+ HsState? hsState = null;
+ string? rendQuery = null;
+ string? timeCreated = null;
+ Reason? reason = null;
+ Reason? remoteReason = null;
+ string? userName = null;
+ string? userPassword = null;
+ List<CircPath> circPaths = new();
+
+ // Optional arguments.
+ while (remainder != "")
+ {
+ // Read <PATH>.
+ if (remainder.StartsWith("$", StringComparison.Ordinal))
+ {
+ string pathVal;
+ (pathVal, remainder) = Tokenizer.ReadUntilSeparator(remainder);
+ circPaths = ParseCircPath(pathVal);
+
+ continue;
+ }
+
+ if (remainder.StartsWith("SOCKS_USERNAME", StringComparison.Ordinal))
+ {
+ (userName, remainder) = Tokenizer.ReadKeyQuotedValueAssignment("SOCKS_USERNAME", remainder);
+ continue;
+ }
+ else if (remainder.StartsWith("SOCKS_PASSWORD", StringComparison.Ordinal))
+ {
+ (userPassword, remainder) = Tokenizer.ReadKeyQuotedValueAssignment("SOCKS_PASSWORD", remainder);
+ continue;
+ }
+
+ // Read KEY=VALUE assignments.
+ (string key, string value, string rest) = Tokenizer.ReadKeyValueAssignment(remainder);
+
+ if (key == "BUILD_FLAGS" && Enum.TryParse(value, out BuildFlag flagVal))
+ {
+ buildFlag = flagVal;
+ }
+ else if (key == "PURPOSE" && Enum.TryParse(value, out Purpose purposeVal))
+ {
+ purpose = purposeVal;
+ }
+ else if (key == "HS_STATE" && Enum.TryParse(value, out HsState stateVal))
+ {
+ hsState = stateVal;
+ }
+ else if (key == "REND_QUERY")
+ {
+ rendQuery = value;
+ }
+ else if (key == "TIME_CREATED")
+ {
+ timeCreated = value;
+ }
+ else if (key == "REASON" && Enum.TryParse(value, out Reason reasonVal))
+ {
+ reason = reasonVal;
+ }
+ else if (key == "REMOTE_REASON" && Enum.TryParse(value, out Reason remoteReasonVal))
+ {
+ remoteReason = remoteReasonVal;
+ }
+ else
+ {
+ Logger.LogError($"Unknown key '{key}'.");
+ }
+
+ remainder = rest;
+ }
+
+ CircuitInfo circuitInfo = new(circuitId, circStatus)
+ {
+ CircPaths = circPaths,
+ BuildFlag = buildFlag,
+ Purpose = purpose,
+ HsState = hsState,
+ RendQuery = rendQuery,
+ TimeCreated = timeCreated,
+ Reason = reason,
+ RemoteReason = remoteReason,
+ UserName = userName,
+ UserPassword = userPassword,
+ };
+
+ circuits.Add(circuitInfo);
+ }
+
+ return new GetInfoCircuitStatusReply(circuits);
+ }
+
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">2.4. General-use tokens (see LongName)</seealso>
+ private static List<CircPath> ParseCircPath(string paths)
+ {
+ List<CircPath> result = new();
+
+ foreach (string path in paths.Split(','))
+ {
+ if (path.IndexOf('=') > -1)
+ {
+ string[] parts = path.Split('=', count: 2);
+ result.Add(new CircPath(FingerPrint: parts[0], Nickname: parts[1]));
+ }
+ else if (path.IndexOf('~') > -1)
+ {
+ string[] parts = path.Split('~', count: 2);
+ result.Add(new CircPath(FingerPrint: parts[0], Nickname: parts[1]));
+ }
+ else
+ {
+ result.Add(new CircPath(FingerPrint: path, Nickname: null));
+ }
+ }
+
+ return result;
+ }
+ }
+
+ /// <remarks>
+ /// Note that the <see cref="CircuitID"/> and <see cref="CircStatus"/> are then only mandatory
+ /// fields in <c>GETINFO circuit-status</c> reply.
+ /// </remarks>
+ public class CircuitInfo
+ {
+ public CircuitInfo(string circuitID, CircStatus circStatus)
+ {
+ CircuitID = circuitID;
+ CircStatus = circStatus;
+ }
+
+ /// <summary>Unique circuit identifier</summary>
+ /// <remarks>
+ /// Currently, Tor only uses digits, but this may change.
+ /// <para>String matches <c>^[a-zA-Z0-9]{1,16}$</c>.</para>
+ /// </remarks>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">2.4. General-use tokens</seealso>
+ public string CircuitID { get; }
+ public CircStatus CircStatus { get; }
+ public List<CircPath> CircPaths { get; init; } = new();
+ public BuildFlag? BuildFlag { get; init; }
+ public Purpose? Purpose { get; init; }
+ public HsState? HsState { get; init; }
+ public string? RendQuery { get; init; }
+ public string? TimeCreated { get; init; }
+ public Reason? Reason { get; init; }
+ public Reason? RemoteReason { get; init; }
+ public string? UserName { get; init; }
+ public string? UserPassword { get; init; }
+ }
+
+ /// <summary>
+ /// </summary>
+ /// <remarks>
+ /// <see cref="FingerPrint"/> is a 40*HEXDIG string.
+ /// <para><see cref="Nickname"/> matches <c>^[a-zA-Z0-9]{1, 19}$</c>.</para>
+ /// </remarks>
+ public record CircPath(string FingerPrint, string? Nickname)
+ {
+ }
+}

MODIFY
WalletWasabi/Tor/Control/Messages/ProtocolInfoReply.cs
WalletWasabi/Tor/Control/Messages/ProtocolInfoReply.cs
@@ -52,7 +52,7 @@ namespace WalletWasabi.Tor.Control.Messages
{
// VersionLine = "250-VERSION" SP "Tor=" TorVersion OptArguments CRLF
// TorVersion = QuotedString
- (string value, _) = Tokenizer.ReadKeyValueAssignment(key: "Tor=", remainder);
+ (string value, _) = Tokenizer.ReadKeyQuotedValueAssignment(key: "Tor=", remainder);
torVersion = value;
}
@@ -64,7 +64,7 @@ namespace WalletWasabi.Tor.Control.Messages
if (optCookieFileRemainder.StartsWith("COOKIEFILE=", StringComparison.Ordinal))
{
- (cookieFilePath, _) = Tokenizer.ReadKeyValueAssignment(key: "COOKIEFILE=", optCookieFileRemainder);
+ (cookieFilePath, _) = Tokenizer.ReadKeyQuotedValueAssignment(key: "COOKIEFILE=", optCookieFileRemainder);
}
}
}

MODIFY
WalletWasabi/Tor/Control/Messages/TorControlReply.cs
WalletWasabi/Tor/Control/Messages/TorControlReply.cs
@@ -24,10 +24,12 @@ namespace WalletWasabi.Tor.Control.Messages
/// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">Section 2.3</seealso>
public class TorControlReply
{
+ private readonly static ReadOnlyCollection<string> NoLines = new List<string>().AsReadOnly();
+
public TorControlReply(StatusCode statusCode)
{
StatusCode = statusCode;
- ResponseLines = new List<string>().AsReadOnly();
+ ResponseLines = NoLines;
}
public TorControlReply(StatusCode statusCode, IList<string> responseLines)

MODIFY
WalletWasabi/Tor/Control/TorControlClient.cs
WalletWasabi/Tor/Control/TorControlClient.cs
@@ -49,6 +49,17 @@ namespace WalletWasabi.Tor.Control
return ProtocolInfoReply.FromReply(reply);
}
+ /// <summary>
+ /// Gets Tor's circuits that are currently available.
+ /// </summary>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">3.9. GETINFO, and 4.1.1. Circuit status changed.</seealso>
+ public async Task<GetInfoCircuitStatusReply> GetCircuitStatusAsync(CancellationToken cancellationToken = default)
+ {
+ TorControlReply reply = await SendCommandAsync("GETINFO circuit-status\r\n", cancellationToken).ConfigureAwait(false);
+
+ return GetInfoCircuitStatusReply.FromReply(reply);
+ }
+
/// <summary>
/// Instructs Tor to shut down when this control connection is closed.
/// </summary>

MODIFY
WalletWasabi/Tor/Control/Utils/Tokenizer.cs
WalletWasabi/Tor/Control/Utils/Tokenizer.cs
@@ -31,11 +31,30 @@ namespace WalletWasabi.Tor.Control.Utils
}
}
+ /// <summary>
+ /// Reads <c>&lt;KEY&gt;=&lt;VALUE&gt;</c>.
+ /// </summary>
+ public static (string key, string value, string remainder) ReadKeyValueAssignment(string input)
+ {
+ int valueStartAt = input.IndexOf('=');
+
+ if (valueStartAt == -1)
+ {
+ throw new TorControlReplyParseException("Missing equal sign ('=').");
+ }
+
+ string key = input[0..valueStartAt];
+
+ (string value, string remainder) = ReadUntilSeparator(input[(valueStartAt+1)..]);
+
+ return (key, value, remainder);
+ }
+
/// <summary>
/// Reads <c>&lt;KEY&gt;=QuotedString</c> string from <paramref name="input"/>.
/// </summary>
/// <returns>Quoted string content.</returns>
- public static (string value, string remainder) ReadKeyValueAssignment(string key, string input)
+ public static (string value, string remainder) ReadKeyQuotedValueAssignment(string key, string input)
{
input = ReadExactString(key, input);

Add support for parsing "GETINFO circuit-status" message.