Skip to content

Commit 7793a2c

Browse files
committed
added app:rotate:app-secret command
1 parent 5c03638 commit 7793a2c

9 files changed

Lines changed: 290 additions & 7 deletions

File tree

.env.dev

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
APP_SECRET=0ec3c0e9ae0961bb9e1d7ae47f458f3f
1+
APP_SECRET=6514635062ba34f218535f9f106fd20b
22

33
# allow access only with https protocol
44
SSL_ONLY=false
@@ -7,7 +7,7 @@ SSL_ONLY=false
77
MAINTENANCE_MODE=false
88

99
# enable paste content encryption
10-
ENCRYPTION_MODE=false
10+
ENCRYPTION_MODE=true
1111

1212
# admin contact email
1313
CONTACT_EMAIL=lukas@becvar.xyz

.env.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ SSL_ONLY=false
77
MAINTENANCE_MODE=false
88

99
# enable paste content encryption
10-
ENCRYPTION_MODE=false
10+
ENCRYPTION_MODE=true
1111

1212
# admin contact email
1313
CONTACT_EMAIL=lukas@becvar.xyz
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use Exception;
6+
use App\Util\AppUtil;
7+
use App\Manager\PasteManager;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Style\SymfonyStyle;
10+
use Symfony\Component\Console\Attribute\AsCommand;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
14+
/**
15+
* Class RotateAppSecretCommand
16+
*
17+
* Command to rotate APP_SECRET key value in environment configuration
18+
*
19+
* @package App\Command
20+
*/
21+
#[AsCommand(name: 'app:rotate:app-secret', description: 'Rotate the APP_SECRET key value in environment configuration')]
22+
class RotateAppSecretCommand extends Command
23+
{
24+
private AppUtil $appUtil;
25+
private PasteManager $pasteManager;
26+
27+
public function __construct(AppUtil $appUtil, PasteManager $pasteManager)
28+
{
29+
$this->appUtil = $appUtil;
30+
$this->pasteManager = $pasteManager;
31+
parent::__construct();
32+
}
33+
34+
/**
35+
* Execute rotate APP_SECRET command
36+
*
37+
* @param InputInterface $input The input interface
38+
* @param OutputInterface $output The output interface
39+
*
40+
* @return int The command exit code
41+
*/
42+
protected function execute(InputInterface $input, OutputInterface $output): int
43+
{
44+
$io = new SymfonyStyle($input, $output);
45+
46+
try {
47+
// get old APP_SECRET key
48+
$oldSecret = $this->appUtil->getEnvValue('APP_SECRET');
49+
50+
// generate new APP_SECRET key
51+
$newSecret = $this->appUtil->generateKey(16);
52+
53+
// re-encrypt all pastes
54+
$this->pasteManager->reEncryptPastes($oldSecret, $newSecret);
55+
56+
// update value in environment configuration
57+
$this->appUtil->updateEnvValue('APP_SECRET', $newSecret);
58+
59+
// success output
60+
$io->success('APP_SECRET has been rotated successfully!');
61+
$io->note('Remember: Sessions, remember-me tokens, and encrypted data may become invalid.');
62+
return Command::SUCCESS;
63+
} catch (Exception $e) {
64+
$io->error('Error during rotation: ' . $e->getMessage());
65+
return Command::FAILURE;
66+
}
67+
}
68+
}

src/Manager/PasteManager.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,50 @@ public function getTotalViews(): int
232232
{
233233
return $this->pasteRepository->getTotalViews();
234234
}
235+
236+
/**
237+
* Re-encrypt all pastes
238+
*
239+
* @param string $oldKey The old encryption key
240+
* @param string $newKey The new encryption key
241+
*
242+
* @return void
243+
*/
244+
public function reEncryptPastes(string $oldKey, string $newKey): void
245+
{
246+
// check if encryption mode is disabled
247+
if (!$this->appUtil->isEncryptionMode()) {
248+
return;
249+
}
250+
251+
// get all pastes
252+
$pastes = $this->pasteRepository->findAll();
253+
254+
// re-encrypt pastes
255+
foreach ($pastes as $paste) {
256+
// get old encrypted content
257+
$content = $paste->getContent();
258+
if ($content == null) {
259+
continue;
260+
}
261+
262+
// decrypt paste content to get original content
263+
$content = $this->securityUtil->decryptAes(encryptedData: $content, key: $oldKey);
264+
if ($content == null) {
265+
$this->errorManager->handleError(
266+
msg: 'error decrypting paste content',
267+
code: Response::HTTP_INTERNAL_SERVER_ERROR
268+
);
269+
}
270+
271+
// re-encrypt paste content
272+
$paste->setContent($this->securityUtil->encryptAes(plainText: $content, key: $newKey));
273+
274+
// persist paste to database
275+
$this->entityManager->persist($paste);
276+
}
277+
278+
// flush changes to database
279+
$this->entityManager->flush();
280+
}
235281
}

