Skip to content

Commit bf5f34c

Browse files
committed
feat: protect signing from timing attacks
1 parent a19279c commit bf5f34c

File tree

8 files changed

+95
-26
lines changed

8 files changed

+95
-26
lines changed

packages/cryptography/composer.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@
66
"require": {
77
"php": "^8.4",
88
"tempest/container": "dev-main",
9-
"tempest/support": "dev-main"
10-
},
11-
"suggest": {
12-
"tempest/clock": "For time-lock support"
9+
"tempest/support": "dev-main",
10+
"tempest/clock": "dev-main"
1311
},
1412
"autoload": {
1513
"psr-4": {

packages/cryptography/src/Signing/GenericSigner.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace Tempest\Cryptography\Signing;
44

55
use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing;
6+
use Tempest\Cryptography\Timelock;
7+
use Tempest\DateTime\Duration;
68

79
final class GenericSigner implements Signer
810
{
@@ -22,6 +24,7 @@ final class GenericSigner implements Signer
2224

2325
public function __construct(
2426
private readonly SigningConfig $config,
27+
private readonly Timelock $timelock,
2528
) {}
2629

2730
public function sign(string $data): Signature
@@ -35,9 +38,12 @@ public function sign(string $data): Signature
3538

3639
public function verify(string $data, Signature $signature): bool
3740
{
38-
return hash_equals(
39-
known_string: $this->sign($data)->signature,
40-
user_string: $signature->signature,
41+
return $this->timelock->invoke(
42+
callback: fn () => hash_equals(
43+
known_string: $this->sign($data)->signature,
44+
user_string: $signature->signature,
45+
),
46+
duration: $this->config->minimumExecutionDuration ?: Duration::zero(),
4147
);
4248
}
4349
}

packages/cryptography/src/Signing/SignerInitializer.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
use Tempest\Container\Container;
66
use Tempest\Container\Initializer;
77
use Tempest\Container\Singleton;
8+
use Tempest\Cryptography\Timelock;
89

910
final class SignerInitializer implements Initializer
1011
{
1112
#[Singleton]
1213
public function initialize(Container $container): Signer
1314
{
14-
return new GenericSigner($container->get(SigningConfig::class));
15+
return new GenericSigner(
16+
config: $container->get(SigningConfig::class),
17+
timelock: $container->get(Timelock::class),
18+
);
1519
}
1620
}

packages/cryptography/src/Signing/SigningConfig.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
namespace Tempest\Cryptography\Signing;
44

5+
use Tempest\DateTime\Duration;
6+
57
final class SigningConfig
68
{
79
/**
810
* @param SigningAlgorithm $algorithm The algorithm used for signing and verifying signatures.
911
* @param non-empty-string $key The key used for signing and verifying signatures.
12+
* @param Duration|false $minimumExecutionDuration The minimum execution duration for signing operations, to prevent timing attacks. Set `false` to disable timing attack protection.
1013
*/
1114
public function __construct(
1215
public SigningAlgorithm $algorithm,
1316
#[\SensitiveParameter]
1417
public string $key,
18+
public false|Duration $minimumExecutionDuration,
1519
) {}
1620
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
return new SigningConfig(
77
algorithm: SigningAlgorithm::SHA256,
88
key: Tempest\env('SIGNING_KEY', default: ''),
9+
minimumExecutionDuration: false,
910
);

packages/cryptography/tests/Signing/SignerTest.php

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,36 @@
33
namespace Tempest\Cryptography\Tests\Signing;
44

55
use PHPUnit\Framework\TestCase;
6+
use Tempest\Clock\Clock;
7+
use Tempest\Clock\GenericClock;
8+
use Tempest\Clock\MockClock;
69
use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing;
710
use Tempest\Cryptography\Signing\GenericSigner;
811
use Tempest\Cryptography\Signing\SigningAlgorithm;
912
use Tempest\Cryptography\Signing\SigningConfig;
13+
use Tempest\Cryptography\Timelock;
14+
use Tempest\DateTime\Duration;
1015

1116
final class SignerTest extends TestCase
1217
{
18+
private function createSigner(SigningConfig $config, ?Clock $clock = null): GenericSigner
19+
{
20+
return new GenericSigner(
21+
config: $config ?? new SigningConfig(
22+
algorithm: SigningAlgorithm::SHA256,
23+
key: 'my_secret_key',
24+
minimumExecutionDuration: false,
25+
),
26+
timelock: new Timelock($clock ?? new GenericClock()),
27+
);
28+
}
29+
1330
public function test_good_signature(): void
1431
{
15-
$signer = new GenericSigner(new SigningConfig(
32+
$signer = $this->createSigner(new SigningConfig(
1633
algorithm: SigningAlgorithm::SHA256,
1734
key: 'my_secret_key',
35+
minimumExecutionDuration: false,
1836
));
1937

2038
$data = 'important data';
@@ -25,9 +43,10 @@ public function test_good_signature(): void
2543

2644
public function test_bad_signature(): void
2745
{
28-
$signer = new GenericSigner(new SigningConfig(
46+
$signer = $this->createSigner(new SigningConfig(
2947
algorithm: SigningAlgorithm::SHA256,
3048
key: 'my_secret_key',
49+
minimumExecutionDuration: false,
3150
));
3251

3352
$data = 'important data';
@@ -41,14 +60,16 @@ public function test_bad_signature(): void
4160

4261
public function test_different_algoritms(): void
4362
{
44-
$signer1 = new GenericSigner(new SigningConfig(
63+
$signer1 = $this->createSigner(new SigningConfig(
4564
algorithm: SigningAlgorithm::SHA256,
4665
key: 'my_secret_key',
66+
minimumExecutionDuration: false,
4767
));
4868

49-
$signer2 = new GenericSigner(new SigningConfig(
69+
$signer2 = $this->createSigner(new SigningConfig(
5070
algorithm: SigningAlgorithm::SHA512,
5171
key: 'my_secret_key',
72+
minimumExecutionDuration: false,
5273
));
5374

5475
$data = 'important data';
@@ -71,19 +92,21 @@ public function test_no_signing_key(): void
7192
{
7293
$this->expectException(SigningKeyWasMissing::class);
7394

74-
$signer = new GenericSigner(new SigningConfig(
95+
$signer = $this->createSigner(new SigningConfig(
7596
algorithm: SigningAlgorithm::SHA256,
7697
key: '',
98+
minimumExecutionDuration: false,
7799
));
78100

79101
$signer->sign('important data');
80102
}
81103

82104
public function test_empty_data(): void
83105
{
84-
$signer = new GenericSigner(new SigningConfig(
106+
$signer = $this->createSigner(new SigningConfig(
85107
algorithm: SigningAlgorithm::SHA256,
86108
key: 'my_secret_key',
109+
minimumExecutionDuration: false,
87110
));
88111

89112
$signature = $signer->sign('');
@@ -94,9 +117,10 @@ public function test_empty_data(): void
94117

95118
public function test_consistent_signature(): void
96119
{
97-
$signer = new GenericSigner(new SigningConfig(
120+
$signer = $this->createSigner(new SigningConfig(
98121
algorithm: SigningAlgorithm::SHA256,
99122
key: 'my_secret_key',
123+
minimumExecutionDuration: false,
100124
));
101125

102126
$data = 'important data';
@@ -109,14 +133,16 @@ public function test_consistent_signature(): void
109133

110134
public function test_different_keys(): void
111135
{
112-
$signer1 = new GenericSigner(new SigningConfig(
136+
$signer1 = $this->createSigner(new SigningConfig(
113137
algorithm: SigningAlgorithm::SHA256,
114138
key: 'signer1_key_foo',
139+
minimumExecutionDuration: false,
115140
));
116141

117-
$signer2 = new GenericSigner(new SigningConfig(
142+
$signer2 = $this->createSigner(new SigningConfig(
118143
algorithm: SigningAlgorithm::SHA512,
119144
key: 'signer2_key_bar',
145+
minimumExecutionDuration: false,
120146
));
121147

122148
$data = 'important data';
@@ -134,4 +160,40 @@ public function test_different_keys(): void
134160
$this->assertFalse($signer1->verify($data, $signature2));
135161
$this->assertFalse($signer2->verify($data, $signature1));
136162
}
163+
164+
public function test_time_protection(): void
165+
{
166+
$signer = $this->createSigner(new SigningConfig(
167+
algorithm: SigningAlgorithm::SHA256,
168+
key: 'my_secret_key',
169+
minimumExecutionDuration: Duration::milliseconds(300),
170+
));
171+
172+
$data = 'important data';
173+
$signature = $signer->sign($data);
174+
175+
$start = microtime(true);
176+
$this->assertTrue($signer->verify($data, $signature));
177+
$elapsed = microtime(true) - $start;
178+
179+
$this->assertGreaterThanOrEqual(0.3, $elapsed);
180+
}
181+
182+
public function test_time_protection_with_mock_clock(): void
183+
{
184+
$signer = $this->createSigner(new SigningConfig(
185+
algorithm: SigningAlgorithm::SHA256,
186+
key: 'my_secret_key',
187+
minimumExecutionDuration: Duration::second(),
188+
), $clock = new MockClock());
189+
190+
$data = 'important data';
191+
$signature = $signer->sign($data);
192+
193+
$ms = $clock->timestamp()->getMilliseconds();
194+
$this->assertTrue($signer->verify($data, $signature));
195+
$elapsed = $clock->timestamp()->getMilliseconds() - $ms;
196+
197+
$this->assertSame(1_000, $elapsed);
198+
}
137199
}

packages/cryptography/tests/TimelockTest.php

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,6 @@
1111

1212
final class TimelockTest extends TestCase
1313
{
14-
protected function setUp(): void
15-
{
16-
parent::setUp();
17-
18-
if (! interface_exists(Clock::class)) {
19-
$this->markTestSkipped('The Clock interface is not available. This test requires the `tempest/clock` package.');
20-
}
21-
}
22-
2314
public function test_callback_is_executed(): void
2415
{
2516
$clock = new GenericClock();

tests/Integration/Cryptography/SignerTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function test_signature_valid(): void
2323
$this->container->config(new SigningConfig(
2424
algorithm: SigningAlgorithm::SHA256,
2525
key: 'my_secret_key',
26+
minimumExecutionDuration: false,
2627
));
2728

2829
$data = 'important data';
@@ -36,6 +37,7 @@ public function test_update_key(): void
3637
$this->container->config(new SigningConfig(
3738
algorithm: SigningAlgorithm::SHA256,
3839
key: 'my_secret_key',
40+
minimumExecutionDuration: false,
3941
));
4042

4143
$signature = $this->signer->sign('important data');
@@ -44,6 +46,7 @@ public function test_update_key(): void
4446
$this->container->config(new SigningConfig(
4547
algorithm: SigningAlgorithm::SHA256,
4648
key: 'my_secret_key2',
49+
minimumExecutionDuration: false,
4750
));
4851

4952
$this->container->unregister(Signer::class);

0 commit comments

Comments
 (0)