Skip to content

Commit 929bc3a

Browse files
authored
[TASK] Add api key encryption
2 parents d4bd532 + ad79cec commit 929bc3a

16 files changed

Lines changed: 1272 additions & 1 deletion

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Backend\FormDataProvider;
14+
15+
use B13\Aim\Crypto\ApiKeyEncryption;
16+
use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
17+
18+
/**
19+
* Decrypts the api_key value before it is rendered in the backend form.
20+
*
21+
* Admins editing a tx_aim_configuration record see the plaintext key and
22+
* can verify or replace it. On submit, the DataHandler hook encrypts the
23+
* value again. If the admin re-saves the record without touching api_key,
24+
* the form re-sends plaintext, which the hook re-encrypts — the value on
25+
* disk stays encrypted throughout.
26+
*/
27+
final class DecryptApiKey implements FormDataProviderInterface
28+
{
29+
public function __construct(private readonly ApiKeyEncryption $encryption) {}
30+
31+
public function addData(array $result): array
32+
{
33+
if (($result['tableName'] ?? '') !== 'tx_aim_configuration') {
34+
return $result;
35+
}
36+
37+
$value = (string)($result['databaseRow']['api_key'] ?? '');
38+
if ($value === '' || !$this->encryption->isEncrypted($value)) {
39+
return $result;
40+
}
41+
42+
$result['databaseRow']['api_key'] = $this->encryption->decrypt($value);
43+
return $result;
44+
}
45+
}

