From b9b3912a1eccf3b8fa46275d36875fb9fab920e4 Mon Sep 17 00:00:00 2001 From: dapphp Date: Mon, 14 Feb 2022 22:14:59 -0800 Subject: [PATCH] Add DirectoryClient methods for getting status vote documents from authorities - #6 --- src/AuthorityStatusDocument.php | 143 +++++++++++++++ src/DirectoryClient.php | 38 +++- src/Parser.php | 300 +++++++++++++++++++++++++++++++- src/ProtocolReply.php | 6 + 4 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 src/AuthorityStatusDocument.php diff --git a/src/AuthorityStatusDocument.php b/src/AuthorityStatusDocument.php new file mode 100644 index 0000000..e8e3130 --- /dev/null +++ b/src/AuthorityStatusDocument.php @@ -0,0 +1,143 @@ + + * + */ + +namespace Dapphp\TorUtils; + +/** + * AuthorityStatusDocument class. This class models a Tor circuit. + * + */ +class AuthorityStatusDocument +{ + /** @var int A document format version. For this code, the latest version known is "3". */ + public $statusVersion = 0; + + /** @var string "vote" or "consensus", depending on the type of the document */ + public $voteStatus = ''; + + /** @var int[] A list of supported methods for generating consensuses from votes. Does not occur in consensuses. */ + public $consensusMethods = []; + + /** @var string The consensus method; does not occur in votes */ + public $consensusMethod = ''; + + /** @var string|null The publication time for this status document (if a vote) */ + public $published = null; + + /** @var string The start of the Interval for this vote. Before this time, the consensus document produced from + * this vote is not officially in use. + */ + public $validAfter = ''; + + /** @var string The time at which the next consensus should be produced; before this time, there is no point in + * downloading another consensus, since there won't be a new one. + */ + public $freshUntil = ''; + + /** @var string The end of the Interval for this vote. After this time, all clients should try to find a more + * recent consensus. + */ + public $validUntil = ''; + + /** @var int The number of seconds allowed to collect votes from all authorities */ + public $voteDelaySeconds = 0; + + /** @var int The number of seconds allowed to collect signatures from all authorities */ + public $distDelaySeconds = 0; + + /** @var string[] A list of recommended Tor versions for client usage. The versions are given as defined by + * version-spec.txt. If absent, no opinion is held about client versions. + */ + public $clientVersions = []; + + /** @var string[] A list of recommended Tor versions for relay usage. The versions are given as defined by + * version-spec.txt. If absent, no opinion is held about server versions. + */ + public $serverVersions = []; + + /** @var string[] A space-separated list of all of the flags that this document might contain. */ + public $knownFlags = []; + + /** @var array A list of the internal performance thresholds that the directory authority had at the moment it was + * forming a vote. + */ + public $flagThresholds = []; + + /** @var string[] */ + public $recommendedClientProtocols = []; + + /** @var string[] */ + public $recommendedRelayProtocols = []; + + /** @var string[] */ + public $requiredClientProtocols = []; + + /** @var string[] */ + public $requiredRelayProtocols = []; + + /** @var array The parameters list, if present, contains a space-separated list of case-sensitive key-value pairs. + * See param-spec.txt for a list of parameters and their meanings. + */ + public $params = []; + + /** @var string The shared random value that was generated during the second-to-last shared randomness protocol run, + * encoded in base64. + */ + public $sharedRandPreviousValue = ''; + + /** @var string The shared random value that was generated during the latest shared + randomness protocol run, encoded in base64. */ + public $sharedRandCurrentValue = ''; + + /** @var array */ + public $authorities = []; + + /** @var RouterDescriptor[] A list of relays along with their information and status according to the document. */ + public $descriptors = []; + + /** @var array List of optional weights to apply to router bandwidths during path selection. Appears at most once + * for a consensus. Does not appear in votes. */ + public $bandwidthWeights = []; + + /** @var array his is a signature of the status document, with the initial item "network-status-version", and the + * signature item "directory-signature", using the signing key. Only one entry for a vote, and at least one for a + * consensus. + */ + public $directorySignatures = []; + +} \ No newline at end of file diff --git a/src/DirectoryClient.php b/src/DirectoryClient.php index 2ef4a72..718d05c 100644 --- a/src/DirectoryClient.php +++ b/src/DirectoryClient.php @@ -367,6 +367,39 @@ public function getServerDescriptor($fingerprint) } } + public function statusVoteCurrentAuthority($address = null) + { + $uri = '/tor/status-vote/current/authority.z'; + + $reply = $this->_request($uri, $address); + + return $this->parser->parseVoteConsensusStatusDocument($reply); + } + + public function statusVoteCurrentConsensus($address = null) + { + $uri = '/tor/status-vote/current/consensus.z'; + + $reply = $this->_request($uri, $address); + + return $this->parser->parseVoteConsensusStatusDocument($reply); + } + + /** + * Make an HTTP GET request to a directory server and return the response + * + * @param string $uri The URI to fetch (e.g. /tor/server/all.z) + * @param string|null $directoryServer The host:port or ip:port of the directory server to use, or null to use + * random selections from the default list + * @return \Dapphp\TorUtils\ProtocolReply If no error occurs, a ProtocolReply object is returned. The first line may + * be the HTTP status line. Implementations must tolerate the first reply line being an HTTP response code. + * @throws \Exception If the request to the directory failed (e.g. 404 Not Found, Connection Timed Out) + */ + public function get($uri, $directoryServer = null) + { + return $this->_request($uri, $directoryServer); + } + /** * Pick a random dir authority to query and perform the HTTP request for directory info * @@ -381,7 +414,10 @@ private function request($uri) do { // pick a server from the list, it is randomized in __construct - if ($this->preferredServer && !$used) { + if ($directoryServer && !$used) { + $server = $directoryServer; + $used = true; + } elseif ($this->preferredServer && !$used) { $server = $this->preferredServer; $used = true; } else { diff --git a/src/Parser.php b/src/Parser.php index e8399e7..51b0991 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -95,6 +95,284 @@ class Parser 'id' => '_parseIdLine', ); + /** + * Parse directory status reply (v3 directory style) + * + * @param ProtocolReply $reply The reply to parse + * @return array Array of \Dapphp\TorUtils\RouterDescriptor objects + */ + public function parseVoteConsensusStatusDocument(ProtocolReply $reply) + { + $doc = new AuthorityStatusDocument(); + $descriptor = null; + $authority = null; + + $line = $reply->shift(); + if (in_array($line, [ '.', '250 OK', '200 OK', '' ])) { + $line = $reply->shift(); + } + + if (empty($line)) { + throw new \Exception('Reply was empty'); + } + + $parts = array_map('trim', explode(' ', $line)); + + if ($parts[0] !== 'network-status-version') { + throw new \Exception('Reply did not begin with network-status-version, got "' . $line . '".'); + } + + $doc->statusVersion = (int)$parts[1]; + + foreach($reply as $line) { + $parts = explode(' ', $line, 2); + $keyword = $parts[0]; + $extra = $parts[1] ?? null; + + switch ($keyword) { + case 'vote-status': + $doc->voteStatus = $extra; + break; + + case 'consensus-methods': + $doc->consensusMethods = explode(' ', $extra); + break; + + case 'consensus-method': + $doc->consensusMethod = (int)$extra; + break; + + case 'published': + $doc->published = $extra; + break; + + case 'valid-after': + $doc->validAfter = $extra; + break; + + case 'fresh-until': + $doc->freshUntil = $extra; + break; + + case 'valid-until': + $doc->validUntil = $extra; + break; + + case 'voting-delay': + $extra = explode(' ', $extra); + $doc->voteDelaySeconds = (int)$extra[0]; + $doc->distDelaySeconds = (int)$extra[1]; + break; + + case 'client-versions': + $doc->clientVersions = array_map('trim', explode(',', $extra)); + break; + + case 'server-versions': + $doc->serverVersions = array_map('trim', explode(',', $extra)); + break; + + case 'known-flags': + $doc->knownFlags = array_map('trim', explode(' ', $extra)); + break; + + case 'flag-thresholds': + $doc->flagThresholds = $this->parseDelimitedData($extra); + break; + + case 'recommended-client-protocols': + $doc->recommendedClientProtocols = $this->parseDelimitedData($extra); + break; + + case 'recommended-relay-protocols': + $doc->recommendedRelayProtocols = $this->parseDelimitedData($extra); + break; + + case 'required-client-protocols': + $doc->requiredClientProtocols = $this->parseDelimitedData($extra); + break; + + case 'required-relay-protocols': + $doc->requiredRelayProtocols = $this->parseDelimitedData($extra); + break; + + case 'params': + $doc->params = $this->parseDelimitedData($extra); + break; + + case 'shared-rand-current-value': + list($numReveals, $value) = explode(' ', $extra); + $doc->sharedRandCurrentValue = $value; + break; + + case 'shared-rand-previous-value': + list($numReveals, $value) = explode(' ', $extra); + $doc->sharedRandPreviousValue = $value; + break; + + case 'dir-source': + if (!empty($authority)) { + $doc->authorities[] = $authority; + } + + list($nickname, $identity, $hostname, $ip, $dirPort, $orPort) = explode(' ', $extra); + $authority = [ + 'nickname' => $nickname, + 'fingerprint' => $identity, + 'hostname' => $hostname, + 'ip_address' => $ip, + 'dir_port' => $dirPort, + 'or_port' => $orPort, + ]; + break; + + case 'contact': + $authority['contact'] = $extra; + break; + + case 'vote-digest': + $authority['vote-digest'] = $extra; + break; + + case 'shared-rand-participate': + $authority['shared-rand-participate'] = true; + break; + + case 'shared-rand-commit': + if (!isset($authority['shared-rand-commit'])) { + // If a vote contains multiple commits from the same authority, the receiver MUST only consider + // the first commit listed. + $parts = explode(' ', $extra); + $authority['shared-rand-commit'] = [ + 'version' => $parts[0], + 'algname' => $parts[1], + 'identity' => $parts[2], + 'commit' => $parts[3], + 'reveal' => isset($parts[4]) ? $parts[4] : null, + ]; + } + break; + + // authority key certificates + case 'dir-key-certificate-version': + case 'fingerprint': + case 'dir-key-published': + case 'dir-key-expires': + $authority[$keyword] = $extra; + break; + + case 'dir-identity-key': + case 'dir-signing-key': + $authority[$keyword] = $this->_parseRsaKey($reply); + break; + + case 'dir-key-crosscert': + // TODO: Implementations MUST verify that the signature is a correct signature of the hash of the identity key using the signing key. + $authority[$keyword] = $this->_parseBlockData($reply, '-----BEGIN ID SIGNATURE-----', '-----END ID SIGNATURE-----'); + break; + + case 'dir-key-certification': + $authority[$keyword] = $this->_parseBlockData($reply, '-----BEGIN SIGNATURE-----', '-----END SIGNATURE-----'); + break; + + case 'r': + if (!empty($authority)) { + $doc->authorities[] = $authority; + $authority = null; + } + if (isset($descriptor) && $descriptor) { + $doc->descriptors[] = $descriptor; + } + + $descriptor = new RouterDescriptor(); + $descriptor->methods = []; + $descriptor->setArray($this->_parseRLine($line)); + break; + + case 'a': + $descriptor->setArray($this->_parseALine($line)); + break; + + case 's': + $descriptor->setArray($this->_parseSLine($line)); + break; + + case 'v': + $descriptor->setArray($this->_parsePlatform($extra)); + break; + + case 'pr': + $descriptor->setArray($this->_parseProtoVersions($extra)); + break; + + case 'w': + $descriptor->setArray($this->_parseWLine($line)); + break; + + case 'p': + $descriptor->setArray($this->_parsePLine($line)); + break; + + case 'm': + list ($methods, $digest) = explode(' ', $extra); + $methods = array_map('trim', explode(',', $methods)); + $digest = $this->parseDelimitedData($digest); + foreach($methods as $method) { + $descriptor->methods[$method][array_keys($digest)[0]] = array_values($digest)[0]; + } + break; + + case 'id': + $parts = explode(' ', $extra); + $descriptor->ed25519_identity = $parts[1]; + break; + + case 'stats': + $descriptor->stats = $this->parseDelimitedData($extra); + break; + + case 'directory-footer': + if (isset($descriptor) && $descriptor) + $doc->descriptors[] = $descriptor; + break; + + case 'bandwidth-weights': + $doc->bandwidthWeights = array_map('intval', $this->parseDelimitedData($extra)); + break; + + case 'directory-signature': + $parts = explode(' ', $extra); + $alg = 'sha1'; + if (count($parts) == 3) { + $alg = array_shift($parts); + } + $identity = $parts[0]; + $digest = $parts[1]; + $signature = $this->_parseBlockData( + $reply, + '-----BEGIN SIGNATURE-----', + '-----END SIGNATURE-----' + ); + + $doc->directorySignatures[] = [ + 'algorithm' => $alg, + 'identity' => $identity, + 'digest' => $digest, + 'signature' => $signature, + ]; + + break; + + default: + echo "MISSED '$keyword' = '$extra'\n"; + break; + + } + } + + return $doc; + } + /** * Parse directory status reply (v3 directory style) * @@ -177,7 +455,10 @@ public function parseDirectoryStatus(ProtocolReply $reply) $line = substr($line, 4); } - $values = explode(' ', $line, 2); if (sizeof($values) < 2) $values[1] = null; + $values = explode(' ', $line, 2); + if (sizeof($values) < 2) { + $values[1] = null; + } list ($keyword, $value) = $values; if ($keyword == 'router' || ($keyword == 'onion-key' && $mds)) { @@ -508,7 +789,7 @@ private function _parseExtraInfoDigest($line) private function _parseHiddenServiceDir($line) { - if (trim($line) == '') { + if (empty($line) || ($line && trim($line) == '')) { $line = '2'; } @@ -781,18 +1062,21 @@ private function _parseBlockData(ProtocolReply $reply, $startDelimiter, $endDeli $line = $reply->current(); if ($line != $startDelimiter) { - throw new ProtocolError('Expected line beginning with "' . $startDelimiter . '"'); + throw new ProtocolError('Expected line beginning with "' . $startDelimiter . '", got ' . $line); } - $key = $line . "\n"; + $data = $line; do { $reply->next(); - $line = $reply->current(); - $key .= $line . "\n"; - } while ($line && $line != $endDelimter); + if (!$reply->valid()) { + throw new \Exception('Reached end of reply without matching end delimiter "' . $endDelimter . '"'); + } + $line = $reply->current(); + $data .= "\n" . $line; + } while ($reply->valid() && $line != $endDelimter); - return $key; + return $data; } public function parseKeywordArguments($input) diff --git a/src/ProtocolReply.php b/src/ProtocolReply.php index 889bac2..918b23a 100644 --- a/src/ProtocolReply.php +++ b/src/ProtocolReply.php @@ -195,6 +195,12 @@ public function isPositiveReply() } } + public function shift(): mixed + { + $this->dirty = true; + return array_shift($this->lines); + } + /** * (non-PHPdoc) * @see Iterator::rewind()