Skip to content

Commit d81ebcc

Browse files
committed
feat(http)!: automatically encrypt cookies (#1447)
1 parent 095eda2 commit d81ebcc

File tree

21 files changed

+307
-117
lines changed

21 files changed

+307
-117
lines changed

packages/core/src/Kernel/LoadConfig.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function find(): array
4545
$suffixes = [
4646
'production' => ['.production.config.php', '.prod.config.php', '.prd.config.php'],
4747
'staging' => ['.staging.config.php', '.stg.config.php'],
48-
'testing' => ['.test.config.php'],
48+
'testing' => ['.test.config.php', '.testing.config.php'],
4949
'development' => ['.dev.config.php', '.local.config.php'],
5050
];
5151

packages/cryptography/src/Encryption/EncryptedData.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ public function serialize(): string
2727
'algorithm' => $this->algorithm->value,
2828
];
2929

30-
return base64_encode(Json\encode($data));
30+
return Json\encode($data, base64: true);
3131
}
3232

3333
public static function unserialize(string $data): self
3434
{
35-
$decoded = Json\decode(base64_decode($data, strict: true));
35+
$decoded = Json\decode($data, base64: true);
3636

3737
if (! is_array($decoded) || ! isset($decoded['payload'], $decoded['iv'], $decoded['tag'], $decoded['signature'], $decoded['algorithm'])) {
3838
throw EncryptedDataWasInvalid::dueToInvalidFormat();

packages/cryptography/src/Encryption/EncryptionConfig.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ final class EncryptionConfig
66
{
77
/**
88
* @param EncryptionAlgorithm $algorithm The algorithm used for encrypting and decrypting values.
9-
* @param non-empty-string $key A private, secure encryption key.
9+
* @param null|non-empty-string $key A private, secure encryption key.
1010
*/
1111
public function __construct(
1212
public EncryptionAlgorithm $algorithm,

packages/cryptography/src/CreateSigningKeyCommand.php renamed to packages/cryptography/src/GenerateSigningKeyCommand.php

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,28 @@
1313

1414
use function Tempest\root_path;
1515

16-
final readonly class CreateSigningKeyCommand
16+
final readonly class GenerateSigningKeyCommand
1717
{
1818
public function __construct(
1919
private EncryptionConfig $encryptionConfig,
2020
private Console $console,
2121
) {}
2222

2323
#[ConsoleCommand('key:generate', description: 'Generates the signing key required to sign and verify data.')]
24-
public function __invoke(): ExitCode
24+
public function __invoke(bool $override = true): ExitCode
2525
{
2626
$key = EncryptionKey::generate($this->encryptionConfig->algorithm);
2727

28+
$this->createDotEnvIfNotExists();
29+
$this->addToDotEnv($key->toString(), $override);
30+
2831
$this->console->writeln();
29-
$this->console->success('Signing key generated successfully.');
3032

31-
$this->createDotEnvIfNotExists();
32-
$this->addToDotEnv($key->toString());
33+
if ($override) {
34+
$this->console->success('Signing key generated successfully.');
35+
} else {
36+
$this->console->info('The signing key already exists.');
37+
}
3338

3439
return ExitCode::SUCCESS;
3540
}
@@ -39,13 +44,13 @@ private function getDotEnvPath(): string
3944
return root_path('.env');
4045
}
4146

42-
private function addToDotEnv(string $key): void
47+
private function addToDotEnv(string $key, bool $override): void
4348
{
4449
$file = Filesystem\read_file($this->getDotEnvPath());
4550

4651
if (! Str\contains($file, 'SIGNING_KEY=')) {
47-
$file .= "\nSIGNING_KEY={$key}\n";
48-
} else {
52+
$file = "SIGNING_KEY={$key}\n" . $file;
53+
} elseif ($override) {
4954
$file = Regex\replace($file, '/^SIGNING_KEY=.*$/m', "SIGNING_KEY={$key}");
5055
}
5156

packages/cryptography/src/Signing/SigningConfig.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ final class SigningConfig
88
{
99
/**
1010
* @param SigningAlgorithm $algorithm The algorithm used for signing and verifying signatures.
11-
* @param non-empty-string $key The key used for signing and verifying signatures.
11+
* @param null|non-empty-string $key The key used for signing and verifying signatures.
1212
* @param Duration|false $minimumExecutionDuration The minimum execution duration for signing operations, to prevent timing attacks. Set `false` to disable timing attack protection.
1313
*/
1414
public function __construct(
1515
public SigningAlgorithm $algorithm,
1616
#[\SensitiveParameter]
17-
public string $key,
17+
public readonly ?string $key,
1818
public false|Duration $minimumExecutionDuration,
1919
) {}
2020
}

packages/cryptography/src/Signing/SigningKey.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ public function __construct(
1818
/**
1919
* Creates a signing key from a string.
2020
*/
21-
public static function fromString(string $key): self
21+
public static function fromString(?string $key): self
2222
{
23-
return new self($key);
23+
return new self($key ?: '');
2424
}
2525

2626
public function __toString(): string

packages/cryptography/src/Signing/signing.config.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55

66
return new SigningConfig(
77
algorithm: SigningAlgorithm::SHA256,
8-
key: Tempest\env('SIGNING_KEY', default: ''),
8+
key: Tempest\env('SIGNING_KEY'),
99
minimumExecutionDuration: false,
1010
);

packages/http/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"tempest/console": "dev-main",
1111
"tempest/mapper": "dev-main",
1212
"tempest/container": "dev-main",
13+
"tempest/cryptography": "dev-main",
1314
"laminas/laminas-diactoros": "^3.3",
1415
"psr/http-factory": "^1.0",
1516
"psr/http-message": "^1.0|^2.0",

packages/http/src/Cookie/Cookie.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Http\Cookie;
66

7+
use InvalidArgumentException;
78
use Stringable;
89
use Tempest\DateTime\DateTimeInterface;
910

@@ -32,6 +33,14 @@ public function __construct(
3233
public ?SameSite $sameSite = null,
3334
) {}
3435

36+
public function withValue(string $value): self
37+
{
38+
$clone = clone $this;
39+
$clone->value = $value;
40+
41+
return $clone;
42+
}
43+
3544
public function __toString(): string
3645
{
3746
$parts = [
@@ -78,4 +87,49 @@ public function getExpiresAtTime(): ?int
7887

7988
return null;
8089
}
90+
91+
/**
92+
* Creates acookie from the `Set-Cookie` header.
93+
*/
94+
public static function createFromString(string $string): self
95+
{
96+
if (! ($attributes = preg_split('/\s*;\s*/', $string, -1, PREG_SPLIT_NO_EMPTY))) {
97+
throw new InvalidArgumentException(sprintf('The raw value of the `Set Cookie` header `%s` could not be parsed.', $string));
98+
}
99+
100+
$nameAndValue = explode('=', array_shift($attributes), 2);
101+
$cookie = ['name' => $nameAndValue[0], 'value' => isset($nameAndValue[1]) ? urldecode($nameAndValue[1]) : ''];
102+
103+
while ($attribute = array_shift($attributes)) {
104+
$attribute = explode('=', $attribute, 2);
105+
$attributeName = strtolower($attribute[0]);
106+
$attributeValue = $attribute[1] ?? null;
107+
108+
if (in_array($attributeName, ['expires', 'domain', 'path', 'samesite'], true)) {
109+
$cookie[$attributeName] = $attributeValue;
110+
continue;
111+
}
112+
113+
if (in_array($attributeName, ['secure', 'httponly'], true)) {
114+
$cookie[$attributeName] = true;
115+
continue;
116+
}
117+
118+
if ($attributeName === 'max-age') {
119+
$cookie['expires'] = time() + ((int) $attributeValue);
120+
}
121+
}
122+
123+
return new Cookie(
124+
key: $cookie['name'],
125+
value: $cookie['value'] ?? null,
126+
expiresAt: isset($cookie['expires']) ? ((int) $cookie['expires']) : null,
127+
maxAge: isset($cookie['max-age']) ? ((int) $cookie['max-age']) : null,
128+
domain: $cookie['domain'] ?? null,
129+
path: $cookie['path'] ?? '/',
130+
secure: isset($cookie['secure']) && $cookie['secure'] === true,
131+
httpOnly: isset($cookie['httponly']) && $cookie['httponly'] === true,
132+
sameSite: isset($cookie['samesite']) ? SameSite::from($cookie['samesite']) : null,
133+
);
134+
}
81135
}

packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
88
use Psr\Http\Message\UploadedFileInterface;
9+
use Tempest\Cryptography\Encryption\Encrypter;
910
use Tempest\Http\Cookie\Cookie;
1011
use Tempest\Http\GenericRequest;
1112
use Tempest\Http\Method;
@@ -19,6 +20,10 @@
1920

2021
final readonly class PsrRequestToGenericRequestMapper implements Mapper
2122
{
23+
public function __construct(
24+
private readonly Encrypter $encrypter,
25+
) {}
26+
2227
public function canMap(mixed $from, mixed $to): bool
2328
{
2429
return false;
@@ -55,7 +60,13 @@ public function map(mixed $from, mixed $to): GenericRequest
5560
'path' => $from->getUri()->getPath(),
5661
'query' => $query,
5762
'files' => $uploads,
58-
'cookies' => Arr\map_iterable($_COOKIE, static fn (string $value, string $key) => new Cookie($key, $value)),
63+
'cookies' => Arr\map_iterable(
64+
array: $_COOKIE,
65+
map: fn (string $value, string $key) => new Cookie(
66+
key: $key,
67+
value: $this->encrypter->decrypt($value),
68+
),
69+
),
5970
...$data,
6071
...$uploads,
6172
])

0 commit comments

Comments
 (0)