get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC", $wpdb->esc_like('dancevault_record_') . '%')); wp_cache_set('record_names', $names, 'dancevault', 60); } $records = []; foreach ($names as $name) { $id = substr($name, strlen('dancevault_record_')); if (preg_match('/\A[a-f0-9]{32}\z/D', $id)) { $row = get_option($name, null); if (is_array($row)) $records[$id] = $row; } } return $records; } private static function guard(): void { if (!current_user_can('manage_options')) wp_die(esc_html__('Forbidden', 'cipherparcel'), '', ['response' => 403]); if (!is_ssl()) wp_die(esc_html__('HTTPS is required.', 'cipherparcel'), '', ['response' => 400]); } private static function request_string(array $fields, string $name): string { $value = $fields[$name] ?? ''; // PHP form/query parsers can supply arrays; never cast them to strings. return is_string($value) ? wp_unslash($value) : ''; } public static function admin(): void { if (!current_user_can('manage_options')) return; echo '

' . esc_html__('CipherParcel', 'cipherparcel') . '

' . esc_html__('Encrypted file delivery. Keep passwords in private correspondence, not page content. No public Media Library attachment is created.', 'cipherparcel') . '

'; if (!extension_loaded('sodium') || !is_ssl()) { echo '

' . esc_html__('Uploads disabled: HTTPS and PHP sodium are required.', 'cipherparcel') . '

'; return; } echo '
'; wp_nonce_field('dancevault_create'); echo '

'; echo '

'; echo '

'; echo '

'; submit_button(__('Create protected download', 'cipherparcel')); echo '

' . esc_html__('Downloads', 'cipherparcel') . '

'; foreach (self::records() as $id => $row) { $url = add_query_arg(['action' => 'dancevault_download', 'id' => $id], admin_url('admin-post.php')); echo '

' . esc_html($row['label']) . '

' . esc_html($row['filename']) . ' — '; /* translators: 1: file size in bytes, 2: UTC expiry date, 3: active or revoked status. */ printf(esc_html__('%1$s bytes; expires %2$s UTC; %3$s', 'cipherparcel'), esc_html((string) $row['size']), esc_html(gmdate('Y-m-d H:i:s', $row['expires'])), $row['revoked'] ? esc_html__('revoked', 'cipherparcel') : esc_html__('active', 'cipherparcel')); echo '

' . esc_html__('SHA-256:', 'cipherparcel') . ' ' . esc_html($row['sha256']) . '

' . esc_html__('Password-controlled download', 'cipherparcel') . '

