diff options
Diffstat (limited to 'lib/private/Files/Stream')
-rw-r--r-- | lib/private/Files/Stream/Encryption.php | 502 | ||||
-rw-r--r-- | lib/private/Files/Stream/HashWrapper.php | 68 | ||||
-rw-r--r-- | lib/private/Files/Stream/Quota.php | 84 | ||||
-rw-r--r-- | lib/private/Files/Stream/SeekableHttpStream.php | 248 |
4 files changed, 902 insertions, 0 deletions
diff --git a/lib/private/Files/Stream/Encryption.php b/lib/private/Files/Stream/Encryption.php new file mode 100644 index 00000000000..ef147ec421f --- /dev/null +++ b/lib/private/Files/Stream/Encryption.php @@ -0,0 +1,502 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Stream; + +use Icewind\Streams\Wrapper; +use OC\Encryption\Exceptions\EncryptionHeaderKeyExistsException; +use OC\Encryption\File; +use OC\Encryption\Util; +use OC\Files\Storage\Storage; +use OCP\Encryption\IEncryptionModule; +use function is_array; +use function stream_context_create; + +class Encryption extends Wrapper { + protected Util $util; + protected File $file; + protected IEncryptionModule $encryptionModule; + protected Storage $storage; + protected \OC\Files\Storage\Wrapper\Encryption $encryptionStorage; + protected string $internalPath; + protected string $cache; + protected ?int $size = null; + protected int $position; + protected ?int $unencryptedSize = null; + protected int $headerSize; + protected int $unencryptedBlockSize; + protected array $header; + protected string $fullPath; + protected bool $signed; + /** + * header data returned by the encryption module, will be written to the file + * in case of a write operation + */ + protected array $newHeader; + /** + * user who perform the read/write operation null for public access + */ + protected ?string $uid; + protected bool $readOnly; + protected bool $writeFlag; + protected array $expectedContextProperties; + protected bool $fileUpdated; + + public function __construct() { + $this->expectedContextProperties = [ + 'source', + 'storage', + 'internalPath', + 'fullPath', + 'encryptionModule', + 'header', + 'uid', + 'file', + 'util', + 'size', + 'unencryptedSize', + 'encryptionStorage', + 'headerSize', + 'signed' + ]; + } + + + /** + * Wraps a stream with the provided callbacks + * + * @param resource $source + * @param string $internalPath relative to mount point + * @param string $fullPath relative to data/ + * @param array $header + * @param string $uid + * @param IEncryptionModule $encryptionModule + * @param Storage $storage + * @param \OC\Files\Storage\Wrapper\Encryption $encStorage + * @param Util $util + * @param File $file + * @param string $mode + * @param int|float $size + * @param int|float $unencryptedSize + * @param int $headerSize + * @param bool $signed + * @param string $wrapper stream wrapper class + * @return resource + * + * @throws \BadMethodCallException + */ + public static function wrap( + $source, + $internalPath, + $fullPath, + array $header, + $uid, + IEncryptionModule $encryptionModule, + Storage $storage, + \OC\Files\Storage\Wrapper\Encryption $encStorage, + Util $util, + File $file, + $mode, + $size, + $unencryptedSize, + $headerSize, + $signed, + $wrapper = Encryption::class, + ) { + $context = stream_context_create([ + 'ocencryption' => [ + 'source' => $source, + 'storage' => $storage, + 'internalPath' => $internalPath, + 'fullPath' => $fullPath, + 'encryptionModule' => $encryptionModule, + 'header' => $header, + 'uid' => $uid, + 'util' => $util, + 'file' => $file, + 'size' => $size, + 'unencryptedSize' => $unencryptedSize, + 'encryptionStorage' => $encStorage, + 'headerSize' => $headerSize, + 'signed' => $signed + ] + ]); + + return self::wrapSource($source, $context, 'ocencryption', $wrapper, $mode); + } + + /** + * add stream wrapper + * + * @param resource|int $source + * @param resource|array $context + * @param string|null $protocol + * @param string|null $class + * @param string $mode + * @return resource + * @throws \BadMethodCallException + */ + protected static function wrapSource($source, $context = [], $protocol = null, $class = null, $mode = 'r+') { + try { + if ($protocol === null) { + $protocol = self::getProtocol($class); + } + + stream_wrapper_register($protocol, $class); + $context = self::buildContext($protocol, $context, $source); + if (self::isDirectoryHandle($source)) { + $wrapped = opendir($protocol . '://', $context); + } else { + $wrapped = fopen($protocol . '://', $mode, false, $context); + } + } catch (\Exception $e) { + stream_wrapper_unregister($protocol); + throw $e; + } + stream_wrapper_unregister($protocol); + return $wrapped; + } + + /** + * @todo this is a copy of \Icewind\Streams\WrapperHandler::buildContext -> combine to one shared method? + */ + private static function buildContext($protocol, $context, $source) { + if (is_array($context)) { + $context['source'] = $source; + return stream_context_create([$protocol => $context]); + } + + return $context; + } + + /** + * Load the source from the stream context and return the context options + * + * @param string|null $name + * @return array + * @throws \BadMethodCallException + */ + protected function loadContext($name = null) { + $context = parent::loadContext($name); + + foreach ($this->expectedContextProperties as $property) { + if (array_key_exists($property, $context)) { + $this->{$property} = $context[$property]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $property . '" options not set'); + } + } + return $context; + } + + public function stream_open($path, $mode, $options, &$opened_path) { + $this->loadContext('ocencryption'); + + $this->position = 0; + $this->cache = ''; + $this->writeFlag = false; + $this->fileUpdated = false; + + if ( + $mode === 'w' + || $mode === 'w+' + || $mode === 'wb' + || $mode === 'wb+' + || $mode === 'r+' + || $mode === 'rb+' + ) { + $this->readOnly = false; + } else { + $this->readOnly = true; + } + + $sharePath = $this->fullPath; + if (!$this->storage->file_exists($this->internalPath)) { + $sharePath = dirname($sharePath); + } + + $accessList = []; + if ($this->encryptionModule->needDetailedAccessList()) { + $accessList = $this->file->getAccessList($sharePath); + } + $this->newHeader = $this->encryptionModule->begin($this->fullPath, $this->uid, $mode, $this->header, $accessList); + $this->unencryptedBlockSize = $this->encryptionModule->getUnencryptedBlockSize($this->signed); + + if ( + $mode === 'w' + || $mode === 'w+' + || $mode === 'wb' + || $mode === 'wb+' + ) { + // We're writing a new file so start write counter with 0 bytes + $this->unencryptedSize = 0; + $this->writeHeader(); + $this->headerSize = $this->util->getHeaderSize(); + $this->size = $this->headerSize; + } else { + $this->skipHeader(); + } + + return true; + } + + public function stream_eof() { + return $this->position >= $this->unencryptedSize; + } + + public function stream_read($count) { + $result = ''; + + $count = min($count, $this->unencryptedSize - $this->position); + while ($count > 0) { + $remainingLength = $count; + // update the cache of the current block + $this->readCache(); + // determine the relative position in the current block + $blockPosition = ($this->position % $this->unencryptedBlockSize); + // if entire read inside current block then only position needs to be updated + if ($remainingLength < ($this->unencryptedBlockSize - $blockPosition)) { + $result .= substr($this->cache, $blockPosition, $remainingLength); + $this->position += $remainingLength; + $count = 0; + // otherwise remainder of current block is fetched, the block is flushed and the position updated + } else { + $result .= substr($this->cache, $blockPosition); + $this->flush(); + $this->position += ($this->unencryptedBlockSize - $blockPosition); + $count -= ($this->unencryptedBlockSize - $blockPosition); + } + } + return $result; + } + + /** + * stream_read_block + * + * This function is a wrapper for function stream_read. + * It calls stream read until the requested $blockSize was received or no remaining data is present. + * This is required as stream_read only returns smaller chunks of data when the stream fetches from a + * remote storage over the internet and it does not care about the given $blockSize. + * + * @param int $blockSize Length of requested data block in bytes + * @return string Data fetched from stream. + */ + private function stream_read_block(int $blockSize): string { + $remaining = $blockSize; + $data = ''; + + do { + $chunk = parent::stream_read($remaining); + $chunk_len = strlen($chunk); + $data .= $chunk; + $remaining -= $chunk_len; + } while (($remaining > 0) && ($chunk_len > 0)); + + return $data; + } + + public function stream_write($data) { + $length = 0; + // loop over $data to fit it in 6126 sized unencrypted blocks + while (isset($data[0])) { + $remainingLength = strlen($data); + + // set the cache to the current 6126 block + $this->readCache(); + + // for seekable streams the pointer is moved back to the beginning of the encrypted block + // flush will start writing there when the position moves to another block + $positionInFile = (int)floor($this->position / $this->unencryptedBlockSize) + * $this->util->getBlockSize() + $this->headerSize; + $resultFseek = $this->parentStreamSeek($positionInFile); + + // only allow writes on seekable streams, or at the end of the encrypted stream + if (!$this->readOnly && ($resultFseek || $positionInFile === $this->size)) { + // switch the writeFlag so flush() will write the block + $this->writeFlag = true; + $this->fileUpdated = true; + + // determine the relative position in the current block + $blockPosition = ($this->position % $this->unencryptedBlockSize); + // check if $data fits in current block + // if so, overwrite existing data (if any) + // update position and liberate $data + if ($remainingLength < ($this->unencryptedBlockSize - $blockPosition)) { + $this->cache = substr($this->cache, 0, $blockPosition) + . $data . substr($this->cache, $blockPosition + $remainingLength); + $this->position += $remainingLength; + $length += $remainingLength; + $data = ''; + // if $data doesn't fit the current block, the fill the current block and reiterate + // after the block is filled, it is flushed and $data is updatedxxx + } else { + $this->cache = substr($this->cache, 0, $blockPosition) + . substr($data, 0, $this->unencryptedBlockSize - $blockPosition); + $this->flush(); + $this->position += ($this->unencryptedBlockSize - $blockPosition); + $length += ($this->unencryptedBlockSize - $blockPosition); + $data = substr($data, $this->unencryptedBlockSize - $blockPosition); + } + } else { + $data = ''; + } + $this->unencryptedSize = max($this->unencryptedSize, $this->position); + } + return $length; + } + + public function stream_tell() { + return $this->position; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + $return = false; + + switch ($whence) { + case SEEK_SET: + $newPosition = $offset; + break; + case SEEK_CUR: + $newPosition = $this->position + $offset; + break; + case SEEK_END: + $newPosition = $this->unencryptedSize + $offset; + break; + default: + return $return; + } + + if ($newPosition > $this->unencryptedSize || $newPosition < 0) { + return $return; + } + + $newFilePosition = (int)floor($newPosition / $this->unencryptedBlockSize) + * $this->util->getBlockSize() + $this->headerSize; + + $oldFilePosition = parent::stream_tell(); + if ($this->parentStreamSeek($newFilePosition)) { + $this->parentStreamSeek($oldFilePosition); + $this->flush(); + $this->parentStreamSeek($newFilePosition); + $this->position = $newPosition; + $return = true; + } + return $return; + } + + public function stream_close() { + $this->flush('end'); + $position = (int)floor($this->position / $this->unencryptedBlockSize); + $remainingData = $this->encryptionModule->end($this->fullPath, $position . 'end'); + if ($this->readOnly === false) { + if (!empty($remainingData)) { + parent::stream_write($remainingData); + } + $this->encryptionStorage->updateUnencryptedSize($this->fullPath, $this->unencryptedSize); + } + $result = parent::stream_close(); + + if ($this->fileUpdated) { + $cache = $this->storage->getCache(); + $cacheEntry = $cache->get($this->internalPath); + if ($cacheEntry) { + $version = $cacheEntry['encryptedVersion'] + 1; + $cache->update($cacheEntry->getId(), ['encrypted' => $version, 'encryptedVersion' => $version, 'unencrypted_size' => $this->unencryptedSize]); + } + } + + return $result; + } + + /** + * write block to file + * @param string $positionPrefix + */ + protected function flush($positionPrefix = '') { + // write to disk only when writeFlag was set to 1 + if ($this->writeFlag) { + // Disable the file proxies so that encryption is not + // automatically attempted when the file is written to disk - + // we are handling that separately here and we don't want to + // get into an infinite loop + $position = (int)floor($this->position / $this->unencryptedBlockSize); + $encrypted = $this->encryptionModule->encrypt($this->cache, $position . $positionPrefix); + $bytesWritten = parent::stream_write($encrypted); + $this->writeFlag = false; + // Check whether the write concerns the last block + // If so then update the encrypted filesize + // Note that the unencrypted pointer and filesize are NOT yet updated when flush() is called + // We recalculate the encrypted filesize as we do not know the context of calling flush() + $completeBlocksInFile = (int)floor($this->unencryptedSize / $this->unencryptedBlockSize); + if ($completeBlocksInFile === (int)floor($this->position / $this->unencryptedBlockSize)) { + $this->size = $this->util->getBlockSize() * $completeBlocksInFile; + $this->size += $bytesWritten; + $this->size += $this->headerSize; + } + } + // always empty the cache (otherwise readCache() will not fill it with the new block) + $this->cache = ''; + } + + /** + * read block to file + */ + protected function readCache() { + // cache should always be empty string when this function is called + // don't try to fill the cache when trying to write at the end of the unencrypted file when it coincides with new block + if ($this->cache === '' && !($this->position === $this->unencryptedSize && ($this->position % $this->unencryptedBlockSize) === 0)) { + // Get the data from the file handle + $data = $this->stream_read_block($this->util->getBlockSize()); + $position = (int)floor($this->position / $this->unencryptedBlockSize); + $numberOfChunks = (int)($this->unencryptedSize / $this->unencryptedBlockSize); + if ($numberOfChunks === $position) { + $position .= 'end'; + } + $this->cache = $this->encryptionModule->decrypt($data, $position); + } + } + + /** + * write header at beginning of encrypted file + * + * @return int|false + * @throws EncryptionHeaderKeyExistsException if header key is already in use + */ + protected function writeHeader() { + $header = $this->util->createHeader($this->newHeader, $this->encryptionModule); + $this->fileUpdated = true; + return parent::stream_write($header); + } + + /** + * read first block to skip the header + */ + protected function skipHeader() { + $this->stream_read_block($this->headerSize); + } + + /** + * call stream_seek() from parent class + * + * @param integer $position + * @return bool + */ + protected function parentStreamSeek($position) { + return parent::stream_seek($position); + } + + /** + * @param string $path + * @param array $options + * @return bool + */ + public function dir_opendir($path, $options) { + return false; + } +} diff --git a/lib/private/Files/Stream/HashWrapper.php b/lib/private/Files/Stream/HashWrapper.php new file mode 100644 index 00000000000..5956ad92549 --- /dev/null +++ b/lib/private/Files/Stream/HashWrapper.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Stream; + +use Icewind\Streams\Wrapper; + +class HashWrapper extends Wrapper { + protected $callback; + protected $hash; + + public static function wrap($source, string $algo, callable $callback) { + $hash = hash_init($algo); + $context = stream_context_create([ + 'hash' => [ + 'source' => $source, + 'callback' => $callback, + 'hash' => $hash, + ], + ]); + return Wrapper::wrapSource($source, $context, 'hash', self::class); + } + + protected function open() { + $context = $this->loadContext('hash'); + + $this->callback = $context['callback']; + $this->hash = $context['hash']; + return true; + } + + public function dir_opendir($path, $options) { + return $this->open(); + } + + public function stream_open($path, $mode, $options, &$opened_path) { + return $this->open(); + } + + public function stream_read($count) { + $result = parent::stream_read($count); + hash_update($this->hash, $result); + return $result; + } + + public function stream_close() { + if (is_callable($this->callback)) { + // if the stream is closed as a result of the end-of-request GC, the hash context might be cleaned up before this stream + if ($this->hash instanceof \HashContext) { + try { + $hash = @hash_final($this->hash); + if ($hash) { + call_user_func($this->callback, $hash); + } + } catch (\Throwable $e) { + } + } + // prevent further calls by potential PHP 7 GC ghosts + $this->callback = null; + } + return parent::stream_close(); + } +} diff --git a/lib/private/Files/Stream/Quota.php b/lib/private/Files/Stream/Quota.php new file mode 100644 index 00000000000..cc737910fd8 --- /dev/null +++ b/lib/private/Files/Stream/Quota.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Stream; + +use Icewind\Streams\Wrapper; + +/** + * stream wrapper limits the amount of data that can be written to a stream + * + * usage: resource \OC\Files\Stream\Quota::wrap($stream, $limit) + */ +class Quota extends Wrapper { + /** + * @var int $limit + */ + private $limit; + + /** + * @param resource $stream + * @param int $limit + * @return resource|false + */ + public static function wrap($stream, $limit) { + $context = stream_context_create([ + 'quota' => [ + 'source' => $stream, + 'limit' => $limit + ] + ]); + return Wrapper::wrapSource($stream, $context, 'quota', self::class); + } + + public function stream_open($path, $mode, $options, &$opened_path) { + $context = $this->loadContext('quota'); + $this->source = $context['source']; + $this->limit = $context['limit']; + + return true; + } + + public function dir_opendir($path, $options) { + return false; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + if ($whence === SEEK_END) { + // go to the end to find out last position's offset + $oldOffset = $this->stream_tell(); + if (fseek($this->source, 0, $whence) !== 0) { + return false; + } + $whence = SEEK_SET; + $offset = $this->stream_tell() + $offset; + $this->limit += $oldOffset - $offset; + } elseif ($whence === SEEK_SET) { + $this->limit += $this->stream_tell() - $offset; + } else { + $this->limit -= $offset; + } + // this wrapper needs to return "true" for success. + // the fseek call itself returns 0 on succeess + return fseek($this->source, $offset, $whence) === 0; + } + + public function stream_read($count) { + $this->limit -= $count; + return fread($this->source, $count); + } + + public function stream_write($data) { + $size = strlen($data); + if ($size > $this->limit) { + $data = substr($data, 0, $this->limit); + $size = $this->limit; + } + $this->limit -= $size; + return fwrite($this->source, $data); + } +} diff --git a/lib/private/Files/Stream/SeekableHttpStream.php b/lib/private/Files/Stream/SeekableHttpStream.php new file mode 100644 index 00000000000..6ce0a880e8d --- /dev/null +++ b/lib/private/Files/Stream/SeekableHttpStream.php @@ -0,0 +1,248 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Stream; + +use Icewind\Streams\File; +use Icewind\Streams\Wrapper; + +/** + * A stream wrapper that uses http range requests to provide a seekable stream for http reading + */ +class SeekableHttpStream implements File { + private const PROTOCOL = 'httpseek'; + + private static bool $registered = false; + + /** + * Registers the stream wrapper using the `httpseek://` url scheme + * $return void + */ + private static function registerIfNeeded() { + if (!self::$registered) { + stream_wrapper_register( + self::PROTOCOL, + self::class + ); + self::$registered = true; + } + } + + /** + * Open a readonly-seekable http stream + * + * The provided callback will be called with byte range and should return an http stream for the requested range + * + * @param callable $callback + * @return false|resource + */ + public static function open(callable $callback) { + $context = stream_context_create([ + SeekableHttpStream::PROTOCOL => [ + 'callback' => $callback + ], + ]); + + SeekableHttpStream::registerIfNeeded(); + return fopen(SeekableHttpStream::PROTOCOL . '://', 'r', false, $context); + } + + /** @var resource */ + public $context; + + /** @var callable */ + private $openCallback; + + /** @var ?resource|closed-resource */ + private $current; + /** @var int $offset offset of the current chunk */ + private int $offset = 0; + /** @var int $length length of the current chunk */ + private int $length = 0; + /** @var int $totalSize size of the full stream */ + private int $totalSize = 0; + private bool $needReconnect = false; + + private function reconnect(int $start): bool { + $this->needReconnect = false; + $range = $start . '-'; + if ($this->hasOpenStream()) { + fclose($this->current); + } + + $stream = ($this->openCallback)($range); + + if ($stream === false) { + $this->current = null; + return false; + } + $this->current = $stream; + + $responseHead = stream_get_meta_data($this->current)['wrapper_data']; + + while ($responseHead instanceof Wrapper) { + $wrapperOptions = stream_context_get_options($responseHead->context); + foreach ($wrapperOptions as $options) { + if (isset($options['source']) && is_resource($options['source'])) { + $responseHead = stream_get_meta_data($options['source'])['wrapper_data']; + continue 2; + } + } + throw new \Exception('Failed to get source stream from stream wrapper of ' . get_class($responseHead)); + } + + $rangeHeaders = array_values(array_filter($responseHead, function ($v) { + return preg_match('#^content-range:#i', $v) === 1; + })); + if (!$rangeHeaders) { + $this->current = null; + return false; + } + $contentRange = $rangeHeaders[0]; + + $content = trim(explode(':', $contentRange)[1]); + $range = trim(explode(' ', $content)[1]); + $begin = intval(explode('-', $range)[0]); + $length = intval(explode('/', $range)[1]); + + if ($begin !== $start) { + $this->current = null; + return false; + } + + $this->offset = $begin; + $this->length = $length; + if ($start === 0) { + $this->totalSize = $length; + } + + return true; + } + + /** + * @return ?resource + */ + private function getCurrent() { + if ($this->needReconnect) { + $this->reconnect($this->offset); + } + if (is_resource($this->current)) { + return $this->current; + } else { + return null; + } + } + + /** + * @return bool + * @psalm-assert-if-true resource $this->current + */ + private function hasOpenStream(): bool { + return is_resource($this->current); + } + + public function stream_open($path, $mode, $options, &$opened_path) { + $options = stream_context_get_options($this->context)[self::PROTOCOL]; + $this->openCallback = $options['callback']; + + return $this->reconnect(0); + } + + public function stream_read($count) { + if (!$this->getCurrent()) { + return false; + } + $ret = fread($this->getCurrent(), $count); + $this->offset += strlen($ret); + return $ret; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + switch ($whence) { + case SEEK_SET: + if ($offset === $this->offset) { + return true; + } else { + $this->offset = $offset; + } + break; + case SEEK_CUR: + if ($offset === 0) { + return true; + } else { + $this->offset += $offset; + } + break; + case SEEK_END: + if ($this->length === 0) { + return false; + } elseif ($this->length + $offset === $this->offset) { + return true; + } else { + $this->offset = $this->length + $offset; + } + break; + } + + if ($this->hasOpenStream()) { + fclose($this->current); + } + $this->current = null; + $this->needReconnect = true; + return true; + } + + public function stream_tell() { + return $this->offset; + } + + public function stream_stat() { + if ($this->getCurrent()) { + $stat = fstat($this->getCurrent()); + if ($stat) { + $stat['size'] = $this->totalSize; + } + return $stat; + } else { + return false; + } + } + + public function stream_eof() { + if ($this->getCurrent()) { + return feof($this->getCurrent()); + } else { + return true; + } + } + + public function stream_close() { + if ($this->hasOpenStream()) { + fclose($this->current); + } + $this->current = null; + } + + public function stream_write($data) { + return false; + } + + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + public function stream_truncate($size) { + return false; + } + + public function stream_lock($operation) { + return false; + } + + public function stream_flush() { + return; //noop because readonly stream + } +} |