Skip to content

Commit 9c10c87

Browse files
authored
Merge pull request #166 from patchlevel/improve-cryptography
improve cryptography implementation
2 parents 9becc90 + 24f2efc commit 9c10c87

24 files changed

+570
-193
lines changed

phpstan-baseline.neon

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,49 @@ parameters:
1919
path: src/Cryptography/PersonalDataPayloadCryptographer.php
2020

2121
-
22-
message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
22+
message: '#^Offset ''k'' on array\{v\: 1, a\: non\-empty\-string, k\: non\-empty\-string, n\?\: non\-empty\-string, d\: non\-empty\-string, t\?\: non\-empty\-string\} on left side of \?\? always exists and is not nullable\.$#'
23+
identifier: nullCoalesce.offset
24+
count: 1
25+
path: src/Extension/Cryptography/BaseCryptographer.php
26+
27+
-
28+
message: '#^Parameter \#1 \$subjectId of callable Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKeyFactory expects non\-empty\-string, string given\.$#'
2329
identifier: argument.type
2430
count: 1
2531
path: src/Extension/Cryptography/BaseCryptographer.php
2632

2733
-
28-
message: '#^Method Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\OpensslCipher\:\:encrypt\(\) should return non\-empty\-string but returns string\.$#'
29-
identifier: return.type
34+
message: '#^Strict comparison using \=\=\= between non\-empty\-string and null will always evaluate to false\.$#'
35+
identifier: identical.alwaysFalse
36+
count: 1
37+
path: src/Extension/Cryptography/BaseCryptographer.php
38+
39+
-
40+
message: '#^Parameter \#1 \$data of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string, string given\.$#'
41+
identifier: argument.type
42+
count: 1
43+
path: src/Extension/Cryptography/Cipher/OpensslCipher.php
44+
45+
-
46+
message: '#^Parameter \#1 \$data of function openssl_decrypt expects string, string\|false given\.$#'
47+
identifier: argument.type
48+
count: 1
49+
path: src/Extension/Cryptography/Cipher/OpensslCipher.php
50+
51+
-
52+
message: '#^Parameter \#3 \$nonce of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string\|null, string\|null given\.$#'
53+
identifier: argument.type
3054
count: 1
3155
path: src/Extension/Cryptography/Cipher/OpensslCipher.php
3256

3357
-
34-
message: '#^Parameter \#1 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
58+
message: '#^Parameter \#1 \$id of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
3559
identifier: argument.type
3660
count: 1
3761
path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php
3862

3963
-
40-
message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
64+
message: '#^Parameter \#3 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
4165
identifier: argument.type
4266
count: 1
4367
path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php

src/Extension/Cryptography/BaseCryptographer.php

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,37 @@
55
namespace Patchlevel\Hydrator\Extension\Cryptography;
66

77
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher;
8-
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey;
98
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKeyFactory;
109
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed;
10+
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptedData;
1111
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptionFailed;
1212
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher;
1313
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipherKeyFactory;
1414
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists;
1515
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore;
1616

17-
use function array_key_exists;
18-
use function base64_decode;
19-
use function base64_encode;
2017
use function is_array;
2118

