<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Encryption at rest for the plugin's secrets — in practice, the activation key.
 *
 * The goal: a key entered in WordPress must NOT sit in the clear in the database, and must not be
 * readable again once saved.
 *
 * How:
 *  - Authenticated encryption with libsodium (secretbox = XSalsa20-Poly1305), bundled in PHP 7.2+
 *    and available through sodium_compat on the WordPress versions we support.
 *  - The encryption KEY is DERIVED from the wp-config.php salts (AUTH_KEY, …), which live OUTSIDE
 *    the database: a dump of the database alone is not enough to decrypt.
 *  - Every encrypted value carries a version prefix ("aipb1:") plus a random nonce, so it is
 *    recognisable and can be superseded later.
 *
 * THE TRADE-OFF, stated rather than discovered: if the wp-config.php salts are regenerated, the
 * existing values can no longer be decrypted and the key has to be entered again. That is the
 * expected cost of salt-derived encryption, and it beats keeping the secret in the clear.
 */
class AIPB_Secret {

	/** Marks a value encrypted with this scheme (version 1). */
	const PREFIX = 'aipb1:';

	/** Is authenticated encryption available on this installation? */
	public static function available() {
		return function_exists( 'sodium_crypto_secretbox' )
			&& function_exists( 'sodium_crypto_secretbox_open' )
			&& defined( 'SODIUM_CRYPTO_SECRETBOX_KEYBYTES' );
	}

	/** Is this value already encrypted with our scheme? */
	public static function is_encrypted( $value ) {
		return is_string( $value ) && 0 === strpos( $value, self::PREFIX );
	}

	/**
	 * Reads a secret from an option and returns it in the clear, repairing the database when
	 * needed:
	 *  - a plaintext value from an earlier version is encrypted on first use;
	 *  - a value encrypted several times over is rewritten with a single layer.
	 * The repair happens once; after that, reading is a plain decrypt.
	 */
	public static function read_option( $option ) {
		$stored = (string) get_option( $option, '' );
		if ( '' === $stored ) {
			return '';
		}

		if ( ! self::is_encrypted( $stored ) ) {
			$plain = trim( $stored );
			if ( '' !== $plain && self::available() ) {
				update_option( $option, self::encrypt( $plain ) );
			}

			return $plain;
		}

		$plain = self::decrypt_deep( $stored );
		if ( '' === $plain ) {
			return '';
		}

		// Was it nested? A single decrypt would still hand back ciphertext.
		if ( self::is_encrypted( self::decrypt( $stored ) ) ) {
			update_option( $option, self::encrypt( $plain ) );
		}

		return trim( $plain );
	}

	/**
	 * Decrypts, peeling off however many layers there are.
	 *
	 * It exists to repair values encrypted MORE THAN ONCE: when an option does not exist yet,
	 * update_option() falls back to add_option() and WordPress runs the sanitize callback twice,
	 * so the first save of a key encrypted it twice. The cause is fixed upstream (the sanitizers
	 * are idempotent now); this recovers what is already in the database.
	 */
	public static function decrypt_deep( $stored, $max_layers = 5 ) {
		$value = (string) $stored;

		for ( $i = 0; $i < $max_layers && self::is_encrypted( $value ); $i++ ) {
			$next = self::decrypt( $value );
			if ( '' === $next ) {
				return ''; // unreadable layer: empty beats garbage in an Authorization header
			}
			$value = $next;
		}

		return self::is_encrypted( $value ) ? '' : $value;
	}

	/**
	 * Encrypts a string. Returns the prefixed ciphertext, or:
	 *  - '' when the input is empty;
	 *  - the input unchanged when encryption is unavailable (rare: we do not block the save).
	 */
	public static function encrypt( $plaintext ) {
		$plaintext = (string) $plaintext;
		if ( '' === $plaintext ) {
			return '';
		}
		if ( ! self::available() ) {
			return $plaintext;
		}

		$nonce  = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$cipher = sodium_crypto_secretbox( $plaintext, $nonce, self::key() );
		$out    = self::PREFIX . base64_encode( $nonce . $cipher );

		sodium_memzero( $plaintext );

		return $out;
	}

	/**
	 * Decrypts a value produced by encrypt(). A value without our prefix comes back unchanged
	 * (compatibility with keys saved in the clear by earlier versions). Returns '' when the value
	 * is encrypted but cannot be decrypted (changed salts, corrupted data).
	 */
	public static function decrypt( $stored ) {
		$stored = (string) $stored;
		if ( ! self::is_encrypted( $stored ) ) {
			return $stored;
		}
		if ( ! self::available() ) {
			return '';
		}

		$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
		if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
			return '';
		}

		$plain = sodium_crypto_secretbox_open(
			substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ),
			substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ),
			self::key()
		);

		return false === $plain ? '' : $plain;
	}

	/**
	 * The 32-byte symmetric key, derived from the wp-config.php salts.
	 *
	 * The salts live in the configuration file, not in the database: that is what makes this
	 * encryption worth anything against an exposed database.
	 */
	protected static function key() {
		$material = '';
		foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT' ) as $const ) {
			if ( defined( $const ) && '' !== (string) constant( $const ) ) {
				$material .= (string) constant( $const );
			}
		}

		if ( '' === $material && function_exists( 'wp_salt' ) ) {
			$material = wp_salt( 'secure_auth' ); // Fallback: still outside the database.
		}

		// A raw 32-byte hash: the key length secretbox requires.
		return substr( hash( 'sha256', 'aipb-secret|' . $material, true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
	}
}
