<?php

namespace iThemesSecurity\Encryption;

use Exception;
use RuntimeException;
use SodiumException;

final class User_Encryption_Sodium implements User_Encryption {
	/**
	 * Prefix for encrypted secrets. Contains a version identifier.
	 *
	 * $t1$ -> v1 (RFC 6238, encrypted with XChaCha20-Poly1305, with a key derived from HMAC-SHA256
	 *                  of SECURE_AUTH_SAL.)
	 *
	 * @var string
	 */
	private const ENCRYPTED_PREFIX = '$t1$';

	/**
	 * Current "version" of the encryption protocol.
	 *
	 * 1 -> $t1$nonce|ciphertext|tag
	 */
	private const ENCRYPTED_VERSION = 1;

	/** @var string */
	private $secret;

	/**
	 * Instantiates a new User Encryption implementation.
	 *
	 * @param string $secret Random bytes.
	 */
	public function __construct( string $secret ) { $this->secret = $secret; }

	public static function is_encrypted( string $message ): bool {
		if ( strlen( $message ) < 40 ) {
			return false;
		}

		if ( strpos( $message, self::ENCRYPTED_PREFIX ) !== 0 ) {
			return false;
		}

		return true;
	}

	public function encrypt( string $message, int $user_id ): string {
		$prefix = $this->get_version_header();

		try {
			$nonce      = random_bytes( 24 );
			$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
				$message,
				$this->serialize_aad( $prefix, $nonce, $user_id ),
				$nonce,
				$this->get_encryption_key()
			);
		} catch ( SodiumException $e ) {
			throw new RuntimeException( 'Encryption failed', 0, $e );
		} catch ( Exception $e ) {
			throw new RuntimeException( 'Nonce generation failed.', 0, $e );
		}

		return self::ENCRYPTED_PREFIX . base64_encode( $nonce . $ciphertext );
	}

	public function decrypt( string $encrypted, int $user_id ): string {
		if ( strlen( $encrypted ) < 4 ) {
			throw new RuntimeException( 'Message is too short to be encrypted' );
		}

		$prefix  = substr( $encrypted, 0, 4 );
		$version = self::get_version_id( $prefix );

		if ( $version !== 1 ) {
			throw new RuntimeException( 'Unknown version: ' . $version );
		}

		$decoded    = base64_decode( substr( $encrypted, 4 ) );
		$nonce      = substr( $decoded, 0, 24 );
		$ciphertext = substr( $decoded, 24 );
		try {
			$decrypted = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
				$ciphertext,
				self::serialize_aad( $prefix, $nonce, $user_id ),
				$nonce,
				$this->get_encryption_key( $version )
			);
		} catch ( SodiumException $ex ) {
			throw new RuntimeException( 'Decryption failed', 0, $ex );
		}

		// If we don't have a string, throw an exception because decryption failed.
		if ( ! is_string( $decrypted ) ) {
			throw new RuntimeException( 'Could not decrypt secret' );
		}

		return $decrypted;
	}

	/**
	 * Serializes the Additional Authenticated Data for secret encryption.
	 *
	 * @param string $prefix  Version prefix.
	 * @param string $nonce   Encryption nonce.
	 * @param int    $user_id User ID.
	 *
	 * @return string
	 */
	private function serialize_aad( string $prefix, string $nonce, int $user_id ): string {
		return $prefix . $nonce . pack( 'N', $user_id );
	}

	/**
	 * Gets the version prefix from a given version number.
	 *
	 * @param int $number Version number.
	 *
	 * @return string
	 * @throws RuntimeException For incorrect versions.
	 */
	private function get_version_header( int $number = self::ENCRYPTED_VERSION ): string {
		switch ( $number ) {
			case 1:
				return '$t1$';
			default:
				throw new RuntimeException( 'Incorrect version number: ' . $number );
		}
	}

	/**
	 * Gets the version prefix from a given version number.
	 *
	 * @param string $prefix Version prefix.
	 *
	 * @return int
	 * @throws RuntimeException For incorrect versions.
	 */
	private function get_version_id( string $prefix = self::ENCRYPTED_PREFIX ): int {
		switch ( $prefix ) {
			case '$t1$':
				return 1;
			default:
				throw new RuntimeException( 'Incorrect version identifier: ' . $prefix );
		}
	}

	/**
	 * Gets the encryption key for encrypting secrets.
	 *
	 * @param int $version Key derivation strategy.
	 *
	 * @return string
	 * @throws RuntimeException For incorrect versions.
	 */
	private function get_encryption_key( int $version = self::ENCRYPTED_VERSION ): string {
		switch ( $version ) {
			case 1:
				return hash_hmac( 'sha256', $this->secret, 'itsec-user-encryption', true );
			default:
				throw new RuntimeException( 'Incorrect version number: ' . $version );
		}
	}
}
