<?php
/**
 * AES-256-GCM encryption helper.
 *
 * The plugin stores only a salt; the encryption key is derived from the user-supplied
 * passphrase via PBKDF2 each time we encrypt/decrypt. We never persist the raw passphrase.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class Encryption {

	private const CIPHER     = 'aes-256-gcm';
	// PBKDF2-SHA256 work factor. OWASP 2023+ guidance is ≥ 600k. The cost is
	// paid once per file at backup/restore time — negligible alongside the
	// archive I/O — so it's safe to be conservative here.
	private const ITERATIONS = 600_000;
	private const KEY_LEN    = 32;
	private const SALT_OPT   = 'backreload_encryption_salt';

	private Options $options;

	public function __construct( Options $options ) {
		$this->options = $options;
	}

	public function is_enabled(): bool {
		return (bool) $this->options->get( 'encryption', false );
	}

	/**
	 * Public encryption parameters for a backup manifest.
	 *
	 * The salt is NOT secret (a salt never is) and MUST travel with an encrypted
	 * backup: the key is PBKDF2(passphrase, salt), so a different server with a
	 * different salt could not derive the key even with the correct passphrase.
	 * The passphrase itself is never included.
	 *
	 * @return array{cipher:string,kdf:string,iterations:int,key_len:int,salt:string}
	 */
	public function manifest_params(): array {
		$salt = get_option( self::SALT_OPT );
		return [
			'cipher'     => self::CIPHER,
			'kdf'        => 'pbkdf2-sha256',
			'iterations' => self::ITERATIONS,
			'key_len'    => self::KEY_LEN,
			'salt'       => is_string( $salt ) ? $salt : '',
		];
	}

	/**
	 * Encrypt $src into $dst with AES-256-GCM.
	 *
	 * NOTE: this implementation loads the whole file into memory because GCM's
	 * authentication tag spans the full plaintext. Files larger than roughly
	 * memory_limit/3 will OOM the PHP process, so we refuse them up front
	 * with a clear error rather than crash mid-backup. A future streaming
	 * variant should switch to AES-256-CTR + HMAC-SHA-256 — see the comment
	 * in the audit notes.
	 *
	 * @throws \RuntimeException if the file is too large or encryption fails.
	 */
	public function encrypt_file( string $src, string $dst, string $passphrase ): bool {
		$size = @filesize( $src );
		if ( false === $size ) {
			throw new \RuntimeException( esc_html(  "Cannot stat source for encryption: {$src}"  )  );
		}

		// Size guard: GCM needs the whole plaintext in memory.
		$limit_bytes = (int) wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) );
		if ( $limit_bytes > 0 ) {
			$safe_max = (int) ( $limit_bytes / 3 );
			if ( $size > $safe_max ) {
				throw new \RuntimeException(  esc_html( sprintf(
					'File too large to encrypt safely: %s exceeds the safe limit (%s, derived from memory_limit %s). ' .
					'Lower the chunk threshold or disable encryption to back up this part.',
					size_format( $size ),
					size_format( $safe_max ),
					ini_get( 'memory_limit' )
				) )  );
			}
		}

		$key = $this->derive_key( $passphrase );
		$iv  = random_bytes( 12 );
		$in  = @fopen( $src, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		$out = @fopen( $dst, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		if ( ! $in || ! $out ) {
			if ( $in )  fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			if ( $out ) fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			throw new \RuntimeException( esc_html(  "Cannot open files for encryption: src={$src} dst={$dst}"  )  );
		}

		try {
			$plain = stream_get_contents( $in );
			$tag    = '';
			$cipher = openssl_encrypt( $plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag );
			if ( false === $cipher ) {
				throw new \RuntimeException( esc_html(  'openssl_encrypt failed: check that the openssl PHP extension is installed and aes-256-gcm is supported.'  )  );
			}
			$written = fwrite( $out, $iv . $tag . $cipher ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			if ( false === $written || $written < ( 28 + strlen( $cipher ) ) ) {
				throw new \RuntimeException( esc_html(  "Short write to encrypted output: {$dst}"  )  );
			}
		} finally {
			fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
			fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; backup/restore reads & writes multi-GB archives via direct handles under the plugin's own wp-content/backreload-backups paths.
		}
		return true;
	}

	/**
	 * @throws \RuntimeException if the file is too large to verify safely.
	 */
	public function decrypt_file( string $src, string $dst, string $passphrase ): bool {
		$size = @filesize( $src );
		if ( false === $size ) {
			throw new \RuntimeException( esc_html(  "Cannot stat encrypted source: {$src}"  )  );
		}
		// Mirror the encrypt-side memory guard. GCM verifies the auth tag over
		// the whole plaintext, so the entire ciphertext has to enter memory at
		// once — there is no safe "stream and validate" with a tag at the end.
		// Refuse oversized files clearly rather than OOM-killing the restore.
		$limit_bytes = (int) wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) );
		if ( $limit_bytes > 0 ) {
			$safe_max = (int) ( $limit_bytes / 3 );
			if ( $size > $safe_max ) {
				throw new \RuntimeException(  esc_html( sprintf(
					'Encrypted file too large to decrypt safely: %s exceeds the safe limit (%s, derived from memory_limit %s). ' .
					'Increase PHP memory_limit, or restore from a non-encrypted copy if available.',
					size_format( $size ),
					size_format( $safe_max ),
					ini_get( 'memory_limit' )
				) )  );
			}
		}

		$key  = $this->derive_key( $passphrase );
		$data = @file_get_contents( $src );
		if ( false === $data || strlen( $data ) < 28 ) { return false; }

		$iv     = substr( $data, 0, 12 );
		$tag    = substr( $data, 12, 16 );
		$cipher = substr( $data, 28 );
		$plain  = openssl_decrypt( $cipher, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag );
		if ( false === $plain ) { return false; }

		return false !== file_put_contents( $dst, $plain );
	}

	/* ─── String encryption for stored secrets ─────────────────────────
	 * Used to encrypt destination passwords / OAuth tokens / webhook
	 * secrets before they hit the database. Key is derived from WordPress
	 * auth constants so it survives plugin restarts but is unique per site.
	 * Format: "backreload_enc_v1:base64(iv|tag|cipher)"
	 */

	// v1: key = sha256(auth_constants). Kept for backward-compatible read of
	// pre-existing secrets that were encrypted before the master-key upgrade.
	const SECRET_PREFIX    = 'backreload_enc_v1:';
	// v2: key = sha256(auth_constants || backreload_master_key). A leak of
	// wp-config alone (very common) no longer leaks destination credentials —
	// the attacker also needs the per-install master key from wp_options.
	//
	// PROBLEM v2 has in the wild: managed-WP hosts, security plugins (Wordfence,
	// iThemes, Sucuri's "Salt rotation"), staging-clone tools, and some Composer
	// wp-config managers rewrite the auth constants. When that happens, the v2
	// key changes and every stored secret becomes un-decryptable — users see
	// FTP/SFTP/S3 auth fail right after a plugin update, security scan, or
	// staging push, with no obvious cause. Hence v3.
	const SECRET_PREFIX_V2 = 'backreload_enc_v2:';
	// v3: key = sha256(backreload_master_key). The master key is a per-install
	// 256-bit random stored in wp_options (autoload=no) and is NEVER touched
	// by anything outside this plugin — so the encryption survives every
	// auth-constant rotation. The narrower threat model (DB-only leak still
	// reveals secrets, since master_key lives in DB) is the same model used
	// by 99% of WP plugins that store credentials, and it removes the much
	// bigger reliability footgun. Activate-time backup of the master key into
	// a sibling option (backreload_master_key_bak) gives one extra recovery
	// path in case of accidental DB option clobber.
	const SECRET_PREFIX_V3 = 'backreload_enc_v3:';

	/**
	 * Encrypt a secret string for at-rest storage.
	 *
	 * SECURITY: fails CLOSED — if encryption cannot run we throw, never store
	 * the plaintext. The previous "fail open" behaviour silently stored FTP
	 * passwords and OAuth tokens in plaintext in wp_options on hosts with a
	 * broken openssl, which would only be discovered during a DB breach.
	 *
	 * @throws \RuntimeException if openssl is unavailable or encryption fails.
	 */
	public function encrypt_string( string $plain ): string {
		if ( '' === $plain ) return '';
		// Don't double-encrypt — any of our recognised prefixes means the value
		// is already ciphertext (possibly an older version that the decrypt
		// path will route appropriately).
		if (
			0 === strpos( $plain, self::SECRET_PREFIX )
			|| 0 === strpos( $plain, self::SECRET_PREFIX_V2 )
			|| 0 === strpos( $plain, self::SECRET_PREFIX_V3 )
		) {
			return $plain;
		}

		if ( ! function_exists( 'openssl_encrypt' ) ) {
			throw new \RuntimeException(
				 esc_html( 'Cannot store a secret: PHP openssl extension is not available. ' .
				'Ask your host to enable openssl before configuring destinations or notifiers that hold secrets.'
			 ) );
		}

		// Always write v3 going forward — stable across auth-constant rotations.
		$key = $this->system_key_v3();
		$iv  = random_bytes( 12 );
		$tag = '';
		$cipher = openssl_encrypt( $plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
		if ( false === $cipher ) {
			throw new \RuntimeException(
				 esc_html( 'openssl_encrypt failed during secret encryption: refusing to store plaintext credentials. ' .
				'Verify your PHP openssl supports aes-256-gcm.'
			 ) );
		}
		return self::SECRET_PREFIX_V3 . base64_encode( $iv . $tag . $cipher );
	}

	public function decrypt_string( string $value ): string {
		if ( '' === $value ) return '';

		// Detect version + pick a candidate key list. Unprefixed values are
		// legacy plaintext from very old installs — return as-is so we never
		// lose data; the next save will re-encrypt them.
		if ( 0 === strpos( $value, self::SECRET_PREFIX_V3 ) ) {
			$body = substr( $value, strlen( self::SECRET_PREFIX_V3 ) );
			$keys = [ $this->system_key_v3() ];
		} elseif ( 0 === strpos( $value, self::SECRET_PREFIX_V2 ) ) {
			$body = substr( $value, strlen( self::SECRET_PREFIX_V2 ) );
			// v2 was auth_constants + master. Try the canonical v2 key first
			// (live auth constants, read in-memory only); if the constants have
			// rotated since this value was written, fall back to v3 (master-only)
			// — some early installs may have written with $material empty, which
			// makes v2 collide with v3 mathematically. A failed decrypt just
			// means the credential must be re-entered (logged below).
			$keys = array_filter( [
				$this->system_key_v2(),
				$this->system_key_v3(),
			] );
		} elseif ( 0 === strpos( $value, self::SECRET_PREFIX ) ) {
			$body = substr( $value, strlen( self::SECRET_PREFIX ) );
			$keys = [ $this->system_key_v1() ];
		} else {
			return $value; // legacy plaintext
		}

		$data = base64_decode( $body, true );
		if ( false === $data || strlen( $data ) < 28 ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic only when WP_DEBUG is on; helps admins debug an unrecoverable secret without surfacing it to end users.
				error_log( 'backreload: stored secret is malformed (corrupt base64 or truncated); decryption returned empty.' );
			}
			return '';
		}
		$iv     = substr( $data, 0,  12 );
		$tag    = substr( $data, 12, 16 );
		$cipher = substr( $data, 28 );

		foreach ( $keys as $key ) {
			$plain = openssl_decrypt( $cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
			if ( false !== $plain ) {
				return $plain;
			}
		}

		// All candidate keys failed. Most likely the wp-config auth constants
		// were rotated by the host / a security plugin since this secret was
		// written (the recurring "FTP password stops working after every plugin
		// update" report). Log loudly so admins notice rather than silently
		// treating it as an empty value — the previous silent return masked
		// this and made FTP auth fail with no breadcrumb. Gated on WP_DEBUG
		// so production sites without debug logging don't see the noise.
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic only when WP_DEBUG is on; helps admins debug a rotated-auth-keys failure.
			error_log( 'backreload: stored secret failed authentication. Either WordPress auth keys were rotated, the backreload_master_key was changed, or the value was tampered with. Re-enter the credential in the destination edit dialog to fix.' );
		}
		return '';
	}

	/** Legacy v1 key — only used to read secrets stored before the v2 rollout. */
	private function system_key_v1(): string {
		$material = '';
		foreach ( [ 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT' ] as $c ) {
			if ( defined( $c ) ) {
				$material .= constant( $c );
			}
		}
		if ( '' === $material ) {
			$material = (string) get_option( 'backreload_secret_key' );
			if ( '' === $material ) {
				$material = bin2hex( random_bytes( 32 ) );
				update_option( 'backreload_secret_key', $material, false );
			}
		}
		return hash( 'sha256', 'backreload-secret:' . $material, true );
	}

	/**
	 * Current key. Folds in a per-install random master key on top of the WP
	 * auth constants, so leaking only wp-config.php does not leak the stored
	 * destination credentials. The master key is generated lazily on first
	 * use and stored as an autoload=no option (not in wp-config).
	 */
	private function system_key_v2(): string {
		$material = $this->auth_material();
		$master   = $this->master_key();
		if ( '' === $material ) {
			// No auth constants — fall back to the legacy persisted key so
			// upgrades from a missing-constants host don't lose data.
			$material = (string) get_option( 'backreload_secret_key' );
		}
		return hash( 'sha256', 'backreload-secret-v2:' . $material . '|' . $master, true );
	}

	/**
	 * v3 key: master-only. Independent of wp-config auth constants, so a host
	 * rotating them does not break stored credentials. The threat model trades
	 * "DB-only leak no longer reveals secrets" (which v2 promised but only by
	 * being brittle) for "secrets survive auth-constant rotation" — the more
	 * common operational concern.
	 */
	private function system_key_v3(): string {
		return hash( 'sha256', 'backreload-secret-v3:' . $this->master_key(), true );
	}

	/**
	 * Concatenated WP auth constants, read IN-MEMORY ONLY for legacy v1/v2
	 * decryption of credentials saved by older plugin versions.
	 *
	 * This material is NEVER persisted, logged, or transmitted. Current
	 * encryption (v3) is derived solely from the plugin's own random master
	 * key (see master_key()) and does not use these constants at all, so
	 * nothing here ever leaves the running request.
	 */
	private function auth_material(): string {
		$material = '';
		foreach ( [ 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT' ] as $c ) {
			if ( defined( $c ) ) {
				$material .= constant( $c );
			}
		}
		return $material;
	}

	/**
	 * Per-install random 256-bit master key, generated lazily on first use.
	 * Backed up to backreload_master_key_bak on first generation so a bad
	 * migration that drops one option still leaves the other recoverable.
	 */
	private function master_key(): string {
		$master = (string) get_option( 'backreload_master_key' );
		if ( '' !== $master ) {
			// Self-heal the backup option if it's missing/diverged.
			if ( (string) get_option( 'backreload_master_key_bak' ) !== $master ) {
				update_option( 'backreload_master_key_bak', $master, false );
			}
			return $master;
		}
		// Master missing — try the backup option before generating fresh.
		$bak = (string) get_option( 'backreload_master_key_bak' );
		if ( '' !== $bak ) {
			update_option( 'backreload_master_key', $bak, false );
			return $bak;
		}
		$master = bin2hex( random_bytes( 32 ) );
		update_option( 'backreload_master_key',     $master, false );
		update_option( 'backreload_master_key_bak', $master, false );
		return $master;
	}

	/** Keys whose values should always be encrypted at rest. */
	public static function is_secret_field( string $key ): bool {
		return in_array( strtolower( $key ), [
			'password', 'passphrase',
			'secret', 'client_secret', 'secret_key',
			'access_key', 'access_token',
			'token', 'refresh_token', 'bot_token', 'app_token', 'user_key',
			'private_key',
			'webhook_url',
		], true );
	}

	/**
	 * Recursively encrypt secret-named keys in an associative config array.
	 * Non-secret values are left untouched.
	 *
	 * @param array<string,mixed> $config
	 * @return array<string,mixed>
	 */
	public function encrypt_config( array $config ): array {
		foreach ( $config as $k => $v ) {
			if ( is_string( $v ) && self::is_secret_field( $k ) ) {
				$config[ $k ] = $this->encrypt_string( $v );
			}
		}
		return $config;
	}

	public function decrypt_config( array $config ): array {
		foreach ( $config as $k => $v ) {
			if ( is_string( $v ) && self::is_secret_field( $k ) ) {
				$config[ $k ] = $this->decrypt_string( $v );
			}
		}
		return $config;
	}

	private function derive_key( string $passphrase ): string {
		$salt = get_option( self::SALT_OPT );
		if ( ! $salt ) {
			$salt = bin2hex( random_bytes( 16 ) );
			update_option( self::SALT_OPT, $salt );
		}
		return hash_pbkdf2( 'sha256', $passphrase, $salt, self::ITERATIONS, self::KEY_LEN, true );
	}
}
