Skip to content

Commit 1b59339

Browse files
committed
implement encryption key rotation for JWE decryption
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
1 parent 8a28625 commit 1b59339

File tree

5 files changed

+72
-26
lines changed

5 files changed

+72
-26
lines changed

lib/Service/JweService.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@
2424
use Jose\Component\Encryption\JWETokenSupport;
2525
use Jose\Component\Encryption\Serializer\CompactSerializer;
2626
use Jose\Component\Encryption\Serializer\JWESerializerManager;
27+
use OCP\AppFramework\Services\IAppConfig;
28+
use Psr\Log\LoggerInterface;
2729

2830
class JweService {
2931

3032
public const CONTENT_ENCRYPTION_ALGORITHM = 'A192CBC-HS384';
3133

3234
public function __construct(
3335
private JwkService $jwkService,
36+
private IAppConfig $appConfig,
37+
private LoggerInterface $logger,
3438
) {
3539
}
3640

@@ -165,9 +169,28 @@ public function decryptSerializedJwe(string $serializedJwe): string {
165169
$myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
166170
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
167171
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
168-
$encPrivJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
169-
170-
return $this->decryptSerializedJweWithKey($serializedJwe, $encPrivJwk);
172+
$encryptionPrivateJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
173+
174+
try {
175+
return $this->decryptSerializedJweWithKey($serializedJwe, $encryptionPrivateJwk);
176+
} catch (\Exception $e) {
177+
// try the old expired key
178+
$oldPemEncryptionKey = $this->appConfig->getAppValueString(JwkService::PEM_EXPIRED_ENC_KEY_SETTINGS_KEY, lazy: true);
179+
$oldPemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
180+
if ($oldPemEncryptionKey === '' || $oldPemEncryptionKeyCreatedAt === 0) {
181+
$this->logger->debug('JWE decryption failed with a fresh key and there is no old key');
182+
throw $e;
183+
}
184+
// the old encryption key is expired for more than an hour, we can't use it
185+
if (time() > $oldPemEncryptionKeyCreatedAt + JwkService::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS + (60 * 60)) {
186+
$this->logger->debug('JWE decryption failed with a fresh key and the old key is expired for more than an hour');
187+
throw $e;
188+
}
189+
$oldSslEncryptionKey = openssl_pkey_get_private($oldPemEncryptionKey);
190+
$oldSslEncryptionKeyDetails = openssl_pkey_get_details($oldSslEncryptionKey);
191+
$oldEncryptionPrivateJwk = $this->jwkService->getJwkFromSslKey($oldSslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
192+
return $this->decryptSerializedJweWithKey($serializedJwe, $oldEncryptionPrivateJwk);
193+
}
171194
}
172195

173196
public function createSerializedJwe(string $payload): string {

lib/Service/JwkService.php

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
class JwkService {
2020

2121
public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
22-
public const PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemSignatureKeyExpiresAt';
23-
public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 60;
22+
public const PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY = 'pemSignatureKeyCreatedAt';
23+
public const PEM_SIG_KEY_EXPIRES_AFTER_SECONDS = 60 * 60;
2424
public const PEM_SIG_KEY_ALGORITHM = 'ES384';
2525
public const PEM_SIG_KEY_CURVE = 'P-384';
2626

2727
public const PEM_ENC_KEY_SETTINGS_KEY = 'pemEncryptionKey';
28-
public const PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemEncryptionKeyExpiresAt';
29-
public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 60;
28+
public const PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY = 'pemEncryptionKeyCreatedAt';
29+
public const PEM_ENC_KEY_EXPIRES_AFTER_SECONDS = 60 * 60;
3030
public const PEM_ENC_KEY_ALGORITHM = 'ECDH-ES+A192KW';
3131
public const PEM_ENC_KEY_CURVE = 'P-384';
32+
// we store the expired encryption key and can use it for one extra hour after a new one has been generated
33+
public const PEM_EXPIRED_ENC_KEY_SETTINGS_KEY = 'pemExpiredEncryptionKey';
34+
public const PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY = 'pemExpiredEncryptionKeyCreatedAt';
3235

3336
public function __construct(
3437
private IAppConfig $appConfig,
@@ -44,13 +47,15 @@ public function __construct(
4447
*/
4548
public function getMyPemSignatureKey(bool $refresh = true): string {
4649
$pemSignatureKey = $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
47-
$pemSignatureKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
50+
$pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
4851

49-
if ($pemSignatureKey === '' || $pemSignatureKeyExpiresAt === 0 || ($refresh && time() > $pemSignatureKeyExpiresAt)) {
52+
if ($pemSignatureKey === ''
53+
|| $pemSignatureKeyCreatedAt === 0
54+
|| ($refresh && (time() > $pemSignatureKeyCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS))) {
5055
$pemSignatureKey = $this->generatePemPrivateKey();
5156
// store the key
5257
$this->appConfig->setAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, $pemSignatureKey, lazy: true);
53-
$this->appConfig->setAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_SIG_KEY_EXPIRES_IN_SECONDS, lazy: true);
58+
$this->appConfig->setAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true);
5459
}
5560
return $pemSignatureKey;
5661
}
@@ -64,13 +69,24 @@ public function getMyPemSignatureKey(bool $refresh = true): string {
6469
*/
6570
public function getMyEncryptionKey(bool $refresh = true): string {
6671
$pemEncryptionKey = $this->appConfig->getAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, lazy: true);
67-
$pemEncryptionKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
68-
69-
if ($pemEncryptionKey === '' || $pemEncryptionKeyExpiresAt === 0 || ($refresh && time() > $pemEncryptionKeyExpiresAt)) {
72+
$pemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
73+
74+
if ($pemEncryptionKey === ''
75+
|| $pemEncryptionKeyCreatedAt === 0
76+
|| ($refresh && (time() > $pemEncryptionKeyCreatedAt + self::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS))
77+
) {
78+
// if we have an old expired key, keep it for one hour
79+
if ($pemEncryptionKey !== ''
80+
&& $pemEncryptionKeyCreatedAt !== 0
81+
&& (time() > $pemEncryptionKeyCreatedAt + self::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS)) {
82+
$this->appConfig->setAppValueString(self::PEM_EXPIRED_ENC_KEY_SETTINGS_KEY, $pemEncryptionKey, lazy: true);
83+
$this->appConfig->setAppValueInt(self::PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY, $pemEncryptionKeyCreatedAt, lazy: true);
84+
}
85+
// generate a new key
7086
$pemEncryptionKey = $this->generatePemPrivateKey();
7187
// store the key
7288
$this->appConfig->setAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, $pemEncryptionKey, lazy: true);
73-
$this->appConfig->setAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_ENC_KEY_EXPIRES_IN_SECONDS, lazy: true);
89+
$this->appConfig->setAppValueInt(self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true);
7490
}
7591
return $pemEncryptionKey;
7692
}
@@ -122,11 +138,14 @@ public function getJwks(): array {
122138
}
123139

124140
public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false, bool $includePrivateKey = false): array {
125-
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
141+
$pemPrivateKeyCreatedAt = $this->appConfig->getAppValueInt(
142+
$isEncryptionKey ? self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY : self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY,
143+
lazy: true,
144+
);
126145
$jwk = [
127146
'kty' => 'EC',
128147
'use' => $isEncryptionKey ? 'enc' : 'sig',
129-
'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyExpiresAt,
148+
'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyCreatedAt,
130149
'crv' => $isEncryptionKey ? self::PEM_ENC_KEY_CURVE : self::PEM_SIG_KEY_CURVE,
131150
'x' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['x']), '+/', '-_'), '='),
132151
'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
@@ -155,7 +174,7 @@ public function generateClientAssertion(Provider $provider, string $discoveryIss
155174
// we refresh (if needed) here to make sure we use a key that will be served to the IdP in a few seconds
156175
$myPemPrivateKey = $this->getMyPemSignatureKey();
157176
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
158-
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
177+
$pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
159178

160179
$payload = [
161180
'sub' => $provider->getClientId(),
@@ -170,7 +189,7 @@ public function generateClientAssertion(Provider $provider, string $discoveryIss
170189
$payload['code'] = $code;
171190
}
172191

173-
return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
192+
return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemSignatureKeyCreatedAt, self::PEM_SIG_KEY_ALGORITHM);
174193
}
175194

176195
public function debug(): array {
@@ -180,8 +199,8 @@ public function debug(): array {
180199
$pubKeyPem = $pubKey['key'];
181200

182201
$payload = ['lll' => 'aaa'];
183-
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
184-
$signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
202+
$pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
203+
$signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemSignatureKeyCreatedAt, self::PEM_SIG_KEY_ALGORITHM);
185204

186205
// check content of JWT
187206
$rawJwks = ['keys' => [$this->getJwkFromSslKey($pubKey)]];

tests/unit/Service/JweServiceTest.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCP\AppFramework\Services\IAppConfig;
1414
use PHPUnit\Framework\MockObject\MockObject;
1515
use PHPUnit\Framework\TestCase;
16+
use Psr\Log\LoggerInterface;
1617

1718
class JweServiceTest extends TestCase {
1819

@@ -27,7 +28,11 @@ public function setUp(): void {
2728
parent::setUp();
2829
$this->appConfig = $this->createMock(IAppConfig::class);
2930
$this->jwkService = new JwkService($this->appConfig);
30-
$this->jweService = new JweService($this->jwkService);
31+
$this->jweService = new JweService(
32+
$this->jwkService,
33+
$this->appConfig,
34+
$this->createMock(LoggerInterface::class),
35+
);
3136
}
3237

3338
public function testJweEncryptionDecryption() {

tests/unit/Service/JwkServiceTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public function testSignatureKeyAndJwt() {
3939
$this->assertStringContainsString('-----END PRIVATE KEY-----', $myPemPrivateKey);
4040

4141
$initialPayload = ['nice' => 'example'];
42-
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
43-
$jwkId = 'sig_key_' . $pemPrivateKeyExpiresAt;
42+
$pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
43+
$jwkId = 'sig_key_' . $pemSignatureKeyCreatedAt;
4444
$signedJwtToken = $this->jwkService->createJwt($initialPayload, $sslPrivateKey, $jwkId, JwkService::PEM_SIG_KEY_ALGORITHM);
4545

4646
// check JWK
@@ -72,8 +72,8 @@ public function testEncryptionKey() {
7272
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
7373
$encJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
7474

75-
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
76-
$encJwkId = 'enc_key_' . $pemPrivateKeyExpiresAt;
75+
$pemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
76+
$encJwkId = 'enc_key_' . $pemEncryptionKeyCreatedAt;
7777

7878
$this->assertEquals('EC', $encJwk['kty']);
7979
$this->assertEquals('enc', $encJwk['use']);

tests/unit/Service/ProvisioningServiceTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,6 @@ public function testProvisionUserInvalidProperties(): void {
248248
$property->method('getName')->willReturn('twitter');
249249
$property->method('getScope')->willReturn(IAccountManager::SCOPE_LOCAL);
250250
$property->method('getValue')->willReturnCallback(function () use (&$twitterProperty) {
251-
echo 'GETTING: ' . $twitterProperty;
252251
return $twitterProperty;
253252
});
254253

0 commit comments

Comments
 (0)