diff --git a/.idea/socialbox-php.iml b/.idea/socialbox-php.iml index c5da166..11e3fb9 100644 --- a/.idea/socialbox-php.iml +++ b/.idea/socialbox-php.iml @@ -2,7 +2,7 @@ - + diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index bb99828..db6a2e3 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -2,14 +2,14 @@ + - - + diff --git a/composer.json b/composer.json index 2386ce4..fc11b64 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "ext-redis": "*", "ext-memcached": "*", "ext-curl": "*", - "ext-gd": "*" + "ext-gd": "*", + "ext-sodium": "*" } } \ No newline at end of file diff --git a/src/Socialbox/Classes/CliCommands/DnsRecordCommand.php b/src/Socialbox/Classes/CliCommands/DnsRecordCommand.php index a066af6..28ee6d5 100644 --- a/src/Socialbox/Classes/CliCommands/DnsRecordCommand.php +++ b/src/Socialbox/Classes/CliCommands/DnsRecordCommand.php @@ -1,46 +1,45 @@ getRpcEndpoint(), - Configuration::getInstanceConfiguration()->getPublicKey() - ); + /** + * @inheritDoc + */ + public static function execute(array $args): int + { + $txt_record = sprintf('v=socialbox;sb-rpc=%s;sb-key=%s', + Configuration::getInstanceConfiguration()->getRpcEndpoint(), + Configuration::getCryptographyConfiguration()->getHostPublicKey() + ); - Logger::getLogger()->info('Please set the following DNS TXT record for the domain:'); - Logger::getLogger()->info(sprintf(' %s', $txt_record)); - return 0; - } + Logger::getLogger()->info('Please set the following DNS TXT record for the domain:'); + Logger::getLogger()->info(sprintf(' %s', $txt_record)); + return 0; + } - /** - * @inheritDoc - */ - public static function getHelpMessage(): string - { - return <<info('cache.database defaulting to 0'); } + Logger::getLogger()->info('Updating configuration...'); Configuration::getConfigurationLib()->save(); // Save Configuration::reload(); // Reload } @@ -261,16 +260,17 @@ } if( - !Configuration::getInstanceConfiguration()->getPublicKey() || - !Configuration::getInstanceConfiguration()->getPrivateKey() || - !Configuration::getInstanceConfiguration()->getEncryptionKeys() + !Configuration::getCryptographyConfiguration()->getHostPublicKey() || + !Configuration::getCryptographyConfiguration()->getHostPrivateKey() || + !Configuration::getCryptographyConfiguration()->getHostPublicKey() ) { + $expires = time() + 31536000; + try { - Logger::getLogger()->info('Generating new key pair...'); - $keyPair = Cryptography::generateKeyPair(); - $encryptionKeys = Cryptography::randomKeyS(230, 314, Configuration::getInstanceConfiguration()->getEncryptionKeysCount()); + Logger::getLogger()->info('Generating new key pair (expires ' . date('Y-m-d H:i:s', $expires) . ')...'); + $signingKeyPair = Cryptography::generateSigningKeyPair(); } catch (CryptographyException $e) { @@ -278,40 +278,35 @@ return 1; } - Logger::getLogger()->info('Updating configuration...'); - Configuration::getConfigurationLib()->set('instance.private_key', $keyPair->getPrivateKey()); - Configuration::getConfigurationLib()->set('instance.public_key', $keyPair->getPublicKey()); - Configuration::getConfigurationLib()->set('instance.encryption_keys', $encryptionKeys); - Configuration::getConfigurationLib()->save(); // Save - Configuration::reload(); // Reload - - Logger::getLogger()->info(sprintf('Set the DNS TXT record for the domain %s to the following value:', Configuration::getInstanceConfiguration()->getDomain())); - Logger::getLogger()->info(sprintf("v=socialbox;sb-rpc=%s;sb-key=%s;", - Configuration::getInstanceConfiguration()->getRpcEndpoint(), $keyPair->getPublicKey() - )); + Configuration::getConfigurationLib()->set('cryptography.host_keypair_expires', $expires); + Configuration::getConfigurationLib()->set('cryptography.host_private_key', $signingKeyPair->getPrivateKey()); + Configuration::getConfigurationLib()->set('cryptography.host_public_key', $signingKeyPair->getPublicKey()); } - try + // If Internal Encryption keys are null or has less keys than configured, populate the configuration + // property with encryption keys. + if( + Configuration::getCryptographyConfiguration()->getInternalEncryptionKeys() === null || + count(Configuration::getCryptographyConfiguration()->getInternalEncryptionKeys()) < Configuration::getCryptographyConfiguration()->getEncryptionKeysCount()) { - if(EncryptionRecordsManager::getRecordCount() < Configuration::getInstanceConfiguration()->getEncryptionRecordsCount()) + Logger::getLogger()->info('Generating internal encryption keys...'); + $encryptionKeys = Configuration::getCryptographyConfiguration()->getInternalEncryptionKeys() ?? []; + while(count($encryptionKeys) < Configuration::getCryptographyConfiguration()->getEncryptionKeysCount()) { - Logger::getLogger()->info('Generating encryption records...'); - EncryptionRecordsManager::generateRecords(Configuration::getInstanceConfiguration()->getEncryptionRecordsCount()); + $encryptionKeys[] = Cryptography::generateEncryptionKey(Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); } - } - catch (CryptographyException $e) - { - Logger::getLogger()->error('Failed to generate encryption records due to a cryptography exception', $e); - return 1; - } - catch (DatabaseOperationException $e) - { - Logger::getLogger()->error('Failed to generate encryption records due to a database error', $e); - return 1; + + Configuration::getConfigurationLib()->set('cryptography.internal_encryption_keys', $encryptionKeys); } - // TODO: Create a host peer here? + Logger::getLogger()->info('Updating configuration...'); + Configuration::getConfigurationLib()->save();; + Configuration::reload(); + Logger::getLogger()->info('Socialbox has been initialized successfully'); + Logger::getLogger()->info(sprintf('Set the DNS TXT record for the domain %s to the following value:', Configuration::getInstanceConfiguration()->getDomain())); + Logger::getLogger()->info(Socialbox::getDnsRecord()); + if(getenv('SB_MODE') === 'automated') { Configuration::getConfigurationLib()->set('instance.enabled', true); diff --git a/src/Socialbox/Classes/ClientCommands/ConnectCommand.php b/src/Socialbox/Classes/ClientCommands/ConnectCommand.php deleted file mode 100644 index e2229b8..0000000 --- a/src/Socialbox/Classes/ClientCommands/ConnectCommand.php +++ /dev/null @@ -1,117 +0,0 @@ -error('The name argument is required, this is the name of the session'); - } - - $workingDirectory = getcwd(); - - if(isset($args['directory'])) - { - if(!is_dir($args['directory'])) - { - Logger::getLogger()->error('The directory provided does not exist'); - return 1; - } - - $workingDirectory = $args['directory']; - } - - $sessionFile = $workingDirectory . DIRECTORY_SEPARATOR . Utilities::sanitizeFileName($args['name']) . '.json'; - - if(!file_exists($sessionFile)) - { - return self::createSession($args, $sessionFile); - } - - Logger::getLogger()->info(sprintf('Session file already exists at %s', $sessionFile)); - return 0; - } - - private static function createSession(array $args, string $sessionFile): int - { - if(!isset($args['domain'])) - { - Logger::getLogger()->error('The domain argument is required, this is the domain of the socialbox instance'); - return 1; - } - - try - { - $client = new SocialClient($args['domain']); - } - catch (DatabaseOperationException $e) - { - Logger::getLogger()->error('Failed to create the client session', $e); - return 1; - } - catch (ResolutionException $e) - { - Logger::getLogger()->error('Failed to resolve the domain', $e); - return 1; - } - - try - { - $keyPair = Cryptography::generateKeyPair(); - $session = $client->createSession($keyPair); - } - catch (CryptographyException | RpcException $e) - { - Logger::getLogger()->error('Failed to create the session', $e); - return 1; - } - - $sessionData = new ClientSession([ - 'domain' => $args['domain'], - 'session_uuid' => $session, - 'public_key' => $keyPair->getPublicKey(), - 'private_key' => $keyPair->getPrivateKey() - ]); - - $sessionData->save($sessionFile); - Logger::getLogger()->info(sprintf('Session created and saved to %s', $sessionFile)); - return 0; - } - - public static function getHelpMessage(): string - { - return << --domain [--directory ] - -Creates a new session with the specified name and domain. The session will be saved to the current working directory by default, or to the specified directory if provided. - -Options: - --name The name of the session to create. - --domain The domain of the socialbox instance. - --directory The directory where the session file should be saved. - -Example: - socialbox connect --name mysession --domain socialbox.example.com -HELP; - - } - - public static function getShortHelpMessage(): string - { - return 'Connect Command - Creates a new session with the specified name and domain'; - } -} \ No newline at end of file diff --git a/src/Socialbox/Classes/Configuration.php b/src/Socialbox/Classes/Configuration.php index b1aa90b..09fd208 100644 --- a/src/Socialbox/Classes/Configuration.php +++ b/src/Socialbox/Classes/Configuration.php @@ -2,19 +2,21 @@ namespace Socialbox\Classes; - use Socialbox\Classes\ClientCommands\StorageConfiguration; use Socialbox\Classes\Configuration\CacheConfiguration; + use Socialbox\Classes\Configuration\CryptographyConfiguration; use Socialbox\Classes\Configuration\DatabaseConfiguration; use Socialbox\Classes\Configuration\InstanceConfiguration; use Socialbox\Classes\Configuration\LoggingConfiguration; use Socialbox\Classes\Configuration\RegistrationConfiguration; use Socialbox\Classes\Configuration\SecurityConfiguration; + use Socialbox\Classes\Configuration\StorageConfiguration; class Configuration { private static ?\ConfigLib\Configuration $configuration = null; private static ?InstanceConfiguration $instanceConfiguration = null; private static ?SecurityConfiguration $securityConfiguration = null; + private static ?CryptographyConfiguration $cryptographyConfiguration = null; private static ?DatabaseConfiguration $databaseConfiguration = null; private static ?LoggingConfiguration $loggingConfiguration = null; private static ?CacheConfiguration $cacheConfiguration = null; @@ -33,19 +35,47 @@ // Instance configuration $config->setDefault('instance.enabled', false); // False by default, requires the user to enable it. + $config->setDefault('instance.name', "Socialbox Server"); $config->setDefault('instance.domain', null); $config->setDefault('instance.rpc_endpoint', null); - $config->setDefault('instance.encryption_keys_count', 5); - $config->setDefault('instance.encryption_records_count', 5); - $config->setDefault('instance.private_key', null); - $config->setDefault('instance.public_key', null); - $config->setDefault('instance.encryption_keys', null); // Security Configuration $config->setDefault('security.display_internal_exceptions', false); $config->setDefault('security.resolved_servers_ttl', 600); $config->setDefault('security.captcha_ttl', 200); + // Cryptography Configuration + // The Unix Timestamp for when the host's keypair should expire + // Setting this value to 0 means the keypair never expires + // Setting this value to null will automatically set the current unix timestamp + 1 year as the value + // This means at initialization, the key is automatically set to expire in a year. + $config->setDefault('cryptography.host_keypair_expires', null); + // The host's public/private keypair in base64 encoding, when null; the initialization process + // will automatically generate a new keypair + $config->setDefault('cryptography.host_public_key', null); + $config->setDefault('cryptography.host_private_key', null); + + // The internal encryption keys used for encrypting data in the database when needed. + // When null, the initialization process will automatically generate a set of keys + // based on the `encryption_keys_count` and `encryption_keys_algorithm` configuration. + // This is an array of base64 encoded keys. + $config->setDefault('cryptography.internal_encryption_keys', null); + + // The number of encryption keys to generate and set to `instance.encryption_keys` this will be used + // to randomly encrypt/decrypt sensitive data in the database, this includes hashes. + // The higher the number the higher performance impact it will have on the server + $config->setDefault('cryptography.encryption_keys_count', 10); + // The host's encryption algorithm, this will be used to generate a set of encryption keys + // This is for internal encryption, these keys are never shared outside this configuration. + // Recommendation: Higher security over performance + $config->setDefault('cryptography.encryption_keys_algorithm', 'xchacha20'); + + // The encryption algorithm to use for encrypted message transport between the client aand the server + // This is the encryption the server tells the client to use and the client must support it. + // Recommendation: Good balance between security and performance + // For universal support & performance, use aes256gcm for best performance or for best security use xchacha20 + $config->setDefault('cryptography.transport_encryption_algorithm', 'chacha20'); + // Database configuration $config->setDefault('database.host', '127.0.0.1'); $config->setDefault('database.port', 3306); @@ -98,6 +128,7 @@ self::$configuration = $config; self::$instanceConfiguration = new InstanceConfiguration(self::$configuration->getConfiguration()['instance']); self::$securityConfiguration = new SecurityConfiguration(self::$configuration->getConfiguration()['security']); + self::$cryptographyConfiguration = new CryptographyConfiguration(self::$configuration->getConfiguration()['cryptography']); self::$databaseConfiguration = new DatabaseConfiguration(self::$configuration->getConfiguration()['database']); self::$loggingConfiguration = new LoggingConfiguration(self::$configuration->getConfiguration()['logging']); self::$cacheConfiguration = new CacheConfiguration(self::$configuration->getConfiguration()['cache']); @@ -140,6 +171,14 @@ return self::$configuration->getConfiguration(); } + /** + * Retrieves the configuration library instance. + * + * This method returns the current Configuration instance from the ConfigLib namespace. + * If the configuration has not been initialized yet, it initializes it first. + * + * @return \ConfigLib\Configuration The configuration library instance. + */ public static function getConfigurationLib(): \ConfigLib\Configuration { if(self::$configuration === null) @@ -180,6 +219,24 @@ return self::$securityConfiguration; } + /** + * Retrieves the cryptography configuration. + * + * This method returns the current CryptographyConfiguration instance. + * If the configuration has not been initialized yet, it initializes it first. + * + * @return CryptographyConfiguration|null The cryptography configuration instance or null if not available. + */ + public static function getCryptographyConfiguration(): ?CryptographyConfiguration + { + if(self::$cryptographyConfiguration === null) + { + self::initializeConfiguration(); + } + + return self::$cryptographyConfiguration; + } + /** * Retrieves the current database configuration. * diff --git a/src/Socialbox/Classes/Configuration/CryptographyConfiguration.php b/src/Socialbox/Classes/Configuration/CryptographyConfiguration.php new file mode 100644 index 0000000..34c8567 --- /dev/null +++ b/src/Socialbox/Classes/Configuration/CryptographyConfiguration.php @@ -0,0 +1,111 @@ +hostKeyPairExpires = $data['host_keypair_expires'] ?? null; + $this->hostPublicKey = $data['host_public_key'] ?? null; + $this->hostPrivateKey = $data['host_private_key'] ?? null; + $this->internalEncryptionKeys = $data['internal_encryption_keys'] ?? null; + $this->encryptionKeysCount = $data['encryption_keys_count']; + $this->encryptionKeysAlgorithm = $data['encryption_keys_algorithm']; + $this->transportEncryptionAlgorithm = $data['transport_encryption_algorithm']; + } + + /** + * Retrieves the expiration timestamp of the host key pair. + * + * @return int|null The expiration timestamp of the host key pair, or null if not set. + */ + public function getHostKeyPairExpires(): ?int + { + return $this->hostKeyPairExpires; + } + + /** + * Retrieves the host's public key. + * + * @return string|null The host's public key, or null if not set. + */ + public function getHostPublicKey(): ?string + { + return $this->hostPublicKey; + } + + /** + * Retrieves the private key associated with the host. + * + * @return string|null The host's private key, or null if not set. + */ + public function getHostPrivateKey(): ?string + { + return $this->hostPrivateKey; + } + + /** + * Retrieves the internal encryption keys. + * + * @return array|null Returns an array of internal encryption keys if set, or null if no keys are available. + */ + public function getInternalEncryptionKeys(): ?array + { + return $this->internalEncryptionKeys; + } + + /** + * Retrieves a random internal encryption key from the available set of encryption keys. + * + * @return string|null Returns a randomly selected encryption key as a string, or null if no keys are available. + */ + public function getRandomInternalEncryptionKey(): ?string + { + return $this->internalEncryptionKeys[array_rand($this->internalEncryptionKeys)]; + } + + /** + * Retrieves the total count of encryption keys. + * + * @return int The number of encryption keys. + */ + public function getEncryptionKeysCount(): int + { + return $this->encryptionKeysCount; + } + + /** + * Retrieves the algorithm used for the encryption keys. + * + * @return string The encryption keys algorithm. + */ + public function getEncryptionKeysAlgorithm(): string + { + return $this->encryptionKeysAlgorithm; + } + + /** + * Retrieves the transport encryption algorithm being used. + * + * @return string The transport encryption algorithm. + */ + public function getTransportEncryptionAlgorithm(): string + { + return $this->transportEncryptionAlgorithm; + } + } \ No newline at end of file diff --git a/src/Socialbox/Classes/Configuration/InstanceConfiguration.php b/src/Socialbox/Classes/Configuration/InstanceConfiguration.php index 7d8b3d2..431b9c4 100644 --- a/src/Socialbox/Classes/Configuration/InstanceConfiguration.php +++ b/src/Socialbox/Classes/Configuration/InstanceConfiguration.php @@ -5,13 +5,9 @@ class InstanceConfiguration { private bool $enabled; + private string $name; private ?string $domain; private ?string $rpcEndpoint; - private int $encryptionKeysCount; - private int $encryptionRecordsCount; - private ?string $privateKey; - private ?string $publicKey; - private ?array $encryptionKeys; /** * Constructor that initializes object properties with the provided data. @@ -22,13 +18,9 @@ public function __construct(array $data) { $this->enabled = (bool)$data['enabled']; + $this->name = $data['name']; $this->domain = $data['domain']; $this->rpcEndpoint = $data['rpc_endpoint']; - $this->encryptionKeysCount = $data['encryption_keys_count']; - $this->encryptionRecordsCount = $data['encryption_records_count']; - $this->privateKey = $data['private_key']; - $this->publicKey = $data['public_key']; - $this->encryptionKeys = $data['encryption_keys']; } /** @@ -41,6 +33,11 @@ return $this->enabled; } + public function getName(): string + { + return $this->name; + } + /** * Retrieves the domain. * @@ -58,62 +55,4 @@ { return $this->rpcEndpoint; } - - /** - * Retrieves the number of encryption keys. - * - * @return int The number of encryption keys. - */ - public function getEncryptionKeysCount(): int - { - return $this->encryptionKeysCount; - } - - /** - * Retrieves the number of encryption records. - * - * @return int The number of encryption records. - */ - public function getEncryptionRecordsCount(): int - { - return $this->encryptionRecordsCount; - } - - /** - * Retrieves the private key. - * - * @return string|null The private key. - */ - public function getPrivateKey(): ?string - { - return $this->privateKey; - } - - /** - * Retrieves the public key. - * - * @return string|null The public key. - */ - public function getPublicKey(): ?string - { - return $this->publicKey; - } - - /** - * Retrieves the encryption keys. - * - * @return array|null The encryption keys. - */ - public function getEncryptionKeys(): ?array - { - return $this->encryptionKeys; - } - - /** - * @return string - */ - public function getRandomEncryptionKey(): string - { - return $this->encryptionKeys[array_rand($this->encryptionKeys)]; - } } \ No newline at end of file diff --git a/src/Socialbox/Classes/ClientCommands/StorageConfiguration.php b/src/Socialbox/Classes/Configuration/StorageConfiguration.php similarity index 96% rename from src/Socialbox/Classes/ClientCommands/StorageConfiguration.php rename to src/Socialbox/Classes/Configuration/StorageConfiguration.php index 0050418..639af4d 100644 --- a/src/Socialbox/Classes/ClientCommands/StorageConfiguration.php +++ b/src/Socialbox/Classes/Configuration/StorageConfiguration.php @@ -1,6 +1,6 @@ self::ALGORITHM, - "private_key_bits" => self::KEY_SIZE, - ]; + private const KEY_TYPE_ENCRYPTION = 'enc:'; + private const KEY_TYPE_SIGNING = 'sig:'; + private const BASE64_VARIANT = SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING; - $res = openssl_pkey_new($config); - if (!$res) + /** + * Generates a new encryption key pair consisting of a public key and a secret key. + * The generated keys are encoded in a specific format and securely handled in memory. + * + * @return KeyPair Returns an instance of KeyPair containing the encoded public and secret keys. + * @throws CryptographyException If key pair generation fails. + */ + public static function generateEncryptionKeyPair(): KeyPair { - throw new CryptographyException('Failed to generate private key: ' . openssl_error_string()); - } - - openssl_pkey_export($res, $privateKeyPem); - $publicKeyPem = openssl_pkey_get_details($res)['key']; - - return new KeyPair( - Utilities::base64encode(self::pemToDer($publicKeyPem)), - Utilities::base64encode(self::pemToDer($privateKeyPem)) - ); - } - - /** - * Converts a PEM formatted key to DER format. - * - * @param string $pemKey The PEM formatted key as a string. - * - * @return string The DER formatted key as a binary string. - */ - private static function pemToDer(string $pemKey): string - { - $pemKey = preg_replace('/-----(BEGIN|END) [A-Z ]+-----/', '', $pemKey); - return Utilities::base64decode(str_replace(["\n", "\r", " "], '', $pemKey)); - } - - /** - * Converts a DER formatted key to PEM format. - * - * @param string $derKey The DER formatted key. - * @param string $type The type of key, either private or public. Default is private. - * @return string The PEM formatted key. - */ - private static function derToPem(string $derKey, string $type): string - { - $formattedKey = chunk_split(Utilities::base64encode($derKey), 64); - $headerFooter = strtoupper($type) === self::PEM_PUBLIC_HEADER - ? "PUBLIC KEY" : "PRIVATE KEY"; - - return "-----BEGIN $headerFooter-----\n$formattedKey-----END $headerFooter-----\n"; - } - - /** - * Signs the given content using the provided private key. - * - * @param string $content The content to be signed. - * @param string $privateKey The private key used to sign the content. - * @param bool $hashContent Whether to hash the content using SHA1 before signing it. Default is false. - * @return string The Base64 encoded signature of the content. - * @throws CryptographyException If the private key is invalid or if the content signing fails. - */ - public static function signContent(string $content, string $privateKey, bool $hashContent=false): string - { - $privateKey = openssl_pkey_get_private(self::derToPem(Utilities::base64decode($privateKey), self::PEM_PRIVATE_HEADER)); - if (!$privateKey) - { - throw new CryptographyException('Invalid private key: ' . openssl_error_string()); - } - - if($hashContent) - { - $content = hash('sha1', $content); - } - - if (!openssl_sign($content, $signature, $privateKey, self::HASH_ALGORITHM)) - { - throw new CryptographyException('Failed to sign content: ' . openssl_error_string()); - } - - return base64_encode($signature); - } - - /** - * Verifies the integrity of the given content using the provided digital signature and public key. - * - * @param string $content The content to be verified. - * @param string $signature The digital signature to verify against. - * @param string $publicKey The public key to use for verification. - * @param bool $hashContent Whether to hash the content using SHA1 before verifying it. Default is false. - * @return bool Returns true if the content verification is successful, false otherwise. - * @throws CryptographyException If the public key is invalid or if the signature verification fails. - */ - public static function verifyContent(string $content, string $signature, string $publicKey, bool $hashContent=false): bool - { - try - { - $publicKey = openssl_pkey_get_public(self::derToPem(Utilities::base64decode($publicKey), self::PEM_PUBLIC_HEADER)); - } - catch(InvalidArgumentException $e) - { - throw new CryptographyException('Failed to decode public key: ' . $e->getMessage()); - } - - if (!$publicKey) - { - throw new CryptographyException('Invalid public key: ' . openssl_error_string()); - } - - if($hashContent) - { - $content = hash('sha1', $content); - } - - try - { - return openssl_verify($content, Utilities::base64decode($signature), $publicKey, self::HASH_ALGORITHM) === 1; - } - catch(InvalidArgumentException $e) - { - throw new CryptographyException('Failed to verify content: ' . $e->getMessage()); - } - } - - /** - * Temporarily signs the provided content by appending a timestamp-based value and signing it. - * - * @param string $content The content to be signed. - * @param string $privateKey The private key used to sign the content. - * @return string The base64 encoded signature of the content with the appended timestamp. - * @throws CryptographyException If the private key is invalid or if the content signing fails. - */ - public static function temporarySignContent(string $content, string $privateKey): string - { - return self::signContent(sprintf('%s|%d', $content, time() / self::TIME_BLOCK), $privateKey); - } - - /** - * Verify the provided temporary signature for the given content using the public key. - * - * @param string $content The content whose signature needs to be verified. - * @param string $signature The signature associated with the content. - * @param string $publicKey The public key to be used for verifying the signature. - * @param int $frames The number of time frames to consider for validating the signature (default is 1). - * @return bool Returns true if the signature is valid within the provided time frames, otherwise false. - * @throws CryptographyException If the public key is invalid or the signature verification fails. - */ - public static function verifyTemporarySignature(string $content, string $signature, string $publicKey, int $frames = 1): bool - { - $currentTime = time() / self::TIME_BLOCK; - for ($i = 0; $i < max(1, $frames); $i++) - { - if (self::verifyContent(sprintf('%s|%d', $content, $currentTime - $i), $signature, $publicKey)) + try { - return true; + $keyPair = sodium_crypto_box_keypair(); + $publicKey = sodium_crypto_box_publickey($keyPair); + $secretKey = sodium_crypto_box_secretkey($keyPair); + + $result = new KeyPair( + self::KEY_TYPE_ENCRYPTION . sodium_bin2base64($publicKey, self::BASE64_VARIANT), + self::KEY_TYPE_ENCRYPTION . sodium_bin2base64($secretKey, self::BASE64_VARIANT) + ); + + // Clean up sensitive data + sodium_memzero($keyPair); + sodium_memzero($secretKey); + + return $result; + } + catch (Exception $e) + { + throw new CryptographyException("Failed to generate encryption keypair: " . $e->getMessage()); } } - return false; - } - /** - * Encrypts the given content using the provided public key. - * - * @param string $content The content to be encrypted. - * @param string $publicKey The public key used for encryption, in DER-encoded format. - * @return string The encrypted content, encoded in base64 format. - * @throws CryptographyException If the public key is invalid or the encryption fails. - */ - public static function encryptContent(string $content, string $publicKey): string - { - try + /** + * Validates a public encryption key to ensure it is properly formatted and of the correct length. + * + * @param string $publicKey The base64-encoded public key to validate. + * @return bool True if the public key is valid, false otherwise. + */ + public static function validatePublicEncryptionKey(string $publicKey): bool { - $publicKey = openssl_pkey_get_public(self::derToPem(Utilities::base64decode($publicKey), self::PEM_PUBLIC_HEADER)); - } - catch(Exception $e) - { - throw new CryptographyException('Failed to decode public key: ' . $e->getMessage()); + if(!str_starts_with($publicKey, 'enc:')) + { + return false; + } + + $base64Key = substr($publicKey, 4); + + try + { + $decodedKey = sodium_base642bin($base64Key, self::BASE64_VARIANT, true); + + if (strlen($decodedKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) + { + return false; + } + + return true; + } + catch (Exception) + { + return false; + } } - if (!$publicKey) + /** + * Generates a new signing key pair consisting of a public key and a secret key. + * + * @return KeyPair An object containing the base64-encoded public and secret keys, each prefixed with the signing key type identifier. + * @throws CryptographyException If the key pair generation process fails. + */ + public static function generateSigningKeyPair(): KeyPair { - throw new CryptographyException('Invalid public key: ' . openssl_error_string()); + try + { + $keyPair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keyPair); + $secretKey = sodium_crypto_sign_secretkey($keyPair); + + $result = new KeyPair( + self::KEY_TYPE_SIGNING . sodium_bin2base64($publicKey, self::BASE64_VARIANT), + self::KEY_TYPE_SIGNING . sodium_bin2base64($secretKey, self::BASE64_VARIANT) + ); + + // Clean up sensitive data + sodium_memzero($keyPair); + sodium_memzero($secretKey); + + return $result; + } + catch (Exception $e) + { + throw new CryptographyException("Failed to generate signing keypair: " . $e->getMessage()); + } } - if (!openssl_public_encrypt($content, $encrypted, $publicKey, self::PADDING)) + /** + * Validates a public signing key for proper format and length. + * + * @param string $publicKey The base64-encoded public signing key to be validated. + * @return bool Returns true if the key is valid, or false if it is invalid. + * @throws CryptographyException If the public key is incorrectly formatted or its length is invalid. + */ + public static function validatePublicSigningKey(string $publicKey): bool { - throw new CryptographyException('Failed to encrypt content: ' . openssl_error_string()); + // Check if the key is prefixed with "sig:" + if (!str_starts_with($publicKey, 'sig:')) + { + // If it doesn't start with "sig:", consider it invalid + return false; + } + + // Remove the "sig:" prefix + $base64Key = substr($publicKey, 4); + + try + { + // Decode the base64 key + $decodedKey = sodium_base642bin($base64Key, self::BASE64_VARIANT, true); + + // Validate the length of the decoded key + return strlen($decodedKey) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES; + } + catch (Exception) + { + // If decoding fails, consider the key invalid + return false; + } } - try + /** + * Performs a Diffie-Hellman Exchange (DHE) to derive a shared secret key using the provided public and private keys. + * + * @param string $publicKey The base64-encoded public key of the other party. + * @param string $privateKey The base64-encoded private key of the local party. + * @return string The base64-encoded derived shared secret key. + * @throws CryptographyException If the provided keys are invalid or the key exchange process fails. + */ + public static function performDHE(string $publicKey, string $privateKey): string { - return base64_encode($encrypted); - } - catch(Exception $e) - { - throw new CryptographyException('Failed to encode encrypted content: ' . $e->getMessage()); - } - } + try + { + if (empty($publicKey) || empty($privateKey)) + { + throw new CryptographyException("Empty key(s) provided"); + } - /** - * Decrypts the provided content using the specified private key. - * - * @param string $content The content to be decrypted, encoded in base64. - * @param string $privateKey The private key for decryption, encoded in base64. - * @return string The decrypted content, encoded in UTF-8. - * @throws CryptographyException If the private key is invalid or the decryption fails. - */ - public static function decryptContent(string $content, string $privateKey): string - { - $privateKey = openssl_pkey_get_private(self::derToPem(Utilities::base64decode($privateKey), self::PEM_PRIVATE_HEADER)); + $publicKey = self::validateAndExtractKey($publicKey, self::KEY_TYPE_ENCRYPTION); + $privateKey = self::validateAndExtractKey($privateKey, self::KEY_TYPE_ENCRYPTION); - if (!$privateKey) - { - throw new CryptographyException('Invalid private key: ' . openssl_error_string()); + $decodedPublicKey = sodium_base642bin($publicKey, self::BASE64_VARIANT, true); + $decodedPrivateKey = sodium_base642bin($privateKey, self::BASE64_VARIANT, true); + + if (strlen($decodedPublicKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) + { + throw new CryptographyException("Invalid public key length"); + } + + if (strlen($decodedPrivateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) + { + throw new CryptographyException("Invalid private key length"); + } + + $sharedSecret = sodium_crypto_scalarmult($decodedPrivateKey, $decodedPublicKey); + $derivedKey = sodium_crypto_generichash($sharedSecret, null, SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $result = sodium_bin2base64($derivedKey, self::BASE64_VARIANT); + + // Clean up sensitive data + sodium_memzero($sharedSecret); + sodium_memzero($derivedKey); + sodium_memzero($decodedPrivateKey); + + return $result; + } + catch (Exception $e) + { + throw new CryptographyException("Failed to perform DHE: " . $e->getMessage()); + } } - if (!openssl_private_decrypt(base64_decode($content), $decrypted, $privateKey, self::PADDING)) + /** + * Encrypts a message using the provided shared secret. + * + * @param string $message The message to be encrypted. + * @param string $sharedSecret The base64-encoded shared secret used for encryption. + * @return string The base64-encoded encrypted message, including a randomly generated nonce. + * @throws CryptographyException If the message or shared secret is invalid or the encryption fails. + */ + public static function encryptShared(string $message, string $sharedSecret): string { - throw new CryptographyException('Failed to decrypt content: ' . openssl_error_string()); + try + { + if (empty($message)) + { + throw new CryptographyException("Empty message provided"); + } + + if (empty($sharedSecret)) + { + throw new CryptographyException("Empty shared secret provided"); + } + + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $key = sodium_base642bin($sharedSecret, self::BASE64_VARIANT, true); + + if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) + { + throw new CryptographyException("Invalid shared secret length"); + } + + $encrypted = sodium_crypto_secretbox($message, $nonce, $key); + $result = sodium_bin2base64($nonce . $encrypted, self::BASE64_VARIANT); + + // Clean up sensitive data + sodium_memzero($key); + + return $result; + } + catch (Exception $e) + { + throw new CryptographyException("Encryption failed: " . $e->getMessage()); + } } - return mb_convert_encoding($decrypted, 'UTF-8', 'auto'); - } + /** + * Decrypts an encrypted message using the provided shared secret. + * + * @param string $encryptedMessage The base64-encoded encrypted message to be decrypted. + * @param string $sharedSecret The base64-encoded shared secret used to decrypt the message. + * @return string The decrypted message. + * @throws CryptographyException If the encrypted message or shared secret is invalid, or the decryption process fails. + */ + public static function decryptShared(string $encryptedMessage, string $sharedSecret): string + { + try + { + if (empty($encryptedMessage)) + { + throw new CryptographyException("Empty encrypted message provided"); + } - public static function validatePublicKey(string $publicKey): bool - { - try - { - $result = openssl_pkey_get_public(self::derToPem(Utilities::base64decode($publicKey), self::PEM_PUBLIC_HEADER)); - } - catch(InvalidArgumentException) - { - return false; + if (empty($sharedSecret)) + { + throw new CryptographyException("Empty shared secret provided"); + } + + $decoded = sodium_base642bin($encryptedMessage, self::BASE64_VARIANT, true); + $key = sodium_base642bin($sharedSecret, self::BASE64_VARIANT, true); + + if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) + { + throw new CryptographyException("Invalid shared secret length"); + } + + if (strlen($decoded) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) + { + throw new CryptographyException("Encrypted message too short"); + } + + $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); + $ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); + + $decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); + + if ($decrypted === false) + { + throw new CryptographyException("Decryption failed: Invalid message or shared secret"); + } + + sodium_memzero($key); + return $decrypted; + } + catch (Exception $e) + { + throw new CryptographyException("Decryption failed: " . $e->getMessage()); + } } - if($result === false) + /** + * Signs a message using the provided private key. + * + * @param string $message The message to be signed. + * @param string $privateKey The base64-encoded private key used for signing. + * @return string The base64-encoded digital signature. + * @throws CryptographyException If the message or private key is invalid, or if signing fails. + */ + public static function signMessage(string $message, string $privateKey): string { - return false; + try + { + if (empty($message)) + { + throw new CryptographyException("Empty message provided"); + } + + if (empty($privateKey)) + { + throw new CryptographyException("Empty private key provided"); + } + + $privateKey = self::validateAndExtractKey($privateKey, self::KEY_TYPE_SIGNING); + $decodedKey = sodium_base642bin($privateKey, self::BASE64_VARIANT, true); + + if (strlen($decodedKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) + { + throw new CryptographyException("Invalid private key length"); + } + + $signature = sodium_crypto_sign_detached($message, $decodedKey); + + sodium_memzero($decodedKey); + return sodium_bin2base64($signature, self::BASE64_VARIANT); + } + catch (Exception $e) + { + throw new CryptographyException("Failed to sign message: " . $e->getMessage()); + } } - return true; - } + /** + * Verifies the validity of a given signature for a message using the provided public key. + * + * @param string $message The original message that was signed. + * @param string $signature The base64-encoded signature to be verified. + * @param string $publicKey The base64-encoded public key used to verify the signature. + * @return bool True if the signature is valid; false otherwise. + * @throws CryptographyException If any parameter is empty, if the public key or signature is invalid, or if the verification process fails. + */ + public static function verifyMessage(string $message, string $signature, string $publicKey): bool + { + try + { + if (empty($message) || empty($signature) || empty($publicKey)) + { + throw new CryptographyException("Empty parameter(s) provided"); + } - public static function validatePrivateKey(string $privateKey): bool - { - try - { - $result = openssl_pkey_get_private(self::derToPem(Utilities::base64decode($privateKey), self::PEM_PRIVATE_HEADER)); - } - catch(InvalidArgumentException) - { - return false; + $publicKey = self::validateAndExtractKey($publicKey, self::KEY_TYPE_SIGNING); + $decodedKey = sodium_base642bin($publicKey, self::BASE64_VARIANT, true); + $decodedSignature = sodium_base642bin($signature, self::BASE64_VARIANT, true); + + if (strlen($decodedKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) + { + throw new CryptographyException("Invalid public key length"); + } + + if (strlen($decodedSignature) !== SODIUM_CRYPTO_SIGN_BYTES) + { + throw new CryptographyException("Invalid signature length"); + } + + return sodium_crypto_sign_verify_detached($decodedSignature, $message, $decodedKey); + } + catch (Exception $e) + { + if($e instanceof CryptographyException) + { + throw $e; + } + + throw new CryptographyException("Failed to verify signature: " . $e->getMessage()); + } } - if($result === false) + /** + * Determines if the provided algorithm is supported. + * + * @param string $algorithm The name of the algorithm to check. + * @return bool True if the algorithm is supported, false otherwise. + */ + public static function isSupportedAlgorithm(string $algorithm): bool { - return false; + return match($algorithm) + { + 'xchacha20', 'chacha20', 'aes256gcm' => true, + default => false + }; } - return true; - } + /** + * Generates a new encryption key for the specified algorithm. + * + * @param string $algorithm The encryption algorithm for which the key is generated. + * Supported values are 'xchacha20', 'chacha20', and 'aes256gcm'. + * @return string The base64-encoded encryption key. + * @throws CryptographyException If the algorithm is unsupported or if key generation fails. + */ + public static function generateEncryptionKey(string $algorithm): string + { + if(!self::isSupportedAlgorithm($algorithm)) + { + throw new CryptographyException('Unsupported Algorithm: ' . $algorithm); + } - /** - * Generates a random sequence of bytes with a length determined between the specified minimum and maximum. - * - * @param int $minLength The minimum length of the generated byte sequence. - * @param int $maxLength The maximum length of the generated byte sequence. - * @return string A hexadecimal string representing the random byte sequence. - * @throws CryptographyException If the random byte generation fails. - */ - public static function randomKey(int $minLength, int $maxLength): string - { - try - { - return bin2hex(random_bytes(random_int($minLength, $maxLength))); - } - catch(RandomException $e) - { - throw new CryptographyException('Failed to generate random bytes: ' . $e->getMessage()); - } - } + try + { + $keygenMethod = match ($algorithm) + { + 'xchacha20' => 'sodium_crypto_aead_xchacha20poly1305_ietf_keygen', + 'chacha20' => 'sodium_crypto_aead_chacha20poly1305_keygen', + 'aes256gcm' => 'sodium_crypto_aead_aes256gcm_keygen', + }; - /** - * Generates an array of random keys, each with a length within the specified range. - * - * @param int $minLength The minimum length for each random key. - * @param int $maxLength The maximum length for each random key. - * @param int $amount The number of random keys to generate. - * @return array An array of randomly generated keys. - * @throws CryptographyException If the random key generation fails. - */ - public static function randomKeys(int $minLength, int $maxLength, int $amount): array - { - $keys = []; - for($i = 0; $i < $amount; $i++) - { - $keys[] = self::randomKey($minLength, $maxLength); + return sodium_bin2base64($keygenMethod(), self::BASE64_VARIANT); + } + catch (Exception $e) + { + if($e instanceof CryptographyException) + { + throw $e; + } + + throw new CryptographyException("Failed to generate encryption key: " . $e->getMessage()); + } } - return $keys; - } + /** + * Validates the provided encryption key against the specified algorithm. + * + * @param string $encryptionKey The encryption key to be validated, encoded in Base64. + * @param string $algorithm The encryption algorithm that the key should match. + * Supported algorithms include 'xchacha20', 'chacha20', and 'aes256gcm'. + * @return bool Returns true if the encryption key is valid for the given algorithm, otherwise false. + */ + public static function validateEncryptionKey(string $encryptionKey, string $algorithm): bool + { + if (empty($encryptionKey)) + { + return false; + } - public static function generateEncryptionKey(): string - { - try - { - return base64_encode(random_bytes(32)); - } - catch (RandomException $e) - { - throw new CryptographyException('Failed to generate encryption key: ' . $e->getMessage()); - } - } + if(!self::isSupportedAlgorithm($algorithm)) + { + return false; + } - /** - * Encrypts the given content for transport using the provided encryption key. - * - * @param string $content The content to be encrypted. - * @param string $encryptionKey The encryption key used for encrypting the content. - * @return string The Base64 encoded string containing the IV and the encrypted content. - * @throws CryptographyException If the IV generation or encryption process fails. - */ - public static function encryptTransport(string $content, string $encryptionKey): string - { - try - { - $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); - } - catch (RandomException $e) - { - throw new CryptographyException('Failed to generate IV: ' . $e->getMessage()); + try + { + $key = sodium_base642bin($encryptionKey, self::BASE64_VARIANT, true); + $keyLength = match ($algorithm) + { + 'xchacha20' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, + 'chacha20' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, + 'aes256gcm' => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES + }; + + if (strlen($key) !== $keyLength) + { + return false; + } + + return true; + } + catch (Exception) + { + return false; + } + finally + { + if (isset($key)) + { + sodium_memzero($key); + } + } } - $encrypted = openssl_encrypt($content, self::TRANSPORT_ENCRYPTION, base64_decode($encryptionKey), OPENSSL_RAW_DATA, $iv); - - if($encrypted === false) + /** + * Encrypts a message using the specified encryption algorithm and key. + * + * @param string $message The plaintext message to be encrypted. + * @param string $encryptionKey A base64-encoded encryption key. + * @param string $algorithm The name of the encryption algorithm to be used (e.g., 'xchacha20', 'chacha20', 'aes256gcm'). + * @return string The base64-encoded encrypted message including the nonce. + * @throws CryptographyException If the message, encryption key, or algorithm is invalid, or if encryption fails. + */ + public static function encryptMessage(string $message, string $encryptionKey, string $algorithm): string { - throw new CryptographyException('Failed to encrypt transport content: ' . openssl_error_string()); + try + { + if (empty($message)) + { + throw new CryptographyException("Empty message provided"); + } + + if (empty($encryptionKey)) + { + throw new CryptographyException("Empty encryption key provided"); + } + + if(!self::isSupportedAlgorithm($algorithm)) + { + throw new CryptographyException('Unsupported Algorithm: ' . $algorithm); + } + + $key = sodium_base642bin($encryptionKey, self::BASE64_VARIANT, true); + + [$nonceLength, $encryptMethod, $keyLength] = match ($algorithm) + { + 'xchacha20' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES], + 'chacha20' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES, 'sodium_crypto_aead_chacha20poly1305_encrypt', SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES], + 'aes256gcm' => [SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, 'sodium_crypto_aead_aes256gcm_encrypt', SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES], + }; + + if (strlen($key) !== $keyLength) + { + throw new CryptographyException("Invalid encryption key length for $algorithm"); + } + + $nonce = random_bytes($nonceLength); + $encrypted = $encryptMethod($message, '', $nonce, $key); + return sodium_bin2base64($nonce . $encrypted, self::BASE64_VARIANT); + } + catch (Exception $e) + { + if($e instanceof CryptographyException) + { + throw $e; + } + + throw new CryptographyException("Message encryption failed: " . $e->getMessage()); + } + finally + { + if (isset($key)) + { + sodium_memzero($key); + } + } } - return base64_encode($iv . $encrypted); - } - - /** - * Decrypts the given encrypted transport content using the provided encryption key. - * - * @param string $encryptedContent The Base64 encoded encrypted content to be decrypted. - * @param string $encryptionKey The Base64 encoded encryption key used for decryption. - * @return string The decrypted content as a string. - * @throws CryptographyException If the decryption process fails. - */ - public static function decryptTransport(string $encryptedContent, string $encryptionKey): string - { - $decodedData = base64_decode($encryptedContent); - $ivLength = openssl_cipher_iv_length(self::TRANSPORT_ENCRYPTION); - - // Perform decryption - $decryption = openssl_decrypt(substr($decodedData, $ivLength), - self::TRANSPORT_ENCRYPTION, - base64_decode($encryptionKey), - OPENSSL_RAW_DATA, - substr($decodedData, 0, $ivLength) - ); - - if($decryption === false) + /** + * Decrypts an encrypted message using the specified encryption key and algorithm. + * + * @param string $encryptedMessage The base64-encoded encrypted message to be decrypted. + * @param string $encryptionKey The base64-encoded encryption key used for decryption. + * @param string $algorithm The encryption algorithm used to encrypt the message (e.g., 'xchacha20', 'chacha20', 'aes256gcm'). + * @return string The decrypted plaintext message. + * @throws CryptographyException If the encrypted message, encryption key, or algorithm is invalid, or if decryption fails. + */ + public static function decryptMessage(string $encryptedMessage, string $encryptionKey, string $algorithm): string { - throw new CryptographyException('Failed to decrypt transport content: ' . openssl_error_string()); + if (empty($encryptedMessage)) + { + throw new CryptographyException("Empty encrypted message provided"); + } + + if (empty($encryptionKey)) + { + throw new CryptographyException("Empty encryption key provided"); + } + + if(!self::isSupportedAlgorithm($algorithm)) + { + throw new CryptographyException('Unsupported Algorithm: ' . $algorithm); + } + + try + { + + $key = sodium_base642bin($encryptionKey, self::BASE64_VARIANT, true); + [$nonceLength, $decryptMethod, $keyLength] = match ($algorithm) + { + 'xchacha20' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES], + 'chacha20' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES, 'sodium_crypto_aead_chacha20poly1305_decrypt', SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES], + 'aes256gcm' => [SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, 'sodium_crypto_aead_aes256gcm_decrypt', SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES] + }; + + if (strlen($key) !== $keyLength) + { + throw new CryptographyException("Invalid encryption key length for $algorithm"); + } + + $decoded = sodium_base642bin($encryptedMessage, self::BASE64_VARIANT, true); + + if (strlen($decoded) < $nonceLength) + { + throw new CryptographyException("Encrypted message is too short"); + } + + $nonce = mb_substr($decoded, 0, $nonceLength, '8bit'); + $ciphertext = mb_substr($decoded, $nonceLength, null, '8bit'); + $decrypted = $decryptMethod($ciphertext, '', $nonce, $key); + + if ($decrypted === false) + { + throw new CryptographyException("Invalid message or encryption key"); + } + + return $decrypted; + } + catch (Exception $e) + { + if($e instanceof CryptographyException) + { + throw $e; + } + + throw new CryptographyException("Message decryption failed: " . $e->getMessage()); + } + finally + { + if (isset($key)) + { + sodium_memzero($key); + } + } } - return $decryption; - } -} \ No newline at end of file + /** + * Hashes a password securely using a memory-hard, CPU-intensive hashing algorithm. + * + * @param string $password The plaintext password to be hashed. + * @return string The hashed password in a secure format. + * @throws CryptographyException If password hashing fails. + */ + public static function hashPassword(string $password): string + { + try + { + return sodium_crypto_pwhash_str($password, SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE); + } + catch (Exception $e) + { + throw new CryptographyException("Failed to hash password: " . $e->getMessage()); + } + } + + /** + * Validates the given Argon2id hash string based on its format and current security requirements. + * + * @param string $hash The hash string to be validated. + * @return bool Returns true if the hash is valid and meets current security standards. + * @throws CryptographyException If the hash format is invalid or does not meet security requirements. + */ + public static function validatePasswordHash(string $hash): bool + { + try + { + // Step 1: Check the format + $argon2id_pattern = '/^\$argon2id\$v=\d+\$m=\d+,t=\d+,p=\d+\$[A-Za-z0-9+\/=]+\$[A-Za-z0-9+\/=]+$/D'; + if (!preg_match($argon2id_pattern, $hash)) + { + throw new CryptographyException("Invalid hash format"); + } + + // Step 2: Check if it needs rehashing (validates the hash structure) + if (sodium_crypto_pwhash_str_needs_rehash($hash, SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE)) + { + throw new CryptographyException("Hash does not meet current security requirements"); + } + + // If all checks pass, the hash is valid. + return true; + } + catch (Exception $e) + { + throw new CryptographyException("Invalid hash: " . $e->getMessage()); + } + } + + /** + * Verifies a password against a stored hash. + * + * @param string $password The password to be verified. + * @param string $hash The stored password hash to be compared against. + * @return bool True if the password matches the hash; false otherwise. + * @throws CryptographyException If the password verification process fails. + */ + public static function verifyPassword(string $password, string $hash): bool + { + self::validatePasswordHash($hash); + + try + { + return sodium_crypto_pwhash_str_verify($hash, $password); + } + catch (Exception $e) + { + throw new CryptographyException("Failed to verify password: " . $e->getMessage()); + } + } + + /** + * Validates a key by ensuring it is not empty, matches the expected type, and extracts the usable portion. + * + * @param string $key The key to be validated and processed. + * @param string $expectedType The expected prefix type of the key. + * @return string The extracted portion of the key after the expected type. + * @throws CryptographyException If the key is empty, the key type is invalid, or the extracted portion is empty. + */ + private static function validateAndExtractKey(string $key, string $expectedType): string + { + if (empty($key)) + { + throw new CryptographyException("Empty key provided"); + } + + if (!str_starts_with($key, $expectedType)) + { + throw new CryptographyException("Invalid key type. Expected {$expectedType}"); + } + + $extractedKey = substr($key, strlen($expectedType)); + if (empty($extractedKey)) + { + throw new CryptographyException("Empty key after type extraction"); + } + + return $extractedKey; + } + } \ No newline at end of file diff --git a/src/Socialbox/Classes/DnsHelper.php b/src/Socialbox/Classes/DnsHelper.php new file mode 100644 index 0000000..a431579 --- /dev/null +++ b/src/Socialbox/Classes/DnsHelper.php @@ -0,0 +1,41 @@ +https?:\/\/[^;]+);sb-key=(?P[^;]+);sb-exp=(?P\d+)/'; + if (preg_match($pattern, $txtRecord, $matches)) + { + return new DnsRecord($matches['rpcEndpoint'], $matches['publicSigningKey'], (int)$matches['expirationTime']); + } + + throw new InvalidArgumentException('Invalid TXT record format.'); + } + } \ No newline at end of file diff --git a/src/Socialbox/Classes/Resources/database/authentication_passwords.sql b/src/Socialbox/Classes/Resources/database/authentication_passwords.sql index 7d91842..5a7b36c 100644 --- a/src/Socialbox/Classes/Resources/database/authentication_passwords.sql +++ b/src/Socialbox/Classes/Resources/database/authentication_passwords.sql @@ -1,11 +1,9 @@ create table authentication_passwords ( - peer_uuid varchar(36) not null comment 'The Universal Unique Identifier for the peer that is associated with this password record' + peer_uuid varchar(36) not null comment 'The Universal Unique Identifier for the peer that is associated with this password record' primary key comment 'The primary unique index of the peer uuid', - iv mediumtext not null comment 'The Initial Vector of the password record', - encrypted_password mediumtext not null comment 'The encrypted password data', - encrypted_tag mediumtext not null comment 'The encrypted tag of the password record', - updated timestamp default current_timestamp() not null comment 'The Timestamp for when this record was last updated', + hash mediumtext not null comment 'The encrypted hash of the password', + updated timestamp default current_timestamp() not null comment 'The Timestamp for when this record was last updated', constraint authentication_passwords_peer_uuid_uindex unique (peer_uuid) comment 'The primary unique index of the peer uuid', constraint authentication_passwords_registered_peers_uuid_fk diff --git a/src/Socialbox/Classes/Resources/database/encryption_records.sql b/src/Socialbox/Classes/Resources/database/encryption_records.sql deleted file mode 100644 index 77c75bf..0000000 --- a/src/Socialbox/Classes/Resources/database/encryption_records.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table encryption_records -( - data mediumtext not null comment 'The data column', - iv mediumtext not null comment 'The initialization vector column', - tag mediumtext not null comment 'The authentication tag used to verify if the data was tampered' -) - comment 'Table for housing encryption records for the server'; - diff --git a/src/Socialbox/Classes/Resources/database/resolved_dns_records.sql b/src/Socialbox/Classes/Resources/database/resolved_dns_records.sql new file mode 100644 index 0000000..5f4c03f --- /dev/null +++ b/src/Socialbox/Classes/Resources/database/resolved_dns_records.sql @@ -0,0 +1,19 @@ +create table resolved_dns_records +( + domain varchar(512) not null comment 'The domain name' + primary key comment 'Unique Index for the server domain', + rpc_endpoint text not null comment 'The endpoint of the RPC server', + public_key text not null comment 'The Public Key of the server', + expires bigint not null comment 'The Unix Timestamp for when the server''s keypair expires', + updated timestamp default current_timestamp() not null comment 'The Timestamp for when this record was last updated', + constraint resolved_dns_records_domain_uindex + unique (domain) comment 'Unique Index for the server domain', + constraint resolved_dns_records_pk + unique (domain) comment 'Unique Index for the server domain' +) + comment 'A table for housing DNS resolutions'; + +create index resolved_dns_records_updated_index + on resolved_dns_records (updated) + comment 'The index for the updated column'; + diff --git a/src/Socialbox/Classes/Resources/database/resolved_servers.sql b/src/Socialbox/Classes/Resources/database/resolved_servers.sql deleted file mode 100644 index 0aa906f..0000000 --- a/src/Socialbox/Classes/Resources/database/resolved_servers.sql +++ /dev/null @@ -1,12 +0,0 @@ -create table resolved_servers -( - domain varchar(512) not null comment 'The domain name' - primary key comment 'Unique Index for the server domain', - endpoint text not null comment 'The endpoint of the RPC server', - public_key text not null comment 'The Public Key of the server', - updated timestamp default current_timestamp() not null comment 'The TImestamp for when this record was last updated', - constraint resolved_servers_domain_uindex - unique (domain) comment 'Unique Index for the server domain' -) - comment 'A table for housing DNS resolutions'; - diff --git a/src/Socialbox/Classes/Resources/database/sessions.sql b/src/Socialbox/Classes/Resources/database/sessions.sql index d24aa40..ddd0b9e 100644 --- a/src/Socialbox/Classes/Resources/database/sessions.sql +++ b/src/Socialbox/Classes/Resources/database/sessions.sql @@ -1,17 +1,22 @@ create table sessions ( - uuid varchar(36) default uuid() not null comment 'The Unique Primary index for the session UUID' + uuid varchar(36) default uuid() not null comment 'The Unique Primary index for the session UUID' primary key, - peer_uuid varchar(36) not null comment 'The peer the session is identified as, null if the session isn''t identified as a peer', - client_name varchar(256) not null comment 'The name of the client that is using this session', - client_version varchar(16) not null comment 'The version of the client', - authenticated tinyint(1) default 0 not null comment 'Indicates if the session is currently authenticated as the peer', - public_key text not null comment 'The client''s public key provided when creating the session', - state enum ('AWAITING_DHE', 'ACTIVE', 'CLOSED', 'EXPIRED') default 'AWAITING_DHE' not null comment 'The status of the session', - encryption_key text null comment 'The key used for encryption that is obtained through a DHE', - flags text null comment 'The current flags that is set to the session', - created timestamp default current_timestamp() not null comment 'The Timestamp for when the session was last created', - last_request timestamp null comment 'The Timestamp for when the last request was made using this session', + peer_uuid varchar(36) not null comment 'The peer the session is identified as, null if the session isn''t identified as a peer', + client_name varchar(256) not null comment 'The name of the client that is using this session', + client_version varchar(16) not null comment 'The version of the client', + authenticated tinyint(1) default 0 not null comment 'Indicates if the session is currently authenticated as the peer', + client_public_signing_key text not null comment 'The client''s public signing key used for signing decrypted messages', + client_public_encryption_key text not null comment 'The Public Key of the client''s encryption key', + server_public_encryption_key text not null comment 'The server''s public encryption key for this session', + server_private_encryption_key text not null comment 'The server''s private encryption key for this session', + private_shared_secret text null comment 'The shared secret encryption key between the Client & Server', + client_transport_encryption_key text null comment 'The encryption key for sending messages to the client', + server_transport_encryption_key text null comment 'The encryption key for sending messages to the server', + state enum ('AWAITING_DHE', 'ACTIVE', 'CLOSED', 'EXPIRED') default 'AWAITING_DHE' not null comment 'The status of the session', + flags text null comment 'The current flags that is set to the session', + created timestamp default current_timestamp() not null comment 'The Timestamp for when the session was last created', + last_request timestamp null comment 'The Timestamp for when the last request was made using this session', constraint sessions_uuid_uindex unique (uuid) comment 'The Unique Primary index for the session UUID', constraint sessions_registered_peers_uuid_fk diff --git a/src/Socialbox/Classes/RpcClient.php b/src/Socialbox/Classes/RpcClient.php index 9c3a42e..99dc878 100644 --- a/src/Socialbox/Classes/RpcClient.php +++ b/src/Socialbox/Classes/RpcClient.php @@ -2,11 +2,10 @@ namespace Socialbox\Classes; - use Socialbox\Enums\Options\ClientOptions; + use Socialbox\Enums\StandardError; use Socialbox\Enums\StandardHeaders; use Socialbox\Enums\Types\RequestType; use Socialbox\Exceptions\CryptographyException; - use Socialbox\Exceptions\DatabaseOperationException; use Socialbox\Exceptions\ResolutionException; use Socialbox\Exceptions\RpcException; use Socialbox\Objects\ExportedSession; @@ -14,6 +13,7 @@ use Socialbox\Objects\PeerAddress; use Socialbox\Objects\RpcRequest; use Socialbox\Objects\RpcResult; + use Socialbox\Objects\Standard\ServerInformation; class RpcClient { @@ -22,9 +22,14 @@ private bool $bypassSignatureVerification; private PeerAddress $peerAddress; - private KeyPair $keyPair; - private string $encryptionKey; - private string $serverPublicKey; + private string $serverPublicSigningKey; + private string $serverPublicEncryptionKey; + private KeyPair $clientSigningKeyPair; + private KeyPair $clientEncryptionKeyPair; + private string $privateSharedSecret; + private string $clientTransportEncryptionKey; + private string $serverTransportEncryptionKey; + private ServerInformation $serverInformation; private string $rpcEndpoint; private string $sessionUuid; @@ -42,14 +47,41 @@ $this->bypassSignatureVerification = false; // If an exported session is provided, no need to re-connect. + // Just use the session details provided. if($exportedSession !== null) { + // Check if the server keypair has expired from the exported session + if(time() > $exportedSession->getServerKeypairExpires()) + { + throw new RpcException('The server keypair has expired, a new session must be created'); + } + $this->peerAddress = PeerAddress::fromAddress($exportedSession->getPeerAddress()); - $this->keyPair = new KeyPair($exportedSession->getPublicKey(), $exportedSession->getPrivateKey()); - $this->encryptionKey = $exportedSession->getEncryptionKey(); - $this->serverPublicKey = $exportedSession->getServerPublicKey(); $this->rpcEndpoint = $exportedSession->getRpcEndpoint(); $this->sessionUuid = $exportedSession->getSessionUuid(); + $this->serverPublicSigningKey = $exportedSession->getServerPublicSigningKey(); + $this->serverPublicEncryptionKey = $exportedSession->getServerPublicEncryptionKey(); + $this->clientSigningKeyPair = new KeyPair($exportedSession->getClientPublicSigningKey(), $exportedSession->getClientPrivateSigningKey()); + $this->clientEncryptionKeyPair = new KeyPair($exportedSession->getClientPublicEncryptionKey(), $exportedSession->getClientPrivateEncryptionKey()); + $this->privateSharedSecret = $exportedSession->getPrivateSharedSecret(); + $this->clientTransportEncryptionKey = $exportedSession->getClientTransportEncryptionKey(); + $this->serverTransportEncryptionKey = $exportedSession->getServerTransportEncryptionKey(); + + // Still solve the server information + $this->serverInformation = self::getServerInformation(); + + // Check if the active keypair has expired + if(time() > $this->serverInformation->getServerKeypairExpires()) + { + throw new RpcException('The server keypair has expired but the server has not provided a new keypair, contact the server administrator'); + } + + // Check if the transport encryption algorithm has changed + if($this->serverInformation->getTransportEncryptionAlgorithm() !== $exportedSession->getTransportEncryptionAlgorithm()) + { + throw new RpcException('The server has changed its transport encryption algorithm, a new session must be created'); + } + return; } @@ -62,51 +94,61 @@ // Set the initial properties $this->peerAddress = $peerAddress; - // If the username is `host` and the domain is the same as this server's domain, we use our keypair - // Essentially this is a special case for the server to contact another server - if($this->peerAddress->isHost()) - { - $this->keyPair = new KeyPair(Configuration::getInstanceConfiguration()->getPublicKey(), Configuration::getInstanceConfiguration()->getPrivateKey()); - } - // Otherwise we generate a random keypair - else - { - $this->keyPair = Cryptography::generateKeyPair(); - } - - $this->encryptionKey = Cryptography::generateEncryptionKey(); - // Resolve the domain and get the server's Public Key & RPC Endpoint - try - { - $resolvedServer = ServerResolver::resolveDomain($this->peerAddress->getDomain(), false); - } - catch (DatabaseOperationException $e) - { - throw new ResolutionException('Failed to resolve domain: ' . $e->getMessage(), 0, $e); - } + $resolvedServer = ServerResolver::resolveDomain($this->peerAddress->getDomain(), false); - $this->serverPublicKey = $resolvedServer->getPublicKey(); - $this->rpcEndpoint = $resolvedServer->getEndpoint(); + // Import the RPC Endpoint & the server's public key. + $this->serverPublicSigningKey = $resolvedServer->getPublicSigningKey(); + $this->rpcEndpoint = $resolvedServer->getRpcEndpoint(); - if(empty($this->serverPublicKey)) + if(empty($this->serverPublicSigningKey)) { throw new ResolutionException('Failed to resolve domain: No public key found for the server'); } - // Attempt to create an encrypted session with the server - $this->sessionUuid = $this->createSession(); + // Resolve basic server information + $this->serverInformation = self::getServerInformation(); + + // Check if the server keypair has expired + if(time() > $this->serverInformation->getServerKeypairExpires()) + { + throw new RpcException('The server keypair has expired but the server has not provided a new keypair, contact the server administrator'); + } + + // If the username is `host` and the domain is the same as this server's domain, we use our signing keypair + // Essentially this is a special case for the server to contact another server + if($this->peerAddress->isHost()) + { + $this->clientSigningKeyPair = new KeyPair(Configuration::getCryptographyConfiguration()->getHostPublicKey(), Configuration::getCryptographyConfiguration()->getHostPrivateKey()); + } + // Otherwise we generate a random signing keypair + else + { + $this->clientSigningKeyPair = Cryptography::generateSigningKeyPair(); + } + + // Always use a random encryption keypair + $this->clientEncryptionKeyPair = Cryptography::generateEncryptionKeyPair(); + + // Create a session with the server, with the method we obtain the Session UUID + // And the server's public encryption key. + $this->createSession(); + + // Generate a transport encryption key on our end using the server's preferred algorithm + $this->clientTransportEncryptionKey = Cryptography::generateEncryptionKey($this->serverInformation->getTransportEncryptionAlgorithm()); + + // Preform the DHE so that transport encryption keys can be exchanged $this->sendDheExchange(); } /** - * Creates a new session by sending an HTTP GET request to the RPC endpoint. - * The request includes specific headers required for session initiation. + * Initiates a new session with the server and retrieves the session UUID. * - * @return string Returns the session UUID received from the server. - * @throws RpcException If the server response is invalid, the session creation fails, or no session UUID is returned. + * @return string The session UUID provided by the server upon successful session creation. + * @throws RpcException If the session cannot be created, if the server does not provide a valid response, + * or critical headers like encryption public key are missing in the server's response. */ - private function createSession(): string + private function createSession(): void { $ch = curl_init(); @@ -116,28 +158,45 @@ StandardHeaders::CLIENT_NAME->value . ': ' . self::CLIENT_NAME, StandardHeaders::CLIENT_VERSION->value . ': ' . self::CLIENT_VERSION, StandardHeaders::IDENTIFY_AS->value . ': ' . $this->peerAddress->getAddress(), + // Always provide our generated encrypted public key + StandardHeaders::ENCRYPTION_PUBLIC_KEY->value . ': ' . $this->clientEncryptionKeyPair->getPublicKey() ]; // If we're not connecting as the host, we need to provide our public key // Otherwise, the server will obtain the public key itself from DNS records rather than trusting the client if(!$this->peerAddress->isHost()) { - $headers[] = StandardHeaders::PUBLIC_KEY->value . ': ' . $this->keyPair->getPublicKey(); + $headers[] = StandardHeaders::SIGNING_PUBLIC_KEY->value . ': ' . $this->clientSigningKeyPair->getPublicKey(); } + $responseHeaders = []; curl_setopt($ch, CURLOPT_URL, $this->rpcEndpoint); curl_setopt($ch, CURLOPT_HTTPGET, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + // Capture the response headers to get the encryption public key + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use (&$responseHeaders) + { + $len = strlen($header); + $header = explode(':', $header, 2); + if (count($header) < 2) // ignore invalid headers + { + return $len; + } + $responseHeaders[strtolower(trim($header[0]))][] = trim($header[1]); + return $len; + }); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $response = curl_exec($ch); + // If the response is false, the request failed if($response === false) { curl_close($ch); throw new RpcException(sprintf('Failed to create the session at %s, no response received', $this->rpcEndpoint)); } + // If the response code is not 201, the request failed $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if($responseCode !== 201) { @@ -151,14 +210,44 @@ throw new RpcException(sprintf('Failed to create the session at %s, server responded with ' . $responseCode . ': ' . $response, $this->rpcEndpoint)); } + // If the response is empty, the server did not provide a session UUID if(empty($response)) { curl_close($ch); throw new RpcException(sprintf('Failed to create the session at %s, server did not return a session UUID', $this->rpcEndpoint)); } + // Get the Encryption Public Key from the server's response headers + $serverPublicEncryptionKey = $responseHeaders[strtolower(StandardHeaders::ENCRYPTION_PUBLIC_KEY->value)][0] ?? null; + + // null check + if($serverPublicEncryptionKey === null) + { + curl_close($ch); + throw new RpcException('Failed to create session at %s, the server did not return a public encryption key'); + } + + // Validate the server's encryption public key + if(!Cryptography::validatePublicEncryptionKey($serverPublicEncryptionKey)) + { + curl_close($ch); + throw new RpcException('The server did not provide a valid encryption public key'); + } + + // If the server did not provide an encryption public key, the response is invalid + // We can't preform the DHE without the server's encryption key. + if ($serverPublicEncryptionKey === null) + { + curl_close($ch); + throw new RpcException('The server did not provide a signature for the response'); + } + curl_close($ch); - return $response; + + // Set the server's encryption key + $this->serverPublicEncryptionKey = $serverPublicEncryptionKey; + // Set the session UUID + $this->sessionUuid = $response; } /** @@ -168,15 +257,26 @@ */ private function sendDheExchange(): void { + // First preform the DHE + try + { + $this->privateSharedSecret = Cryptography::performDHE($this->serverPublicEncryptionKey, $this->clientEncryptionKeyPair->getPrivateKey()); + } + catch(CryptographyException $e) + { + throw new RpcException('Failed to preform DHE: ' . $e->getMessage(), StandardError::CRYPTOGRAPHIC_ERROR->value, $e); + } + // Request body should contain the encrypted key, the client's public key, and the session UUID // Upon success the server should return 204 without a body try { - $encryptedKey = Cryptography::encryptContent($this->encryptionKey, $this->serverPublicKey); + $encryptedKey = Cryptography::encryptShared($this->clientTransportEncryptionKey, $this->privateSharedSecret); + $signature = Cryptography::signMessage($this->clientTransportEncryptionKey, $this->clientSigningKeyPair->getPrivateKey()); } catch (CryptographyException $e) { - throw new RpcException('Failed to encrypt DHE exchange data', 0, $e); + throw new RpcException('Failed to encrypt DHE exchange data', StandardError::CRYPTOGRAPHIC_ERROR->value, $e); } $ch = curl_init(); @@ -186,6 +286,7 @@ curl_setopt($ch, CURLOPT_HTTPHEADER, [ StandardHeaders::REQUEST_TYPE->value . ': ' . RequestType::DHE_EXCHANGE->value, StandardHeaders::SESSION_UUID->value . ': ' . $this->sessionUuid, + StandardHeaders::SIGNATURE->value . ': ' . $signature ]); curl_setopt($ch, CURLOPT_POSTFIELDS, $encryptedKey); @@ -194,17 +295,28 @@ if($response === false) { curl_close($ch); - throw new RpcException('Failed to send DHE exchange, no response received'); + throw new RpcException('Failed to send DHE exchange, no response received', StandardError::CRYPTOGRAPHIC_ERROR->value); } $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - if($responseCode !== 204) + if($responseCode !== 200) { curl_close($ch); - throw new RpcException('Failed to send DHE exchange, server responded with ' . $responseCode . ': ' . $response); + throw new RpcException('Failed to send DHE exchange, server responded with ' . $responseCode . ': ' . $response, StandardError::CRYPTOGRAPHIC_ERROR->value); } - curl_close($ch); + try + { + $this->serverTransportEncryptionKey = Cryptography::decryptShared($response, $this->privateSharedSecret); + } + catch(CryptographyException $e) + { + throw new RpcException('Failed to decrypt DHE exchange data', 0, $e); + } + finally + { + curl_close($ch); + } } /** @@ -218,8 +330,16 @@ { try { - $encryptedData = Cryptography::encryptTransport($jsonData, $this->encryptionKey); - $signature = Cryptography::signContent($jsonData, $this->keyPair->getPrivateKey(), true); + $encryptedData = Cryptography::encryptMessage( + message: $jsonData, + encryptionKey: $this->serverTransportEncryptionKey, + algorithm: $this->serverInformation->getTransportEncryptionAlgorithm() + ); + + $signature = Cryptography::signMessage( + message: $jsonData, + privateKey: $this->clientSigningKeyPair->getPrivateKey(), + ); } catch (CryptographyException $e) { @@ -289,7 +409,11 @@ try { - $decryptedResponse = Cryptography::decryptTransport($responseString, $this->encryptionKey); + $decryptedResponse = Cryptography::decryptMessage( + encryptedMessage: $responseString, + encryptionKey: $this->clientTransportEncryptionKey, + algorithm: $this->serverInformation->getTransportEncryptionAlgorithm() + ); } catch (CryptographyException $e) { @@ -298,7 +422,7 @@ if (!$this->bypassSignatureVerification) { - $signature = $headers['signature'][0] ?? null; + $signature = $headers[strtolower(StandardHeaders::SIGNATURE->value)][0] ?? null; if ($signature === null) { throw new RpcException('The server did not provide a signature for the response'); @@ -306,7 +430,11 @@ try { - if (!Cryptography::verifyContent($decryptedResponse, $signature, $this->serverPublicKey, true)) + if(!Cryptography::verifyMessage( + message: $decryptedResponse, + signature: $signature, + publicKey: $this->serverPublicSigningKey, + )) { throw new RpcException('Failed to verify the response signature'); } @@ -333,6 +461,59 @@ } } + /** + * Retrieves server information by making an RPC request. + * + * @return ServerInformation The parsed server information received in the response. + * @throws RpcException If the request fails, no response is received, or the server returns an error status code or invalid data. + */ + public function getServerInformation(): ServerInformation + { + $ch = curl_init(); + + // Basic session details + $headers = [ + StandardHeaders::REQUEST_TYPE->value . ': ' . RequestType::INFO->value, + StandardHeaders::CLIENT_NAME->value . ': ' . self::CLIENT_NAME, + StandardHeaders::CLIENT_VERSION->value . ': ' . self::CLIENT_VERSION, + ]; + + curl_setopt($ch, CURLOPT_URL, $this->rpcEndpoint); + curl_setopt($ch, CURLOPT_HTTPGET, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $response = curl_exec($ch); + + if($response === false) + { + curl_close($ch); + throw new RpcException(sprintf('Failed to get server information at %s, no response received', $this->rpcEndpoint)); + } + + $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + if($responseCode !== 200) + { + curl_close($ch); + + if(empty($response)) + { + throw new RpcException(sprintf('Failed to get server information at %s, server responded with ' . $responseCode, $this->rpcEndpoint)); + } + + throw new RpcException(sprintf('Failed to get server information at %s, server responded with ' . $responseCode . ': ' . $response, $this->rpcEndpoint)); + } + + if(empty($response)) + { + curl_close($ch); + throw new RpcException(sprintf('Failed to get server information at %s, server returned an empty response', $this->rpcEndpoint)); + } + + curl_close($ch); + return ServerInformation::fromArray(json_decode($response, true)); + } + /** * Sends an RPC request and retrieves the corresponding RPC response. * @@ -395,12 +576,19 @@ { return new ExportedSession([ 'peer_address' => $this->peerAddress->getAddress(), - 'private_key' => $this->keyPair->getPrivateKey(), - 'public_key' => $this->keyPair->getPublicKey(), - 'encryption_key' => $this->encryptionKey, - 'server_public_key' => $this->serverPublicKey, 'rpc_endpoint' => $this->rpcEndpoint, - 'session_uuid' => $this->sessionUuid + 'session_uuid' => $this->sessionUuid, + 'transport_encryption_algorithm' => $this->serverInformation->getTransportEncryptionAlgorithm(), + 'server_keypair_expires' => $this->serverInformation->getServerKeypairExpires(), + 'server_public_signing_key' => $this->serverPublicSigningKey, + 'server_public_encryption_key' => $this->serverPublicEncryptionKey, + 'client_public_signing_key' => $this->clientSigningKeyPair->getPublicKey(), + 'client_private_signing_key' => $this->clientSigningKeyPair->getPrivateKey(), + 'client_public_encryption_key' => $this->clientEncryptionKeyPair->getPublicKey(), + 'client_private_encryption_key' => $this->clientEncryptionKeyPair->getPrivateKey(), + 'private_shared_secret' => $this->privateSharedSecret, + 'client_transport_encryption_key' => $this->clientTransportEncryptionKey, + 'server_transport_encryption_key' => $this->serverTransportEncryptionKey ]); } } \ No newline at end of file diff --git a/src/Socialbox/Classes/SecuredPassword.php b/src/Socialbox/Classes/SecuredPassword.php deleted file mode 100644 index 30e031f..0000000 --- a/src/Socialbox/Classes/SecuredPassword.php +++ /dev/null @@ -1,96 +0,0 @@ -decrypt(); - $saltedPassword = $decrypted->getSalt() . $password; - $derivedKey = hash_pbkdf2('sha512', $saltedPassword, $decrypted->getPepper(), self::ITERATIONS, self::KEY_LENGTH / 8, true); - - try - { - $iv = random_bytes(openssl_cipher_iv_length(self::ENCRYPTION_ALGORITHM)); - } - catch (RandomException $e) - { - throw new CryptographyException("Failed to generate IV for password encryption", $e); - } - - $tag = null; - $encryptedPassword = openssl_encrypt($derivedKey, self::ENCRYPTION_ALGORITHM, base64_decode($decrypted->getKey()), OPENSSL_RAW_DATA, $iv, $tag); - - if ($encryptedPassword === false) - { - throw new CryptographyException("Password encryption failed"); - } - - return new SecurePasswordRecord([ - 'peer_uuid' => $peerUuid, - 'iv' => base64_encode($iv), - 'encrypted_password' => base64_encode($encryptedPassword), - 'encrypted_tag' => base64_encode($tag), - 'updated' => (new DateTime())->setTimestamp(time()) - ]); - } - - /** - * Verifies the provided password against the secured data and encryption records. - * - * @param string $input The user-provided password to be verified. - * @param SecurePasswordRecord $secured An array containing encrypted data required for verification. - * @param EncryptionRecord[] $encryptionRecords An array of encryption records used to perform decryption and validation. - * @return bool Returns true if the password matches the secured data; otherwise, returns false. - * @throws CryptographyException - */ - public static function verifyPassword(string $input, SecurePasswordRecord $secured, array $encryptionRecords): bool - { - foreach ($encryptionRecords as $record) - { - $decrypted = $record->decrypt(); - $saltedInput = $decrypted->getSalt() . $input; - $derivedKey = hash_pbkdf2('sha512', $saltedInput, $decrypted->getPepper(), self::ITERATIONS, self::KEY_LENGTH / 8, true); - - // Validation by re-encrypting and comparing - $encryptedTag = base64_decode($secured->getEncryptedTag()); - $reEncryptedPassword = openssl_encrypt($derivedKey, - self::ENCRYPTION_ALGORITHM, base64_decode($decrypted->getKey()), OPENSSL_RAW_DATA, - base64_decode($secured->getIv()), $encryptedTag - ); - - if ($reEncryptedPassword !== false && hash_equals($reEncryptedPassword, base64_decode($secured->getEncryptedPassword()))) - { - return true; - } - } - - return false; - } - } diff --git a/src/Socialbox/Classes/ServerResolver.php b/src/Socialbox/Classes/ServerResolver.php index 8d18f94..c74a656 100644 --- a/src/Socialbox/Classes/ServerResolver.php +++ b/src/Socialbox/Classes/ServerResolver.php @@ -2,32 +2,31 @@ namespace Socialbox\Classes; - use Socialbox\Exceptions\DatabaseOperationException; + use InvalidArgumentException; use Socialbox\Exceptions\ResolutionException; - use Socialbox\Managers\ResolvedServersManager; - use Socialbox\Objects\ResolvedServer; + use Socialbox\Managers\ResolvedDnsRecordsManager; + use Socialbox\Objects\DnsRecord; class ServerResolver { - private const string PATTERN = '/v=socialbox;sb-rpc=(https?:\/\/[^;]+);sb-key=([^;]+)/'; - /** - * Resolves a given domain to fetch the RPC endpoint and public key from its DNS TXT records. + * Resolves a domain by retrieving and parsing its DNS TXT records. + * Optionally checks a database for cached resolution data before performing a DNS query. * - * @param string $domain The domain to be resolved. - * @return ResolvedServer An instance of ResolvedServer containing the endpoint and public key. - * @throws ResolutionException If the DNS TXT records cannot be resolved or if required information is missing. - * @throws DatabaseOperationException + * @param string $domain The domain name to resolve. + * @param bool $useDatabase Whether to check the database for cached resolution data; defaults to true. + * @return DnsRecord The parsed DNS record for the given domain. + * @throws ResolutionException If the DNS TXT records cannot be retrieved or parsed. */ - public static function resolveDomain(string $domain, bool $useDatabase=true): ResolvedServer + public static function resolveDomain(string $domain, bool $useDatabase=true): DnsRecord { - // First query the database to check if the domain is already resolved - if($useDatabase) + // Check the database if enabled + if ($useDatabase) { - $resolvedServer = ResolvedServersManager::getResolvedServer($domain); - if($resolvedServer !== null) + $resolvedServer = ResolvedDnsRecordsManager::getDnsRecord($domain); + if ($resolvedServer !== null) { - return $resolvedServer->toResolvedServer(); + return $resolvedServer; } } @@ -38,23 +37,23 @@ } $fullRecord = self::concatenateTxtRecords($txtRecords); - if (preg_match(self::PATTERN, $fullRecord, $matches)) + + try { - $endpoint = trim($matches[1]); - $publicKey = trim(str_replace(' ', '', $matches[2])); - if (empty($endpoint)) + // Parse the TXT record using DnsHelper + $record = DnsHelper::parseTxt($fullRecord); + + // Cache the resolved server record in the database + if($useDatabase) { - throw new ResolutionException(sprintf("Failed to resolve RPC endpoint for %s", $domain)); + ResolvedDnsRecordsManager::addResolvedServer($domain, $record); } - if (empty($publicKey)) - { - throw new ResolutionException(sprintf("Failed to resolve public key for %s", $domain)); - } - return new ResolvedServer($endpoint, $publicKey); + + return $record; } - else + catch (InvalidArgumentException $e) { - throw new ResolutionException(sprintf("Failed to find valid SocialBox record for %s", $domain)); + throw new ResolutionException(sprintf("Failed to find valid SocialBox record for %s: %s", $domain, $e->getMessage())); } } @@ -64,9 +63,9 @@ * @param string $domain The domain name to fetch TXT records for. * @return array|false An array of DNS TXT records on success, or false on failure. */ - private static function dnsGetTxtRecords(string $domain) + private static function dnsGetTxtRecords(string $domain): array|false { - return dns_get_record($domain, DNS_TXT); + return @dns_get_record($domain, DNS_TXT); } /** diff --git a/src/Socialbox/Classes/StandardMethods/SettingsSetDisplayPicture.php b/src/Socialbox/Classes/StandardMethods/SettingsSetDisplayPicture.php index ddd77d2..e79b2ce 100644 --- a/src/Socialbox/Classes/StandardMethods/SettingsSetDisplayPicture.php +++ b/src/Socialbox/Classes/StandardMethods/SettingsSetDisplayPicture.php @@ -38,14 +38,14 @@ if($decodedImage === false) { - return $rpcRequest->produceError(StandardError::BAD_REQUEST, "Failed to decode JPEG image base64 data"); + return $rpcRequest->produceError(StandardError::RPC_BAD_REQUEST, "Failed to decode JPEG image base64 data"); } $sanitizedImage = Utilities::resizeImage(Utilities::sanitizeJpeg($decodedImage), 126, 126); } catch(Exception $e) { - throw new StandardException('Failed to process JPEG image: ' . $e->getMessage(), StandardError::BAD_REQUEST, $e); + throw new StandardException('Failed to process JPEG image: ' . $e->getMessage(), StandardError::RPC_BAD_REQUEST, $e); } try diff --git a/src/Socialbox/Classes/Utilities.php b/src/Socialbox/Classes/Utilities.php index 351acc5..ce7c985 100644 --- a/src/Socialbox/Classes/Utilities.php +++ b/src/Socialbox/Classes/Utilities.php @@ -122,22 +122,37 @@ return $headers; } - /** - * Converts a Throwable object into a formatted string. - * - * @param Throwable $e The throwable to be converted into a string. - * @return string The formatted string representation of the throwable, including the exception class, message, file, line, and stack trace. - */ - public static function throwableToString(Throwable $e): string + public static function throwableToString(Throwable $e, int $level=0): string { - return sprintf( - "%s: %s in %s:%d\nStack trace:\n%s", - get_class($e), - $e->getMessage(), - $e->getFile(), - $e->getLine(), - $e->getTraceAsString() + // Indentation for nested exceptions + $indentation = str_repeat(' ', $level); + + // Basic information about the Throwable + $type = get_class($e); + $message = $e->getMessage() ?: 'No message'; + $file = $e->getFile() ?: 'Unknown file'; + $line = $e->getLine() ?? 'Unknown line'; + + // Compose the base string representation of this Throwable + $result = sprintf("%s%s: %s\n%s in %s on line %s\n", + $indentation, $type, $message, $indentation, $file, $line ); + + // Append stack trace if available + $stackTrace = $e->getTraceAsString(); + if (!empty($stackTrace)) + { + $result .= $indentation . " Stack trace:\n" . $indentation . " " . str_replace("\n", "\n" . $indentation . " ", $stackTrace) . "\n"; + } + + // Recursively append the cause if it exists + $previous = $e->getPrevious(); + if ($previous) + { + $result .= $indentation . "Caused by:\n" . self::throwableToString($previous, $level + 1); + } + + return $result; } /** diff --git a/src/Socialbox/Enums/DatabaseObjects.php b/src/Socialbox/Enums/DatabaseObjects.php index 95700af..14d7f8f 100644 --- a/src/Socialbox/Enums/DatabaseObjects.php +++ b/src/Socialbox/Enums/DatabaseObjects.php @@ -5,8 +5,7 @@ enum DatabaseObjects : string { case VARIABLES = 'variables.sql'; - case ENCRYPTION_RECORDS = 'encryption_records.sql'; - case RESOLVED_SERVERS = 'resolved_servers.sql'; + case RESOLVED_DNS_RECORDS = 'resolved_dns_records.sql'; case REGISTERED_PEERS = 'registered_peers.sql'; @@ -24,7 +23,7 @@ { return match ($this) { - self::VARIABLES, self::ENCRYPTION_RECORDS, self::RESOLVED_SERVERS => 0, + self::VARIABLES, self::RESOLVED_DNS_RECORDS => 0, self::REGISTERED_PEERS => 1, self::AUTHENTICATION_PASSWORDS, self::CAPTCHA_IMAGES, self::SESSIONS, self::EXTERNAL_SESSIONS => 2, }; diff --git a/src/Socialbox/Enums/StandardError.php b/src/Socialbox/Enums/StandardError.php index 6b7544a..ef54bfd 100644 --- a/src/Socialbox/Enums/StandardError.php +++ b/src/Socialbox/Enums/StandardError.php @@ -7,16 +7,21 @@ enum StandardError : int // Fallback Codes case UNKNOWN = -1; + // Server/Request Errors + case INTERNAL_SERVER_ERROR = -100; + case SERVER_UNAVAILABLE = -101; + case BAD_REQUEST = -102; + case FORBIDDEN = -103; + case UNAUTHORIZED = -104; + case RESOLUTION_FAILED = -105; + case CRYPTOGRAPHIC_ERROR = -106; + // RPC Errors case RPC_METHOD_NOT_FOUND = -1000; case RPC_INVALID_ARGUMENTS = -1001; - - // Server Errors - case INTERNAL_SERVER_ERROR = -2000; - case SERVER_UNAVAILABLE = -2001; + CASE RPC_BAD_REQUEST = -1002; // Client Errors - case BAD_REQUEST = -3000; case METHOD_NOT_ALLOWED = -3001; // Authentication/Cryptography Errors diff --git a/src/Socialbox/Enums/StandardHeaders.php b/src/Socialbox/Enums/StandardHeaders.php index 931280d..630b2fb 100644 --- a/src/Socialbox/Enums/StandardHeaders.php +++ b/src/Socialbox/Enums/StandardHeaders.php @@ -8,10 +8,12 @@ enum StandardHeaders : string { case REQUEST_TYPE = 'Request-Type'; + case ERROR_CODE = 'Error-Code'; case IDENTIFY_AS = 'Identify-As'; case CLIENT_NAME = 'Client-Name'; case CLIENT_VERSION = 'Client-Version'; - case PUBLIC_KEY = 'Public-Key'; + case SIGNING_PUBLIC_KEY = 'Signing-Public-Key'; + case ENCRYPTION_PUBLIC_KEY = 'Encryption-Public-Key'; case SESSION_UUID = 'Session-UUID'; case SIGNATURE = 'Signature'; diff --git a/src/Socialbox/Enums/Types/RequestType.php b/src/Socialbox/Enums/Types/RequestType.php index 0beef55..f908aea 100644 --- a/src/Socialbox/Enums/Types/RequestType.php +++ b/src/Socialbox/Enums/Types/RequestType.php @@ -4,6 +4,11 @@ enum RequestType : string { + /** + * Represents the action of getting server information (Non-RPC Request) + */ + case INFO = 'info'; + /** * Represents the action of initiating a session. */ diff --git a/src/Socialbox/Managers/EncryptionRecordsManager.php b/src/Socialbox/Managers/EncryptionRecordsManager.php deleted file mode 100644 index 3d2cc90..0000000 --- a/src/Socialbox/Managers/EncryptionRecordsManager.php +++ /dev/null @@ -1,205 +0,0 @@ -prepare('SELECT COUNT(*) FROM encryption_records'); - $stmt->execute(); - return $stmt->fetchColumn(); - } - catch (PDOException $e) - { - throw new DatabaseOperationException('Failed to retrieve encryption record count', $e); - } - } - - /** - * Inserts a new encryption record into the encryption_records table. - * - * @param EncryptionRecord $record The encryption record to insert, containing data, IV, and tag. - * @return void - * @throws DatabaseOperationException If the insertion into the database fails. - */ - private static function insertRecord(EncryptionRecord $record): void - { - try - { - $stmt = Database::getConnection()->prepare('INSERT INTO encryption_records (data, iv, tag) VALUES (?, ?, ?)'); - - $data = $record->getData(); - $stmt->bindParam(1, $data); - - $iv = $record->getIv(); - $stmt->bindParam(2, $iv); - - $tag = $record->getTag(); - $stmt->bindParam(3, $tag); - - $stmt->execute(); - } - catch(PDOException $e) - { - - throw new DatabaseOperationException('Failed to insert encryption record into the database', $e); - } - } - - /** - * Retrieves a random encryption record from the database. - * - * @return EncryptionRecord An instance of EncryptionRecord containing the data of a randomly selected record. - * @throws DatabaseOperationException If an error occurs while attempting to retrieve the record from the database. - */ - public static function getRandomRecord(): EncryptionRecord - { - try - { - $stmt = Database::getConnection()->prepare('SELECT * FROM encryption_records ORDER BY RAND() LIMIT 1'); - $stmt->execute(); - $data = $stmt->fetch(); - - return new EncryptionRecord($data); - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to retrieve a random encryption record', $e); - } - } - - /** - * Retrieves all encryption records from the database. - * - * @return EncryptionRecord[] An array of EncryptionRecord instances, each representing a record from the database. - * @throws DatabaseOperationException If an error occurs while attempting to retrieve the records from the database. - */ - public static function getAllRecords(): array - { - try - { - $stmt = Database::getConnection()->prepare('SELECT * FROM encryption_records'); - $stmt->execute(); - $data = $stmt->fetchAll(); - - $records = []; - foreach ($data as $record) - { - $records[] = new EncryptionRecord($record); - } - - return $records; - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to retrieve all encryption records', $e); - } - } - - /** - * Generates encryption records and inserts them into the database until the specified total count is reached. - * - * @param int $count The total number of encryption records desired in the database. - * @return int The number of new records that were created and inserted. - * @throws CryptographyException - * @throws DatabaseOperationException - */ - public static function generateRecords(int $count): int - { - $currentCount = self::getRecordCount(); - if($currentCount >= $count) - { - return 0; - } - - $created = 0; - for($i = 0; $i < $count - $currentCount; $i++) - { - self::insertRecord(self::generateEncryptionRecord()); - $created++; - } - - return $created; - } - - /** - * Generates a new encryption record containing a key, pepper, and salt. - * - * @return EncryptionRecord An instance of EncryptionRecord containing an encrypted structure - * with the generated key, pepper, and salt. - * @throws CryptographyException If random byte generation fails during the creation of the encryption record. - */ - private static function generateEncryptionRecord(): EncryptionRecord - { - try - { - $key = random_bytes(self::KEY_LENGTH / 8); - $pepper = bin2hex(random_bytes(SecuredPassword::PEPPER_LENGTH / 2)); - $salt = bin2hex(random_bytes(self::KEY_LENGTH / 16)); - - } - catch (RandomException $e) - { - throw new CryptographyException("Random bytes generation failed", $e->getCode(), $e); - } - - return self::encrypt(['key' => base64_encode($key), 'pepper' => $pepper, 'salt' => $salt,]); - } - - /** - * Encrypts the given vault item and returns an EncryptionRecord containing the encrypted data. - * - * @param array $vaultItem The associative array representing the vault item to be encrypted. - * @return EncryptionRecord An instance of EncryptionRecord containing the encrypted vault data, initialization vector (IV), and authentication tag. - * @throws CryptographyException If the initialization vector generation or vault encryption process fails. - */ - private static function encrypt(array $vaultItem): EncryptionRecord - { - $serializedVault = json_encode($vaultItem); - - try - { - $iv = random_bytes(openssl_cipher_iv_length(SecuredPassword::ENCRYPTION_ALGORITHM)); - } - catch (RandomException $e) - { - throw new CryptographyException("IV generation failed", $e->getCode(), $e); - } - $tag = null; - - $encryptedVault = openssl_encrypt($serializedVault, SecuredPassword::ENCRYPTION_ALGORITHM, - Configuration::getInstanceConfiguration()->getRandomEncryptionKey(), OPENSSL_RAW_DATA, $iv, $tag - ); - - if ($encryptedVault === false) - { - throw new CryptographyException("Vault encryption failed"); - } - - return new EncryptionRecord([ - 'data' => base64_encode($encryptedVault), - 'iv' => base64_encode($iv), - 'tag' => base64_encode($tag), - ]); - } - } \ No newline at end of file diff --git a/src/Socialbox/Managers/PasswordManager.php b/src/Socialbox/Managers/PasswordManager.php index 6de3a9f..313cc4f 100644 --- a/src/Socialbox/Managers/PasswordManager.php +++ b/src/Socialbox/Managers/PasswordManager.php @@ -2,13 +2,15 @@ namespace Socialbox\Managers; + use DateTime; use PDO; + use PDOException; + use Socialbox\Classes\Configuration; + use Socialbox\Classes\Cryptography; use Socialbox\Classes\Database; - use Socialbox\Classes\SecuredPassword; use Socialbox\Exceptions\CryptographyException; use Socialbox\Exceptions\DatabaseOperationException; use Socialbox\Objects\Database\RegisteredPeerRecord; - use Socialbox\Objects\Database\SecurePasswordRecord; class PasswordManager { @@ -34,154 +36,139 @@ return $stmt->fetchColumn() > 0; } - catch (\PDOException $e) + catch (PDOException $e) { throw new DatabaseOperationException('An error occurred while checking the password usage in the database', $e); } } /** - * Sets a password for a given user or peer record by securely encrypting it - * and storing it in the authentication_passwords database table. + * Sets a secured password for the given peer UUID or registered peer record. * - * @param string|RegisteredPeerRecord $peerUuid The UUID of the peer or an instance of RegisteredPeerRecord. - * @param string $password The plaintext password to be securely stored. - * @throws CryptographyException If an error occurs while securing the password. - * @throws DatabaseOperationException If an error occurs while attempting to store the password in the database. - * @throws \DateMalformedStringException If the updated timestamp cannot be formatted. + * @param string|RegisteredPeerRecord $peerUuid The unique identifier or registered peer record of the user. + * @param string $hash The plaintext password to be securely stored. * @return void + * @throws DatabaseOperationException If an error occurs while storing the password in the database. + * @throws CryptographyException If an error occurs during password encryption or hashing. */ - public static function setPassword(string|RegisteredPeerRecord $peerUuid, string $password): void + public static function setPassword(string|RegisteredPeerRecord $peerUuid, string $hash): void { if($peerUuid instanceof RegisteredPeerRecord) { $peerUuid = $peerUuid->getUuid(); } - - $encryptionRecord = EncryptionRecordsManager::getRandomRecord(); - $securedPassword = SecuredPassword::securePassword($peerUuid, $password, $encryptionRecord); + + // Throws an exception if the hash is invalid + Cryptography::validatePasswordHash($hash); + + $encryptionKey = Configuration::getCryptographyConfiguration()->getRandomInternalEncryptionKey(); + $securedPassword = Cryptography::encryptMessage($hash, $encryptionKey, Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); try { - $stmt = Database::getConnection()->prepare("INSERT INTO authentication_passwords (peer_uuid, iv, encrypted_password, encrypted_tag) VALUES (:peer_uuid, :iv, :encrypted_password, :encrypted_tag)"); + $stmt = Database::getConnection()->prepare("INSERT INTO authentication_passwords (peer_uuid, hash) VALUES (:peer_uuid, :hash)"); $stmt->bindParam(":peer_uuid", $peerUuid); - - $iv = $securedPassword->getIv(); - $stmt->bindParam(':iv', $iv); - - $encryptedPassword = $securedPassword->getEncryptedPassword(); - $stmt->bindParam(':encrypted_password', $encryptedPassword); - - $encryptedTag = $securedPassword->getEncryptedTag(); - $stmt->bindParam(':encrypted_tag', $encryptedTag); + $stmt->bindParam(':hash', $securedPassword); $stmt->execute(); } - catch(\PDOException $e) + catch(PDOException $e) { throw new DatabaseOperationException(sprintf('Failed to set password for user %s', $peerUuid), $e); } } /** - * Updates the password for a given peer identified by their UUID or a RegisteredPeerRecord. + * Updates the secured password associated with the given peer UUID. * - * @param string|RegisteredPeerRecord $peerUuid The UUID of the peer or an instance of RegisteredPeerRecord. - * @param string $newPassword The new password to be set for the peer. - * @throws CryptographyException If an error occurs while securing the new password. - * @throws DatabaseOperationException If the update operation fails due to a database error. - * @throws \DateMalformedStringException If the updated timestamp cannot be formatted. - * @returns void + * @param string|RegisteredPeerRecord $peerUuid The unique identifier or registered peer record of the user. + * @param string $hash The new password to be stored securely. + * @return void + * @throws DatabaseOperationException If an error occurs while updating the password in the database. + * @throws CryptographyException If an error occurs while encrypting the password or validating the hash. */ - public static function updatePassword(string|RegisteredPeerRecord $peerUuid, string $newPassword): void + public static function updatePassword(string|RegisteredPeerRecord $peerUuid, string $hash): void { if($peerUuid instanceof RegisteredPeerRecord) { $peerUuid = $peerUuid->getUuid(); } + Cryptography::validatePasswordHash($hash); - $encryptionRecord = EncryptionRecordsManager::getRandomRecord(); - $securedPassword = SecuredPassword::securePassword($peerUuid, $newPassword, $encryptionRecord); + $encryptionKey = Configuration::getCryptographyConfiguration()->getRandomInternalEncryptionKey(); + $securedPassword = Cryptography::encryptMessage($hash, $encryptionKey, Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); try { - $stmt = Database::getConnection()->prepare("UPDATE authentication_passwords SET iv=:iv, encrypted_password=:encrypted_password, encrypted_tag=:encrypted_tag, updated=:updated WHERE peer_uuid=:peer_uuid"); - $stmt->bindParam(":peer_uuid", $peerUuid); - - $iv = $securedPassword->getIv(); - $stmt->bindParam(':iv', $iv); - - $encryptedPassword = $securedPassword->getEncryptedPassword(); - $stmt->bindParam(':encrypted_password', $encryptedPassword); - - $encryptedTag = $securedPassword->getEncryptedTag(); - $stmt->bindParam(':encrypted_tag', $encryptedTag); - - $updated = $securedPassword->getUpdated()->format('Y-m-d H:i:s'); + $stmt = Database::getConnection()->prepare("UPDATE authentication_passwords SET hash=:hash, updated=:updated WHERE peer_uuid=:peer_uuid"); + $updated = (new DateTime())->setTimestamp(time()); + $stmt->bindParam(':hash', $securedPassword); $stmt->bindParam(':updated', $updated); + $stmt->bindParam(':peer_uuid', $peerUuid); $stmt->execute(); } - catch(\PDOException $e) + catch(PDOException $e) { throw new DatabaseOperationException(sprintf('Failed to update password for user %s', $peerUuid), $e); } } /** - * Retrieves the password record associated with the given peer UUID. + * Verifies a given password against a stored password hash for a specific peer. * - * @param string|RegisteredPeerRecord $peerUuid The UUID of the peer or an instance of RegisteredPeerRecord. - * @return SecurePasswordRecord|null Returns a SecurePasswordRecord if found, or null if no record is present. - * @throws DatabaseOperationException If a database operation error occurs during the retrieval process. + * @param string|RegisteredPeerRecord $peerUuid The unique identifier of the peer, or an instance of RegisteredPeerRecord. + * @param string $hash The password hash to verify. + * @return bool Returns true if the password matches the stored hash; false otherwise. + * @throws CryptographyException If the password hash is invalid or an error occurs during the cryptographic operation. + * @throws DatabaseOperationException If an error occurs during the database operation. */ - private static function getPassword(string|RegisteredPeerRecord $peerUuid): ?SecurePasswordRecord + public static function verifyPassword(string|RegisteredPeerRecord $peerUuid, string $hash): bool { if($peerUuid instanceof RegisteredPeerRecord) { $peerUuid = $peerUuid->getUuid(); } + Cryptography::validatePasswordHash($hash); + try { - $statement = Database::getConnection()->prepare("SELECT * FROM authentication_passwords WHERE peer_uuid=:peer_uuid LIMIT 1"); - $statement->bindParam(':peer_uuid', $peerUuid); + $stmt = Database::getConnection()->prepare('SELECT hash FROM authentication_passwords WHERE peer_uuid=:uuid'); + $stmt->bindParam(':uuid', $peerUuid); + $stmt->execute(); - $statement->execute(); - $data = $statement->fetch(PDO::FETCH_ASSOC); - - if ($data === false) + $record = $stmt->fetch(PDO::FETCH_ASSOC); + if($record === false) { - return null; + return false; } - return SecurePasswordRecord::fromArray($data); - } - catch(\PDOException $e) - { - throw new DatabaseOperationException(sprintf('Failed to retrieve password record for user %s', $peerUuid), $e); - } - } + $encryptedHash = $record['hash']; + $decryptedHash = null; + foreach(Configuration::getCryptographyConfiguration()->getInternalEncryptionKeys() as $key) + { + try + { + $decryptedHash = Cryptography::decryptMessage($encryptedHash, $key, Configuration::getCryptographyConfiguration()->getEncryptionKeysAlgorithm()); + } + catch(CryptographyException) + { + continue; + } + } - /** - * Verifies if the provided password matches the secured password associated with the given peer UUID. - * - * @param string|RegisteredPeerRecord $peerUuid The unique identifier or registered peer record of the user. - * @param string $password The password to be verified. - * @return bool Returns true if the password is verified successfully; otherwise, false. - * @throws DatabaseOperationException If an error occurs while retrieving the password record from the database. - * @throws CryptographyException If an error occurs while verifying the password. - */ - public static function verifyPassword(string|RegisteredPeerRecord $peerUuid, string $password): bool - { - $securedPassword = self::getPassword($peerUuid); - if($securedPassword === null) - { - return false; - } + if($decryptedHash === null) + { + throw new CryptographyException('Cannot decrypt hashed password'); + } - $encryptionRecords = EncryptionRecordsManager::getAllRecords(); - return SecuredPassword::verifyPassword($password, $securedPassword, $encryptionRecords); + return Cryptography::verifyPassword($hash, $decryptedHash); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('An error occurred while verifying the password', $e); + } } } \ No newline at end of file diff --git a/src/Socialbox/Managers/ResolvedDnsRecordsManager.php b/src/Socialbox/Managers/ResolvedDnsRecordsManager.php new file mode 100644 index 0000000..b277462 --- /dev/null +++ b/src/Socialbox/Managers/ResolvedDnsRecordsManager.php @@ -0,0 +1,184 @@ +prepare("SELECT COUNT(*) FROM resolved_dns_records WHERE domain=?"); + $statement->bindParam(1, $domain); + $statement->execute(); + return $statement->fetchColumn() > 0; + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to check if a resolved server exists in the database', $e); + } + } + + /** + * Deletes a resolved server record from the database for the provided domain. + * + * @param string $domain The domain name of the resolved server to be deleted. + * @return void + * @throws DatabaseOperationException If the deletion process encounters a database error. + */ + public static function deleteResolvedServer(string $domain): void + { + try + { + $statement = Database::getConnection()->prepare("DELETE FROM resolved_dns_records WHERE domain=?"); + $statement->bindParam(1, $domain); + $statement->execute(); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to delete a resolved server from the database', $e); + } + } + + /** + * Retrieves the last updated timestamp of a resolved server from the database for a given domain. + * + * This method queries the database to fetch the timestamp indicating when the resolved server + * associated with the specified domain was last updated. + * + * @param string $domain The domain name for which the last updated timestamp is to be retrieved. + * @return DateTime The DateTime object representing the last updated timestamp of the resolved server. + * + * @throws DatabaseOperationException If the operation to retrieve the updated timestamp from the + * database fails. + */ + public static function getResolvedServerUpdated(string $domain): DateTime + { + try + { + $statement = Database::getConnection()->prepare("SELECT updated FROM resolved_dns_records WHERE domain=?"); + $statement->bindParam(1, $domain); + $statement->execute(); + $result = $statement->fetchColumn(); + return new DateTime($result); + } + catch(Exception $e) + { + throw new DatabaseOperationException('Failed to get the updated date of a resolved server from the database', $e); + } + } + + /** + * Retrieves a DNS record for the specified domain from the database. + * + * This method fetches the DNS record details, such as the RPC endpoint, public key, + * and expiration details, associated with the provided domain. If no record is found, + * it returns null. + * + * @param string $domain The domain name for which the DNS record is to be retrieved. + * @return DnsRecord|null The DNS record object if found, or null if no record exists for the given domain. + * + * @throws DatabaseOperationException If the operation to retrieve the DNS record from + * the database fails. + */ + public static function getDnsRecord(string $domain): ?DnsRecord + { + try + { + $statement = Database::getConnection()->prepare("SELECT * FROM resolved_dns_records WHERE domain=?"); + $statement->bindParam(1, $domain); + $statement->execute(); + $result = $statement->fetch(); + + if($result === false) + { + return null; + } + + return DnsRecord::fromArray([ + 'rpc_endpoint' => $result['rpc_endpoint'], + 'public_key' => $result['public_key'], + 'expires' => $result['expires'] + ]); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to get a resolved server from the database', $e); + } + } + + /** + * Adds or updates a resolved server in the database based on the provided domain and DNS record. + * + * If a resolved server for the given domain already exists in the database, the server's details + * will be updated. Otherwise, a new record will be inserted into the database. + * + * @param string $domain The domain name associated with the resolved server. + * @param DnsRecord $dnsRecord An object containing DNS record details such as the RPC endpoint, + * public key, and expiration details. + * @return void + * @throws DatabaseOperationException If the operation to add or update the resolved server in + * the database fails. + */ + public static function addResolvedServer(string $domain, DnsRecord $dnsRecord): void + { + $endpoint = $dnsRecord->getRpcEndpoint(); + $publicKey = $dnsRecord->getPublicSigningKey(); + + if(self::resolvedServerExists($domain)) + { + $statement = Database::getConnection()->prepare("UPDATE resolved_dns_records SET rpc_endpoint=?, public_key=?, expires=?, updated=? WHERE domain=?"); + $statement->bindParam(1, $endpoint); + $statement->bindParam(2, $publicKey); + $expires = (new DateTime())->setTimestamp($dnsRecord->getExpires()); + $statement->bindParam(3, $expires); + $updated = new DateTime(); + $statement->bindParam(4, $updated); + $statement->bindParam(5, $domain); + $statement->execute(); + + if($statement->rowCount() === 0) + { + throw new DatabaseOperationException('Failed to update a resolved server in the database'); + } + + return; + } + + try + { + $statement = Database::getConnection()->prepare("INSERT INTO resolved_dns_records (domain, rpc_endpoint, public_key, expires, updated) VALUES (?, ?, ?, ?, ?)"); + $statement->bindParam(1, $domain); + $statement->bindParam(2, $endpoint); + $statement->bindParam(3, $publicKey); + $expires = (new DateTime())->setTimestamp($dnsRecord->getExpires()); + $statement->bindParam(4, $expires); + $updated = new DateTime(); + $statement->bindParam(5, $updated); + $statement->execute(); + + if($statement->rowCount() === 0) + { + throw new DatabaseOperationException('Failed to add a resolved server to the database'); + } + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to add a resolved server to the database', $e); + } + } + } \ No newline at end of file diff --git a/src/Socialbox/Managers/ResolvedServersManager.php b/src/Socialbox/Managers/ResolvedServersManager.php deleted file mode 100644 index 781f6f4..0000000 --- a/src/Socialbox/Managers/ResolvedServersManager.php +++ /dev/null @@ -1,152 +0,0 @@ -prepare("SELECT COUNT(*) FROM resolved_servers WHERE domain=?"); - $statement->bindParam(1, $domain); - $statement->execute(); - return $statement->fetchColumn() > 0; - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to check if a resolved server exists in the database', $e); - } - } - - /** - * Deletes a resolved server from the database. - * - * @param string $domain The domain name of the server to be deleted. - * @return void - * @throws DatabaseOperationException If the deletion operation fails. - */ - public static function deleteResolvedServer(string $domain): void - { - try - { - $statement = Database::getConnection()->prepare("DELETE FROM resolved_servers WHERE domain=?"); - $statement->bindParam(1, $domain); - $statement->execute(); - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to delete a resolved server from the database', $e); - } - } - - /** - * Retrieves the last updated date of a resolved server based on its domain. - * - * @param string $domain The domain of the resolved server. - * @return DateTime The last updated date and time of the resolved server. - */ - public static function getResolvedServerUpdated(string $domain): DateTime - { - try - { - $statement = Database::getConnection()->prepare("SELECT updated FROM resolved_servers WHERE domain=?"); - $statement->bindParam(1, $domain); - $statement->execute(); - $result = $statement->fetchColumn(); - return new DateTime($result); - } - catch(Exception $e) - { - throw new DatabaseOperationException('Failed to get the updated date of a resolved server from the database', $e); - } - } - - /** - * Retrieves the resolved server record from the database for a given domain. - * - * @param string $domain The domain name for which to retrieve the resolved server record. - * @return ResolvedServerRecord|null The resolved server record associated with the given domain. - * @throws DatabaseOperationException If there is an error retrieving the resolved server record from the database. - * @throws \DateMalformedStringException If the date string is malformed. - */ - public static function getResolvedServer(string $domain): ?ResolvedServerRecord - { - try - { - $statement = Database::getConnection()->prepare("SELECT * FROM resolved_servers WHERE domain=?"); - $statement->bindParam(1, $domain); - $statement->execute(); - $result = $statement->fetch(); - - if($result === false) - { - return null; - } - - return ResolvedServerRecord::fromArray($result); - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to get a resolved server from the database', $e); - } - } - - /** - * Adds or updates a resolved server in the database. - * - * @param string $domain The domain name of the resolved server. - * @param ResolvedServer $resolvedServer The resolved server object containing endpoint and public key. - * @return void - * @throws DatabaseOperationException If a database operation fails. - */ - public static function addResolvedServer(string $domain, ResolvedServer $resolvedServer): void - { - $endpoint = $resolvedServer->getEndpoint(); - $publicKey = $resolvedServer->getPublicKey(); - - if(self::resolvedServerExists($domain)) - { - try - { - $statement = Database::getConnection()->prepare("UPDATE resolved_servers SET endpoint=?, public_key=?, updated=NOW() WHERE domain=?"); - $statement->bindParam(1, $endpoint); - $statement->bindParam(2, $publicKey); - $statement->bindParam(3, $domain); - $statement->execute(); - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to update a resolved server in the database', $e); - } - } - - try - { - $statement = Database::getConnection()->prepare("INSERT INTO resolved_servers (domain, endpoint, public_key) VALUES (?, ?, ?)"); - $statement->bindParam(1, $domain); - $statement->bindParam(2, $endpoint); - $statement->bindParam(3, $publicKey); - $statement->execute(); - } - catch(PDOException $e) - { - throw new DatabaseOperationException('Failed to add a resolved server to the database', $e); - } - } -} \ No newline at end of file diff --git a/src/Socialbox/Managers/SessionManager.php b/src/Socialbox/Managers/SessionManager.php index 4111e8d..88e4b57 100644 --- a/src/Socialbox/Managers/SessionManager.php +++ b/src/Socialbox/Managers/SessionManager.php @@ -19,34 +19,27 @@ use Socialbox\Exceptions\StandardException; use Socialbox\Objects\Database\RegisteredPeerRecord; use Socialbox\Objects\Database\SessionRecord; + use Socialbox\Objects\KeyPair; use Symfony\Component\Uid\Uuid; class SessionManager { - /** - * Creates a new session with the given public key. - * - * @param string $publicKey The public key to associate with the new session. - * @param RegisteredPeerRecord $peer - * - * @throws InvalidArgumentException If the public key is empty or invalid. - * @throws DatabaseOperationException If there is an error while creating the session in the database. - */ - public static function createSession(string $publicKey, RegisteredPeerRecord $peer, string $clientName, string $clientVersion): string + public static function createSession(RegisteredPeerRecord $peer, string $clientName, string $clientVersion, string $clientPublicSigningKey, string $clientPublicEncryptionKey, KeyPair $serverEncryptionKeyPair): string { - if($publicKey === '') + if($clientPublicSigningKey === '' || Cryptography::validatePublicSigningKey($clientPublicSigningKey) === false) { - throw new InvalidArgumentException('The public key cannot be empty'); + throw new InvalidArgumentException('The public key is not a valid Ed25519 public key'); } - if(!Cryptography::validatePublicKey($publicKey)) + if($clientPublicEncryptionKey === '' || Cryptography::validatePublicEncryptionKey($clientPublicEncryptionKey) === false) { - throw new InvalidArgumentException('The given public key is invalid'); + throw new InvalidArgumentException('The public key is not a valid X25519 public key'); } $uuid = Uuid::v4()->toRfc4122(); $flags = []; + // TODO: Update this to support `host` peers if($peer->isEnabled()) { $flags[] = SessionFlags::AUTHENTICATION_REQUIRED; @@ -119,13 +112,18 @@ try { - $statement = Database::getConnection()->prepare("INSERT INTO sessions (uuid, peer_uuid, client_name, client_version, public_key, flags) VALUES (?, ?, ?, ?, ?, ?)"); - $statement->bindParam(1, $uuid); - $statement->bindParam(2, $peerUuid); - $statement->bindParam(3, $clientName); - $statement->bindParam(4, $clientVersion); - $statement->bindParam(5, $publicKey); - $statement->bindParam(6, $implodedFlags); + $statement = Database::getConnection()->prepare("INSERT INTO sessions (uuid, peer_uuid, client_name, client_version, client_public_signing_key, client_public_encryption_key, server_public_encryption_key, server_private_encryption_key, flags) VALUES (:uuid, :peer_uuid, :client_name, :client_version, :client_public_signing_key, :client_public_encryption_key, :server_public_encryption_key, :server_private_encryption_key, :flags)"); + $statement->bindParam(':uuid', $uuid); + $statement->bindParam(':peer_uuid', $peerUuid); + $statement->bindParam(':client_name', $clientName); + $statement->bindParam(':client_version', $clientVersion); + $statement->bindParam(':client_public_signing_key', $clientPublicSigningKey); + $statement->bindParam(':client_public_encryption_key', $clientPublicEncryptionKey); + $serverPublicEncryptionKey = $serverEncryptionKeyPair->getPublicKey(); + $statement->bindParam(':server_public_encryption_key', $serverPublicEncryptionKey); + $serverPrivateEncryptionKey = $serverEncryptionKeyPair->getPrivateKey(); + $statement->bindParam(':server_private_encryption_key', $serverPrivateEncryptionKey); + $statement->bindParam(':flags', $implodedFlags); $statement->execute(); } catch(PDOException $e) @@ -186,7 +184,6 @@ // Convert the timestamp fields to DateTime objects $data['created'] = new DateTime($data['created']); - if(isset($data['last_request']) && $data['last_request'] !== null) { $data['last_request'] = new DateTime($data['last_request']); @@ -205,53 +202,6 @@ } } - /** - * Update the authenticated peer associated with the given session UUID. - * - * @param string $uuid The UUID of the session to update. - * @param RegisteredPeerRecord|string $registeredPeerUuid - * @return void - * @throws DatabaseOperationException - */ - public static function updatePeer(string $uuid, RegisteredPeerRecord|string $registeredPeerUuid): void - { - if($registeredPeerUuid instanceof RegisteredPeerRecord) - { - $registeredPeerUuid = $registeredPeerUuid->getUuid(); - } - - Logger::getLogger()->verbose(sprintf("Assigning peer %s to session %s", $registeredPeerUuid, $uuid)); - - try - { - $statement = Database::getConnection()->prepare("UPDATE sessions SET peer_uuid=? WHERE uuid=?"); - $statement->bindParam(1, $registeredPeerUuid); - $statement->bindParam(2, $uuid); - $statement->execute(); - } - catch (PDOException $e) - { - throw new DatabaseOperationException('Failed to update authenticated peer', $e); - } - } - - public static function updateAuthentication(string $uuid, bool $authenticated): void - { - Logger::getLogger()->verbose(sprintf("Marking session %s as authenticated: %s", $uuid, $authenticated ? 'true' : 'false')); - - try - { - $statement = Database::getConnection()->prepare("UPDATE sessions SET authenticated=? WHERE uuid=?"); - $statement->bindParam(1, $authenticated); - $statement->bindParam(2, $uuid); - $statement->execute(); - } - catch (PDOException $e) - { - throw new DatabaseOperationException('Failed to update authenticated peer', $e); - } - } - /** * Updates the last request timestamp for a given session by its UUID. * @@ -305,24 +255,28 @@ } /** - * Updates the encryption key for the specified session. + * Updates the encryption keys and session state for a specific session UUID in the database. * - * @param string $uuid The unique identifier of the session for which the encryption key is to be set. - * @param string $encryptionKey The new encryption key to be assigned. + * @param string $uuid The unique identifier for the session to update. + * @param string $privateSharedSecret The private shared secret to secure communication. + * @param string $clientEncryptionKey The client's encryption key used for transport security. + * @param string $serverEncryptionKey The server's encryption key used for transport security. * @return void - * @throws DatabaseOperationException If the database operation fails. + * @throws DatabaseOperationException If an error occurs during the database operation. */ - public static function setEncryptionKey(string $uuid, string $encryptionKey): void + public static function setEncryptionKeys(string $uuid, string $privateSharedSecret, string $clientEncryptionKey, string $serverEncryptionKey): void { Logger::getLogger()->verbose(sprintf('Setting the encryption key for %s', $uuid)); try { $state_value = SessionState::ACTIVE->value; - $statement = Database::getConnection()->prepare('UPDATE sessions SET state=?, encryption_key=? WHERE uuid=?'); - $statement->bindParam(1, $state_value); - $statement->bindParam(2, $encryptionKey); - $statement->bindParam(3, $uuid); + $statement = Database::getConnection()->prepare('UPDATE sessions SET state=:state, private_shared_secret=:private_shared_secret, client_transport_encryption_key=:client_transport_encryption_key, server_transport_encryption_key=:server_transport_encryption_key WHERE uuid=:uuid'); + $statement->bindParam(':state', $state_value); + $statement->bindParam(':private_shared_secret', $privateSharedSecret); + $statement->bindParam(':client_transport_encryption_key', $clientEncryptionKey); + $statement->bindParam(':server_transport_encryption_key', $serverEncryptionKey); + $statement->bindParam(':uuid', $uuid); $statement->execute(); } diff --git a/src/Socialbox/Objects/ClientRequest.php b/src/Socialbox/Objects/ClientRequest.php index 00bd0fc..15e60cd 100644 --- a/src/Socialbox/Objects/ClientRequest.php +++ b/src/Socialbox/Objects/ClientRequest.php @@ -2,10 +2,7 @@ namespace Socialbox\Objects; - use InvalidArgumentException; use Socialbox\Classes\Cryptography; - use Socialbox\Classes\Utilities; - use Socialbox\Enums\SessionState; use Socialbox\Enums\StandardHeaders; use Socialbox\Enums\Types\RequestType; use Socialbox\Exceptions\CryptographyException; @@ -18,7 +15,7 @@ class ClientRequest { private array $headers; - private RequestType $requestType; + private ?RequestType $requestType; private ?string $requestBody; private ?string $clientName; @@ -27,6 +24,14 @@ private ?string $sessionUuid; private ?string $signature; + /** + * Initializes the instance with the provided request headers and optional request body. + * + * @param array $headers An associative array of request headers used to set properties such as client name, version, and others. + * @param string|null $requestBody The optional body of the request, or null if not provided. + * + * @return void + */ public function __construct(array $headers, ?string $requestBody) { $this->headers = $headers; @@ -34,17 +39,28 @@ $this->clientName = $headers[StandardHeaders::CLIENT_NAME->value] ?? null; $this->clientVersion = $headers[StandardHeaders::CLIENT_VERSION->value] ?? null; - $this->requestType = RequestType::from($headers[StandardHeaders::REQUEST_TYPE->value]); + $this->requestType = RequestType::tryFrom($headers[StandardHeaders::REQUEST_TYPE->value]); $this->identifyAs = $headers[StandardHeaders::IDENTIFY_AS->value] ?? null; $this->sessionUuid = $headers[StandardHeaders::SESSION_UUID->value] ?? null; $this->signature = $headers[StandardHeaders::SIGNATURE->value] ?? null; } + /** + * Retrieves the headers. + * + * @return array Returns an array of headers. + */ public function getHeaders(): array { return $this->headers; } + /** + * Checks if the specified header exists in the collection of headers. + * + * @param StandardHeaders|string $header The header to check, either as a StandardHeaders enum or a string. + * @return bool Returns true if the header exists, otherwise false. + */ public function headerExists(StandardHeaders|string $header): bool { if(is_string($header)) @@ -55,6 +71,12 @@ return isset($this->headers[$header->value]); } + /** + * Retrieves the value of a specified header. + * + * @param StandardHeaders|string $header The header to retrieve, provided as either a StandardHeaders enum or a string key. + * @return string|null Returns the header value if it exists, or null if the header does not exist. + */ public function getHeader(StandardHeaders|string $header): ?string { if(!$this->headerExists($header)) @@ -70,26 +92,51 @@ return $this->headers[$header->value]; } + /** + * Retrieves the request body. + * + * @return string|null Returns the request body as a string if available, or null if not set. + */ public function getRequestBody(): ?string { return $this->requestBody; } + /** + * Retrieves the name of the client. + * + * @return string|null Returns the client's name if set, or null if not available. + */ public function getClientName(): ?string { return $this->clientName; } + /** + * Retrieves the client version. + * + * @return string|null Returns the client version if available, or null if not set. + */ public function getClientVersion(): ?string { return $this->clientVersion; } - public function getRequestType(): RequestType + /** + * Retrieves the request type associated with the current instance. + * + * @return RequestType|null Returns the associated RequestType if available, or null if not set. + */ + public function getRequestType(): ?RequestType { return $this->requestType; } + /** + * Retrieves the peer address the instance identifies as. + * + * @return PeerAddress|null Returns a PeerAddress instance if the identification address is set, or null otherwise. + */ public function getIdentifyAs(): ?PeerAddress { if($this->identifyAs === null) @@ -100,11 +147,21 @@ return PeerAddress::fromAddress($this->identifyAs); } + /** + * Retrieves the UUID of the current session. + * + * @return string|null Returns the session UUID if available, or null if it is not set. + */ public function getSessionUuid(): ?string { return $this->sessionUuid; } + /** + * Retrieves the current session associated with the session UUID. + * + * @return SessionRecord|null Returns the associated SessionRecord if the session UUID exists, or null if no session UUID is set. + */ public function getSession(): ?SessionRecord { if($this->sessionUuid === null) @@ -115,6 +172,11 @@ return SessionManager::getSession($this->sessionUuid); } + /** + * Retrieves the associated peer for the current session. + * + * @return RegisteredPeerRecord|null Returns the associated RegisteredPeerRecord if available, or null if no session exists. + */ public function getPeer(): ?RegisteredPeerRecord { $session = $this->getSession(); @@ -127,11 +189,22 @@ return RegisteredPeerManager::getPeer($session->getPeerUuid()); } + /** + * Retrieves the signature value. + * + * @return string|null The signature value or null if not set + */ public function getSignature(): ?string { return $this->signature; } + /** + * Verifies the signature of the provided decrypted content. + * + * @param string $decryptedContent The decrypted content to verify the signature against. + * @return bool True if the signature is valid, false otherwise. + */ private function verifySignature(string $decryptedContent): bool { if($this->getSignature() == null || $this->getSessionUuid() == null) @@ -141,7 +214,11 @@ try { - return Cryptography::verifyContent($decryptedContent, $this->getSignature(), $this->getSession()->getPublicKey(), true); + return Cryptography::verifyMessage( + message: $decryptedContent, + signature: $this->getSignature(), + publicKey: $this->getSession()->getClientPublicSigningKey() + ); } catch(CryptographyException) { @@ -156,52 +233,12 @@ * @return RpcRequest[] The parsed RpcRequest objects * @throws RequestException Thrown if the request is invalid */ - public function getRpcRequests(): array + public function getRpcRequests(string $json): array { - if($this->getSessionUuid() === null) + $body = json_decode($json, true); + if($body === false) { - throw new RequestException("Session UUID required", 400); - } - - // Get the existing session - $session = $this->getSession(); - - // If we're awaiting a DHE, encryption is not possible at this point - if($session->getState() === SessionState::AWAITING_DHE) - { - throw new RequestException("DHE exchange required", 400); - } - - // If the session is not active, we can't serve these requests - if($session->getState() !== SessionState::ACTIVE) - { - throw new RequestException("Session is not active", 400); - } - - // Attempt to decrypt the content and verify the signature of the request - try - { - $decrypted = Cryptography::decryptTransport($this->requestBody, $session->getEncryptionKey()); - - if(!$this->verifySignature($decrypted)) - { - throw new RequestException("Invalid request signature", 401); - } - } - catch (CryptographyException $e) - { - throw new RequestException("Failed to decrypt request body", 400, $e); - } - - // At this stage, all checks has passed; we can try parsing the RPC request - try - { - // Decode the request body - $body = Utilities::jsonDecode($decrypted); - } - catch(InvalidArgumentException $e) - { - throw new RequestException("Invalid JSON in request body: " . $e->getMessage(), 400, $e); + throw new RequestException('Malformed JSON', 400); } // If the body only contains a method, we assume it's a single request diff --git a/src/Socialbox/Objects/Database/DecryptedRecord.php b/src/Socialbox/Objects/Database/DecryptedRecord.php deleted file mode 100644 index 3757949..0000000 --- a/src/Socialbox/Objects/Database/DecryptedRecord.php +++ /dev/null @@ -1,41 +0,0 @@ -key = $data['key']; - $this->pepper = $data['pepper']; - $this->salt = $data['salt']; - } - - /** - * @return string - */ - public function getKey(): string - { - return $this->key; - } - - /** - * @return string - */ - public function getPepper(): string - { - return $this->pepper; - } - - /** - * @return string - */ - public function getSalt(): string - { - return $this->salt; - } - } \ No newline at end of file diff --git a/src/Socialbox/Objects/Database/EncryptionRecord.php b/src/Socialbox/Objects/Database/EncryptionRecord.php deleted file mode 100644 index 707c39d..0000000 --- a/src/Socialbox/Objects/Database/EncryptionRecord.php +++ /dev/null @@ -1,83 +0,0 @@ -data = $data['data']; - $this->iv = $data['iv']; - $this->tag = $data['tag']; - } - - /** - * Retrieves the stored data. - * - * @return string The stored data. - */ - public function getData(): string - { - return $this->data; - } - - /** - * Retrieves the initialization vector (IV). - * - * @return string The initialization vector. - */ - public function getIv(): string - { - return $this->iv; - } - - /** - * Retrieves the tag. - * - * @return string The tag. - */ - public function getTag(): string - { - return $this->tag; - } - - /** - * Decrypts the encrypted record using available encryption keys. - * - * Iterates through the configured encryption keys to attempt decryption of the data. - * If successful, returns a DecryptedRecord object with the decrypted data. - * Throws an exception if decryption fails with all available keys. - * - * @return DecryptedRecord The decrypted record containing the original data. - * @throws CryptographyException If decryption fails with all provided keys. - */ - public function decrypt(): DecryptedRecord - { - foreach(Configuration::getInstanceConfiguration()->getEncryptionKeys() as $encryptionKey) - { - $decryptedVault = openssl_decrypt(base64_decode($this->data), SecuredPassword::ENCRYPTION_ALGORITHM, - $encryptionKey, OPENSSL_RAW_DATA, base64_decode($this->iv), base64_decode($this->tag) - ); - - if ($decryptedVault !== false) - { - return new DecryptedRecord(json_decode($decryptedVault, true)); - } - } - - throw new CryptographyException("Decryption failed"); - } - } \ No newline at end of file diff --git a/src/Socialbox/Objects/Database/ResolvedServerRecord.php b/src/Socialbox/Objects/Database/ResolvedServerRecord.php index 12a31fb..5488c87 100644 --- a/src/Socialbox/Objects/Database/ResolvedServerRecord.php +++ b/src/Socialbox/Objects/Database/ResolvedServerRecord.php @@ -1,110 +1,144 @@ domain = (string)$data['domain']; - $this->endpoint = (string)$data['endpoint']; - $this->publicKey = (string)$data['public_key']; + private string $domain; + private string $endpoint; + private string $publicKey; + private DateTime $expires; + private DateTime $updated; - if(is_null($data['updated'])) + /** + * Constructs a new instance of the class. + * + * @param array $data An associative array containing the domain, endpoint, public_key, and updated values. + * @throws \DateMalformedStringException + */ + public function __construct(array $data) { - $this->updated = new DateTime(); + $this->domain = (string)$data['domain']; + $this->endpoint = (string)$data['endpoint']; + $this->publicKey = (string)$data['public_key']; + + if(is_null($data['expires'])) + { + $this->expires = new DateTime(); + } + elseif (is_int($data['expires'])) + { + $this->expires = (new DateTime())->setTimestamp($data['expires']); + } + elseif (is_string($data['expires'])) + { + $this->expires = new DateTime($data['expires']); + } + else + { + $this->expires = $data['expires']; + } + + if(is_null($data['updated'])) + { + $this->updated = new DateTime(); + } + elseif (is_int($data['updated'])) + { + $this->updated = (new DateTime())->setTimestamp($data['updated']); + } + elseif (is_string($data['updated'])) + { + $this->updated = new DateTime($data['updated']); + } + else + { + $this->updated = $data['updated']; + } } - elseif (is_string($data['updated'])) + + /** + * Retrieves the domain value. + * + * @return string The domain as a string. + */ + public function getDomain(): string { - $this->updated = new DateTime($data['updated']); + return $this->domain; } - else + + /** + * Retrieves the configured endpoint. + * + * @return string The endpoint as a string. + */ + public function getEndpoint(): string { - $this->updated = $data['updated']; + return $this->endpoint; } - } - /** - * - * @return string The domain value. - */ - public function getDomain(): string - { - return $this->domain; - } + /** + * Retrieves the public key. + * + * @return string The public key as a string. + */ + public function getPublicKey(): string + { + return $this->publicKey; + } - /** - * - * @return string The endpoint value. - */ - public function getEndpoint(): string - { - return $this->endpoint; - } + /** + * Retrieves the expiration timestamp. + * + * @return DateTime The DateTime object representing the expiration time. + */ + public function getExpires(): DateTime + { + return $this->expires; + } - /** - * - * @return string The public key. - */ - public function getPublicKey(): string - { - return $this->publicKey; - } + /** + * Retrieves the timestamp of the last update. + * + * @return DateTime The DateTime object representing the last update time. + */ + public function getUpdated(): DateTime + { + return $this->updated; + } - /** - * Retrieves the timestamp of the last update. - * - * @return DateTime The DateTime object representing the last update time. - */ - public function getUpdated(): DateTime - { - return $this->updated; - } + /** + * Fetches the DNS record based on the provided endpoint, public key, and expiration time. + * + * @return DnsRecord An instance of the DnsRecord containing the endpoint, public key, and expiration timestamp. + */ + public function getDnsRecord(): DnsRecord + { + return new DnsRecord($this->endpoint, $this->publicKey, $this->expires->getTimestamp()); + } - /** - * Converts the record to a ResolvedServer object. - * - * @return ResolvedServer The ResolvedServer object. - */ - public function toResolvedServer(): ResolvedServer - { - return new ResolvedServer($this->endpoint, $this->publicKey); - } + /** + * @inheritDoc + */ + public static function fromArray(array $data): object + { + return new self($data); + } - /** - * @inheritDoc - * @throws \DateMalformedStringException - */ - public static function fromArray(array $data): object - { - return new self($data); - } - - /** - * @inheritDoc - */ - public function toArray(): array - { - return [ - 'domain' => $this->domain, - 'endpoint' => $this->endpoint, - 'public_key' => $this->publicKey, - 'updated' => $this->updated->format('Y-m-d H:i:s') - ]; - } -} \ No newline at end of file + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'domain' => $this->domain, + 'endpoint' => $this->endpoint, + 'public_key' => $this->publicKey, + 'updated' => $this->updated->format('Y-m-d H:i:s') + ]; + } + } \ No newline at end of file diff --git a/src/Socialbox/Objects/Database/SecurePasswordRecord.php b/src/Socialbox/Objects/Database/SecurePasswordRecord.php deleted file mode 100644 index 239fafa..0000000 --- a/src/Socialbox/Objects/Database/SecurePasswordRecord.php +++ /dev/null @@ -1,108 +0,0 @@ -peerUuid = $data['peer_uuid']; - $this->iv = $data['iv']; - $this->encryptedPassword = $data['encrypted_password']; - $this->encryptedTag = $data['encrypted_tag']; - - if($data['updated'] instanceof DateTime) - { - $this->updated = $data['updated']; - } - else - { - $this->updated = new DateTime($data['updated']); - } - } - - /** - * Retrieves the UUID of the peer. - * - * @return string The UUID of the peer. - */ - public function getPeerUuid(): string - { - return $this->peerUuid; - } - - /** - * Retrieves the initialization vector (IV) value. - * - * @return string The initialization vector. - */ - public function getIv(): string - { - return $this->iv; - } - - /** - * Retrieves the encrypted password. - * - * @return string The encrypted password. - */ - public function getEncryptedPassword(): string - { - return $this->encryptedPassword; - } - - /** - * Retrieves the encrypted tag. - * - * @return string The encrypted tag. - */ - public function getEncryptedTag(): string - { - return $this->encryptedTag; - } - - /** - * Retrieves the updated timestamp. - * - * @return DateTime The updated timestamp. - */ - public function getUpdated(): DateTime - { - return $this->updated; - } - - public function toArray(): array - { - return [ - 'peer_uuid' => $this->peerUuid, - 'iv' => $this->iv, - 'encrypted_password' => $this->encryptedPassword, - 'encrypted_tag' => $this->encryptedTag, - 'updated' => $this->updated->format('Y-m-d H:i:s') - ]; - } - - public static function fromArray(array $data): SecurePasswordRecord - { - return new SecurePasswordRecord($data); - } - } \ No newline at end of file diff --git a/src/Socialbox/Objects/Database/SessionRecord.php b/src/Socialbox/Objects/Database/SessionRecord.php index 77d75df..ec21c7c 100644 --- a/src/Socialbox/Objects/Database/SessionRecord.php +++ b/src/Socialbox/Objects/Database/SessionRecord.php @@ -15,9 +15,13 @@ private string $clientName; private string $clientVersion; private bool $authenticated; - private string $publicKey; + private string $clientPublicSigningKey; + public string $clientPublicEncryptionKey; + private string $serverPublicEncryptionKey; + private string $serverPrivateEncryptionKey; + private ?string $clientTransportEncryptionKey; + private ?string $serverTransportEncryptionKey; private SessionState $state; - private ?string $encryptionKey; /** * @var SessionFlags[] */ @@ -42,10 +46,14 @@ $this->clientName = $data['client_name']; $this->clientVersion = $data['client_version']; $this->authenticated = $data['authenticated'] ?? false; - $this->publicKey = $data['public_key']; + $this->clientPublicSigningKey = $data['client_public_signing_key']; + $this->clientPublicEncryptionKey = $data['client_public_encryption_key']; + $this->serverPublicEncryptionKey = $data['server_public_encryption_key']; + $this->serverPrivateEncryptionKey = $data['server_private_encryption_key']; + $this->clientTransportEncryptionKey = $data['client_transport_encryption_key'] ?? null; + $this->serverTransportEncryptionKey = $data['server_transport_encryption_key'] ?? null; $this->created = $data['created']; $this->lastRequest = $data['last_request']; - $this->encryptionKey = $data['encryption_key'] ?? null; $this->flags = SessionFlags::fromString($data['flags']); if(SessionState::tryFrom($data['state']) == null) @@ -99,9 +107,55 @@ * * @return string Returns the public key as a string. */ - public function getPublicKey(): string + public function getClientPublicSigningKey(): string { - return $this->publicKey; + return $this->clientPublicSigningKey; + } + + /** + * Retrieves the encryption key associated with the instance. + * + * @return string|null Returns the encryption key as a string, or null if not set. + */ + public function getClientPublicEncryptionKey(): ?string + { + return $this->clientPublicEncryptionKey; + } + + /** + * @return string + */ + public function getServerPublicEncryptionKey(): string + { + return $this->serverPublicEncryptionKey; + } + + /** + * @return string + */ + public function getServerPrivateEncryptionKey(): string + { + return $this->serverPrivateEncryptionKey; + } + + /** + * Retrieves the client encryption key associated with the instance. + * + * @return string|null Returns the client encryption key as a string, or null if not set. + */ + public function getClientTransportEncryptionKey(): ?string + { + return $this->clientTransportEncryptionKey; + } + + /** + * Retrieves the server encryption key associated with the instance. + * + * @return string|null Returns the server encryption key as a string, or null if not set. + */ + public function getServerTransportEncryptionKey(): ?string + { + return $this->serverTransportEncryptionKey; } /** @@ -114,16 +168,6 @@ return $this->state; } - /** - * Retrieves the encryption key associated with the instance. - * - * @return string|null Returns the encryption key as a string. - */ - public function getEncryptionKey(): ?string - { - return $this->encryptionKey; - } - /** * Retrieves the creation date and time of the object. * @@ -194,6 +238,11 @@ return $this->clientVersion; } + /** + * Converts the current session state into a standard session state object. + * + * @return \Socialbox\Objects\Standard\SessionState The standardized session state object. + */ public function toStandardSessionState(): \Socialbox\Objects\Standard\SessionState { return new \Socialbox\Objects\Standard\SessionState([ @@ -207,10 +256,7 @@ /** - * Creates a new instance of the class using the provided array data. - * - * @param array $data An associative array of data used to initialize the object properties. - * @return object Returns a newly created object instance. + * @inheritDoc */ public static function fromArray(array $data): object { @@ -218,10 +264,7 @@ } /** - * Converts the object's properties to an associative array. - * - * @return array An associative array representing the object's data, including keys 'uuid', 'peer_uuid', - * 'authenticated', 'public_key', 'state', 'flags', 'created', and 'last_request'. + * @inheritDoc */ public function toArray(): array { @@ -229,7 +272,12 @@ 'uuid' => $this->uuid, 'peer_uuid' => $this->peerUuid, 'authenticated' => $this->authenticated, - 'public_key' => $this->publicKey, + 'client_public_signing_key' => $this->clientPublicSigningKey, + 'client_public_encryption_key' => $this->clientPublicEncryptionKey, + 'server_public_encryption_key' => $this->serverPublicEncryptionKey, + 'server_private_encryption_key' => $this->serverPrivateEncryptionKey, + 'client_transport_encryption_key' => $this->clientTransportEncryptionKey, + 'server_transport_encryption_key' => $this->serverTransportEncryptionKey, 'state' => $this->state->value, 'flags' => SessionFlags::toString($this->flags), 'created' => $this->created, diff --git a/src/Socialbox/Objects/DnsRecord.php b/src/Socialbox/Objects/DnsRecord.php new file mode 100644 index 0000000..6a1ca33 --- /dev/null +++ b/src/Socialbox/Objects/DnsRecord.php @@ -0,0 +1,67 @@ +rpcEndpoint = $rpcEndpoint; + $this->publicSigningKey = $publicSigningKey; + $this->expires = $expires; + } + + /** + * Retrieves the RPC endpoint. + * + * @return string The RPC endpoint. + */ + public function getRpcEndpoint(): string + { + return $this->rpcEndpoint; + } + + /** + * Retrieves the public signing key. + * + * @return string Returns the public signing key as a string. + */ + public function getPublicSigningKey(): string + { + return $this->publicSigningKey; + } + + /** + * Retrieves the expiration time. + * + * @return int The expiration timestamp as an integer. + */ + public function getExpires(): int + { + return $this->expires; + } + + /** + * Creates a new instance of DnsRecord from the provided array of data. + * + * @param array $data An associative array containing the keys 'rpc_endpoint', 'public_key', and 'expires' + * required to instantiate a DnsRecord object. + * @return DnsRecord Returns a new DnsRecord instance populated with the data from the array. + */ + public static function fromArray(array $data): DnsRecord + { + return new DnsRecord($data['rpc_endpoint'], $data['public_key'], $data['expires']); + } + } \ No newline at end of file diff --git a/src/Socialbox/Objects/ExportedSession.php b/src/Socialbox/Objects/ExportedSession.php index e1ca7be..84be108 100644 --- a/src/Socialbox/Objects/ExportedSession.php +++ b/src/Socialbox/Objects/ExportedSession.php @@ -2,49 +2,63 @@ namespace Socialbox\Objects; + use Socialbox\Interfaces\SerializableInterface; + /** * Represents an exported session containing cryptographic keys, identifiers, and endpoints. */ - class ExportedSession + class ExportedSession implements SerializableInterface { private string $peerAddress; - private string $privateKey; - private string $publicKey; - private string $encryptionKey; - private string $serverPublicKey; private string $rpcEndpoint; - private string $sessionUuid; + private string $sessionUUID; + private string $transportEncryptionAlgorithm; + private int $serverKeypairExpires; + private string $serverPublicSigningKey; + private string $serverPublicEncryptionKey; + private string $clientPublicSigningKey; + private string $clientPrivateSigningKey; + private string $clientPublicEncryptionKey; + private string $clientPrivateEncryptionKey; + private string $privateSharedSecret; + private string $clientTransportEncryptionKey; + private string $serverTransportEncryptionKey; /** - * Initializes a new instance of the class with the provided data. + * Constructor method to initialize class properties from the provided data array. * - * @param array $data An associative array containing the configuration data. - * Expected keys: - * - 'peer_address': The address of the peer. - * - 'private_key': The private key for secure communication. - * - 'public_key': The public key for secure communication. - * - 'encryption_key': The encryption key used for communication. - * - 'server_public_key': The server's public key. - * - 'rpc_endpoint': The RPC endpoint for network communication. - * - 'session_uuid': The unique identifier for the session. + * @param array $data Associative array containing the required properties such as: + * 'peer_address', 'rpc_endpoint', 'session_uuid', + * 'server_public_signing_key', 'server_public_encryption_key', + * 'client_public_signing_key', 'client_private_signing_key', + * 'client_public_encryption_key', 'client_private_encryption_key', + * 'private_shared_secret', 'client_transport_encryption_key', + * 'server_transport_encryption_key'. * * @return void */ public function __construct(array $data) { $this->peerAddress = $data['peer_address']; - $this->privateKey = $data['private_key']; - $this->publicKey = $data['public_key']; - $this->encryptionKey = $data['encryption_key']; - $this->serverPublicKey = $data['server_public_key']; $this->rpcEndpoint = $data['rpc_endpoint']; - $this->sessionUuid = $data['session_uuid']; + $this->sessionUUID = $data['session_uuid']; + $this->transportEncryptionAlgorithm = $data['transport_encryption_algorithm']; + $this->serverKeypairExpires = $data['server_keypair_expires']; + $this->serverPublicSigningKey = $data['server_public_signing_key']; + $this->serverPublicEncryptionKey = $data['server_public_encryption_key']; + $this->clientPublicSigningKey = $data['client_public_signing_key']; + $this->clientPrivateSigningKey = $data['client_private_signing_key']; + $this->clientPublicEncryptionKey = $data['client_public_encryption_key']; + $this->clientPrivateEncryptionKey = $data['client_private_encryption_key']; + $this->privateSharedSecret = $data['private_shared_secret']; + $this->clientTransportEncryptionKey = $data['client_transport_encryption_key']; + $this->serverTransportEncryptionKey = $data['server_transport_encryption_key']; } /** - * Retrieves the address of the peer. + * Retrieves the peer address associated with the current instance. * - * @return string The peer's address as a string. + * @return string The peer address. */ public function getPeerAddress(): string { @@ -52,47 +66,7 @@ } /** - * Retrieves the private key. - * - * @return string The private key. - */ - public function getPrivateKey(): string - { - return $this->privateKey; - } - - /** - * Retrieves the public key. - * - * @return string The public key. - */ - public function getPublicKey(): string - { - return $this->publicKey; - } - - /** - * Retrieves the encryption key. - * - * @return string The encryption key. - */ - public function getEncryptionKey(): string - { - return $this->encryptionKey; - } - - /** - * Retrieves the public key of the server. - * - * @return string The server's public key. - */ - public function getServerPublicKey(): string - { - return $this->serverPublicKey; - } - - /** - * Retrieves the RPC endpoint URL. + * Retrieves the RPC endpoint. * * @return string The RPC endpoint. */ @@ -102,38 +76,150 @@ } /** - * Retrieves the unique identifier for the current session. + * Retrieves the session UUID associated with the current instance. * * @return string The session UUID. */ - public function getSessionUuid(): string + public function getSessionUUID(): string { - return $this->sessionUuid; + return $this->sessionUUID; } /** - * Converts the current instance into an array representation. + * Retrieves the transport encryption algorithm being used. * - * @return array An associative array containing the instance properties and their respective values. + * @return string The transport encryption algorithm. + */ + public function getTransportEncryptionAlgorithm(): string + { + return $this->transportEncryptionAlgorithm; + } + + /** + * Retrieves the expiration time of the server key pair. + * + * @return int The expiration timestamp of the server key pair. + */ + public function getServerKeypairExpires(): int + { + return $this->serverKeypairExpires; + } + + /** + * Retrieves the public signing key of the server. + * + * @return string The server's public signing key. + */ + public function getServerPublicSigningKey(): string + { + return $this->serverPublicSigningKey; + } + + /** + * Retrieves the server's public encryption key. + * + * @return string The server's public encryption key. + */ + public function getServerPublicEncryptionKey(): string + { + return $this->serverPublicEncryptionKey; + } + + /** + * Retrieves the client's public signing key. + * + * @return string The client's public signing key. + */ + public function getClientPublicSigningKey(): string + { + return $this->clientPublicSigningKey; + } + + /** + * Retrieves the client's private signing key. + * + * @return string The client's private signing key. + */ + public function getClientPrivateSigningKey(): string + { + return $this->clientPrivateSigningKey; + } + + /** + * Retrieves the public encryption key of the client. + * + * @return string The client's public encryption key. + */ + public function getClientPublicEncryptionKey(): string + { + return $this->clientPublicEncryptionKey; + } + + /** + * Retrieves the client's private encryption key. + * + * @return string The client's private encryption key. + */ + public function getClientPrivateEncryptionKey(): string + { + return $this->clientPrivateEncryptionKey; + } + + /** + * Retrieves the private shared secret associated with the current instance. + * + * @return string The private shared secret. + */ + public function getPrivateSharedSecret(): string + { + return $this->privateSharedSecret; + } + + /** + * Retrieves the client transport encryption key. + * + * @return string The client transport encryption key. + */ + public function getClientTransportEncryptionKey(): string + { + return $this->clientTransportEncryptionKey; + } + + /** + * Retrieves the server transport encryption key associated with the current instance. + * + * @return string The server transport encryption key. + */ + public function getServerTransportEncryptionKey(): string + { + return $this->serverTransportEncryptionKey; + } + + /** + * @inheritDoc */ public function toArray(): array { return [ 'peer_address' => $this->peerAddress, - 'private_key' => $this->privateKey, - 'public_key' => $this->publicKey, - 'encryption_key' => $this->encryptionKey, - 'server_public_key' => $this->serverPublicKey, 'rpc_endpoint' => $this->rpcEndpoint, - 'session_uuid' => $this->sessionUuid + 'session_uuid' => $this->sessionUUID, + 'transport_encryption_algorithm' => $this->transportEncryptionAlgorithm, + 'server_keypair_expires' => $this->serverKeypairExpires, + 'server_public_signing_key' => $this->serverPublicSigningKey, + 'server_public_encryption_key' => $this->serverPublicEncryptionKey, + 'client_public_signing_key' => $this->clientPublicSigningKey, + 'client_private_signing_key' => $this->clientPrivateSigningKey, + 'client_public_encryption_key' => $this->clientPublicEncryptionKey, + 'client_private_encryption_key' => $this->clientPrivateEncryptionKey, + 'private_shared_secret' => $this->privateSharedSecret, + 'client_transport_encryption_key' => $this->clientTransportEncryptionKey, + 'server_transport_encryption_key' => $this->serverTransportEncryptionKey, ]; } /** - * Creates an instance of ExportedSession from the provided array. - * - * @param array $data The input data used to construct the ExportedSession instance. - * @return ExportedSession The new ExportedSession instance created from the given data. + * @inheritDoc */ public static function fromArray(array $data): ExportedSession { diff --git a/src/Socialbox/Objects/ResolvedServer.php b/src/Socialbox/Objects/ResolvedServer.php index 4ecd4e5..8683aa6 100644 --- a/src/Socialbox/Objects/ResolvedServer.php +++ b/src/Socialbox/Objects/ResolvedServer.php @@ -1,25 +1,11 @@ endpoint = $endpoint; - $this->publicKey = $publicKey; - } - - public function getEndpoint(): string - { - return $this->endpoint; - } - - public function getPublicKey(): string - { - return $this->publicKey; - } -} \ No newline at end of file + private DnsRecord $dnsRecord; + private ServerInformation $serverInformation; + } \ No newline at end of file diff --git a/src/Socialbox/Objects/ResolvedServerOld.php b/src/Socialbox/Objects/ResolvedServerOld.php new file mode 100644 index 0000000..4ecd4e5 --- /dev/null +++ b/src/Socialbox/Objects/ResolvedServerOld.php @@ -0,0 +1,25 @@ +endpoint = $endpoint; + $this->publicKey = $publicKey; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } +} \ No newline at end of file diff --git a/src/Socialbox/Objects/Standard/ServerInformation.php b/src/Socialbox/Objects/Standard/ServerInformation.php new file mode 100644 index 0000000..101d33a --- /dev/null +++ b/src/Socialbox/Objects/Standard/ServerInformation.php @@ -0,0 +1,75 @@ +serverName = $data['server_name']; + $this->serverKeypairExpires = $data['server_keypair_expires']; + $this->transportEncryptionAlgorithm = $data['transport_encryption_algorithm']; + } + + /** + * Retrieves the name of the server. + * + * @return string The server name. + */ + public function getServerName(): string + { + return $this->serverName; + } + + /** + * Retrieves the expiration time of the server key pair. + * + * @return int The expiration timestamp of the server key pair. + */ + public function getServerKeypairExpires(): int + { + return $this->serverKeypairExpires; + } + + /** + * Retrieves the transport encryption algorithm being used. + * + * @return string The transport encryption algorithm. + */ + public function getTransportEncryptionAlgorithm(): string + { + return $this->transportEncryptionAlgorithm; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $data): ServerInformation + { + return new self($data); + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'server_name' => $this->serverName, + 'server_keypair_expires' => $this->serverKeypairExpires, + 'transport_encryption_algorithm' => $this->transportEncryptionAlgorithm, + ]; + } + } \ No newline at end of file diff --git a/src/Socialbox/Socialbox.php b/src/Socialbox/Socialbox.php index 1cde47b..3895268 100644 --- a/src/Socialbox/Socialbox.php +++ b/src/Socialbox/Socialbox.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Socialbox\Classes\Configuration; use Socialbox\Classes\Cryptography; + use Socialbox\Classes\DnsHelper; use Socialbox\Classes\Logger; use Socialbox\Classes\ServerResolver; use Socialbox\Classes\Utilities; @@ -16,6 +17,7 @@ use Socialbox\Enums\StandardHeaders; use Socialbox\Enums\StandardMethods; use Socialbox\Enums\Types\RequestType; + use Socialbox\Exceptions\CryptographyException; use Socialbox\Exceptions\DatabaseOperationException; use Socialbox\Exceptions\RequestException; use Socialbox\Exceptions\StandardException; @@ -23,34 +25,37 @@ use Socialbox\Managers\SessionManager; use Socialbox\Objects\ClientRequest; use Socialbox\Objects\PeerAddress; + use Socialbox\Objects\Standard\ServerInformation; + use Throwable; class Socialbox { /** - * Handles incoming client requests by validating required headers and processing - * the request based on its type. The method ensures proper handling of - * specific request types like RPC, session initiation, and DHE exchange, - * while returning an appropriate HTTP response for invalid or missing data. + * Handles incoming client requests by parsing request headers, determining the request type, + * and routing the request to the appropriate handler method. Implements error handling for + * missing or invalid request types. * * @return void */ public static function handleRequest(): void { $requestHeaders = Utilities::getRequestHeaders(); - if(!isset($requestHeaders[StandardHeaders::REQUEST_TYPE->value])) { - http_response_code(400); - print('Missing required header: ' . StandardHeaders::REQUEST_TYPE->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::REQUEST_TYPE->value); return; } $clientRequest = new ClientRequest($requestHeaders, file_get_contents('php://input') ?? null); - // Handle the request type, only `init` and `dhe` are not encrypted using the session's encrypted key + // Handle the request type, only `init` and `dhe` are not encrypted using the session's encrypted key // RPC Requests must be encrypted and signed by the client, vice versa for server responses. - switch(RequestType::tryFrom($clientRequest->getHeader(StandardHeaders::REQUEST_TYPE))) + switch($clientRequest->getRequestType()) { + case RequestType::INFO: + self::handleInformationRequest(); + break; + case RequestType::INITIATE_SESSION: self::handleInitiateSession($clientRequest); break; @@ -64,58 +69,66 @@ break; default: - http_response_code(400); - print('Invalid Request-Type header'); - break; + self::returnError(400, StandardError::BAD_REQUEST, 'Invalid Request-Type header'); } } /** - * Validates the headers in an initialization request to ensure that all - * required information is present and properly formatted. This includes - * checking for headers such as Client Name, Client Version, Public Key, - * and Identify-As, as well as validating the Identify-As header value. - * If any validation fails, a corresponding HTTP response code and message - * are returned. + * Handles an information request by setting the appropriate HTTP response code, + * content type headers, and printing the server information in JSON format. * - * @param ClientRequest $clientRequest The client request containing headers to validate. + * @return void + */ + private static function handleInformationRequest(): void + { + http_response_code(200); + header('Content-Type: application/json'); + Logger::getLogger()->info(json_encode(self::getServerInformation()->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + print(json_encode(self::getServerInformation()->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * Validates the initial headers of a client request to ensure all required headers exist + * and contain valid values. If any validation fails, an error response is returned. * + * @param ClientRequest $clientRequest The client request containing headers to be validated. * @return bool Returns true if all required headers are valid, otherwise false. */ private static function validateInitHeaders(ClientRequest $clientRequest): bool { if(!$clientRequest->getClientName()) { - http_response_code(400); - print('Missing required header: ' . StandardHeaders::CLIENT_NAME->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::CLIENT_NAME->value); return false; } if(!$clientRequest->getClientVersion()) { - http_response_code(400); - print('Missing required header: ' . StandardHeaders::CLIENT_VERSION->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::CLIENT_VERSION->value); return false; } - if(!$clientRequest->headerExists(StandardHeaders::PUBLIC_KEY)) + if(!$clientRequest->headerExists(StandardHeaders::SIGNING_PUBLIC_KEY)) { - http_response_code(400); - print('Missing required header: ' . StandardHeaders::PUBLIC_KEY->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::SIGNING_PUBLIC_KEY->value); + return false; + } + + if(!$clientRequest->headerExists(StandardHeaders::ENCRYPTION_PUBLIC_KEY)) + { + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::ENCRYPTION_PUBLIC_KEY->value); return false; } if(!$clientRequest->headerExists(StandardHeaders::IDENTIFY_AS)) { - http_response_code(400); - print('Missing required header: ' . StandardHeaders::IDENTIFY_AS->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::IDENTIFY_AS->value); return false; } if(!Validator::validatePeerAddress($clientRequest->getHeader(StandardHeaders::IDENTIFY_AS))) { - http_response_code(400); - print('Invalid Identify-As header: ' . $clientRequest->getHeader(StandardHeaders::IDENTIFY_AS)); + self::returnError(400, StandardError::BAD_REQUEST, 'Invalid Identify-As header: ' . $clientRequest->getHeader(StandardHeaders::IDENTIFY_AS)); return false; } @@ -123,24 +136,25 @@ } /** - * Processes a client request to initiate a session. Validates required headers, - * ensures the peer is authorized and enabled, and creates a new session UUID - * if all checks pass. Handles edge cases like missing headers, invalid inputs, - * or unauthorized peers. + * Handles the initiation of a session for a client request. This involves validating headers, + * verifying peer identities, resolving domains, registering peers if necessary, and finally + * creating a session while providing the required session UUID as a response. * - * @param ClientRequest $clientRequest The request from the client containing - * the required headers and information. + * @param ClientRequest $clientRequest The incoming client request containing all necessary headers + * and identification information required to initiate the session. * @return void */ private static function handleInitiateSession(ClientRequest $clientRequest): void { + // This is only called for the `init` request type if(!self::validateInitHeaders($clientRequest)) { return; } // We always accept the client's public key at first - $publicKey = $clientRequest->getHeader(StandardHeaders::PUBLIC_KEY); + $clientPublicSigningKey = $clientRequest->getHeader(StandardHeaders::SIGNING_PUBLIC_KEY); + $clientPublicEncryptionKey = $clientRequest->getHeader(StandardHeaders::ENCRYPTION_PUBLIC_KEY); // If the peer is identifying as the same domain if($clientRequest->getIdentifyAs()->getDomain() === Configuration::getInstanceConfiguration()->getDomain()) @@ -148,9 +162,8 @@ // Prevent the peer from identifying as the host unless it's coming from an external domain if($clientRequest->getIdentifyAs()->getUsername() === ReservedUsernames::HOST->value) { - http_response_code(403); - print('Unauthorized: The requested peer is not allowed to identify as the host'); - return; + self::returnError(403, StandardError::FORBIDDEN, 'Unauthorized: Not allowed to identify as the host'); + return; } } // If the peer is identifying as an external domain @@ -159,64 +172,49 @@ // Only allow the host to identify as an external peer if($clientRequest->getIdentifyAs()->getUsername() !== ReservedUsernames::HOST->value) { - http_response_code(403); - print('Unauthorized: The requested peer is not allowed to identify as an external peer'); + self::returnError(403, StandardError::FORBIDDEN, 'Forbidden: Any external peer must identify as the host, only the host can preform actions on behalf of it\'s peers'); return; } try { - // We need to obtain the public key of the host, since we can't trust the client + // We need to obtain the public key of the host, since we can't trust the client (Use database) $resolvedServer = ServerResolver::resolveDomain($clientRequest->getIdentifyAs()->getDomain()); - // Override the public key with the resolved server's public key - $publicKey = $resolvedServer->getPublicKey(); - } - catch (Exceptions\ResolutionException $e) - { - Logger::getLogger()->error('Failed to resolve the host domain', $e); - http_response_code(409); - print('Conflict: Failed to resolve the host domain: ' . $e->getMessage()); - return; + // Override the public signing key with the resolved server's public key + // Encryption key can be left as is. + $clientPublicSigningKey = $resolvedServer->getPublicSigningKey(); } catch (Exception $e) { - Logger::getLogger()->error('An internal error occurred while resolving the host domain', $e); - http_response_code(500); - if(Configuration::getSecurityConfiguration()->isDisplayInternalExceptions()) - { - print(Utilities::throwableToString($e)); - } - else - { - print('An internal error occurred'); - } - + self::returnError(502, StandardError::RESOLUTION_FAILED, 'Conflict: Failed to resolve the host domain: ' . $e->getMessage(), $e); return; } } try { + // Check if we have a registered peer with the same address $registeredPeer = RegisteredPeerManager::getPeerByAddress($clientRequest->getIdentifyAs()); // If the peer is registered, check if it is enabled if($registeredPeer !== null && !$registeredPeer->isEnabled()) { - // Refuse to create a session if the peer is disabled/banned - // This also prevents multiple sessions from being created for the same peer - // A cron job should be used to clean up disabled peers - http_response_code(403); - print('Unauthorized: The requested peer is disabled/banned'); + // Refuse to create a session if the peer is disabled/banned, this usually happens when + // a peer gets banned or more commonly when a client attempts to register as this peer but + // destroyed the session before it was created. + // This is to prevent multiple sessions from being created for the same peer, this is cleaned up + // with a cron job using `socialbox clean-sessions` + self::returnError(403, StandardError::FORBIDDEN, 'Unauthorized: The requested peer is disabled/banned'); return; } + // Otherwise the peer isn't registered, so we need to register it else { // Check if registration is enabled if(!Configuration::getRegistrationConfiguration()->isRegistrationEnabled()) { - http_response_code(403); - print('Unauthorized: Registration is disabled'); + self::returnError(401, StandardError::UNAUTHORIZED, 'Unauthorized: Registration is disabled'); return; } @@ -226,141 +224,220 @@ $registeredPeer = RegisteredPeerManager::getPeer($peerUuid); } - // Create the session UUID - $sessionUuid = SessionManager::createSession($publicKey, $registeredPeer, $clientRequest->getClientName(), $clientRequest->getClientVersion()); + // Generate server's encryption keys for this session + $serverEncryptionKey = Cryptography::generateEncryptionKeyPair(); + + // Create the session passing on the registered peer, client name, version, and public keys + $sessionUuid = SessionManager::createSession($registeredPeer, $clientRequest->getClientName(), $clientRequest->getClientVersion(), $clientPublicSigningKey, $clientPublicEncryptionKey, $serverEncryptionKey); + + // The server responds back with the session UUID & The server's public encryption key as the header http_response_code(201); // Created + header('Content-Type: text/plain'); + header(StandardHeaders::ENCRYPTION_PUBLIC_KEY->value . ': ' . $serverEncryptionKey->getPublicKey()); print($sessionUuid); // Return the session UUID } catch(InvalidArgumentException $e) { - http_response_code(412); // Precondition failed - print($e->getMessage()); // Why the request failed + // This is usually thrown due to an invalid input + self::returnError(400, StandardError::BAD_REQUEST, $e->getMessage(), $e); } catch(Exception $e) { - Logger::getLogger()->error('An internal error occurred while initiating the session', $e); - http_response_code(500); // Internal server error - if(Configuration::getSecurityConfiguration()->isDisplayInternalExceptions()) - { - print(Utilities::throwableToString($e)); - } - else - { - print('An internal error occurred'); - } + self::returnError(500, StandardError::INTERNAL_SERVER_ERROR, 'An internal error occurred while initiating the session', $e); } } /** - * Handles the Diffie-Hellman key exchange by decrypting the encrypted key passed on from the client using - * the server's private key and setting the encryption key to the session. + * Handles the Diffie-Hellman Ephemeral (DHE) key exchange process between the client and server, + * ensuring secure transport encryption key negotiation. The method validates request headers, + * session state, and cryptographic operations, and updates the session with the resulting keys + * and state upon successful negotiation. * - * 412: Headers malformed - * 400: Bad request - * 500: Internal server error - * 204: Success, no content. + * @param ClientRequest $clientRequest The request object containing headers, body, and session details + * required to perform the DHE exchange. * - * @param ClientRequest $clientRequest * @return void */ private static function handleDheExchange(ClientRequest $clientRequest): void { - // Check if the session UUID is set in the headers + // Check if the session UUID is set in the headers, bad request if not if(!$clientRequest->headerExists(StandardHeaders::SESSION_UUID)) { - Logger::getLogger()->verbose('Missing required header: ' . StandardHeaders::SESSION_UUID->value); - - http_response_code(412); - print('Missing required header: ' . StandardHeaders::SESSION_UUID->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::SESSION_UUID->value); return; } - // Check if the request body is empty + if(!$clientRequest->headerExists(StandardHeaders::SIGNATURE)) + { + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::SIGNATURE->value); + return; + } + + if(empty($clientRequest->getHeader(StandardHeaders::SIGNATURE))) + { + self::returnError(400, StandardError::BAD_REQUEST, 'Bad request: The signature is empty'); + return; + } + + // Check if the request body is empty, bad request if so if(empty($clientRequest->getRequestBody())) { - Logger::getLogger()->verbose('Bad request: The key exchange request body is empty'); - - http_response_code(400); - print('Bad request: The key exchange request body is empty'); + self::returnError(400, StandardError::BAD_REQUEST, 'Bad request: The key exchange request body is empty'); return; } - // Check if the session is awaiting a DHE exchange - if($clientRequest->getSession()->getState() !== SessionState::AWAITING_DHE) + // Check if the session is awaiting a DHE exchange, forbidden if not + $session = $clientRequest->getSession(); + if($session->getState() !== SessionState::AWAITING_DHE) { - Logger::getLogger()->verbose('Bad request: The session is not awaiting a DHE exchange'); - - http_response_code(400); - print('Bad request: The session is not awaiting a DHE exchange'); + self::returnError(403, StandardError::FORBIDDEN, 'Bad request: The session is not awaiting a DHE exchange'); return; } + + // DHE STAGE: CLIENT -> SERVER + // Server & Client: Begin the DHE exchange using the exchanged public keys. + // On the client's side, same method but with the server's public key & client's private key try { - // Attempt to decrypt the encrypted key passed on from the client - $encryptionKey = Cryptography::decryptContent($clientRequest->getRequestBody(), Configuration::getInstanceConfiguration()->getPrivateKey()); + $sharedSecret = Cryptography::performDHE($session->getClientPublicEncryptionKey(), $session->getServerPrivateEncryptionKey()); } - catch (Exceptions\CryptographyException $e) + catch (CryptographyException $e) { - Logger::getLogger()->error(sprintf('Bad Request: Failed to decrypt the key for session %s', $clientRequest->getSessionUuid()), $e); - - http_response_code(400); - print('Bad Request: Cryptography error, make sure you have encrypted the key using the server\'s public key; ' . $e->getMessage()); + Logger::getLogger()->error('Failed to perform DHE exchange', $e); + self::returnError(422, StandardError::CRYPTOGRAPHIC_ERROR, 'DHE exchange failed', $e); return; } + // STAGE 1: CLIENT -> SERVER try { - // Finally set the encryption key to the session - SessionManager::setEncryptionKey($clientRequest->getSessionUuid(), $encryptionKey); + // Attempt to decrypt the encrypted key passed on from the client using the shared secret + $clientTransportEncryptionKey = Cryptography::decryptShared($clientRequest->getRequestBody(), $sharedSecret); + } + catch (CryptographyException $e) + { + self::returnError(400, StandardError::CRYPTOGRAPHIC_ERROR, 'Failed to decrypt the key', $e); + return; + } + + // Get the signature from the client and validate it against the decrypted key + $clientSignature = $clientRequest->getHeader(StandardHeaders::SIGNATURE); + if(!Cryptography::verifyMessage($clientTransportEncryptionKey, $clientSignature, $session->getClientPublicSigningKey())) + { + self::returnError(401, StandardError::UNAUTHORIZED, 'Invalid signature'); + return; + } + + // Validate the encryption key given by the client + if(!Cryptography::validateEncryptionKey($clientTransportEncryptionKey, Configuration::getCryptographyConfiguration()->getTransportEncryptionAlgorithm())) + { + self::returnError(400, StandardError::BAD_REQUEST, 'The transport encryption key is invalid and does not meet the server\'s requirements'); + return; + } + + // Receive stage complete, now we move on to the server's response + + // STAGE 2: SERVER -> CLIENT + try + { + // Generate the server's transport encryption key (our side) + $serverTransportEncryptionKey = Cryptography::generateEncryptionKey(Configuration::getCryptographyConfiguration()->getTransportEncryptionAlgorithm()); + + // Sign the shared secret using the server's private key + $signature = Cryptography::signMessage($serverTransportEncryptionKey, Configuration::getCryptographyConfiguration()->getHostPrivateKey()); + // Encrypt the server's transport key using the shared secret + $encryptedServerTransportKey = Cryptography::encryptShared($serverTransportEncryptionKey, $sharedSecret); + } + catch (CryptographyException $e) + { + Logger::getLogger()->error('Failed to generate the server\'s transport encryption key', $e); + self::returnError(500, StandardError::INTERNAL_SERVER_ERROR, 'There was an error while trying to process the DHE exchange', $e); + return; + } + + // Now update the session details with all the encryption keys and the state + try + { + SessionManager::setEncryptionKeys($clientRequest->getSessionUuid(), $sharedSecret, $clientTransportEncryptionKey, $serverTransportEncryptionKey); + SessionManager::updateState($clientRequest->getSessionUuid(), SessionState::ACTIVE); } catch (DatabaseOperationException $e) { Logger::getLogger()->error('Failed to set the encryption key for the session', $e); - http_response_code(500); - - if(Configuration::getSecurityConfiguration()->isDisplayInternalExceptions()) - { - print(Utilities::throwableToString($e)); - } - else - { - print('Internal Server Error: Failed to set the encryption key for the session'); - } - + self::returnError(500, StandardError::INTERNAL_SERVER_ERROR, 'Failed to set the encryption key for the session', $e); return; } - Logger::getLogger()->info(sprintf('DHE exchange completed for session %s', $clientRequest->getSessionUuid())); - http_response_code(204); // Success, no content + // Return the encrypted transport key for the server back to the client. + http_response_code(200); + header('Content-Type: application/octet-stream'); + header(StandardHeaders::SIGNATURE->value . ': ' . $signature); + print($encryptedServerTransportKey); } /** - * Handles incoming RPC requests from a client, processes each request, - * and returns the appropriate response(s) or error(s). + * Handles a Remote Procedure Call (RPC) request, ensuring proper decryption, + * signature verification, and response encryption, while processing one or more + * RPC methods as specified in the request. + * + * @param ClientRequest $clientRequest The RPC client request containing headers, body, and session information. * - * @param ClientRequest $clientRequest The client's request containing one or multiple RPC calls. * @return void */ private static function handleRpc(ClientRequest $clientRequest): void { + // Client: Encrypt the request body using the server's encryption key & sign it using the client's private key + // Server: Decrypt the request body using the servers's encryption key & verify the signature using the client's public key + // Server: Encrypt the response using the client's encryption key & sign it using the server's private key + if(!$clientRequest->headerExists(StandardHeaders::SESSION_UUID)) { - Logger::getLogger()->verbose('Missing required header: ' . StandardHeaders::SESSION_UUID->value); + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::SESSION_UUID->value); + return; + } - http_response_code(412); - print('Missing required header: ' . StandardHeaders::SESSION_UUID->value); + if(!$clientRequest->headerExists(StandardHeaders::SIGNATURE)) + { + self::returnError(400, StandardError::BAD_REQUEST, 'Missing required header: ' . StandardHeaders::SIGNATURE->value); + return; + } + + // Get the client session + $session = $clientRequest->getSession(); + + // Verify if the session is active + if($session->getState() !== SessionState::ACTIVE) + { + self::returnError(403, StandardError::FORBIDDEN, 'Session is not active'); return; } try { - $clientRequests = $clientRequest->getRpcRequests(); + // Attempt to decrypt the request body using the server's encryption key + $decryptedContent = Cryptography::decryptMessage($clientRequest->getRequestBody(), $session->getServerTransportEncryptionKey(), Configuration::getCryptographyConfiguration()->getTransportEncryptionAlgorithm()); + } + catch(CryptographyException $e) + { + self::returnError(400, StandardError::CRYPTOGRAPHIC_ERROR, 'Failed to decrypt request', $e); + return; + } + + // Attempt to verify the decrypted content using the client's public signing key + if(!Cryptography::verifyMessage($decryptedContent, $clientRequest->getSignature(), $session->getClientPublicSigningKey())) + { + self::returnError(400, StandardError::CRYPTOGRAPHIC_ERROR, 'Signature verification failed'); + return; + } + + try + { + $clientRequests = $clientRequest->getRpcRequests($decryptedContent); } catch (RequestException $e) { - http_response_code($e->getCode()); - print($e->getMessage()); + self::returnError($e->getCode(), $e->getStandardError(), $e->getMessage()); return; } @@ -442,16 +519,24 @@ return; } + $session = $clientRequest->getSession(); + try { - $encryptedResponse = Cryptography::encryptTransport($response, $clientRequest->getSession()->getEncryptionKey()); - $signature = Cryptography::signContent($response, Configuration::getInstanceConfiguration()->getPrivateKey(), true); + $encryptedResponse = Cryptography::encryptMessage( + message: $response, + encryptionKey: $session->getClientTransportEncryptionKey(), + algorithm: Configuration::getCryptographyConfiguration()->getTransportEncryptionAlgorithm() + ); + + $signature = Cryptography::signMessage( + message: $response, + privateKey: Configuration::getCryptographyConfiguration()->getHostPrivateKey() + ); } catch (Exceptions\CryptographyException $e) { - Logger::getLogger()->error('Failed to encrypt the response', $e); - http_response_code(500); - print('Internal Server Error: Failed to encrypt the response'); + self::returnError(500, StandardError::INTERNAL_SERVER_ERROR, 'Failed to encrypt the server response', $e); return; } @@ -460,4 +545,69 @@ header(StandardHeaders::SIGNATURE->value . ': ' . $signature); print($encryptedResponse); } + + /** + * Sends an error response by setting the HTTP response code, headers, and printing an error message. + * Optionally includes exception details in the response if enabled in the configuration. + * Logs the error message and any associated exception. + * + * @param int $responseCode The HTTP response code to send. + * @param StandardError $standardError The standard error containing error details. + * @param string|null $message An optional error message to display. Defaults to the message from the StandardError instance. + * @param Throwable|null $e An optional throwable to include in logs and the response, if enabled. + * + * @return void + */ + private static function returnError(int $responseCode, StandardError $standardError, ?string $message=null, ?Throwable $e=null): void + { + if($message === null) + { + $message = $standardError->getMessage(); + } + + http_response_code($responseCode); + header('Content-Type: text/plain'); + header(StandardHeaders::ERROR_CODE->value . ': ' . $standardError->value); + print($message); + + if(Configuration::getSecurityConfiguration()->isDisplayInternalExceptions() && $e !== null) + { + print(PHP_EOL . PHP_EOL . Utilities::throwableToString($e)); + } + + if($e !== null) + { + Logger::getLogger()->error($message, $e); + } + } + + /** + * Retrieves the server information by assembling data from the configuration settings. + * + * @return ServerInformation An instance of ServerInformation containing details such as server name, hashing algorithm, + * transport AES mode, and AES key length. + */ + public static function getServerInformation(): ServerInformation + { + return ServerInformation::fromArray([ + 'server_name' => Configuration::getInstanceConfiguration()->getName(), + 'server_keypair_expires' => Configuration::getCryptographyConfiguration()->getHostKeyPairExpires(), + 'transport_encryption_algorithm' => Configuration::getCryptographyConfiguration()->getTransportEncryptionAlgorithm() + ]); + } + + /** + * Retrieves the DNS record by generating a TXT record using the RPC endpoint, + * host public key, and host key pair expiration from the configuration. + * + * @return string The generated DNS TXT record. + */ + public static function getDnsRecord(): string + { + return DnsHelper::generateTxt( + Configuration::getInstanceConfiguration()->getRpcEndpoint(), + Configuration::getCryptographyConfiguration()->getHostPublicKey(), + Configuration::getCryptographyConfiguration()->getHostKeyPairExpires() + ); + } } \ No newline at end of file diff --git a/tests/Socialbox/Classes/CryptographyTest.php b/tests/Socialbox/Classes/CryptographyTest.php new file mode 100644 index 0000000..882787d --- /dev/null +++ b/tests/Socialbox/Classes/CryptographyTest.php @@ -0,0 +1,439 @@ +assertInstanceOf(KeyPair::class, $keyPair); + $this->assertNotEmpty($keyPair->getPublicKey()); + $this->assertNotEmpty($keyPair->getPrivateKey()); + } + + /** + * Test that the generated public key starts with the defined encryption key type prefix. + */ + public function testGeneratedPublicKeyHasEncryptionPrefix(): void + { + $keyPair = Cryptography::generateEncryptionKeyPair(); + + $this->assertStringStartsWith('enc:', $keyPair->getPublicKey()); + } + + /** + * Test that the generated private key starts with the defined encryption key type prefix. + */ + public function testGeneratedPrivateKeyHasEncryptionPrefix(): void + { + $keyPair = Cryptography::generateEncryptionKeyPair(); + + $this->assertStringStartsWith('enc:', $keyPair->getPrivateKey()); + } + + /** + * Test that the generated keys are of different base64-encoded string values. + */ + public function testPublicAndPrivateKeysAreDifferent(): void + { + $keyPair = Cryptography::generateEncryptionKeyPair(); + + $this->assertNotEquals($keyPair->getPublicKey(), $keyPair->getPrivateKey()); + } + + + /** + * Test that generateSigningKeyPair generates a KeyPair with valid keys. + */ + public function testGenerateSigningKeyPairProducesValidKeyPair(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + + $this->assertInstanceOf(KeyPair::class, $keyPair); + $this->assertNotEmpty($keyPair->getPublicKey()); + $this->assertNotEmpty($keyPair->getPrivateKey()); + } + + /** + * Test that the generated public key starts with the defined signing key type prefix. + */ + public function testGeneratedPublicKeyHasSigningPrefix(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + + $this->assertStringStartsWith('sig:', $keyPair->getPublicKey()); + } + + /** + * Test that the generated private key starts with the defined signing key type prefix. + */ + public function testGeneratedPrivateKeyHasSigningPrefix(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + + $this->assertStringStartsWith('sig:', $keyPair->getPrivateKey()); + } + + /** + * Test that performDHE successfully calculates a shared secret with valid keys. + */ + public function testPerformDHESuccessfullyCalculatesSharedSecret(): void + { + $aliceKeyPair = Cryptography::generateEncryptionKeyPair(); + $aliceSigningKeyPair = Cryptography::generateSigningKeyPair(); + $bobKeyPair = Cryptography::generateEncryptionKeyPair(); + $bobSigningKeyPair = Cryptography::generateSigningKeyPair(); + + // Alice performs DHE with Bob + $aliceSharedSecret = Cryptography::performDHE($bobKeyPair->getPublicKey(), $aliceKeyPair->getPrivateKey()); + // Bob performs DHE with Alice + $bobSharedSecret = Cryptography::performDHE($aliceKeyPair->getPublicKey(), $bobKeyPair->getPrivateKey()); + $this->assertEquals($aliceSharedSecret, $bobSharedSecret); + + // Alice sends "Hello, Bob!" to Bob, signing the message and encrypting it with the shared secret + $message = "Hello, Bob!"; + $aliceSignature = Cryptography::signMessage($message, $aliceSigningKeyPair->getPrivateKey()); + $encryptedMessage = Cryptography::encryptShared($message, $aliceSharedSecret); + + // Bob decrypts the message and verifies the signature + $decryptedMessage = Cryptography::decryptShared($encryptedMessage, $bobSharedSecret); + $isValid = Cryptography::verifyMessage($decryptedMessage, $aliceSignature, $aliceSigningKeyPair->getPublicKey()); + $this->assertEquals($message, $decryptedMessage); + $this->assertTrue($isValid); + + // Bob sends "Hello, Alice!" to Alice, signing the message and encrypting it with the shared secret + $message = "Hello, Alice!"; + $bobSignature = Cryptography::signMessage($message, $bobSigningKeyPair->getPrivateKey()); + $encryptedMessage = Cryptography::encryptShared($message, $bobSharedSecret); + + // Alice decrypts the message and verifies the signature + $decryptedMessage = Cryptography::decryptShared($encryptedMessage, $aliceSharedSecret); + $isValid = Cryptography::verifyMessage($decryptedMessage, $bobSignature, $bobSigningKeyPair->getPublicKey()); + $this->assertEquals($message, $decryptedMessage); + $this->assertTrue($isValid); + } + + /** + * Test that performDHE throws an exception when an invalid public key is used. + */ + public function testPerformDHEThrowsExceptionForInvalidPublicKey(): void + { + $encryptionKeyPair = Cryptography::generateEncryptionKeyPair(); + $invalidPublicKey = 'invalid_key'; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage('Invalid key type. Expected enc:'); + + Cryptography::performDHE($invalidPublicKey, $encryptionKeyPair->getPrivateKey()); + } + + /** + * Test that performDHE throws an exception when an invalid private key is used. + */ + public function testPerformDHEThrowsExceptionForInvalidPrivateKey(): void + { + $encryptionKeyPair = Cryptography::generateEncryptionKeyPair(); + $invalidPrivateKey = 'invalid_key'; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage('Invalid key type. Expected enc:'); + + Cryptography::performDHE($encryptionKeyPair->getPublicKey(), $invalidPrivateKey); + } + + + /** + * Test that encrypt correctly encrypts a message with a valid shared secret. + */ + public function testEncryptSuccessfullyEncryptsMessage(): void + { + $sharedSecret = Cryptography::performDHE( + Cryptography::generateEncryptionKeyPair()->getPublicKey(), + Cryptography::generateEncryptionKeyPair()->getPrivateKey() + ); + $message = "Test message"; + + $encryptedMessage = Cryptography::encryptShared($message, $sharedSecret); + + $this->assertNotEmpty($encryptedMessage); + $this->assertNotEquals($message, $encryptedMessage); + } + + /** + * Test that encrypt throws an exception when given an invalid shared secret. + */ + public function testEncryptThrowsExceptionForInvalidSharedSecret(): void + { + $invalidSharedSecret = "invalid_secret"; + $message = "Test message"; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Encryption failed"); + + Cryptography::encryptShared($message, $invalidSharedSecret); + } + + /** + * Test that the encrypted message is different from the original message. + */ + public function testEncryptProducesDifferentMessage(): void + { + $sharedSecret = Cryptography::performDHE( + Cryptography::generateEncryptionKeyPair()->getPublicKey(), + Cryptography::generateEncryptionKeyPair()->getPrivateKey() + ); + $message = "Another test message"; + + $encryptedMessage = Cryptography::encryptShared($message, $sharedSecret); + + $this->assertNotEquals($message, $encryptedMessage); + } + + /** + * Test that decrypt successfully decrypts an encrypted message with a valid shared secret. + */ + public function testDecryptSuccessfullyDecryptsMessage(): void + { + $sharedSecret = Cryptography::performDHE( + Cryptography::generateEncryptionKeyPair()->getPublicKey(), + Cryptography::generateEncryptionKeyPair()->getPrivateKey() + ); + $message = "Decryption test message"; + + $encryptedMessage = Cryptography::encryptShared($message, $sharedSecret); + $decryptedMessage = Cryptography::decryptShared($encryptedMessage, $sharedSecret); + + $this->assertEquals($message, $decryptedMessage); + } + + /** + * Test that decrypt throws an exception when given an invalid shared secret. + */ + public function testDecryptThrowsExceptionForInvalidSharedSecret(): void + { + $sharedSecret = Cryptography::performDHE( + Cryptography::generateEncryptionKeyPair()->getPublicKey(), + Cryptography::generateEncryptionKeyPair()->getPrivateKey() + ); + $invalidSharedSecret = "invalid_shared_secret"; + $message = "Decryption failure case"; + + $encryptedMessage = Cryptography::encryptShared($message, $sharedSecret); + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Decryption failed"); + + Cryptography::decryptShared($encryptedMessage, $invalidSharedSecret); + } + + /** + * Test that decrypt throws an exception when the encrypted data is tampered with. + */ + public function testDecryptThrowsExceptionForTamperedEncryptedMessage(): void + { + $sharedSecret = Cryptography::performDHE( + Cryptography::generateEncryptionKeyPair()->getPublicKey(), + Cryptography::generateEncryptionKeyPair()->getPrivateKey() + ); + $message = "Tampered message"; + + $encryptedMessage = Cryptography::encryptShared($message, $sharedSecret); + $tamperedMessage = $encryptedMessage . "tampered_data"; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Decryption failed"); + + Cryptography::decryptShared($tamperedMessage, $sharedSecret); + } + + /** + * Test that sign successfully signs a message with a valid private key. + */ + public function testSignSuccessfullySignsMessage(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + $message = "Message to sign"; + + $signature = Cryptography::signMessage($message, $keyPair->getPrivateKey()); + + $this->assertNotEmpty($signature); + } + + /** + * Test that sign throws an exception when an invalid private key is used. + */ + public function testSignThrowsExceptionForInvalidPrivateKey(): void + { + $invalidPrivateKey = "invalid_key"; + $message = "Message to sign"; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Failed to sign message"); + + Cryptography::signMessage($message, $invalidPrivateKey); + } + + /** + * Test that verify successfully validates a correct signature with a valid message and public key. + */ + public function testVerifySuccessfullyValidatesSignature(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + $message = "Message to verify"; + $signature = Cryptography::signMessage($message, $keyPair->getPrivateKey()); + + $isValid = Cryptography::verifyMessage($message, $signature, $keyPair->getPublicKey()); + + $this->assertTrue($isValid); + } + + /** + * Test that verify fails for an invalid signature. + */ + public function testVerifyFailsForInvalidSignature(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + $message = "Message to verify"; + $signature = "invalid_signature"; + + $this->expectException(Exception::class); + + Cryptography::verifyMessage($message, $signature, $keyPair->getPublicKey()); + } + + /** + * Test that verify throws an exception for an invalid public key. + */ + public function testVerifyThrowsExceptionForInvalidPublicKey(): void + { + $keyPair = Cryptography::generateSigningKeyPair(); + $message = "Message to verify"; + $signature = Cryptography::signMessage($message, $keyPair->getPrivateKey()); + $invalidPublicKey = "invalid_public_key"; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Failed to verify signature"); + + Cryptography::verifyMessage($message, $signature, $invalidPublicKey); + } + + /** + * Test that verify throws an exception for a public key with the wrong type prefix. + */ + public function testVerifyThrowsExceptionForInvalidKeyType(): void + { + $encryptionKeyPair = Cryptography::generateEncryptionKeyPair(); + $message = "Message to verify"; + $signature = "invalid_signature"; + + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Invalid key type. Expected sig:"); + + Cryptography::verifyMessage($message, $signature, $encryptionKeyPair->getPublicKey()); + } + + /** + * Test that generateTransportKey creates a valid transport key for the default algorithm. + */ + public function testGenerateTransportKeyCreatesValidKeyForDefaultAlgo(): void + { + $transportKey = Cryptography::generateEncryptionKey(); + $decodedKey = sodium_base642bin($transportKey, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING, true); + + $this->assertNotEmpty($transportKey); + $this->assertEquals(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, strlen($decodedKey)); + } + + /** + * Test that generateTransportKey creates valid keys for specific supported algorithms. + */ + public function testGenerateTransportKeyCreatesValidKeysForAlgorithms(): void + { + $algorithms = [ + 'xchacha20' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, + 'chacha20' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, + 'aes256gcm' => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES + ]; + + foreach ($algorithms as $algorithm => $expectedKeyLength) { + $transportKey = Cryptography::generateEncryptionKey($algorithm); + $decodedKey = sodium_base642bin($transportKey, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING, true); + + $this->assertNotEmpty($transportKey); + $this->assertEquals($expectedKeyLength, strlen($decodedKey)); + } + } + + /** + * Test that generateTransportKey throws an exception when given an invalid algorithm. + */ + public function testGenerateTransportKeyThrowsExceptionForInvalidAlgorithm(): void + { + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Unsupported algorithm"); + + Cryptography::generateEncryptionKey("invalid_algorithm"); + } + + /** + * Test that generateTransportKey creates valid keys for other supported algorithms. + */ + public function testGenerateTransportKeyCreatesValidKeyForOtherSupportedAlgorithms(): void + { + $algorithms = [ + 'xchacha20' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, + 'chacha20' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, + 'aes256gcm' => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES + ]; + + foreach ($algorithms as $algorithm => $expectedKeyLength) { + $transportKey = Cryptography::generateEncryptionKey($algorithm); + $decodedKey = sodium_base642bin($transportKey, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING, true); + + $this->assertNotEmpty($transportKey); + $this->assertEquals($expectedKeyLength, strlen($decodedKey)); + } + } + + /** + * Test that generateTransportKey throws an exception for unsupported algorithms. + */ + public function testGenerateTransportKeyThrowsExceptionForUnsupportedAlgorithm(): void + { + $this->expectException(CryptographyException::class); + $this->expectExceptionMessage("Unsupported algorithm"); + + Cryptography::generateEncryptionKey('invalid_algo'); + } + + public function testEncryptTransportMessageSuccessfullyEncryptsMessage(): void + { + $algorithms = [ + 'xchacha20' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, + 'chacha20' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, + 'aes256gcm' => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES + ]; + + foreach ($algorithms as $algorithm => $keyLength) { + $transportKey = Cryptography::generateEncryptionKey($algorithm); + $this->assertNotEmpty($transportKey); + $this->assertEquals($keyLength, strlen(sodium_base642bin($transportKey, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING, true))); + $message = "Test message"; + + $encryptedMessage = Cryptography::encryptMessage($message, $transportKey); + $decryptedMessage = Cryptography::decryptMessage($encryptedMessage, $transportKey); + + $this->assertEquals($message, $decryptedMessage); + } + } + } \ No newline at end of file