2219
/**
2320
* @experimental
24-
* @phpstan-type EncryptedDataV1 array{
25-
* __enc: 'v1',
26-
* data: non-empty-string,
27-
* method?: non-empty-string,
28-
* iv?: non-empty-string,
21+
* @phpstan-type EncryptedDataArray array{
22+
* v: 1,
23+
* a: non-empty-string,
24+
* k: non-empty-string,
25+
* n?: non-empty-string, // base64
26+
* d: non-empty-string, // base64 ciphertext
27+
* t?: non-empty-string, // base64 (for AEAD)
2928
* }
3029
*/
3130
final class BaseCryptographer implements Cryptographer
3231
{
32+
private const VERSION_KEY = 'v';
33+
private const METHOD_KEY = 'a';
34+
private const KEY_ID_KEY = 'k';
35+
private const NONCE_KEY = 'n';
36+
private const DATA_KEY = 'd';
37+
private const TAG_KEY = 't';
38+
3339
public function __construct(
3440
private readonly Cipher $cipher,
3541
private readonly CipherKeyStore $cipherKeyStore,
@@ -38,50 +44,71 @@ public function __construct(
3844
}
3945

4046
/**
41-
* @return EncryptedDataV1
47+
* @return EncryptedDataArray
4248
*
4349
* @throws EncryptionFailed
4450
*/
4551
public function encrypt(string $subjectId, mixed $value): array
4652
{
4753
try {
48-
$cipherKey = $this->cipherKeyStore->get($subjectId);
54+
$cipherKey = $this->cipherKeyStore->currentKeyFor($subjectId);
4955
} catch (CipherKeyNotExists) {
50-
$cipherKey = ($this->cipherKeyFactory)();
51-
$this->cipherKeyStore->store($subjectId, $cipherKey);
56+
$cipherKey = ($this->cipherKeyFactory)($subjectId);
57+
$this->cipherKeyStore->store($cipherKey->id, $cipherKey);
5258
}
5359

54-
return [
55-
'__enc' => 'v1',
56-
'data' => $this->cipher->encrypt($cipherKey, $value),
57-
'method' => $cipherKey->method,
58-
'iv' => base64_encode($cipherKey->iv),
60+
$parameter = $this->cipher->encrypt($cipherKey, $value);
61+
62+
$result = [
63+
self::VERSION_KEY => 1,
64+
self::METHOD_KEY => $parameter->method,
65+
self::KEY_ID_KEY => $cipherKey->id,
66+
self::DATA_KEY => $parameter->data,
5967
];
68+
69+
if ($parameter->nonce !== null) {
70+
$result[self::NONCE_KEY] = $parameter->nonce;
71+
}
72+
73+
if ($parameter->tag !== null) {
74+
$result[self::TAG_KEY] = $parameter->tag;
75+
}
76+
77+
return $result;
6078
}
6179

6280
/**
63-
* @param EncryptedDataV1 $encryptedData
81+
* @param EncryptedDataArray $encryptedData
6482
*
6583
* @throws CipherKeyNotExists
6684
* @throws DecryptionFailed
6785
*/
6886
public function decrypt(string $subjectId, mixed $encryptedData): mixed
6987
{
70-
$cipherKey = $this->cipherKeyStore->get($subjectId);
88+
$keyId = $encryptedData[self::KEY_ID_KEY] ?? null;
89+
90+
if ($keyId === null) {
91+
throw DecryptionFailed::missingKeyId();
92+
}
93+
94+
$cipherKey = $this->cipherKeyStore->get($keyId);
7195

7296
return $this->cipher->decrypt(
73-
new CipherKey(
74-
$cipherKey->key,
75-
$encryptedData['method'] ?? $cipherKey->method,
76-
isset($encryptedData['iv']) ? base64_decode($encryptedData['iv']) : $cipherKey->iv,
97+
$cipherKey,
98+
new EncryptedData(
99+
$encryptedData[self::DATA_KEY],
100+
$encryptedData[self::METHOD_KEY],
101+
$encryptedData[self::NONCE_KEY] ?? null,
102+
$encryptedData[self::TAG_KEY] ?? null,
77103
),
78-
$encryptedData['data'],
79104
);
80105
}
81106

82107
public function supports(mixed $value): bool
83108
{
84-
return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1';
109+
return is_array($value)
110+
&& isset($value[self::VERSION_KEY], $value[self::METHOD_KEY], $value[self::KEY_ID_KEY], $value[self::DATA_KEY])
111+
&& $value[self::VERSION_KEY] === 1;
85112
}
86113

87114
/** @param non-empty-string $method */

src/Extension/Cryptography/Cipher/Cipher.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@
77
/** @experimental */
88
interface Cipher
99
{
10-
/**
11-
* @return non-empty-string
12-
*
13-
* @throws EncryptionFailed
14-
*/
15-
public function encrypt(CipherKey $key, mixed $data): string;
10+
/** @throws EncryptionFailed */
11+
public function encrypt(CipherKey $key, mixed $data): EncryptedData;
1612

1713
/** @throws DecryptionFailed */
18-
public function decrypt(CipherKey $key, string $data): mixed;
14+
public function decrypt(CipherKey $key, EncryptedData $parameter): mixed;
1915
}

src/Extension/Cryptography/Cipher/CipherKey.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,25 @@
44

55
namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher;
66

7+
use DateTimeImmutable;
8+
use SensitiveParameter;
9+
710
/** @experimental */
811
final class CipherKey
912
{
1013
/**
14+
* @param non-empty-string $id
15+
* @param non-empty-string $subjectId
1116
* @param non-empty-string $key
1217
* @param non-empty-string $method
13-
* @param non-empty-string $iv
1418
*/
1519
public function __construct(
20+
public readonly string $id,
21+
public readonly string $subjectId,
22+
#[SensitiveParameter]
1623
public readonly string $key,
1724
public readonly string $method,
18-
public readonly string $iv,
25+
public readonly DateTimeImmutable $createdAt,
1926
) {
2027
}
2128
}

src/Extension/Cryptography/Cipher/CipherKeyFactory.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
/** @experimental */
88
interface CipherKeyFactory
99
{
10-
/** @throws CreateCipherKeyFailed */
11-
public function __invoke(): CipherKey;
10+
/**
11+
* @param non-empty-string $subjectId
12+
*
13+
* @throws CreateCipherKeyFailed
14+
*/
15+
public function __invoke(string $subjectId): CipherKey;
1216
}

src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@
66

77
use Patchlevel\Hydrator\HydratorException;
88
use RuntimeException;
9+
use Throwable;
10+
11+
use function sprintf;
912

1013
/** @experimental */
1114
final class CreateCipherKeyFailed extends RuntimeException implements HydratorException
1215
{
13-
public function __construct()
16+
private function __construct(string $message, Throwable|null $previous = null)
17+
{
18+
parent::__construct($message, 0, $previous);
19+
}
20+
21+
public static function forMethod(string $method, string $reason): self
22+
{
23+
return new self(sprintf('Failed to create cipher key for method "%s": %s', $method, $reason));
24+
}
25+
26+
public static function invalidKeyLength(string $method): self
1427
{
15-
parent::__construct('Create cipher key failed.');
28+
return new self(sprintf('Invalid key length for method "%s".', $method));
1629
}
1730
}

src/Extension/Cryptography/Cipher/DecryptionFailed.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,35 @@
66

77
use Patchlevel\Hydrator\HydratorException;
88
use RuntimeException;
9+
use Throwable;
10+
11+
use function sprintf;
912

1013
/** @experimental */
1114
final class DecryptionFailed extends RuntimeException implements HydratorException
1215
{
13-
public function __construct()
16+
private function __construct(string $message, Throwable|null $previous = null)
17+
{
18+
parent::__construct($message, 0, $previous);
19+
}
20+
21+
public static function forMethod(string $method, Throwable|null $previous = null): self
22+
{
23+
return new self(sprintf('Decryption failed for method "%s".', $method), $previous);
24+
}
25+
26+
public static function invalidBase64(string $field): self
27+
{
28+
return new self(sprintf('Invalid base64 encoding in field "%s".', $field));
29+
}
30+
31+
public static function missingKeyId(): self
32+
{
33+
return new self('Missing key ID in encrypted data.');
34+
}
35+
36+
public static function invalidJson(Throwable|null $previous = null): self
1437
{
15-
parent::__construct('Decryption failed.');
38+
return new self('Failed to decode JSON data.', $previous);
1639
}
1740
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher;
6+
7+
/** @experimental */
8+
final readonly class EncryptedData
9+
{
10+
/**
11+
* @param non-empty-string $data
12+
* @param non-empty-string $method
13+
* @param non-empty-string|null $nonce
14+
* @param non-empty-string|null $tag
15+
*/
16+
public function __construct(
17+
public string $data,
18+
public string $method,
19+
public string|null $nonce,
20+
public string|null $tag = null,
21+
) {
22+
}
23+
}

src/Extension/Cryptography/Cipher/EncryptionFailed.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@
66

77
use Patchlevel\Hydrator\HydratorException;
88
use RuntimeException;
9+
use Throwable;
10+
11+
use function sprintf;
912

1013
/** @experimental */
1114
final class EncryptionFailed extends RuntimeException implements HydratorException
1215
{
13-
public function __construct()
16+
private function __construct(string $message, Throwable|null $previous = null)
17+
{
18+
parent::__construct($message, 0, $previous);
19+
}
20+
21+
public static function forMethod(string $method, Throwable|null $previous = null): self
22+
{
23+
return new self(sprintf('Encryption failed for method "%s".', $method), $previous);
24+
}
25+
26+
public static function invalidIvLength(string $method): self
1427
{
15-
parent::__construct('Encryption failed.');
28+
return new self(sprintf('Invalid IV length for method "%s".', $method));
1629
}
1730
}

0 commit comments

Comments
 (0)