Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- name: Install Composer dependencies
run: composer install --ansi --no-interaction --no-progress

- name: Generate encryption keys
run: bin/console encryption:generate-keys

- name: Validate mapping
run: bin/console doctrine:schema:validate --skip-sync -vvv --ansi --no-interaction

Expand Down Expand Up @@ -113,6 +116,9 @@ jobs:
- name: Build assets
run: npm run build

- name: Generate encryption keys
run: bin/console encryption:generate-keys

- name: Create database schema
run: bin/console doctrine:schema:create --env=test

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/config/dirigent.php
/config/dirigent.yaml
/config/dirigent.yml
/config/encryption/
/config/packages/dirigent.yaml
/storage/

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ RUN set -e; \
php83-phar \
php83-session \
php83-simplexml \
php83-sodium \
php83-tokenizer \
php83-xml \
postgresql \
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"ext-ctype": "*",
"ext-curl": "*",
"ext-iconv": "*",
"ext-sodium": "*",
"cebe/markdown": "^1.2",
"composer/composer": "^2.7",
"doctrine/doctrine-bundle": "^2.11",
Expand Down
5 changes: 3 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ doctrine:

profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true

types:
encrypted_text: CodedMonkey\Dirigent\Doctrine\Type\EncryptedTextType
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
Expand Down
2 changes: 2 additions & 0 deletions config/packages/doctrine_migrations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ doctrine_migrations:
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false
services:
'Doctrine\Migrations\Version\MigrationFactory': CodedMonkey\Dirigent\Doctrine\MigrationFactory
1 change: 1 addition & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ services:
public: true
arguments:
-
'encryption:generate-keys': '@CodedMonkey\Dirigent\Command\EncryptionGenerateKeysCommand'
'packages:update': '@CodedMonkey\Dirigent\Command\PackagesUpdateCommand'
7 changes: 7 additions & 0 deletions docker/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ parameters:
kernel_secret: '%env(default:kernel_secret_file:KERNEL_SECRET)%'
kernel_secret_file: '%env(default::file:KERNEL_SECRET_FILE)%'

dirigent:
encryption:
private_key: '%env(DECRYPTION_KEY)%'
private_key_path: '%env(DECRYPTION_KEY_FILE)%'
public_key: '%env(ENCRYPTION_KEY)%'
public_key_path: '%env(ENCRYPTION_KEY_FILE)%'

framework:
secret: '%kernel_secret%'

Expand Down
4 changes: 4 additions & 0 deletions docker/env.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
'DIRIGENT_IMAGE' => '1',
'SYMFONY_DOTENV_PATH' => './.env.dirigent',

'DECRYPTION_KEY' => '',
'DECRYPTION_KEY_FILE' => '/srv/config/secrets/decryption_key',
'ENCRYPTION_KEY' => '',
'ENCRYPTION_KEY_FILE' => '/srv/config/secrets/encryption_key',
'GITHUB_TOKEN' => '',
'KERNEL_SECRET_FILE' => '/srv/config/secrets/kernel_secret',
'MAILER_DSN' => 'null://null',
Expand Down
5 changes: 5 additions & 0 deletions docker/scripts/init/40-encryption-generate-keys.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env sh

set -e

bin/console encryption:generate-keys --no-ansi --no-interaction
71 changes: 69 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,77 @@ sidebar_position: 90

# Security

## Kernel secret