src/Util/AppUtil.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Util;
44

55
use Exception;
6+
use InvalidArgumentException;
67
use Symfony\Component\Yaml\Yaml;
78
use Symfony\Component\HttpKernel\KernelInterface;
89

@@ -22,6 +23,23 @@ public function __construct(KernelInterface $kernelInterface)
2223
$this->kernelInterface = $kernelInterface;
2324
}
2425

26+
/**
27+
* Generate random key
28+
*
29+
* @param int $length The key length
30+
*
31+
* @return string The generated key
32+
*/
33+
public function generateKey(int $length = 16): string
34+
{
35+
// check if length is valid
36+
if ($length < 1) {
37+
throw new InvalidArgumentException('Length must be greater than 0.');
38+
}
39+
40+
return bin2hex(random_bytes($length));
41+
}
42+
2543
/**
2644
* Get environment variable value
2745
*

src/Util/SecurityUtil.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@ public function unescapeString(string $string): string
4040
*
4141
* @param string $plainText The plain text to encrypt
4242
* @param string $method The encryption method (default: AES-128-CBC)
43+
* @param string|null $key The encryption key (default: APP_SECRET)
4344
*
4445
* @return string The base64-encoded encrypted string
4546
*/
46-
public function encryptAes(string $plainText, string $method = 'AES-128-CBC'): string
47+
public function encryptAes(string $plainText, string $method = 'AES-128-CBC', ?string $key = null): string
4748
{
48-
$key = $_ENV['APP_SECRET'];
49+
// get default encryption key
50+
if ($key == null) {
51+
$key = $_ENV['APP_SECRET'];
52+
}
4953

5054
// derive a fixed-size key using PBKDF2 with SHA-256
5155
$derivedKey = hash_pbkdf2("sha256", $key, "", 10000, 32);
@@ -67,12 +71,16 @@ public function encryptAes(string $plainText, string $method = 'AES-128-CBC'): s
6771
*
6872
* @param string $encryptedData The base64-encoded encrypted string
6973
* @param string $method The encryption method (default: AES-128-CBC)
74+
* @param string|null $key The encryption key (default: APP_SECRET)
7075
*
7176
* @return string|null The decrypted string or null on error
7277
*/
73-
public function decryptAes(string $encryptedData, string $method = 'AES-128-CBC'): ?string
78+
public function decryptAes(string $encryptedData, string $method = 'AES-128-CBC', ?string $key = null): ?string
7479
{
75-
$key = $_ENV['APP_SECRET'];
80+
// get default encryption key
81+
if ($key == null) {
82+
$key = $_ENV['APP_SECRET'];
83+
}
7684

7785
// derive a fixed-size key using PBKDF2 with SHA-256
7886
$derivedKey = hash_pbkdf2("sha256", $key, "", 10000, 32);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace App\Tests\Command;
4+
5+
use Exception;
6+
use App\Util\AppUtil;
7+
use App\Manager\PasteManager;
8+
use PHPUnit\Framework\TestCase;
9+
use App\Command\RotateAppSecretCommand;
10+
use PHPUnit\Framework\MockObject\MockObject;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Tester\CommandTester;
13+
14+
/**
15+
* Class RotateAppSecretCommandTest
16+
*
17+
* Test cases for execute secret key rotation command
18+
*
19+
* @package App\Tests\Command
20+
*/
21+
class RotateAppSecretCommandTest extends TestCase
22+
{
23+
private CommandTester $commandTester;
24+
private AppUtil & MockObject $appUtil;
25+
private RotateAppSecretCommand $command;
26+
private PasteManager & MockObject $pasteManager;
27+
28+
protected function setUp(): void
29+
{
30+
// mock dependencies
31+
$this->appUtil = $this->createMock(AppUtil::class);
32+
$this->pasteManager = $this->createMock(PasteManager::class);
33+
34+
// initialize command instance
35+
$this->command = new RotateAppSecretCommand($this->appUtil, $this->pasteManager);
36+
$this->commandTester = new CommandTester($this->command);
37+
}
38+
39+
/**
40+
* Test execute command successfully
41+
*
42+
* @return void
43+
*/
44+
public function testExecuteSuccess(): void
45+
{
46+
// mock generateKey
47+
$this->appUtil->expects($this->once())->method('generateKey')->with(16)->willReturn('new-secret-value');
48+
49+
// mock updateEnvValue
50+
$this->appUtil->expects($this->once())->method('updateEnvValue')->with('APP_SECRET', 'new-secret-value');
51+
52+
// execute command
53+
$exitCode = $this->commandTester->execute([]);
54+
55+
// get command output
56+
$output = $this->commandTester->getDisplay();
57+
58+
// assert output
59+
$this->assertStringContainsString('APP_SECRET has been rotated successfully', $output);
60+
$this->assertEquals(Command::SUCCESS, $exitCode);
61+
}
62+
63+
/**
64+
* Test execute command with exception
65+
*
66+
* @return void
67+
*/
68+
public function testExecuteThrowsException(): void
69+
{
70+
// mock generateKey to throw exception
71+
$this->appUtil->method('generateKey')->willThrowException(new Exception('Something went wrong'));
72+
73+
// execute command
74+
$exitCode = $this->commandTester->execute([]);
75+
76+
// get command output
77+
$output = $this->commandTester->getDisplay();
78+
79+
// assert output
80+
$this->assertStringContainsString('Error during rotation: Something went wrong', $output);
81+
$this->assertEquals(Command::FAILURE, $exitCode);
82+
}
83+
}

tests/Manager/PasteManagerTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,34 @@ public function testGetTotalViews(): void
152152
// assert result
153153
$this->assertSame(10, $result);
154154
}
155+
156+
/**
157+
* Test re-encrypt pastes
158+
*
159+
* @return void
160+
*/
161+
public function testReEncryptPastes(): void
162+
{
163+
$oldKey = 'old-key';
164+
$newKey = 'new-key';
165+
166+
// simulate encryption mode
167+
$this->appUtilMock->method('isEncryptionMode')->willReturn(true);
168+
169+
// mock paste repository
170+
$pasteMock = $this->createMock(Paste::class);
171+
$pasteMock->method('getContent')->willReturn('encrypted-content');
172+
$this->pasteRepositoryMock->method('findAll')->willReturn([$pasteMock]);
173+
174+
// mock encryption methods to return expected values
175+
$this->securityUtilMock->method('decryptAes')->willReturn('decrypted-content');
176+
$this->securityUtilMock->method('encryptAes')->willReturn('re-encrypted-content');
177+
178+
// expect entity manager to call persist and flush
179+
$this->entityManagerMock->expects($this->once())->method('persist')->with($pasteMock);
180+
$this->entityManagerMock->expects($this->once())->method('flush');
181+
182+
// call tested method
183+
$this->pasteManager->reEncryptPastes($oldKey, $newKey);
184+
}
155185
}

