diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1dedc839 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +###################### +# Compiled source # +###################### +*.com +*.class +*.dll +*.exe +*.o +*.so + +###################### +# Packages # +###################### +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar + +###################### +# Logs and databases # +###################### +*.log + +###################### +# Global # +###################### +.DS_Store +.DS_Store\? +._* +.Spotlight-V100 +.Trashes +Icon\? +*.sublime-workspace +*.sublime-project +atlassian-ide-plugin.xml +.idea/ +.project +ehthumbs.db +Thumbs.db +Vagrantfile +.vagrant +php-cgi.core +.sass-cache + +# codeception (only stage *.dist.yml config files) +/codeception.yml +/tests/codeception.yml +/tests/*.suite.yml +/tests/_output/* +/tests/_data/* +!/tests/_data/.gitkeep +/tests/Support/_generated/* \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..85e10350 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@dachcom.ch. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..5c9e622a --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,12 @@ +| Q | A +| ---------------- | ----- +| Bug report? | yes/no +| Feature request? | yes/no +| BC Break report? | yes/no +| RFC? | yes/no + + \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..56c1098b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +# License +Copyright (C) 2024 DACHCOM.DIGITAL + +This software is available under the GNU General Public License version 3 (GPLv3). + +### GNU General Public License version 3 (GPLv3) +If you decide to choose the GPLv3 license, you must comply with the following terms: + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +[GNU General Public License](https://www.gnu.org/licenses/gpl-3.0.en.html) \ No newline at end of file diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..d6cb8bac --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +| Q | A +| ------------- | --- +| Bug fix? | yes/no +| New feature? | yes/no +| BC breaks? | no +| Deprecations? | yes/no +| Fixed tickets | #... + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..26640a83 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Pimcore Secure Storage Bundle +[![Software License](https://img.shields.io/badge/license-GPLv3-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Latest Release](https://img.shields.io/packagist/v/dachcom-digital/secure-storage.svg?style=flat-square)](https://packagist.org/packages/dachcom-digital/secure-storage) + +### Release Plan + +| Release | Supported Pimcore Versions | Supported Symfony Versions | Release Date | Maintained | Branch | +|---------|----------------------------|----------------------------|--------------|----------------|--------| +| **1.x** | `^11.2` | `6.2` | -- | Feature Branch | master | + + +## Installation + +```json +"require" : { + "dachcom-digital/secure-storage" : "~1.0.0", +} +``` + +Add Bundle to `bundles.php`: +```php +return [ + SecureStorageBundle\SecureStorageBundle::class => ['all' => true], +]; +``` + +## Description +Encrypt/Decrypt assets on the fly! + +## Usage + +> [!CAUTION] +> This is a very, very dangerous bundle which can lead to heavy data loss, if you're not careful! +> Please read the instructions carefully! + +## Safety Instructions +- Do not define paths with existing assets. Create a new folder or delete all assets first. Those assets can't be opened after defined (since they're not encrypted) +- You'll never be able to remove those paths from configuration. If you have to, you need to download the assets from backend first +- Do not change the key, after you pushed this to production. Encrypted assets will be end up corrupt + +## Limitations +- The secure adapter only supports the `LocalFilesystemAdapter`. This is fine, since other adapters like aws or cloudflare usually already support encryption by default +- Thumbnails can't be generated, since pimcore uses the `getLocaleFileFromStream` method in `TemporaryFileHelperTrait`. This is something we might can fix in the near future + +## Configuration + +```yaml +secure_storage: + encrypter: + options: + cipher: 'aes-128-cbc' # default + key: 'your-12-bit-key' # create your key with base64_encode(openssl_random_pseudo_bytes(16)); + + secured_fly_system_storages: + + # form builder (if you want to encrypt form builder data) + - + storage: form_builder.chunk.storage + paths: null + - + storage: form_builder.files.storage + paths: null + + # pimcore + - + storage: pimcore.asset.storage + paths: + - /secure-storage + - /formdata +``` + +## Custom Encrypter +TBD + +*** + +## Copyright and license +Copyright: [DACHCOM.DIGITAL](http://dachcom-digital.ch) +For licensing details please visit [LICENSE.md](LICENSE.md) + +## Upgrade Info +Before updating, please [check our upgrade notes!](UPGRADE.md) diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..453dc3ee --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1 @@ +# Upgrade Notes diff --git a/composer.json b/composer.json new file mode 100755 index 00000000..cf1d6dcb --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "dachcom-digital/secure-storage", + "type": "pimcore-bundle", + "license": "GPL-3.0-or-later", + "description": "Pimcore Security Storage Bundle", + "keywords": ["pimcore", "security", "flystem", "encryption"], + "homepage": "https://github.com/dachcom-digital/pimcore-secure-storage", + "authors": [ + { + "name": "DACHCOM.DIGITAL Stefan Hagspiel", + "email": "shagspiel@dachcom.ch", + "homepage": "http://www.dachcom.com/", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "SecureStorageBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "": "src/" + } + }, + "extra": { + "pimcore": { + "bundles": [ + "SecureStorageBundle\\SecureStorageBundle" + ] + } + }, + "require": { + "pimcore/pimcore": "^11.0" + }, + "require-dev": { + "codeception/codeception": "^5.0", + "codeception/module-symfony": "^3.1", + "codeception/module-webdriver": "^4.0", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-symfony": "^1.0", + "symplify/easy-coding-standard": "^9.0" + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 00000000..ee78c90f --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,2 @@ +imports: + - { resource: services/*.yaml } \ No newline at end of file diff --git a/ecs.php b/ecs.php new file mode 100644 index 00000000..b5e05a8e --- /dev/null +++ b/ecs.php @@ -0,0 +1,162 @@ +parameters(); + $parameters->set(Option::SETS, [SetList::CLEAN_CODE, SetList::PSR_12]); + + $services = $containerConfigurator->services(); + + $services->set(Fixer\Basic\BracesFixer::class) + ->call('configure', [ + [ + 'allow_single_line_closure' => true, + ] + ]); + + $services->set(Fixer\PhpTag\BlankLineAfterOpeningTagFixer::class); + + $services->set(Fixer\Operator\ConcatSpaceFixer::class) + ->call('configure', [ + [ + 'spacing' => 'one', + ] + ]); + + $services->set(Fixer\Operator\NewWithBracesFixer::class); + + $services->set(Fixer\Phpdoc\PhpdocAlignFixer::class) + ->call('configure', [ + [ + 'tags' => ['method', 'param', 'property', 'return', 'throws', 'type', 'var'], + ] + ]); + + $services->set(Fixer\Operator\BinaryOperatorSpacesFixer::class) + ->call('configure', [ + [ + 'operators' => [ + '=' => 'single_space', + '=>' => 'align', + ] + ] + ]); + $services->set(Fixer\Operator\IncrementStyleFixer::class) + ->call('configure', [ + [ + 'style' => 'post', + ] + ]); + + $services->set(Fixer\Operator\UnaryOperatorSpacesFixer::class); + $services->set(Fixer\Whitespace\BlankLineBeforeStatementFixer::class); + $services->set(Fixer\CastNotation\CastSpacesFixer::class); + $services->set(Fixer\LanguageConstruct\DeclareEqualNormalizeFixer::class); + $services->set(Fixer\FunctionNotation\FunctionTypehintSpaceFixer::class); + $services->set(Fixer\Comment\SingleLineCommentStyleFixer::class) + ->call('configure', [ + [ + 'comment_types' => ['hash'], + ] + ]); + + $services->set(Fixer\ControlStructure\IncludeFixer::class); + $services->set(Fixer\CastNotation\LowercaseCastFixer::class); + $services->set(Fixer\ClassNotation\ClassAttributesSeparationFixer::class) + ->call('configure', [ + [ + 'elements' => [ + 'const' => 'none', + 'method' => 'one', + 'property' => 'none', + 'trait_import' => 'none' + ], + ] + ]); + + $services->set(Fixer\Casing\NativeFunctionCasingFixer::class); + $services->set(Fixer\ClassNotation\NoBlankLinesAfterClassOpeningFixer::class); + $services->set(Fixer\Phpdoc\NoBlankLinesAfterPhpdocFixer::class); + $services->set(Fixer\Comment\NoEmptyCommentFixer::class); + $services->set(Fixer\Phpdoc\NoEmptyPhpdocFixer::class); + $services->set(Fixer\Phpdoc\PhpdocSeparationFixer::class); + $services->set(Fixer\Semicolon\NoEmptyStatementFixer::class); + $services->set(Fixer\Whitespace\ArrayIndentationFixer::class); + $services->set(Fixer\Whitespace\NoExtraBlankLinesFixer::class) + ->call('configure', [ + [ + 'tokens' => ['curly_brace_block', 'extra', 'parenthesis_brace_block', 'square_brace_block', 'throw', 'use'], + ] + ]); + + $services->set(Fixer\NamespaceNotation\NoLeadingNamespaceWhitespaceFixer::class); + $services->set(Fixer\ArrayNotation\NoMultilineWhitespaceAroundDoubleArrowFixer::class); + $services->set(Fixer\CastNotation\NoShortBoolCastFixer::class); + $services->set(Fixer\Semicolon\NoSinglelineWhitespaceBeforeSemicolonsFixer::class); + $services->set(Fixer\Whitespace\NoSpacesAroundOffsetFixer::class); + $services->set(Fixer\ControlStructure\NoTrailingCommaInListCallFixer::class); + $services->set(Fixer\ControlStructure\NoUnneededControlParenthesesFixer::class); + $services->set(Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer::class); + $services->set(Fixer\Whitespace\NoWhitespaceInBlankLineFixer::class); + $services->set(Fixer\ArrayNotation\NormalizeIndexBraceFixer::class); + $services->set(Fixer\Operator\ObjectOperatorWithoutWhitespaceFixer::class); + $services->set(Fixer\Phpdoc\PhpdocAnnotationWithoutDotFixer::class); + $services->set(Fixer\Phpdoc\PhpdocIndentFixer::class); + $services->set(Fixer\Phpdoc\PhpdocInlineTagFixer::class); + $services->set(Fixer\Phpdoc\PhpdocNoAccessFixer::class); + $services->set(Fixer\Phpdoc\PhpdocNoEmptyReturnFixer::class); + $services->set(Fixer\Phpdoc\PhpdocNoPackageFixer::class); + $services->set(Fixer\Phpdoc\PhpdocNoUselessInheritdocFixer::class); + $services->set(Fixer\Phpdoc\PhpdocReturnSelfReferenceFixer::class); + $services->set(Fixer\Phpdoc\PhpdocScalarFixer::class); + $services->set(Fixer\Phpdoc\PhpdocSingleLineVarSpacingFixer::class); + $services->set(Fixer\Phpdoc\PhpdocSummaryFixer::class); + $services->set(Fixer\Phpdoc\PhpdocToCommentFixer::class); + $services->set(Fixer\Phpdoc\PhpdocTrimFixer::class); + $services->set(Fixer\Phpdoc\PhpdocTypesFixer::class); + $services->set(Fixer\Phpdoc\PhpdocVarWithoutNameFixer::class); + $services->set(Fixer\FunctionNotation\ReturnTypeDeclarationFixer::class); + $services->set(Fixer\ClassNotation\SelfAccessorFixer::class); + $services->set(Fixer\CastNotation\ShortScalarCastFixer::class); + $services->set(Fixer\StringNotation\SingleQuoteFixer::class); + $services->set(Fixer\Semicolon\SpaceAfterSemicolonFixer::class); + $services->set(Fixer\Operator\StandardizeNotEqualsFixer::class); + $services->set(Fixer\Operator\TernaryOperatorSpacesFixer::class); + $services->set(Fixer\ArrayNotation\TrimArraySpacesFixer::class); + $services->set(Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer::class); + + $services->set(Fixer\ClassNotation\ClassDefinitionFixer::class) + ->call('configure', [ + [ + 'single_line' => true, + ] + ]); + + $services->set(Fixer\Casing\MagicConstantCasingFixer::class); + $services->set(Fixer\FunctionNotation\MethodArgumentSpaceFixer::class); + $services->set(Fixer\Alias\NoMixedEchoPrintFixer::class) + ->call('configure', [ + [ + 'use' => 'echo', + ] + ]); + + $services->set(Fixer\Import\NoLeadingImportSlashFixer::class); + $services->set(Fixer\PhpUnit\PhpUnitFqcnAnnotationFixer::class); + $services->set(Fixer\Phpdoc\PhpdocNoAliasTagFixer::class); + $services->set(Fixer\NamespaceNotation\SingleBlankLineBeforeNamespaceFixer::class); + $services->set(Fixer\ClassNotation\SingleClassElementPerStatementFixer::class); + + # new since PHP-CS-Fixer 2.6 + $services->set(Fixer\ClassNotation\NoUnneededFinalMethodFixer::class); + $services->set(Fixer\Semicolon\SemicolonAfterInstructionFixer::class); + + # new since 2.11 + $services->set(Fixer\Operator\StandardizeIncrementFixer::class); +}; \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..58b21458 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,14 @@ +includes: + - %currentWorkingDirectory%/vendor/phpstan/phpstan-symfony/extension.neon +parameters: + scanFiles: + - %currentWorkingDirectory%/vendor/pimcore/pimcore/stubs/dynamic-constants.php + reportUnmatchedIgnoredErrors: false + symfony: + container_xml_path: %currentWorkingDirectory%/var/cache/test/TestKernelTestDebugContainer.xml + constant_hassers: false + excludePaths: + # as long we don't install the dependencies :( + - src/MetaData/Extractor/ThirdParty/News/EntryMetaExtractor.php + - src/MetaData/Extractor/ThirdParty/CoreShop/OGExtractor.php + - src/MetaData/Extractor/ThirdParty/CoreShop/TitleDescriptionExtractor.php diff --git a/src/DependencyInjection/CompilerPass/FlysystemStoragePass.php b/src/DependencyInjection/CompilerPass/FlysystemStoragePass.php new file mode 100644 index 00000000..63869743 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/FlysystemStoragePass.php @@ -0,0 +1,58 @@ +getParameter('pimcore.secure_storage.config'); + + if (!$container->hasDefinition($securedStorageConfig['encrypter']['class'])) { + $encrypterDefinition = new Definition($securedStorageConfig['encrypter']['class']); + $encrypterDefinition->setAutoconfigured(true); + $container->setDefinition($securedStorageConfig['encrypter']['class'], $encrypterDefinition); + } + + /** @var EncrypterInterface $encrypter */ + $encrypter = $container->get($securedStorageConfig['encrypter']['class']); + + $optionsResolver = new OptionsResolver(); + $encrypter::configureOptions($optionsResolver); + + foreach ($securedStorageConfig['secured_fly_system_storages'] as $storageConfig) { + + $adapterName = sprintf('flysystem.adapter.%s', $storageConfig['storage']); + $securedAdapterName = sprintf('flysystem.adapter.secured.%s', $storageConfig['storage']); + + if (!$container->hasDefinition($adapterName)) { + continue; + } + + if ($container->getDefinition($adapterName)->getClass() !== LocalFilesystemAdapter::class) { + continue; + } + + $securedAdapter = new Definition(SecuredAdapter::class); + $securedAdapter->setArguments([ + $optionsResolver->resolve($securedStorageConfig['encrypter']['options']), + $storageConfig['paths'], + new Reference($securedStorageConfig['encrypter']['class']), + new Reference(sprintf('%s.inner', $securedAdapterName)) + ]); + + $securedAdapter->setDecoratedService($adapterName); + + $container->setDefinition($securedAdapterName, $securedAdapter); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..0d060eac --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,45 @@ +getRootNode(); + + $rootNode + ->children() + ->arrayNode('encrypter') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('class')->defaultValue(OpenSslEncrypter::class)->end() + ->variableNode('options')->defaultValue([])->end() + ->end() + ->end() + ->arrayNode('secured_fly_system_storages') + ->prototype('array') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('storage') + ->isRequired() + ->validate() + ->ifNull() + ->thenInvalid('Invalid storage %s') + ->end() + ->end() + ->arrayNode('paths') + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/SecureStorageExtension.php b/src/DependencyInjection/SecureStorageExtension.php new file mode 100644 index 00000000..07f89e8a --- /dev/null +++ b/src/DependencyInjection/SecureStorageExtension.php @@ -0,0 +1,27 @@ +processConfiguration($configuration, $configs); + + $loader = new YamlFileLoader($container, new FileLocator([__DIR__ . '/../../config'])); + $loader->load('services.yaml'); + + $container->setParameter('pimcore.secure_storage.config', $config); + } +} diff --git a/src/Encrypter/EncrypterInterface.php b/src/Encrypter/EncrypterInterface.php new file mode 100644 index 00000000..c22ec53b --- /dev/null +++ b/src/Encrypter/EncrypterInterface.php @@ -0,0 +1,20 @@ +setDefaults([ + 'cipher' => 'aes-128-cbc', + 'key' => null + ]); + + $optionsResolver->setRequired('key'); + $optionsResolver->setAllowedTypes('cipher', ['string']); + $optionsResolver->setAllowedTypes('key', ['string']); + $optionsResolver->setAllowedValues('cipher', self::SUPPORTED_CIPHERS); + } + + public static function appendEncryption($stream, array $options): void + { + stream_filter_append( + $stream, + self::FILTERNAME_PREFIX . self::MODE_ENCRYPT, + STREAM_FILTER_READ, + $options + ); + } + + public static function appendDecryption($stream, array $options): void + { + stream_filter_append( + $stream, + self::FILTERNAME_PREFIX . self::MODE_DECRYPT, + STREAM_FILTER_READ, + $options + ); + } + + public function onCreate(): bool + { + $this->iv = null; + $this->buffer = ''; + + $length = openssl_cipher_iv_length($this->params['cipher']); + + $this->blockSize = $length; + + $this->mode = match ($this->filtername) { + self::FILTERNAME_PREFIX . self::MODE_ENCRYPT => self::MODE_ENCRYPT, + self::FILTERNAME_PREFIX . self::MODE_DECRYPT => self::MODE_DECRYPT, + }; + + return true; + } + + public function filter($in, $out, &$consumed, $closing): int + { + try { + $this->handleIv($in, $out, $consumed); + } catch (FeedMeException) { + return PSFS_FEED_ME; + } + + while ($bucket = stream_bucket_make_writeable($in)) { + + $this->buffer .= $bucket->data; + $consumed += strlen($bucket->data); + + while (strlen($this->buffer) >= self::CHUNK_SIZE) { + + $chunk = substr($this->buffer, 0, self::CHUNK_SIZE - (self::CHUNK_SIZE % $this->blockSize)); // align chunk to block size + $this->buffer = substr($this->buffer, self::CHUNK_SIZE - (self::CHUNK_SIZE % $this->blockSize)); // keep remainder in buffer + + $processed = match ($this->mode) { + self::MODE_ENCRYPT => $this->encryptChunkData($chunk), + self::MODE_DECRYPT => $this->decryptChunkData($chunk), + }; + + if ($processed === false) { + throw new \Exception(sprintf('[%s] Error: %s', $this->mode, openssl_error_string())); + } + + $newBucket = stream_bucket_new($this->stream, $processed); + stream_bucket_append($out, $newBucket); + } + } + + if (!$closing) { + return PSFS_PASS_ON; + } + + if ($this->buffer === '') { + return PSFS_PASS_ON; + } + + $processed = match ($this->mode) { + self::MODE_ENCRYPT => $this->encryptClosingData(), + self::MODE_DECRYPT => $this->decryptClosingData(), + }; + + if ($processed === false) { + throw new \Exception(openssl_error_string()); + } + + $newBucket = stream_bucket_new($this->stream, $processed); + stream_bucket_append($out, $newBucket); + + $this->buffer = ''; + + return PSFS_PASS_ON; + } + + private function encryptChunkData($chunk): mixed + { + return openssl_encrypt($chunk, $this->params['cipher'], $this->params['key'], self::OPENSSL_OPTIONS, $this->iv); + } + + private function encryptClosingData(): mixed + { + $padLength = $this->blockSize - strlen($this->buffer) % $this->blockSize; + $this->buffer .= str_repeat(chr($padLength), $padLength); + + return openssl_encrypt($this->buffer, $this->params['cipher'], $this->params['key'], self::OPENSSL_OPTIONS, $this->iv); + } + + private function decryptChunkData($chunk): mixed + { + return openssl_decrypt($chunk, $this->params['cipher'], $this->params['key'], self::OPENSSL_OPTIONS, $this->iv); + } + + private function decryptClosingData(): mixed + { + $processed = openssl_decrypt($this->buffer, $this->params['cipher'], $this->params['key'], self::OPENSSL_OPTIONS, $this->iv); + + if ($processed !== false) { + // Remove PKCS7 padding during decryption + $padLength = ord(substr($processed, -1)); + if ($padLength > 0 && $padLength <= $this->blockSize) { + $processed = substr($processed, 0, -$padLength); + } + } + + return $processed; + } + + private function handleIv($in, $out, &$consumed): void + { + if ($this->iv !== null) { + return; + } + + if ($this->mode === self::MODE_ENCRYPT) { + $this->iv = random_bytes($this->blockSize); + $ivBucket = stream_bucket_new($this->stream, $this->iv); + stream_bucket_append($out, $ivBucket); + + return; + } + + // Handle IV for decryption: extract it from the first block of data + if ($this->mode === self::MODE_DECRYPT) { + + $bucket = stream_bucket_make_writeable($in); + + if ($bucket !== null) { + + $this->buffer .= $bucket->data; + $consumed += strlen($bucket->data); + + // If we don't have enough data for the IV, continue accumulating + if (strlen($this->buffer) < $this->blockSize) { + throw new FeedMeException(); + } + + // Extract the IV from the first block of data + $this->iv = substr($this->buffer, 0, $this->blockSize); + $this->buffer = substr($this->buffer, $this->blockSize); // Remove IV from buffer + } + } + } +} \ No newline at end of file diff --git a/src/Exception/FeedMeException.php b/src/Exception/FeedMeException.php new file mode 100644 index 00000000..b5458d2a --- /dev/null +++ b/src/Exception/FeedMeException.php @@ -0,0 +1,8 @@ +securedPaths)) { + return true; + } + + foreach($this->securedPaths as $securedPath) { + if (str_starts_with($path, ltrim($securedPath, '/'))) { + return true; + } + } + + return false; + } + + public function fileExists(string $path): bool + { + return $this->inner->fileExists($path); + } + + public function directoryExists(string $path): bool + { + return $this->inner->directoryExists($path); + } + + public function write(string $path, string $contents, Config $config): void + { + if (!$this->isSecuredPath($path)) { + $this->inner->write($path, $contents, $config); + + return; + } + + $stream = fopen('php://temp', 'w+'); + fwrite($stream, $contents); + rewind($stream); + + $this->writeStream($path, $stream, $config); + + fclose($stream); + } + + public function writeStream(string $path, $contents, Config $config): void + { + if (!$this->isSecuredPath($path)) { + $this->inner->writeStream($path, $contents, $config); + + return; + } + + $this->encrypter::appendEncryption($contents, $this->encyrpterOptions); + + $this->inner->writeStream($path, $contents, $config); + } + + public function read(string $path): string + { + if (!$this->isSecuredPath($path)) { + return $this->inner->read($path); + } + + $stream = $this->readStream($path); + $contents = stream_get_contents($stream); + fclose($stream); + + return $contents; + } + + public function readStream(string $path) + { + if (!$this->isSecuredPath($path)) { + return $this->inner->readStream($path); + } + + $contents = $this->inner->readStream($path); + + $this->encrypter::appendDecryption($contents, $this->encyrpterOptions); + + return $contents; + } + + public function delete(string $path): void + { + $this->inner->delete($path); + } + + public function deleteDirectory(string $path): void + { + $this->inner->deleteDirectory($path); + } + + public function createDirectory(string $path, Config $config): void + { + $this->inner->createDirectory($path, $config); + } + + public function setVisibility(string $path, string $visibility): void + { + $this->inner->setVisibility($path, $visibility); + } + + public function visibility(string $path): FileAttributes + { + return $this->inner->visibility($path); + } + + public function mimeType(string $path): FileAttributes + { + if (!$this->isSecuredPath($path)) { + return $this->inner->mimeType($path); + } + + // @todo: + // guessing mime type by its content is not possible because of the encrypted file + // mostly the extension will be used to guess mime type + + return $this->inner->mimeType($path); + } + + public function lastModified(string $path): FileAttributes + { + return $this->inner->lastModified($path); + } + + public function fileSize(string $path): FileAttributes + { + if (!$this->isSecuredPath($path)) { + return $this->inner->fileSize($path); + } + + // @todo: the file size returns size of encrypted file, not the original one + + return $this->inner->fileSize($path); + } + + public function listContents(string $path, bool $deep): iterable + { + return $this->inner->listContents($path, $deep); + } + + public function move(string $source, string $destination, Config $config): void + { + $this->inner->move($source, $destination, $config); + } + + public function copy(string $source, string $destination, Config $config): void + { + $this->inner->copy($source, $destination, $config); + } + + public function __call(string $method, array $parameters) + { + return $this->forwardCallTo($this->inner, $method, $parameters); + } +} \ No newline at end of file diff --git a/src/SecureStorageBundle.php b/src/SecureStorageBundle.php new file mode 100644 index 00000000..086ff42e --- /dev/null +++ b/src/SecureStorageBundle.php @@ -0,0 +1,30 @@ +addCompilerPass(new FlysystemStoragePass()); + } + + public function getPath(): string + { + return \dirname(__DIR__); + } + + protected function getComposerPackageName(): string + { + return self::PACKAGE_NAME; + } +}