Skip to content

Commit e5c60aa

Browse files
committed
improve cryptography implementation
1 parent 22f3b9c commit e5c60aa

23 files changed

Lines changed: 409 additions & 193 deletions

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: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,26 @@
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
@@ -38,50 +37,71 @@ public function __construct(
3837
}
3938

4039
/**
41-
* @return EncryptedDataV1
40+
* @return EncryptedDataArray
4241
*
4342
* @throws EncryptionFailed
4443
*/
4544
public function encrypt(string $subjectId, mixed $value): array
4645
{
4746
try {
48-
$cipherKey = $this->cipherKeyStore->get($subjectId);
47+
$cipherKey = $this->cipherKeyStore->currentKeyFor($subjectId);
4948
} catch (CipherKeyNotExists) {
50-
$cipherKey = ($this->cipherKeyFactory)();
51-
$this->cipherKeyStore->store($subjectId, $cipherKey);
49+
$cipherKey = ($this->cipherKeyFactory)($subjectId);
50+
$this->cipherKeyStore->store($cipherKey->id, $cipherKey);
5251
}
5352

54-
return [
55-
'__enc' => 'v1',
56-
'data' => $this->cipher->encrypt($cipherKey, $value),
57-
'method' => $cipherKey->method,
58-
'iv' => base64_encode($cipherKey->iv),
53+
$parameter = $this->cipher->encrypt($cipherKey, $value);
54+
55+
$result = [
56+
'v' => 1,
57+
'a' => $parameter->method,
58+
'k' => $cipherKey->id,
59+
'd' => $parameter->data,
5960
];
61+
62+
if ($parameter->nonce !== null) {
63+
$result['n'] = $parameter->nonce;
64+
}
65+
66+
if ($parameter->tag !== null) {
67+
$result['t'] = $parameter->tag;
68+
}
69+
70+
return $result;
6071
}
6172

6273
/**
63-
* @param EncryptedDataV1 $encryptedData
74+
* @param EncryptedDataArray $encryptedData
6475
*
6576
* @throws CipherKeyNotExists
6677
* @throws DecryptionFailed
6778
*/
6879
public function decrypt(string $subjectId, mixed $encryptedData): mixed
6980
{
70-
$cipherKey = $this->cipherKeyStore->get($subjectId);
81+
$keyId = $encryptedData['k'] ?? null;
82+
83+
if ($keyId === null) {
84+
throw DecryptionFailed::missingKeyId();
85+
}
86+
87+
$cipherKey = $this->cipherKeyStore->get($keyId);
7188

7289
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,
90+
$cipherKey,
91+
new EncryptedData(
92+
$encryptedData['d'],
93+
$encryptedData['a'],
94+
$encryptedData['n'] ?? null,
95+
$encryptedData['t'] ?? null,
7796
),
78-
$encryptedData['data'],
7997
);
8098
}
8199

82100
public function supports(mixed $value): bool
83101
{
84-
return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1';
102+
return is_array($value)
103+
&& isset($value['v'], $value['a'], $value['k'], $value['d'])
104+
&& $value['v'] === 1;
85105
}
86106

87107
/** @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)