normalizeToBinaryKey($key); $baseNonce = random_bytes(self::NONCE_LENGTH); $chunkSize = self::DEFAULT_CHUNK_SIZE; // Write header fwrite($dest, self::MAGIC_HEADER); fwrite($dest, pack('N', $chunkSize)); fwrite($dest, $baseNonce); $chunkIndex = 0; while (! feof($source)) { $plaintext = fread($source, $chunkSize); if ($plaintext === false || $plaintext === '') { break; } $nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex); $tag = ''; $ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_LENGTH, ); if ($ciphertext === false) { throw new RuntimeException('Encryption failed at chunk '.$chunkIndex); } fwrite($dest, $tag); fwrite($dest, $ciphertext); $chunkIndex++; } } catch (RuntimeException $e) { fclose($source); fclose($dest); @unlink($destPath); throw $e; } fclose($source); fclose($dest); } /** * Decrypt a file and return the plaintext content. */ public function decryptFile(string $encryptedPath, string $key): string { if ($this->isChunkedFormat($encryptedPath)) { $parts = []; foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { $parts[] = $chunk; } return implode('', $parts); } return $this->decryptLegacy($encryptedPath, $key); } /** * Decrypt a file and stream the response. */ public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse { $headers = [ 'Content-Type' => $mimeType ?: 'application/octet-stream', 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'), ]; if ($fileSize !== null) { $headers['Content-Length'] = $fileSize; } if ($this->isChunkedFormat($encryptedPath)) { return new StreamedResponse(function () use ($encryptedPath, $key): void { foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { echo $chunk; flush(); } }, 200, $headers); } $content = $this->decryptLegacy($encryptedPath, $key); if (! isset($headers['Content-Length'])) { $headers['Content-Length'] = strlen($content); } return new StreamedResponse(function () use ($content): void { echo $content; }, 200, $headers); } /** * Return a closure that decrypts a file into a temporary stream resource. * Suitable for ZipStream's addFileFromCallback. */ public function decryptFileToCallback(string $encryptedPath, string $key): Closure { return function () use ($encryptedPath, $key) { $tmp = tmpfile(); if ($tmp === false) { throw new RuntimeException('Cannot create temporary file'); } if ($this->isChunkedFormat($encryptedPath)) { foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { fwrite($tmp, $chunk); } } else { fwrite($tmp, $this->decryptLegacy($encryptedPath, $key)); } rewind($tmp); return $tmp; }; } /** * Normalize a hex key to binary. */ private function normalizeToBinaryKey(string $key): string { return strlen($key) === 64 ? hex2bin($key) : $key; } /** * Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes. */ private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string { $nonce = $baseNonce; $indexBytes = pack('N', $chunkIndex); for ($i = 0; $i < 4; $i++) { $nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i]; } return $nonce; } /** * Check if a file uses the chunked encryption format. */ private function isChunkedFormat(string $path): bool { $handle = fopen($path, 'rb'); if ($handle === false) { return false; } $magic = fread($handle, 8); fclose($handle); return $magic === self::MAGIC_HEADER; } /** * Decrypt a legacy single-block encrypted file. * Format: [12-byte nonce][16-byte auth tag][ciphertext] */ private function decryptLegacy(string $encryptedPath, string $key): string { $data = file_get_contents($encryptedPath); if ($data === false) { throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); } $binaryKey = $this->normalizeToBinaryKey($key); $nonce = substr($data, 0, self::NONCE_LENGTH); $tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH); $ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, ); if ($plaintext === false) { throw new RuntimeException('Decryption failed - wrong key or corrupted data'); } return $plaintext; } /** * Generator that yields decrypted plaintext chunks from a chunked encrypted file. * * @return Generator */ private function decryptChunks(string $encryptedPath, string $key): Generator { $handle = fopen($encryptedPath, 'rb'); if ($handle === false) { throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); } try { // Read header $magic = fread($handle, 8); if ($magic !== self::MAGIC_HEADER) { throw new RuntimeException('Invalid chunked file format'); } $chunkSizeData = fread($handle, 4); $chunkSize = unpack('N', $chunkSizeData)[1]; $baseNonce = fread($handle, self::NONCE_LENGTH); if (strlen($baseNonce) !== self::NONCE_LENGTH) { throw new RuntimeException('Invalid chunked file: truncated header'); } $binaryKey = $this->normalizeToBinaryKey($key); $chunkIndex = 0; while (! feof($handle)) { $tag = fread($handle, self::TAG_LENGTH); if ($tag === false || strlen($tag) === 0) { break; } if (strlen($tag) !== self::TAG_LENGTH) { throw new RuntimeException('Invalid chunked file: truncated tag at chunk '.$chunkIndex); } $ciphertext = fread($handle, $chunkSize); if ($ciphertext === false || $ciphertext === '') { throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex); } $nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, ); if ($plaintext === false) { throw new RuntimeException('Decryption failed - wrong key or corrupted data'); } yield $plaintext; $chunkIndex++; } } finally { fclose($handle); } } }