tests/Util/AppUtilTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Tests\Util;
44

55
use App\Util\AppUtil;
6+
use InvalidArgumentException;
67
use PHPUnit\Framework\TestCase;
78
use PHPUnit\Framework\MockObject\MockObject;
89
use Symfony\Component\HttpKernel\KernelInterface;
@@ -28,6 +29,35 @@ protected function setUp(): void
2829
$this->appUtil = new AppUtil($this->kernelInterface);
2930
}
3031

32+
/**
33+
* Test generate key with invalid length
34+
*
35+
* @return void
36+
*/
37+
public function testGenerateKeyWithInvalidLength(): void
38+
{
39+
// expect exception
40+
$this->expectException(InvalidArgumentException::class);
41+
42+
// call tested method
43+
$this->appUtil->generateKey(0);
44+
}
45+
46+
/**
47+
* Test generate key with valid length
48+
*
49+
* @return void
50+
*/
51+
public function testGenerateKeyWithValidLength(): void
52+
{
53+
// call tested method
54+
$result = $this->appUtil->generateKey(16);
55+
56+
// assert result
57+
$this->assertIsString($result);
58+
$this->assertEquals(32, strlen($result));
59+
}
60+
3161
/**
3262
* Test get environment variable
3363
*

0 commit comments

Comments
 (0)