diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -41,6 +41,7 @@ 'CommandException' => 'future/exec/CommandException.php', 'ConduitClient' => 'conduit/ConduitClient.php', 'ConduitClientException' => 'conduit/ConduitClientException.php', + 'ConduitClientTestCase' => 'conduit/__tests__/ConduitClientTestCase.php', 'ConduitFuture' => 'conduit/ConduitFuture.php', 'ExecFuture' => 'future/exec/ExecFuture.php', 'ExecFutureTestCase' => 'future/exec/__tests__/ExecFutureTestCase.php', @@ -493,6 +494,7 @@ 'BaseHTTPFuture' => 'Future', 'CommandException' => 'Exception', 'ConduitClientException' => 'Exception', + 'ConduitClientTestCase' => 'PhutilTestCase', 'ConduitFuture' => 'FutureProxy', 'ExecFuture' => 'Future', 'ExecFutureTestCase' => 'PhutilTestCase', diff --git a/src/conduit/ConduitClient.php b/src/conduit/ConduitClient.php --- a/src/conduit/ConduitClient.php +++ b/src/conduit/ConduitClient.php @@ -8,6 +8,12 @@ private $timeout = 300.0; private $username; private $password; + private $publicKey; + private $privateKey; + + const AUTH_ASYMMETRIC = 'asymmetric'; + + const SIGNATURE_CONSIGN_1 = 'Consign1.0/'; public function getConnectionID() { return $this->connectionID; @@ -37,6 +43,15 @@ return $this; } + public function setSigningKeys( + $public_key, + PhutilOpaqueEnvelope $private_key) { + + $this->publicKey = $public_key; + $this->privateKey = $private_key; + return $this; + } + public function callMethod($method, array $params) { $meta = array(); @@ -59,6 +74,15 @@ unset($params['certificate']); } + if ($this->privateKey && $this->publicKey) { + $meta['auth.type'] = self::AUTH_ASYMMETRIC; + $meta['auth.key'] = $this->publicKey; + $meta['auth.host'] = $this->getHostString(); + + $signature = $this->signRequest($method, $params, $meta); + $meta['auth.signature'] = $signature; + } + if ($meta) { $params['__conduit__'] = $meta; } @@ -102,4 +126,191 @@ return $this; } + private function getHostString() { + $uri = new PhutilURI($this->uri); + $host = $uri->getDomain(); + $port = $uri->getPort(); + if (!$port) { + switch ($uri->getProtocol()) { + case 'https': + $port = 443; + break; + default: + $port = 80; + break; + } + } + + return $host.':'.$port; + } + + private function signRequest( + $method, + array $params, + array $meta) { + + $input = self::encodeRequestDataForSignature( + $method, + $params, + $meta); + + $signature = null; + $result = openssl_sign( + $input, + $signature, + $this->privateKey->openEnvelope()); + if (!$result) { + throw new Exception('Unable to sign Conduit request with signing key.'); + } + + return self::SIGNATURE_CONSIGN_1.base64_encode($signature); + } + + public static function verifySignature( + $method, + array $params, + array $meta, + $openssl_public_key) { + + $auth_type = idx($meta, 'auth.type'); + switch ($auth_type) { + case self::AUTH_ASYMMETRIC: + break; + default: + throw new Exception( + pht( + 'Unable to verify request signature, specified "auth.type" '. + '("%s") is unknown.', + $auth_type)); + } + + $public_key = idx($meta, 'auth.key'); + if (!strlen($public_key)) { + throw new Exception( + pht( + 'Unable to verify request signature, no "auth.key" present in '. + 'request protocol information.')); + } + + $signature = idx($meta, 'auth.signature'); + if (!strlen($signature)) { + throw new Exception( + pht( + 'Unable to verify request signature, no "auth.signature" present '. + 'in request protocol information.')); + } + + $prefix = self::SIGNATURE_CONSIGN_1; + if (strncmp($signature, $prefix, strlen($prefix)) !== 0) { + throw new Exception( + pht( + 'Unable to verify request signature, signature format is not '. + 'known.')); + } + $signature = substr($signature, strlen($prefix)); + + $input = self::encodeRequestDataForSignature( + $method, + $params, + $meta); + + $signature = base64_decode($signature); + + $trap = new PhutilErrorTrap(); + $result = @openssl_verify( + $input, + $signature, + $openssl_public_key); + $err = $trap->getErrorsAsString(); + $trap->destroy(); + + if ($result === 1) { + // Signature is good. + return true; + } else if ($result === 0) { + // Signature is bad. + throw new Exception( + pht( + 'Request signature verification failed: signature is not correct.')); + } else { + // Some kind of error. + if (strlen($err)) { + throw new Exception( + pht( + 'OpenSSL encountered an error verifying the request signature: '. + '%s', + $err)); + } else { + throw new Exception( + pht( + 'OpenSSL encountered an unknown error verifying the request.', + $err)); + } + } + } + + private static function encodeRequestDataForSignature( + $method, + array $params, + array $meta) { + + unset($meta['auth.signature']); + + $structure = array( + 'method' => $method, + 'protocol' => $meta, + 'parameters' => $params, + ); + + return self::encodeRawDataForSignature($structure); + } + + public static function encodeRawDataForSignature($data) { + $out = array(); + + if (is_array($data)) { + if (!$data || (array_keys($data) == range(0, count($data) - 1))) { + $out[] = 'A'; + $out[] = count($data); + $out[] = ':'; + foreach ($data as $value) { + $out[] = self::encodeRawDataForSignature($value); + } + } else { + ksort($data); + $out[] = 'O'; + $out[] = count($data); + $out[] = ':'; + foreach ($data as $key => $value) { + $out[] = self::encodeRawDataForSignature($key); + $out[] = self::encodeRawDataForSignature($value); + } + } + } else if (is_string($data)) { + $out[] = 'S'; + $out[] = strlen($data); + $out[] = ':'; + $out[] = $data; + } else if (is_integer($data)) { + $out[] = 'I'; + $out[] = strlen((string)$data); + $out[] = ':'; + $out[] = (string)$data; + } else if (is_null($data)) { + $out[] = 'N'; + $out[] = ':'; + } else if ($data === true) { + $out[] = 'B1:'; + } else if ($data === false) { + $out[] = 'B0:'; + } else { + throw new Exception( + pht( + 'Unexpected data type in request data: %s.', + gettype($data))); + } + + return implode('', $out); + } + } diff --git a/src/conduit/__tests__/ConduitClientTestCase.php b/src/conduit/__tests__/ConduitClientTestCase.php new file mode 100644 --- /dev/null +++ b/src/conduit/__tests__/ConduitClientTestCase.php @@ -0,0 +1,34 @@ + array( + 'nothing' => null, + 'emptystring' => '', + ), + 'empty' => array( + ), + 'list' => array( + 15, + 'quack', + true, + false, + ), + 'a' => array( + 'key' => 'value', + 'key2' => 'value2', + ), + ); + + $expect = + 'O4:S1:aO2:S3:keyS5:valueS4:key2S6:value2S5:emptyA0:S4:listA4:I2:15'. + 'S5:quackB1:B0:S1:zO2:S11:emptystringS0:S7:nothingN:'; + + $this->assertEqual( + $expect, + ConduitClient::encodeRawDataForSignature($input)); + } + +}