From 38f73174e22747e51f8e7d994acfdc66868eb82c Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:18:26 -0400 Subject: [PATCH 01/14] [.gitignore] stop docker-compose annoyingness --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6ed95489e41..b28c5e95d40 100644 --- a/.gitignore +++ b/.gitignore @@ -232,6 +232,7 @@ config/* !config/nginx.conf !config/php-fpm.conf !config/php.ini +docker-compose.* ###################### ## VisualStudioCode ## From 2f875458afb675e6a64281517f58bf0dfe35f1a9 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:21:22 -0400 Subject: [PATCH 02/14] [config.default.ini.php] add new encrypted URL setting --- config.default.ini.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config.default.ini.php b/config.default.ini.php index 8f7de832120..b03d2e94191 100644 --- a/config.default.ini.php +++ b/config.default.ini.php @@ -26,6 +26,16 @@ enabled_bridges[] = Youtube enabled_bridges[] = YouTubeCommunityTabBridge +; Encrypted URL key. A random string between 16 and 64 characters long which is used to generate +; compressed and encrypted feed URLs, to keep private information secret. +; +; NEVER SHARE THIS KEY. +; A password generator should be used to create this string. Whitespace is NOT ALLOWED. +; +; If this value is empty (default), then encrypted URLs cannot be used. +; Example key (DO NOT USE THIS): "b3c7@hsLqk)P(SJvjCBDUy]GMg6RamdHxEWV8K9nA4QN.p_5" +enc_url_key = "" + ; Defines the timezone used by RSS-Bridge ; Find a list of supported timezones at ; https://www.php.net/manual/en/timezones.php From d206ce35898c0873378c8fed1308a73479e07351 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:25:27 -0400 Subject: [PATCH 03/14] [formatting] allow 'stringify' to accept Request objects --- actions/DisplayAction.php | 2 +- formats/AtomFormat.php | 2 +- formats/HtmlFormat.php | 31 +++++++++++++++++++++++-------- formats/JsonFormat.php | 2 +- formats/MrssFormat.php | 2 +- formats/PlaintextFormat.php | 2 +- formats/SfeedFormat.php | 2 +- lib/FormatAbstract.php | 2 +- tests/FormatTest.php | 2 +- tests/Formats/BaseFormatTest.php | 2 +- 10 files changed, 32 insertions(+), 17 deletions(-) diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index 93813004f22..64b326d739a 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -165,7 +165,7 @@ private function createResponse(Request $request, BridgeAbstract $bridge, string 'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT', 'content-type' => $format->getMimeType() . '; charset=' . $format->getCharset(), ]; - return new Response($format->stringify(), 200, $headers); + return new Response($format->stringify($request), 200, $headers); } private function createFeedItemFromException($e, BridgeAbstract $bridge): FeedItem diff --git a/formats/AtomFormat.php b/formats/AtomFormat.php index 5c9f2b6acfb..e2a431b2256 100644 --- a/formats/AtomFormat.php +++ b/formats/AtomFormat.php @@ -14,7 +14,7 @@ class AtomFormat extends FormatAbstract protected const ATOM_NS = 'http://www.w3.org/2005/Atom'; protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; - public function stringify() + public function stringify(?Request $request) { $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; diff --git a/formats/HtmlFormat.php b/formats/HtmlFormat.php index 37ef3a930db..23bee4b3d7e 100644 --- a/formats/HtmlFormat.php +++ b/formats/HtmlFormat.php @@ -4,7 +4,7 @@ class HtmlFormat extends FormatAbstract { const MIME_TYPE = 'text/html'; - public function stringify() + public function stringify(?Request $request) { // This query string is url encoded $queryString = $_SERVER['QUERY_STRING']; @@ -13,6 +13,12 @@ public function stringify() $formatFactory = new FormatFactory(); $formats = []; + if (str_contains(strtolower($queryString), strtolower(UrlEncryptionService::PARAMETER_NAME . '='))) { + $encryptionToken = 'yes'; + } else { + $encryptionToken = null; + } + // Create all formats (except HTML) $formatNames = $formatFactory->getFormatNames(); foreach ($formatNames as $formatName) { @@ -20,7 +26,14 @@ public function stringify() continue; } // The format url is relative, but should be absolute in order to help feed readers. - $formatUrl = '?' . str_ireplace('format=Html', 'format=' . $formatName, $queryString); + if (str_contains(strtolower($queryString), 'format=html')) { + $formatUrl = '?' . str_ireplace('format=Html', 'format=' . $formatName, $queryString); + } else { + // If we're viewing the HtmlFormat and the 'format' GET parameter isn't here, this is likely an + // encrypted URL being viewed. Handle this by reconstructing the raw URL with the new format. + $formatUrl = '?' . http_build_query($request->toArray()); + $formatUrl .= (strlen($formatUrl) > 1 ? '&' : '') . 'format=' . $formatName; + } $formatObject = $formatFactory->create($formatName); $formats[] = [ 'url' => $formatUrl, @@ -48,12 +61,14 @@ public function stringify() } $html = render_template(__DIR__ . '/../templates/html-format.html.php', [ - 'charset' => $this->getCharset(), - 'title' => $feedArray['name'], - 'formats' => $formats, - 'uri' => $feedArray['uri'], - 'items' => $items, - 'donation_uri' => $donationUri, + 'charset' => $this->getCharset(), + 'title' => $feedArray['name'], + 'formats' => $formats, + 'uri' => $feedArray['uri'], + 'items' => $items, + 'donation_uri' => $donationUri, + 'encryption_token' => $encryptionToken, + 'bridge_name' => $request->get('bridge'), ]); // Remove invalid characters ini_set('mbstring.substitute_character', 'none'); diff --git a/formats/JsonFormat.php b/formats/JsonFormat.php index 586aae0afba..c844d0190f8 100644 --- a/formats/JsonFormat.php +++ b/formats/JsonFormat.php @@ -23,7 +23,7 @@ class JsonFormat extends FormatAbstract 'uid', ]; - public function stringify() + public function stringify(?Request $request) { $feedArray = $this->getFeed(); diff --git a/formats/MrssFormat.php b/formats/MrssFormat.php index aaa1d0cd1b8..dcbe6fe95d1 100644 --- a/formats/MrssFormat.php +++ b/formats/MrssFormat.php @@ -32,7 +32,7 @@ class MrssFormat extends FormatAbstract protected const ATOM_NS = 'http://www.w3.org/2005/Atom'; protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; - public function stringify() + public function stringify(?Request $request) { $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; diff --git a/formats/PlaintextFormat.php b/formats/PlaintextFormat.php index 4e18caa6058..0bc95c64fa0 100644 --- a/formats/PlaintextFormat.php +++ b/formats/PlaintextFormat.php @@ -4,7 +4,7 @@ class PlaintextFormat extends FormatAbstract { const MIME_TYPE = 'text/plain'; - public function stringify() + public function stringify(?Request $request) { $feed = $this->getFeed(); foreach ($this->getItems() as $item) { diff --git a/formats/SfeedFormat.php b/formats/SfeedFormat.php index 33740aaa863..bc59b7b7c93 100644 --- a/formats/SfeedFormat.php +++ b/formats/SfeedFormat.php @@ -4,7 +4,7 @@ class SfeedFormat extends FormatAbstract { const MIME_TYPE = 'text/plain'; - public function stringify() + public function stringify(?Request $request) { $text = ''; foreach ($this->getItems() as $item) { diff --git a/lib/FormatAbstract.php b/lib/FormatAbstract.php index 28eb4bbfa7a..dc6590c9591 100644 --- a/lib/FormatAbstract.php +++ b/lib/FormatAbstract.php @@ -12,7 +12,7 @@ abstract class FormatAbstract protected array $feed = []; - abstract public function stringify(); + abstract public function stringify(?Request $request); public function setFeed(array $feed) { diff --git a/tests/FormatTest.php b/tests/FormatTest.php index b5df395cccd..41ec92d3858 100644 --- a/tests/FormatTest.php +++ b/tests/FormatTest.php @@ -58,7 +58,7 @@ public function testBridge() class TestFormat extends \FormatAbstract { - public function stringify() + public function stringify(?\Request $request) { } } diff --git a/tests/Formats/BaseFormatTest.php b/tests/Formats/BaseFormatTest.php index 8999e7722af..d72ea1c5998 100644 --- a/tests/Formats/BaseFormatTest.php +++ b/tests/Formats/BaseFormatTest.php @@ -64,6 +64,6 @@ protected function formatData(string $formatName, \stdClass $sample): string $format->setFeed($sample->meta); $format->setLastModified(strtotime('2000-01-01 12:00:00 UTC')); - return $format->stringify(); + return $format->stringify(null); } } From 774285112b911d65e4b0576ffaf16becf80319b7 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:26:40 -0400 Subject: [PATCH 04/14] [URLEncryptionService lib] create service for URL crypto --- lib/UrlEncryptionService.php | 173 +++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 lib/UrlEncryptionService.php diff --git a/lib/UrlEncryptionService.php b/lib/UrlEncryptionService.php new file mode 100644 index 00000000000..f175730bac0 --- /dev/null +++ b/lib/UrlEncryptionService.php @@ -0,0 +1,173 @@ +rawTokenFromRequest = $rawToken; + $this->key = self::getKey(); + } + + public static function generateFromQueryString(string $q): string + { + if (!self::enabled()) { + throw new \Exception('URL encryption is not enabled (an empty key cannot be used).'); + } + + // Always trim off leading '?' marks if they appear in the input. + if (str_starts_with($q, '?')) { + $q = substr($q, 1); + } + + if (!$q) { + throw new \Exception('The incoming query string to encrypt cannot be empty.'); + } + + if (!in_array(self::CIPHER, openssl_get_cipher_methods())) { + throw new \Exception('The cipher "' . self::CIPHER . '" is not supported for this RSS-Bridge instance.'); + } + + $ivLength = openssl_cipher_iv_length(self::CIPHER); + $iv = openssl_random_pseudo_bytes($ivLength); + + // Encrypt the compressed data. + $cipherText = openssl_encrypt( + $q, + self::CIPHER, + self::getKey(), + 0, + $iv + ); + + if (!$cipherText) { + throw new \Exception('Failed to generate an encrypted URL (invalid ciphertext).'); + } + + // The object to marshal later is in the concatenated format below. + // $raw[:1] = one-byte length of the init vector (n) + // $raw[1:n] = raw init vector + // $raw[n:] = decoded (raw) ciphertext + // + // This same structure is used when decoding and decrypting incoming tokens to + // rebuild the original query string. + $raw = chr($ivLength & 0xFF); + $raw .= $iv; + $raw .= base64_decode($cipherText); + + return base64_encode($raw); + } + + public static function enabled(): bool + { + return !!self::getKey(); + } + + public static function getKey(): ?string + { + $key = trim(Configuration::getConfig('system', 'enc_url_key', '')); + + if (!$key) { + // No key means the URL encryption feature is disabled. + return null; + } + + if ($key === 'b3c7@hsLqk)P(SJvjCBDUy]GMg6RamdHxEWV8K9nA4QN.p_5') { + throw new \Exception('You cannot use the example URL encryption key... Don\'t be lazy.'); + } + + if (strlen($key) > 64 || strlen($key) < 16) { + throw new \Exception('The URL encryption key must be between 16 and 64 characters long.'); + } + + if (preg_match('#\s#', $key)) { + throw new \Exception('The URL encryption key cannot contain whitespace.'); + } + + return $key; + } + + public static function fromRequest(Request &$request): ?self + { + if (!self::enabled()) { + return null; + } + + self::$instance = new self($request->get(self::PARAMETER_NAME)); + self::$instance->decrypt(); + + return self::$instance; + } + + public function toArray(): array + { + return $this->extractedContext; + } + + private function decrypt(): void + { + if (!$this->key) { + throw new \Exception('URL encryption is not enabled (an empty key cannot be used).'); + } + + if (!$this->rawTokenFromRequest) { + throw new \Exception('The request does not contain a decrypt-able token.'); + } + + $t = $this->rawTokenFromRequest; + $ivLength = ord($t[0]); + + if (!$t) { + throw new \Exception('Invalid token base64 value.'); + } elseif (!$ivLength || $ivLength > 32) { + throw new \Exception('Invalid initialization vector length.'); + } elseif ($ivLength >= strlen($t)) { + throw new \Exception('No payload to decrypt.'); + } + + $iv = substr($t, 1, $ivLength); + $cipherText = base64_encode(substr($t, $ivLength + 1)); + + $originalQuery = openssl_decrypt( + $cipherText, + self::CIPHER, + self::getKey(), + 0, + $iv + ); + + if (!$originalQuery) { + throw new \Exception('Failed to decrypt the given token.'); + } + + $result = []; + parse_str($originalQuery, $result); + + if (!count($result)) { + throw new \Exception('The encrypted token did not result in a parseable query string.'); + } + + // Finally, set the extracted context store and put the _eut back in. + // This gets bubbled up to the 'get' container of a Request instance later. + $this->extractedContext = $result; + } +} \ No newline at end of file From ca2aeb948f6d3f6091612e041b2ef8d2c1d6eb2e Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:27:22 -0400 Subject: [PATCH 05/14] [http (lib)] add method to try decrypting a URL token --- lib/http.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/http.php b/lib/http.php index 39f0c72710f..85cd15c6cc8 100644 --- a/lib/http.php +++ b/lib/http.php @@ -218,6 +218,16 @@ public function toArray(): array { return $this->get; } + + public function tryDecryptUrl(): void + { + $urlEncryptionService = UrlEncryptionService::fromRequest($this); + if (!$urlEncryptionService) { + throw new \Exception('The encrypted URL token is not valid.'); + } + + $this->get = $urlEncryptionService->toArray(); + } } final class Response From 06c5fc7a7440640eefdc292c975899358954e81d Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:28:11 -0400 Subject: [PATCH 06/14] [validator (lib)] do not validate '_eut' inputs --- lib/ParameterValidator.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/ParameterValidator.php b/lib/ParameterValidator.php index e2783586210..8a2aa3cb7c3 100644 --- a/lib/ParameterValidator.php +++ b/lib/ParameterValidator.php @@ -10,7 +10,13 @@ public function validateInput(array &$input, $contexts): array $errors = []; foreach ($input as $name => $value) { + if ($name === UrlEncryptionService::PARAMETER_NAME && UrlEncryptionService::enabled()) { + // Do not validate against encrypted URL tokens. + continue; + } + $registered = false; + foreach ($contexts as $contextName => $contextParameters) { if (!array_key_exists($name, $contextParameters)) { continue; From 315bc242e78b06e590ba56073ce39e83dc35a4e5 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:28:55 -0400 Subject: [PATCH 07/14] [URLEncryptionService tests] add functional testing --- tests/UrlEncryptionTest.php | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/UrlEncryptionTest.php diff --git a/tests/UrlEncryptionTest.php b/tests/UrlEncryptionTest.php new file mode 100644 index 00000000000..65f1b65a7e8 --- /dev/null +++ b/tests/UrlEncryptionTest.php @@ -0,0 +1,69 @@ +assertEquals(null, UrlEncryptionService::getKey()); + + Configuration::loadConfiguration([ + 'system' => [ + 'enc_url_key' => '1234567890123456789012345678901234567890' + ] + ]); + $this->assertEquals('1234567890123456789012345678901234567890', UrlEncryptionService::getKey()); + } + + public function testEnabled(): void + { + Configuration::loadConfiguration([ + 'system' => [ + 'enc_url_key' => 'aBigStupidDummyKeyForEncryptingURLs' + ] + ]); + $this->assertTrue(UrlEncryptionService::enabled()); + + Configuration::loadConfiguration([ + 'system' => [ + 'enc_url_key' => '' + ] + ]); + $this->assertTrue(!UrlEncryptionService::enabled()); + } + + // NOTE: Testing encryption on its own with a 'known' key isn't really + // feasible because the initialization vector changes with each call. + public function testEncryptionAndDecryption(): void + { + Configuration::loadConfiguration([ + 'system' => [ + 'enc_url_key' => 'aBigStupidDummyKeyForEncryptingURLs' + ] + ]); + + $q = 'action=display&bridge=TheHackerNewsBridge&format=Html'; + + $params = []; + parse_str($q, $params); + + $enc = UrlEncryptionService::generateFromQueryString($q); + $req = Request::fromCli([ + UrlEncryptionService::PARAMETER_NAME => $enc + ]); + + $req->tryDecryptUrl(); + + $this->assertEquals('display', $req->get('action')); + $this->assertEquals('TheHackerNewsBridge', $req->get('bridge')); + $this->assertEquals('Html', $req->get('format')); + } +} \ No newline at end of file From 51a4b990b82f1b1ccbfaf5f57f723b5f77ed6ed1 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:31:30 -0400 Subject: [PATCH 08/14] [template] correct a cardinal PHP sin --- templates/html-format.html.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/html-format.html.php b/templates/html-format.html.php index bc95c5d04e7..6f1fcc1081d 100644 --- a/templates/html-format.html.php +++ b/templates/html-format.html.php @@ -30,7 +30,7 @@
- + From 038eeb3b6fe18bad640a1ba514fe71ee7d4592fb Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:33:44 -0400 Subject: [PATCH 09/14] [BridgeAbstract] seal 'getParameters' (no method overrides) and enforce no definition of bridge inputs with the enc token key name --- lib/BridgeAbstract.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/BridgeAbstract.php b/lib/BridgeAbstract.php index 2467dec60e1..a0c87e6d96e 100644 --- a/lib/BridgeAbstract.php +++ b/lib/BridgeAbstract.php @@ -104,9 +104,27 @@ public function getMaintainer(): string /** * A more correct method name would have been "getContexts" */ - public function getParameters(): array + final public function getParameters(): array { - return static::PARAMETERS; + $parameters = static::PARAMETERS; + + if (UrlEncryptionService::enabled()) { + // A parameter cannot be defined which collides with the special encryption parameter. + $illegalToken = array_key_exists(UrlEncryptionService::PARAMETER_NAME, $parameters); + foreach ($parameters as $k => $v) { + $illegalToken |= array_key_exists(UrlEncryptionService::PARAMETER_NAME, $v); + } + + if ($illegalToken) { + throw new \Exception( + 'The parameter name "' . UrlEncryptionService::PARAMETER_NAME + . '" is reserved for encrypted URLs. Remove this from the PARAMETERS definition in bridge "' + . $this->getName() . '".' + ); + } + } + + return $parameters; } public function getItems() From 64cad86dc15572c2a5a17cb96e1a269d6d8976b4 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:36:34 -0400 Subject: [PATCH 10/14] [RssBridge] rearrange some early 'main' method logic: maint mode should come first, followed by no auth, then enc checking, then finally normal processing --- lib/RssBridge.php | 51 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/lib/RssBridge.php b/lib/RssBridge.php index 1bb5f5ea452..dcbe62daae5 100644 --- a/lib/RssBridge.php +++ b/lib/RssBridge.php @@ -25,21 +25,8 @@ public function __construct() public function main(array $argv = []): Response { - if ($argv) { - parse_str(implode('&', array_slice($argv, 1)), $cliArgs); - $request = Request::fromCli($cliArgs); - } else { - $request = Request::fromGlobals(); - } - - foreach ($request->toArray() as $key => $value) { - if (!is_string($value)) { - return new Response(render(__DIR__ . '/../templates/error.html.php', [ - 'message' => "Query parameter \"$key\" is not a string.", - ]), 400); - } - } - + // The check for maintenance mode should always occur first since it has no + // dependencies, and nothing else needs to come before it for bootstrapping. if (Configuration::getConfig('system', 'enable_maintenance_mode')) { return new Response(render(__DIR__ . '/../templates/error.html.php', [ 'title' => '503 Service Unavailable', @@ -47,6 +34,13 @@ public function main(array $argv = []): Response ]), 503); } + if ($argv) { + parse_str(implode('&', array_slice($argv, 1)), $cliArgs); + $request = Request::fromCli($cliArgs); + } else { + $request = Request::fromGlobals(); + } + // HTTP Basic auth check if (Configuration::getConfig('authentication', 'enable')) { if (Configuration::getConfig('authentication', 'password') === '') { @@ -72,6 +66,33 @@ public function main(array $argv = []): Response // At this point the username and password was correct } + // If the URL contains an encrypted token, then the rest of the current URL + // parameters are discarded and the encrypted token is decrypted, decompressed, + // and expanded into the Request object's 'get' container. The user should NEVER + // be redirected to another URL that would expose what params that are in the + // current page. + if ( + $request->get(UrlEncryptionService::PARAMETER_NAME) + && UrlEncryptionService::enabled() + ) { + try { + $request->tryDecryptUrl(); + } catch (\Exception $e) { + return new Response( + render(__DIR__ . '/../templates/error.html.php', ['message' => $e->getMessage()]), + 401 + ); + } + } + + foreach ($request->toArray() as $key => $value) { + if (!is_string($value)) { + return new Response(render(__DIR__ . '/../templates/error.html.php', [ + 'message' => "Query parameter \"$key\" is not a string.", + ]), 400); + } + } + // Add token as attribute to request $request = $request->withAttribute('token', $request->get('token')); From cbbc0e64f9e1cedd01952cdf4801fae66c9b1ef7 Mon Sep 17 00:00:00 2001 From: Zack Puhl Date: Mon, 29 Jul 2024 19:38:20 -0400 Subject: [PATCH 11/14] [template (HTML)] add option to get encrypted URL (and toggle based on its presence) --- templates/html-format.html.php | 57 +++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/templates/html-format.html.php b/templates/html-format.html.php index 6f1fcc1081d..a9f019a6805 100644 --- a/templates/html-format.html.php +++ b/templates/html-format.html.php @@ -29,19 +29,62 @@ + +
+
+ + 🔒 Encrypt This Feed + +
+

Obscure the parameters used to create this feed. This is essential if your + feed includes private data like API tokens, passphrases, etc.

+

+ If you specifically want an encrypted link to another format, click the button below and choose + a format from the generated page. +

+

+ + + + +

+
+
+
+ +
+

 Encrypted

+
+ +
- - - - + + + + + + + - - -

-
- -
- -
-

 Encrypted

-
+ + +
+
+ + 🔒 Encrypt This Feed + +
+

Obscure the parameters used to create this feed. This is essential if your + feed includes private data like API tokens, passphrases, etc.

+

+ If you specifically want an encrypted link to another format, click the button below and choose + a format from the generated page. +

+

+ + + + +

+
+
+
+ +
+

 Encrypted

+
+