To learn more about how and why the kernel secret is used, check out the [Symfony documentation](https://symfony.com/doc/7.2/reference/configuration/framework.html#secret).

:::note

When using the standalone image, the kernel secret is generated automatically. See the [Image secrets](#image-secrets)
section to learn more.

:::

To configure the kernel secret through a custom environment variable, use the following configuration:

```yaml
framework:
secret: '%env(KERNEL_SECRET)%'
```

## Encryption

In some cases, Dirigent needs to store sensitive information in the database, like GitHub access tokens or SSH keys
that are used for authenticating to private repositories. As a safety precaution, this data is encrypted during
runtime through an encryption key before being stored securely in the database. The encryption key has to be created
before running the application.

### Generate encryption key pair

To generate an encryption key pair, run the following command:

```shell
bin/dirigent encryption:generate-keys
```

:::note

This page is a stub.
When using the standalone image, this is done automatically when starting the container. See the [Image secrets](#image-secrets)
section to learn more.

:::

## Secrets
This generates both a (private) decryption key and a (public) encryption key, both need to exist for Dirigent to
function. The location of the keys can be changed in the configuration. For example, to use environment variables
to configure the encryption keys, use the following configuration:

```yaml
dirigent:
encryption:
private_key: '%env(DECRYPTION_KEY)%'
private_key_path: '%env(DECRYPTION_KEY_FILE)%'
public_key: '%env(ENCRYPTION_KEY)%'
public_key_path: '%env(ENCRYPTION_KEY_FILE)%'
```

### Rotate encryption keys

```yaml
dirigent:
encryption:
rotated_keys:
- '%env(OLD_DECRYPTION_KEY)%'
rotated_key_paths:
- '%env(OLD_DECRYPTION_KEY_FILE)%'
```

## Image secrets

When using the standalone image, secrets are stored in the `/srv/config/secrets` directory by default.

- `decryption_key`
Unless configured through `DECRYPTION_KEY` or `DECRYPTION_KEY_FILE` environment variables.
- `encryption_key`
Unless configured through `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE` environment variables.
- `kernel_secret`
Unless configured through `KERNEL_SECRET` or `KERNEL_SECRET_FILE` environment variables.
79 changes: 79 additions & 0 deletions migrations/Version20250311205816.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use CodedMonkey\Dirigent\Encryption\Encryption;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Psr\Log\LoggerInterface;

final class Version20250311205816 extends AbstractMigration
{
public function __construct(
Connection $connection,
LoggerInterface $logger,
private readonly Encryption $encryptionUtility,
) {
parent::__construct($connection, $logger);
}

public function getDescription(): string
{
return 'Encrypt sensitive credentials fields';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE credentials ALTER username TYPE TEXT');
$this->addSql('ALTER TABLE credentials ALTER password TYPE TEXT');
$this->addSql('ALTER TABLE credentials ALTER token TYPE TEXT');

$credentialsCollection = $this->connection->fetchAllAssociative('SELECT id, username, password, token FROM credentials');

foreach ($credentialsCollection as $credentials) {
if (null !== $credentials['username']) {
$sealedUsername = $this->encryptionUtility->seal($credentials['username']);
$this->addSql('UPDATE credentials SET username = ? WHERE id = ?', [$sealedUsername, $credentials['id']]);
}

if (null !== $credentials['password']) {
$sealedPassword = $this->encryptionUtility->seal($credentials['password']);
$this->addSql('UPDATE credentials SET password = ? WHERE id = ?', [$sealedPassword, $credentials['id']]);
}

if (null !== $credentials['token']) {
$sealedToken = $this->encryptionUtility->seal($credentials['token']);
$this->addSql('UPDATE credentials SET token = ? WHERE id = ?', [$sealedToken, $credentials['id']]);
}
}
}

public function down(Schema $schema): void
{
$credentialsCollection = $this->connection->fetchAllAssociative('SELECT id, username, password, token FROM credentials');

foreach ($credentialsCollection as $credentials) {
if (null !== $credentials['username']) {
$username = $this->encryptionUtility->reveal($credentials['username']);
$this->addSql('UPDATE credentials SET username = ? WHERE id = ?', [$username, $credentials['id']]);
}

if (null !== $credentials['password']) {
$password = $this->encryptionUtility->reveal($credentials['password']);
$this->addSql('UPDATE credentials SET password = ? WHERE id = ?', [$password, $credentials['id']]);
}

if (null !== $credentials['token']) {
$token = $this->encryptionUtility->reveal($credentials['token']);
$this->addSql('UPDATE credentials SET token = ? WHERE id = ?', [$token, $credentials['id']]);
}
}

$this->addSql('ALTER TABLE credentials ALTER username TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE credentials ALTER password TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE credentials ALTER token TYPE VARCHAR(255)');
}
}
15 changes: 15 additions & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ parameters:
identifier: method.notFound
count: 1
path: src/DependencyInjection/DirigentConfiguration.php
-
message: '#^Left side of \|\| is always false\.$#'
identifier: booleanOr.leftAlwaysFalse
count: 1
path: src/Encryption/Encryption.php
-
message: '#^Right side of \|\| is always false\.$#'
identifier: booleanOr.rightAlwaysFalse
count: 1
path: src/Encryption/Encryption.php
-
message: '#^Property CodedMonkey\\Dirigent\\EventListener\\EncryptionListener\:\:\$connection is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: src/EventListener/EncryptionListener.php
91 changes: 91 additions & 0 deletions src/Command/EncryptionGenerateKeysCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace CodedMonkey\Dirigent\Command;

use CodedMonkey\Dirigent\Encryption\Encryption;
use CodedMonkey\Dirigent\Encryption\EncryptionException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\StyleInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;

#[AsCommand(
name: 'encryption:generate-keys',
description: 'Generates an encryption key pair',
)]
class EncryptionGenerateKeysCommand extends Command
{
public function __construct(
public readonly ?string $privateKey,
public readonly ?string $privateKeyPath,
public readonly ?string $publicKey,
public readonly ?string $publicKeyPath,
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

if ($this->privateKey || $this->publicKey) {
$io->info('Encryption key files are disabled.');

return Command::SUCCESS;
}

if (!$this->privateKeyPath || !$this->publicKeyPath) {
$io->warning('Please provide a path for both a public and a private encryption key.');

return Command::FAILURE;
}

$filesystem = new Filesystem();

$decryptionKeyExists = $filesystem->exists($this->privateKeyPath);
$encryptionKeyExists = $filesystem->exists($this->publicKeyPath);

if (!$decryptionKeyExists && $encryptionKeyExists) {
$io->error('Unable to generate (private) decryption key because a (public) encryption key exists.');

return Command::FAILURE;
} elseif ($decryptionKeyExists && $encryptionKeyExists) {
$io->info('Encryption keys already exist.');
} elseif ($decryptionKeyExists && !$encryptionKeyExists) {
$decryptionKey = sodium_hex2bin($filesystem->readFile($this->privateKeyPath));
$encryptionKey = sodium_crypto_box_publickey($decryptionKey);

$filesystem->dumpFile($this->publicKeyPath, sodium_bin2hex($encryptionKey));

$io->success('Generated a new (public) encryption key.');
} else {
$decryptionKey = sodium_crypto_box_keypair();
$encryptionKey = sodium_crypto_box_publickey($decryptionKey);

$filesystem->dumpFile($this->privateKeyPath, sodium_bin2hex($decryptionKey));
$filesystem->dumpFile($this->publicKeyPath, sodium_bin2hex($encryptionKey));

$io->success('Generated encryption keys.');
}

return $this->validateKeys($io);
}

private function validateKeys(StyleInterface $output): int
{
$encryption = Encryption::create(null, $this->privateKeyPath, null, $this->publicKeyPath, [], []);

try {
$encryption->validate();

return Command::SUCCESS;
} catch (EncryptionException $exception) {
$output->error($exception->getMessage());

return Command::FAILURE;
}
}
}
Loading