Skip to content

Commit

Permalink
Merge pull request #583 from jolicode/crypto-function
Browse files Browse the repository at this point in the history
Add support for some crypto functions
  • Loading branch information
pyrech authored Nov 29, 2024
2 parents 411a51d + 1a67128 commit bd87ba1
Show file tree
Hide file tree
Showing 14 changed files with 345 additions and 10 deletions.
4 changes: 2 additions & 2 deletions .github/actions/cache/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ runs:
set -e
# Should be the same command as the one in tools/static/castor.php
cache_dirname_linux_amd64=$(tests/bin/compile-get-cache-key phar-location-is-not-used-in-cache-key --os=linux --arch=x86_64 --php-extensions=mbstring,phar,posix,tokenizer,pcntl,curl,filter,openssl)
cache_dirname_linux_amd64=$(tests/bin/compile-get-cache-key phar-location-is-not-used-in-cache-key --os=linux --arch=x86_64 --php-extensions=mbstring,phar,posix,tokenizer,pcntl,curl,filter,openssl,sodium)
cache_key_linux_amd64=$(basename $cache_dirname_linux_amd64)
echo cache_dirname_linux_amd64=$cache_dirname_linux_amd64 >> $GITHUB_ENV
echo cache_key_linux_amd64=$cache_key_linux_amd64 >> $GITHUB_ENV
# Should be the same command as the one in tools/static/castor.php
cache_dirname_darwin_amd64=$(tests/bin/compile-get-cache-key phar-location-is-not-used-in-cache-key --os=macos --arch=x86_64 --php-extensions=mbstring,phar,posix,tokenizer,pcntl,curl,filter,openssl)
cache_dirname_darwin_amd64=$(tests/bin/compile-get-cache-key phar-location-is-not-used-in-cache-key --os=macos --arch=x86_64 --php-extensions=mbstring,phar,posix,tokenizer,pcntl,curl,filter,openssl,sodium)
cache_key_darwin_amd64=$(basename $cache_dirname_darwin_amd64)
echo cache_dirname_darwin_amd64=$cache_dirname_darwin_amd64 >> $GITHUB_ENV
echo cache_key_darwin_amd64=$cache_key_darwin_amd64 >> $GITHUB_ENV
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Not released yet

### Features

* Add `encrypt_with_password()`, `decrypt_with_password()`,
`encrypt_file_with_password()`, and `decrypt_file_with_password()` functions to
encrypt and decrypt data

### Fixes

* Add more missing vendor classes into stubs
Expand Down
12 changes: 8 additions & 4 deletions bin/generate-tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@
'help',
// Never complete or impossible to run
'castor:debug',
'open:documentation',
'open:multiple',
'watch:fs-change',
'watch:parallel-change',
'watch:stop',
'open:documentation',
'open:multiple',
// Not examples
'castor:compile',
'castor:phar:build',
Expand All @@ -87,18 +87,22 @@
'qa:phpstan:phpstan',
'qa:phpstan:update',
// Customized tests
'crypto:decrypt',
'crypto:encrypt',
'crypto:decrypt-file',
'crypto:encrypt-file',
'fingerprint:task-with-a-fingerprint-and-force',
'fingerprint:task-with-a-fingerprint-global',
'fingerprint:task-with-a-fingerprint',
'fingerprint:task-with-complete-fingerprint-check',
'list',
'log:all-level',
'log:error',
'log:info',
'log:with-context',
'list',
'parallel:sleep',
'remote-import:remote-tasks',
'remote-import:remote-task-class',
'remote-import:remote-tasks',
'run:ls',
'run:run-parallel',
'symfony:greet',
Expand Down
80 changes: 80 additions & 0 deletions doc/going-further/helpers/crypto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Cryptography helpers

## The `encrypt_with_password()` function

Castor provides a `encrypt_with_password()` function to allow to encrypt a
content with a password:

```php
use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\encrypt_with_password;
use function Castor\io;

#[AsTask(description: 'Encrypt content with a password')]
function encrypt(#[AsArgument()] string $content = "Hello you!"): void
{
io()->writeln(encrypt_with_password($content, 'my super secret password'));
}
```

> [!NOTE]
> Under the hood, Castor use libsodium for encryption.
## The `decrypt_with_password()` function

Castor provides a `decrypt_with_password()` function to allow to decrypt a
content with a password:

```php
use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\decrypt_with_password;
use function Castor\io;

#[AsTask(description: 'Decrypt content with a password',)]
function decrypt(string $content): void
{
io()->writeln(decrypt_with_password($content, 'my super secret password'));
}
```

## The `encrypt_file_with_password()` function

Castor provides a `encrypt_file_with_password()` function to allow to encrypt a
file with a password:

```php
use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\encrypt_file_with_password;
use function Castor\io;

#[AsTask(description: 'Encrypt file with a password')]
function encrypt_file(string $file): void
{
encrypt_file_with_password($file, 'my super secret password');
}
```

## The `decrypt_file_with_password()` function

Castor provides a `decrypt_file_with_password()` function to allow to decrypt a
file with a password:

```php
use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\decrypt_file_with_password;
use function Castor\io;

#[AsTask(description: 'Decrypt file with a password')]
function decrypt_file(string $file): void
{
decrypt_file_with_password($file, 'my super secret password');
}
```
4 changes: 4 additions & 0 deletions doc/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Castor provides the following built-in functions:
- [`capture`](getting-started/run.md#the-capture-function)
- [`check`](going-further/helpers/assertion.md#the-check-function)
- [`context`](getting-started/context.md#the-context-function)
- [`decrypt_file_with_password`](going-further/helpers/cryto.md#the-decrpty_file_with_password-function)
- [`decrypt_with_password`](going-further/helpers/cryto.md#the-decrpty_with_password-function)
- [`encrypt_file_with_password`](going-further/helpers/cryto.md#the-encrypt_file_with_password-function)
- [`encrypt_with_password`](going-further/helpers/cryto.md#the-encrypt_with_password-function)
- [`exit_code`](getting-started/run.md#the-exit_code-function)
- [`finder`](going-further/helpers/filesystem.md#the-finder-function)
- [`fingerprint`](going-further/helpers/fingerprint.md#the-fingerprint-function)
Expand Down
36 changes: 36 additions & 0 deletions examples/cryto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace crypto;

use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\decrypt_file_with_password;
use function Castor\decrypt_with_password;
use function Castor\encrypt_file_with_password;
use function Castor\encrypt_with_password;
use function Castor\io;

#[AsTask(description: 'Encrypt content with a password')]
function encrypt(#[AsArgument()] string $content = 'Hello you!'): void
{
io()->writeln(encrypt_with_password($content, 'my super secret password'));
}

#[AsTask(description: 'Decrypt content with a password', )]
function decrypt(string $content): void
{
io()->writeln(decrypt_with_password($content, 'my super secret password'));
}

#[AsTask(description: 'Encrypt file with a password')]
function encrypt_file(string $file): void
{
encrypt_file_with_password($file, 'my super secret password');
}

#[AsTask(description: 'Decrypt file with a password')]
function decrypt_file(string $file): void
{
decrypt_file_with_password($file, 'my super secret password');
}
2 changes: 2 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Castor\Console\Output\SectionOutput;
use Castor\Fingerprint\FingerprintHelper;
use Castor\Helper\Notifier;
use Castor\Helper\SymmetricCrypto;
use Castor\Helper\Waiter;
use Castor\Http\HttpDownloader;
use Castor\Import\Importer;
Expand Down Expand Up @@ -52,6 +53,7 @@ public function __construct(
public readonly SymfonyStyle $symfonyStyle,
public readonly Waiter $waiter,
public readonly WatchRunner $watchRunner,
public readonly SymmetricCrypto $symmetricCrypto,
) {
}

Expand Down
88 changes: 88 additions & 0 deletions src/Helper/SymmetricCrypto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Castor\Helper;

use Psr\Log\LoggerInterface;

/**
* @internal
*/
class SymmetricCrypto
{
public function __construct(
private LoggerInterface $logger,
) {
}

public function encrypt(#[\SensitiveParameter] string $content, #[\SensitiveParameter] string $password): string
{
if (!\extension_loaded('sodium')) {
throw new \RuntimeException('The sodium extension is required to use crypto functions.');
}

if (mb_strlen($password) < 8) {
$this->logger->warning('The password is too short. It is recommended to use at least 8 characters.');
}

$nonce = random_bytes(\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$salt = random_bytes(\SODIUM_CRYPTO_PWHASH_SALTBYTES);

$key = sodium_crypto_pwhash(
\SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);

sodium_memzero($password);

$encrypted = sodium_crypto_secretbox($content, $nonce, $key);

sodium_memzero($content);
sodium_memzero($key);

return base64_encode($nonce . $salt . $encrypted);
}

public function decrypt(string $encoded, #[\SensitiveParameter] string $password): string
{
if (!\extension_loaded('sodium')) {
throw new \RuntimeException('The sodium extension is required to use crypto functions.');
}

$decoded = base64_decode($encoded);

$nonce = substr($decoded, 0, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
if (\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES !== \strlen($nonce)) {
throw new \RuntimeException('Failed to decrypt the content. Impossible to extract nonce.');
}

$salt = substr($decoded, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, \SODIUM_CRYPTO_PWHASH_SALTBYTES);
if (\SODIUM_CRYPTO_PWHASH_SALTBYTES !== \strlen($salt)) {
throw new \RuntimeException('Failed to decrypt the content. Impossible to extract salt.');
}

$cipherText = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES + \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$key = sodium_crypto_pwhash(
\SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);

sodium_memzero($password);

$decrypted = sodium_crypto_secretbox_open($cipherText, $nonce, $key);
if (false === $decrypted) {
throw new \RuntimeException('Failed to decrypt the content.');
}

sodium_memzero($key);

return $decrypted;
}
}
58 changes: 58 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,64 @@ function yaml_dump(mixed $input, int $inline = 2, int $indent = 4, int $flags =
return Yaml::dump($input, $inline, $indent, $flags);
}

function encrypt_with_password(string $content, string $password): string
{
return Container::get()->symmetricCrypto->encrypt($content, $password);
}

function decrypt_with_password(string $content, string $password): string
{
return Container::get()->symmetricCrypto->decrypt($content, $password);
}

function encrypt_file_with_password(string $sourcePath, string $password, ?string $destinationPath = null): void
{
if (!file_exists($sourcePath)) {
throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $sourcePath));
}

$content = file_get_contents($sourcePath);
if (false === $content) {
throw new \RuntimeException(\sprintf('Failed to read the file "%s".', $sourcePath));
}

$encrypted = encrypt_with_password($content, $password);

$destinationPath ??= "{$sourcePath}.dec";
Container::get()->fs->dumpFile($destinationPath, $encrypted);

$sourcePermissions = fileperms($sourcePath);
if (false === $sourcePermissions) {
throw new \RuntimeException(\sprintf('Failed to get the permissions of the file "%s".', $sourcePath));
}

fs()->chmod($destinationPath, $sourcePermissions);
}

function decrypt_file_with_password(string $sourcePath, string $password, ?string $destinationPath = null): void
{
if (!file_exists($sourcePath)) {
throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $sourcePath));
}

$content = file_get_contents($sourcePath);
if (false === $content) {
throw new \RuntimeException(\sprintf('Failed to read the file "%s".', $sourcePath));
}

$decrypted = decrypt_with_password($content, $password);

$destinationPath ??= "{$sourcePath}.dec";
Container::get()->fs->dumpFile($destinationPath, $decrypted);

$sourcePermissions = fileperms($sourcePath);
if (false === $sourcePermissions) {
throw new \RuntimeException(\sprintf('Failed to get the permissions of the file "%s".', $sourcePath));
}

fs()->chmod($destinationPath, $sourcePermissions);
}

function guard_min_version(string $minVersion): void
{
$currentVersion = Container::get()->application->getVersion();
Expand Down
25 changes: 25 additions & 0 deletions tests/Examples/CryptoDecryptTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Castor\Tests\Examples;

use Castor\Tests\Helper\OutputCleaner;
use Castor\Tests\TaskTestCase;
use Symfony\Component\Process\Exception\ProcessFailedException;

class CryptoDecryptTest extends TaskTestCase
{
// crypto:decrypt
public function test(): void
{
$process = $this->runTask(['crypto:decrypt', 'rEg3vPkg1De1I91jmK4cuYlP5Pov1Fm0CVqkG3kFFtwjbSM6zi5yB5UugNppdFkOtiyzcbKr1QbCkF+qa2ymgL8PRw==']);

if (0 !== $process->getExitCode()) {
throw new ProcessFailedException($process);
}

$output = OutputCleaner::cleanOutput($process->getOutput());

$this->assertSame("hello there\n", $output);
$this->assertSame('', $process->getErrorOutput());
}
}
Loading

0 comments on commit bd87ba1

Please sign in to comment.