Classes/Command/RotateApiKeys.php

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Command;
14+
15+
use B13\Aim\Crypto\ApiKeyEncryption;
16+
use B13\Aim\Exception\ApiKeyEncryptionException;
17+
use Symfony\Component\Console\Attribute\AsCommand;
18+
use Symfony\Component\Console\Command\Command;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Input\InputOption;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
use TYPO3\CMS\Core\Database\Connection;
23+
use TYPO3\CMS\Core\Database\ConnectionPool;
24+
25+
/**
26+
* Re-encrypts stored AiM provider API keys after $TYPO3_CONF_VARS[SYS][encryptionKey]
27+
* has been rotated.
28+
*
29+
* Workflow per row:
30+
* 1. Skip plaintext / endpoint URL / unencrypted value
31+
* 2. Decrypts with the current system key, skip already migrated
32+
* 3. Decrypts with the supplied --old-key, re-encrypt with the current system key
33+
* 4. Decrypts with neither is reported as unrecoverable, abort without writes
34+
*
35+
* Idempotent: a second run with the same --old-key is a no-op.
36+
*/
37+
#[AsCommand(
38+
name: 'aim:rotateApiKeys',
39+
description: 'Re-encrypt stored AiM API keys after a SYS/encryptionKey rotation.',
40+
)]
41+
final class RotateApiKeys extends Command
42+
{
43+
private const TABLE = 'tx_aim_configuration';
44+
45+
public function __construct(
46+
private readonly ApiKeyEncryption $encryption,
47+
private readonly ConnectionPool $connectionPool,
48+
) {
49+
parent::__construct();
50+
}
51+
52+
protected function configure(): void
53+
{
54+
$this
55+
->setHelp(
56+
'When $TYPO3_CONF_VARS[SYS][encryptionKey] has been rotated, existing encrypted '
57+
. 'API keys can no longer be read. This command takes the previous value of the '
58+
. 'system key, decrypts each stored API key with it, and re-encrypts using the '
59+
. 'current system key.'
60+
)
61+
->addOption(
62+
'old-key',
63+
null,
64+
InputOption::VALUE_REQUIRED,
65+
'The previous value of $TYPO3_CONF_VARS[SYS][encryptionKey].',
66+
)
67+
->addOption(
68+
'dry-run',
69+
null,
70+
InputOption::VALUE_NONE,
71+
'Report what would change without writing.',
72+
);
73+
}
74+
75+
protected function execute(InputInterface $input, OutputInterface $output): int
76+
{
77+
$oldKey = (string)$input->getOption('old-key');
78+
if ($oldKey === '') {
79+
$output->writeln('<error>--old-key is required.</error>');
80+
return Command::FAILURE;
81+
}
82+
$dryRun = (bool)$input->getOption('dry-run');
83+
84+
[$toRotate, $unrecoverable, $alreadyCurrent, $unencrypted] = $this->classifyRows($oldKey);
85+
86+
if ($unrecoverable !== []) {
87+
$output->writeln('<error>The following rows cannot be decrypted with the supplied old key:</error>');
88+
foreach ($unrecoverable as $uid => $message) {
89+
$output->writeln(sprintf(' - uid=%d: %s', $uid, $message));
90+
}
91+
$output->writeln('<error>Aborting without writes. Verify the --old-key value.</error>');
92+
return Command::FAILURE;
93+
}
94+
95+
if ($toRotate === []) {
96+
$output->writeln('Nothing to rotate.');
97+
$this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted);
98+
return Command::SUCCESS;
99+
}
100+
101+
if ($dryRun) {
102+
$output->writeln(sprintf(
103+
'<info>[dry-run] Would re-encrypt %d row(s).</info>',
104+
count($toRotate),
105+
));
106+
$this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted);
107+
return Command::SUCCESS;
108+
}
109+
110+
$connection = $this->connectionPool->getConnectionForTable(self::TABLE);
111+
foreach ($toRotate as $uid => $plaintext) {
112+
$connection->update(
113+
self::TABLE,
114+
['api_key' => $this->encryption->encrypt($plaintext)],
115+
['uid' => $uid],
116+
['api_key' => Connection::PARAM_STR],
117+
);
118+
}
119+
120+
$output->writeln(sprintf(
121+
'<info>Re-encrypted %d API key(s).</info>',
122+
count($toRotate),
123+
));
124+
$this->writeSkipBreakdown($output, $alreadyCurrent, $unencrypted);
125+
return Command::SUCCESS;
126+
}
127+
128+
private function writeSkipBreakdown(OutputInterface $output, int $alreadyCurrent, int $unencrypted): void
129+
{
130+
if ($alreadyCurrent > 0) {
131+
$output->writeln(sprintf(
132+
' - %d row(s) already use the current system key',
133+
$alreadyCurrent,
134+
));
135+
}
136+
if ($unencrypted > 0) {
137+
$output->writeln(sprintf(
138+
' - %d row(s) are not encrypted (endpoint URLs or empty values)',
139+
$unencrypted,
140+
));
141+
}
142+
}
143+
144+
/**
145+
* @return array{0: array<int, string>, 1: array<int, string>, 2: int, 3: int}
146+
* [uid => plaintext to re-encrypt], [uid => failure message], already-current count, unencrypted count
147+
*/
148+
private function classifyRows(string $oldKey): array
149+
{
150+
$toRotate = [];
151+
$unrecoverable = [];
152+
$alreadyCurrent = 0;
153+
$unencrypted = 0;
154+
155+
foreach ($this->fetchAllRows() as $row) {
156+
$uid = (int)$row['uid'];
157+
$value = (string)$row['api_key'];
158+
159+
if ($value === '' || $this->encryption->isEndpointUrl($value) || !$this->encryption->isEncrypted($value)) {
160+
$unencrypted++;
161+
continue;
162+
}
163+
164+
try {
165+
$this->encryption->decrypt($value);
166+
$alreadyCurrent++;
167+
continue;
168+
} catch (ApiKeyEncryptionException) {
169+
// Fall through to old-key attempt
170+
}
171+
172+
try {
173+
$toRotate[$uid] = $this->encryption->decryptWithSystemKey($value, $oldKey);
174+
} catch (ApiKeyEncryptionException $e) {
175+
$unrecoverable[$uid] = $e->getMessage();
176+
}
177+
}
178+
179+
return [$toRotate, $unrecoverable, $alreadyCurrent, $unencrypted];
180+
}
181+
182+
/**
183+
* @return list<array{uid: int|string, api_key: string|null}>
184+
*/
185+
private function fetchAllRows(): array
186+
{
187+
$qb = $this->connectionPool->getQueryBuilderForTable(self::TABLE);
188+
$qb->getRestrictions()->removeAll();
189+
return $qb->select('uid', 'api_key')
190+
->from(self::TABLE)
191+
->executeQuery()
192+
->fetchAllAssociative();
193+
}
194+
}

0 commit comments

Comments
 (0)