'; if (!$row['revoked']) { echo '
'; wp_nonce_field('dancevault_revoke'); echo ''; submit_button(__('Revoke access', 'cipherparcel'), 'secondary'); echo '
'; } echo '
'; } echo ''; } public static function create(): void { self::guard(); check_admin_referer('dancevault_create'); $output = null; $input = null; $path = ''; $created = false; try { $password = self::request_string($_POST, 'vault_password'); $days = filter_var(self::request_string($_POST, 'days'), FILTER_VALIDATE_INT); $label = sanitize_text_field(self::request_string($_POST, 'label')); if (strlen($password) < 16 || strlen($password) > 128 || $days === false || $days < 1 || $days > 90 || $label === '' || strlen($label) > 480) throw new RuntimeException('Invalid fields.'); // Validate upload provenance/error/size below; sanitizing a PHP temporary path would corrupt it. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The filename is separately sanitized; bytes never become executable public files. $file = $_FILES['vault_file'] ?? []; if (($file['error'] ?? -1) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new RuntimeException('Upload failed.'); $size = filesize($file['tmp_name']); if ($size === false || $size < 1 || $size > self::MAX_BYTES) throw new RuntimeException('Invalid file size.'); $filename = sanitize_file_name(basename($file['name'])); if ($filename === '') throw new RuntimeException('Invalid filename.'); $key = self::key(true); $id = bin2hex(random_bytes(16)); $path = self::directory() . '/' . $id . '.dvlt'; // WP_Filesystem has no stream-handle API; whole-file get_contents/put_contents would break bounded encryption. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read a proven PHP upload as a bounded binary stream. $input = fopen($file['tmp_name'], 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Exclusive creation prevents overwriting an existing encrypted container. $output = fopen($path, 'xb'); $created = is_resource($output); if (!$input || !$output) throw new RuntimeException('Storage unavailable.'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Restrict the newly created local ciphertext before streaming data into it. chmod($path, 0600); $metadata = \DanceVault\Crypto::encrypt($input, $output, $key); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the bounded binary stream acquired above. fclose($input); $input = null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the exclusive ciphertext stream before recording success. fclose($output); $output = null; sodium_memzero($key); $row = $metadata + ['label' => $label, 'filename' => $filename, 'password_hash' => wp_hash_password($password), 'expires' => time() + $days * DAY_IN_SECONDS, 'revoked' => false]; if (!add_option('dancevault_record_' . $id, $row, '', false)) throw new RuntimeException('Could not persist vault record.'); wp_cache_delete('record_names', 'dancevault'); wp_safe_redirect(admin_url('tools.php?page=dancevault')); exit; } catch (Throwable $error) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Release an acquired stream on failure. if (is_resource($input)) fclose($input); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Release an acquired stream on failure. if (is_resource($output)) fclose($output); if ($created && $path !== '' && is_file($path)) wp_delete_file($path); wp_die(esc_html__('Protected upload failed. No download was created. Check PHP upload limits, sodium and storage.', 'cipherparcel'), '', ['response' => 400]); } } public static function revoke(): void { self::guard(); check_admin_referer('dancevault_revoke'); $id = self::request_string($_POST, 'id'); if (preg_match('/\A[a-f0-9]{32}\z/D', $id)) { $row = get_option('dancevault_record_' . $id, null); if (is_array($row)) { $row['revoked'] = true; if (!update_option('dancevault_record_' . $id, $row, false) && !get_option('dancevault_record_' . $id)['revoked']) wp_die(esc_html__('Revocation failed.', 'cipherparcel'), '', ['response' => 500]); } } wp_safe_redirect(admin_url('tools.php?page=dancevault')); exit; } public static function download(): void { // Older WordPress wp_die() sends nocache_headers again for errors. // Scope this filter to this terminating download request, not public pages. add_filter('nocache_headers', static function (array $headers): array { $headers['Cache-Control'] = 'private, no-store, max-age=0'; return $headers; }, PHP_INT_MAX); nocache_headers(); header('Cache-Control: private, no-store, max-age=0'); header('X-Robots-Tag: noindex, nofollow, noarchive'); header('X-Content-Type-Options: nosniff'); header("Content-Security-Policy: default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); header('Referrer-Policy: no-referrer'); if (!is_ssl()) wp_die(esc_html__('HTTPS required.', 'cipherparcel'), '', ['response' => 400]); // Public recipient route: the per-file password authenticates every download; it does not perform administrator mutations. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A public GET displays only the password form, never file contents or private metadata. $id = self::request_string($_GET, 'id'); $row = preg_match('/\A[a-f0-9]{32}\z/D', $id) ? get_option('dancevault_record_' . $id, null) : null; if (!$row || $row['revoked'] || time() >= $row['expires']) wp_die(esc_html__('Download unavailable.', 'cipherparcel'), '', ['response' => 404]); $address = filter_var(self::request_string($_SERVER, 'REMOTE_ADDR'), FILTER_VALIDATE_IP) ?: 'unavailable'; $rate = 'dv_' . hash_hmac('sha256', $id . '|' . $address, wp_salt('auth')); $failures = (int) get_transient($rate); if ($failures >= 10) wp_die(esc_html__('Try again later.', 'cipherparcel'), '', ['response' => 429]); if (sanitize_text_field(self::request_string($_SERVER, 'REQUEST_METHOD')) !== 'POST') { header('Content-Type: text/html; charset=UTF-8'); echo '' . esc_html__('Protected download — CipherParcel', 'cipherparcel') . '

' . esc_html__('Protected download', 'cipherparcel') . '

'; exit; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public recipients need the file password, not a WordPress user session/nonce; requests reveal no plaintext without it. $password = self::request_string($_POST, 'password'); if (strlen($password) > 128 || !wp_check_password($password, $row['password_hash'])) { set_transient($rate, $failures + 1, 15 * MINUTE_IN_SECONDS); wp_die(esc_html__('Incorrect password.', 'cipherparcel'), '', ['response' => 403]); } $input = null; $plain = null; try { $key = self::key(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local ciphertext stream; validated ID never contains a path. $input = fopen(self::directory() . '/' . $id . '.dvlt', 'rb'); // tmpfile uses the system temporary directory, never the public uploads directory. $plain = tmpfile(); if (!$input || !$plain) throw new RuntimeException('Storage unavailable.'); $result = \DanceVault\Crypto::decrypt($input, $plain, $key); sodium_memzero($key); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Release the acquired ciphertext stream. fclose($input); $input = null; if ($result['size'] !== $row['size'] || !hash_equals($row['sha256'], $result['sha256'])) throw new RuntimeException('Integrity mismatch.'); rewind($plain); while (ob_get_level() > 0) ob_end_clean(); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="download"; filename*=UTF-8\'\'' . rawurlencode($row['filename'])); header('Content-Length: ' . $row['size']); header('Accept-Ranges: none'); fpassthru($plain); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing tmpfile atomically removes the private plaintext temporary file. fclose($plain); exit; } catch (Throwable $error) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Release an acquired stream on integrity failure. if (is_resource($input)) fclose($input); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Remove any private temporary plaintext before returning an error. if (is_resource($plain)) fclose($plain); wp_die(esc_html__('Download unavailable.', 'cipherparcel'), '', ['response' => 503]); } } } DanceVault_Plugin::boot();