From d4b15806fa2761f57dc36a4e4233aaf0c5455184 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:03:27 -0500 Subject: [PATCH 01/35] Tighten permissions on HPKE keys --- config/hpke.php | 1 + 1 file changed, 1 insertion(+) diff --git a/config/hpke.php b/config/hpke.php index e673160..2a946f3 100644 --- a/config/hpke.php +++ b/config/hpke.php @@ -49,5 +49,6 @@ 'encaps-key' => Base64UrlSafe::encodeUnpadded($encapsKey->bytes), ], JSON_PRETTY_PRINT)); + chmod(__DIR__ . '/hpke.json', 0600); } return new HPKE($hpke, $decapsKey, $encapsKey); From 5622c8cda4ef10eaecf7a207cab1ca8aaef2f95e Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:07:38 -0500 Subject: [PATCH 02/35] Add "burndown-enabled" to /api/info --- src/Meta/Params.php | 6 ++++++ src/RequestHandlers/Api/Info.php | 1 + tests/Meta/ParamsTest.php | 5 ++++- tests/RequestHandlers/Api/InfoTest.php | 4 +++- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Meta/Params.php b/src/Meta/Params.php index 16b5009..25b6d24 100644 --- a/src/Meta/Params.php +++ b/src/Meta/Params.php @@ -23,6 +23,7 @@ public function __construct( public string $hostname = 'localhost', public string $cacheKey = '', public int $httpCacheTtl = 60, + public bool $serverAllowsBurnDown = true, ) { if (!Tree::isHashFunctionAllowed($this->hashAlgo)) { throw new DependencyException('Disallowed hash algorithm'); @@ -49,6 +50,11 @@ public function getActorUsername(): string return $this->actorUsername; } + public function getBurnDownEnabled(): bool + { + return $this->serverAllowsBurnDown; + } + public function getCacheKey(): string { return $this->cacheKey; diff --git a/src/RequestHandlers/Api/Info.php b/src/RequestHandlers/Api/Info.php index bb00f68..e40eb2f 100644 --- a/src/RequestHandlers/Api/Info.php +++ b/src/RequestHandlers/Api/Info.php @@ -40,6 +40,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface '!pkd-context' => 'fedi-e2ee:v1/api/info', 'current-time' => $this->time(), 'actor' => $actor, + 'burndown-enabled' => $params->getBurnDownEnabled(), 'public-key' => $keys->publicKey->toString(), ]); } diff --git a/tests/Meta/ParamsTest.php b/tests/Meta/ParamsTest.php index adcfcd3..e7e9b4a 100644 --- a/tests/Meta/ParamsTest.php +++ b/tests/Meta/ParamsTest.php @@ -25,6 +25,7 @@ public function testDefaults(): void $this->assertSame('', $params->getCacheKey()); $this->assertSame(60, $params->httpCacheTtl); $this->assertSame(60, $params->getHttpCacheTtl()); + $this->assertSame(true, $params->getBurnDownEnabled()); } public function testInvalidHashAlgo(): void @@ -121,7 +122,8 @@ public function testConstructorExplicit(): void actorUsername: 'alice', hostname: 'example.com', cacheKey: 'test-key', - httpCacheTtl: 100 + httpCacheTtl: 100, + serverAllowsBurnDown: false, ); $this->assertSame('sha512', $params->hashAlgo); $this->assertSame(30, $params->otpMaxLife); @@ -129,5 +131,6 @@ public function testConstructorExplicit(): void $this->assertSame('example.com', $params->hostname); $this->assertSame('test-key', $params->cacheKey); $this->assertSame(100, $params->httpCacheTtl); + $this->assertSame(false, $params->serverAllowsBurnDown); } } diff --git a/tests/RequestHandlers/Api/InfoTest.php b/tests/RequestHandlers/Api/InfoTest.php index 4b255be..e08e464 100644 --- a/tests/RequestHandlers/Api/InfoTest.php +++ b/tests/RequestHandlers/Api/InfoTest.php @@ -47,8 +47,9 @@ public function testHandle(): void $this->assertArrayHasKey('!pkd-context', $decoded); $this->assertArrayHasKey('current-time', $decoded); $this->assertArrayHasKey('actor', $decoded); + $this->assertArrayHasKey('burndown-enabled', $decoded); $this->assertArrayHasKey('public-key', $decoded); - $this->assertCount(4, $decoded, 'Response should have exactly 4 keys'); + $this->assertCount(5, $decoded, 'Response should have exactly 5 keys'); // Verify values $this->assertLessThanOrEqual(time(), (int) $decoded['current-time']); @@ -58,6 +59,7 @@ public function testHandle(): void $params = $config->getParams(); $expectedActor = $params->actorUsername . '@' . $params->hostname; $this->assertSame($expectedActor, $decoded['actor']); + $this->assertSame($params->serverAllowsBurnDown, $decoded['burndown-enabled']); $this->assertNotInTransaction(); } } From 531682244b8887eb3daf40e00a199bcc7c071220 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:09:40 -0500 Subject: [PATCH 03/35] Fix burndown/revoke nits --- src/RequestHandlers/Api/BurnDown.php | 4 ++-- src/RequestHandlers/Api/Revoke.php | 2 +- tests/RequestHandlers/Api/BurnDownTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/RequestHandlers/Api/BurnDown.php b/src/RequestHandlers/Api/BurnDown.php index ca086b0..39a92d0 100644 --- a/src/RequestHandlers/Api/BurnDown.php +++ b/src/RequestHandlers/Api/BurnDown.php @@ -64,7 +64,7 @@ public function __construct() * @throws TableException * @throws InvalidArgumentException */ - #[Route("/api/revoke")] + #[Route("/api/burndown")] #[Override] public function handle(ServerRequestInterface $request): ResponseInterface { @@ -86,7 +86,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface '!pkd-context' => 'fedi-e2ee:v1/api/burndown', 'time' => $this->time(), 'status' => false, - ]); + ], 400); } } } diff --git a/src/RequestHandlers/Api/Revoke.php b/src/RequestHandlers/Api/Revoke.php index a0b7d29..fdc4cba 100644 --- a/src/RequestHandlers/Api/Revoke.php +++ b/src/RequestHandlers/Api/Revoke.php @@ -64,7 +64,7 @@ public function __construct() * @throws SodiumException * @throws TableException */ - #[Route("/api/burndown")] + #[Route("/api/revoke")] #[Override] public function handle(ServerRequestInterface $request): ResponseInterface { diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index ca75e5a..174bd05 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -366,7 +366,7 @@ public function testHandleInvalidSignature(): void // Handle request - should fail signature verification $this->clearOldTransaction($config); $response = $burnDownHandler->handle($request); - $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $body = json_decode($response->getBody()->getContents(), true); $this->assertSame('fedi-e2ee:v1/api/burndown', $body['!pkd-context']); From b8050c20e5083dae7428c4f7f2b7917c7a3ab68b Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:11:01 -0500 Subject: [PATCH 04/35] Loop on decremented variable --- src/RateLimit/DefaultRateLimiting.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RateLimit/DefaultRateLimiting.php b/src/RateLimit/DefaultRateLimiting.php index 3aa23a6..7e0ad5f 100644 --- a/src/RateLimit/DefaultRateLimiting.php +++ b/src/RateLimit/DefaultRateLimiting.php @@ -187,7 +187,7 @@ public function getCooledDown(RateLimitData $data): RateLimitData break; } - } while ($data->failures > 0); + } while ($failures > 0); // Either way, return the updated rate-limit info: return $data->withFailures($failures)->withCooldownStart($start); } From d87c3bc69c241d9a6479dc47395d8496df66d36f Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:13:53 -0500 Subject: [PATCH 05/35] Boyscouting --- src/RequestHandlers/Api/BurnDown.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/RequestHandlers/Api/BurnDown.php b/src/RequestHandlers/Api/BurnDown.php index 39a92d0..560b175 100644 --- a/src/RequestHandlers/Api/BurnDown.php +++ b/src/RequestHandlers/Api/BurnDown.php @@ -3,6 +3,7 @@ namespace FediE2EE\PKDServer\RequestHandlers\Api; use FediE2EE\PKD\Crypto\Exceptions\{ + BundleException, CryptoException, HttpSignatureException, JsonException, @@ -12,11 +13,13 @@ use FediE2EE\PKDServer\Exceptions\{ ActivityPubException, CacheException, + ConcurrentException, DependencyException, FetchException, ProtocolException, TableException }; +use DateMalformedStringException; use FediE2EE\PKDServer\{ Meta\Route, Protocol @@ -25,10 +28,12 @@ ActivityStreamsTrait, ReqTrait }; +use JsonException as BaseJsonException; use Override; use ParagonIE\Certainty\Exception\CertaintyException; use ParagonIE\HPKE\HPKEException; use Psr\SimpleCache\InvalidArgumentException; +use Random\RandomException; use Psr\Http\Message\{ ResponseInterface, ServerRequestInterface @@ -52,17 +57,22 @@ public function __construct() } /** + * @throws BaseJsonException + * @throws BundleException * @throws CacheException * @throws CertaintyException + * @throws ConcurrentException * @throws CryptoException + * @throws DateMalformedStringException * @throws DependencyException * @throws HPKEException + * @throws InvalidArgumentException * @throws JsonException * @throws NotImplementedException * @throws ParserException + * @throws RandomException * @throws SodiumException * @throws TableException - * @throws InvalidArgumentException */ #[Route("/api/burndown")] #[Override] @@ -70,6 +80,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface { try { $as = $this->getVerifiedStream($request); + // We set $isActivityPub to false here because this payload is sent over HTTP. + // This is important because BurnDown MUST NOT be sent over ActivityPub. /** @var array{action: string, result: bool, latest-root: string} $result */ $result = $this->protocol->process($as, false); return $this->json([ From a1cf9d9ef6a4066c97812aae7a3c92a0e103e840 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:27:44 -0500 Subject: [PATCH 06/35] Fix actor/hostname handling --- public/index.php | 3 +++ src/Traits/NetworkTrait.php | 4 +--- tests/Traits/NetworkTraitTest.php | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/public/index.php b/public/index.php index 63e76d9..3dcdd10 100644 --- a/public/index.php +++ b/public/index.php @@ -29,6 +29,9 @@ if ($ex instanceof RateLimitException) { // Rate-limited by the Middleware http_response_code(420); + if (!is_null($ex->rateLimitedUntil)) { + header('Retry-After: ' . $ex->rateLimitedUntil->format(DateTimeInterface::ATOM)); + } echo $ex->getMessage(), PHP_EOL; if (!is_null($ex->rateLimitedUntil)) { echo 'Try again after: ', diff --git a/src/Traits/NetworkTrait.php b/src/Traits/NetworkTrait.php index f8e8810..c6c10b2 100644 --- a/src/Traits/NetworkTrait.php +++ b/src/Traits/NetworkTrait.php @@ -169,9 +169,7 @@ public function getRequestActor(ServerRequestInterface $request): ?string if (!is_string($actorID)) { return null; } - - $parsed = parse_url($actorID); - return $parsed['host'] ?? null; + return $actorID; } public function getRequestDomain(ServerRequestInterface $request): ?string diff --git a/tests/Traits/NetworkTraitTest.php b/tests/Traits/NetworkTraitTest.php index fbec8a0..79a1a32 100644 --- a/tests/Traits/NetworkTraitTest.php +++ b/tests/Traits/NetworkTraitTest.php @@ -3,6 +3,7 @@ namespace FediE2EE\PKDServer\Tests\Traits; use FediE2EE\PKDServer\Traits\NetworkTrait; +use GuzzleHttp\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -121,4 +122,23 @@ public function testIpv6Mask(string $input, int $maskBits, string $expected): vo $dummy = $this->getDummyClass(); $this->assertSame($expected, $dummy->ipv6Mask($input, $maskBits)); } + public function testGetRequestActor(): void + { + $dummy = $this->getDummyClass(); + $json = json_encode(['actor' => 'https://example.com/users/alice']); + $this->assertSame( + 'https://example.com/users/alice', + $dummy->getRequestActor(new ServerRequest('GET', '/', [], $json, '1.1')) + ); + } + + public function testGetRequestDomain(): void + { + $dummy = $this->getDummyClass(); + $json = json_encode(['actor' => 'https://example.com/users/alice']); + $this->assertSame( + 'example.com', + $dummy->getRequestDomain(new ServerRequest('GET', '/', [], $json, '1.1')) + ); + } } From 7ab1aed6cfe8bcdc3fad74f333baf0c1fa4069f5 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:27:54 -0500 Subject: [PATCH 07/35] TOTP is required for BurnDown --- src/Tables/PublicKeys.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tables/PublicKeys.php b/src/Tables/PublicKeys.php index db1dbc4..a043204 100644 --- a/src/Tables/PublicKeys.php +++ b/src/Tables/PublicKeys.php @@ -386,7 +386,7 @@ protected function verifyBurnDownTotp( } $totp = $totpTable->getTotpByDomain($domain); if (!$totp) { - return; + throw new ProtocolException('No TOTP secret enrolled for this domain'); } $ts = $this->verifyTOTP($totp['secret'], $otp); if (is_null($ts)) { From 2e3fbe2a22e2ce13d8ba0bd8018aa35a4121db83 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:31:56 -0500 Subject: [PATCH 08/35] Don't interpolate SQL strings TIL you can parameterize OFFSET and LIMIT and it... just works. --- src/Tables/MerkleState.php | 6 ++++-- src/Tables/ReplicaHistory.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 8efcdc7..fa20af5 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -405,8 +405,10 @@ public function getHashesSince(string $oldRoot, int $limit, int $offset = 0): ar "SELECT publickeyhash, contents, contenthash, wrappedkeys, root, created, signature FROM pkd_merkle_leaves WHERE merkleleafid > ? - LIMIT {$limit} OFFSET {$offset}", - $oldRootID + LIMIT ? OFFSET ?", + $oldRootID, + $limit, + $offset ); $return = []; $keyWrapping = new KeyWrapping($this->config()); diff --git a/src/Tables/ReplicaHistory.php b/src/Tables/ReplicaHistory.php index 7a408fe..98b391d 100644 --- a/src/Tables/ReplicaHistory.php +++ b/src/Tables/ReplicaHistory.php @@ -75,8 +75,10 @@ public function getHistory(int $peerID, int $limit = 100, int $offset = 0): arra FROM pkd_replica_history WHERE peer = ? ORDER BY replicahistoryid DESC - LIMIT {$limit} OFFSET {$offset}", - $peerID + LIMIT ? OFFSET ?", + $peerID, + $limit, + $offset ); return $this->formatHistory($results); } From 3c3f4c232fa8e0cd1a9104752ca88fad24c82929 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:35:53 -0500 Subject: [PATCH 09/35] Enforce BurnDown --- src/RequestHandlers/Api/BurnDown.php | 3 +++ tests/RequestHandlers/Api/BurnDownTest.php | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/RequestHandlers/Api/BurnDown.php b/src/RequestHandlers/Api/BurnDown.php index 560b175..f6cbbb5 100644 --- a/src/RequestHandlers/Api/BurnDown.php +++ b/src/RequestHandlers/Api/BurnDown.php @@ -78,6 +78,9 @@ public function __construct() #[Override] public function handle(ServerRequestInterface $request): ResponseInterface { + if (!$this->config()->getParams()->getBurnDownEnabled()) { + return $this->error('BurnDown is not enabled'); + } try { $as = $this->getVerifiedStream($request); // We set $isActivityPub to false here because this payload is sent over HTTP. diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index 174bd05..719188e 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -288,14 +288,14 @@ public function testHandle(): void // 8. Handle request and verify response $response = $burnDownHandler->handle($request); - $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(400, $response->getStatusCode()); $body = json_decode($response->getBody()->getContents(), true); $this->assertSame('fedi-e2ee:v1/api/burndown', $body['!pkd-context']); // Verify time field is present and is a string $this->assertArrayHasKey('time', $body); $this->assertIsString($body['time']); - $this->assertTrue($body['status']); + $this->assertFalse($body['status']); // 9. Verify actor's keys were burned $this->assertCount(0, $pkTable->getPublicKeysFor($canonActor)); From f86498a3642c6596c2a2ba159c644940de0ae0b7 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:38:23 -0500 Subject: [PATCH 10/35] Cap penalty to 2^60 times the base penalty --- src/RateLimit/DefaultRateLimiting.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RateLimit/DefaultRateLimiting.php b/src/RateLimit/DefaultRateLimiting.php index 7e0ad5f..f01ce67 100644 --- a/src/RateLimit/DefaultRateLimiting.php +++ b/src/RateLimit/DefaultRateLimiting.php @@ -251,7 +251,8 @@ public function getIntervalFromFailureCount(int $failures): DateInterval if ($failures < 1) { return new DateInterval('PT0S'); } - $milliseconds = $this->baseDelay << ($failures - 1); + $shift = min($failures - 1, 60); + $milliseconds = $this->baseDelay << $shift; $seconds = (int) floor($milliseconds / 1000); $us = ($milliseconds % 1000) * 1000; $interval = DateInterval::createFromDateString($seconds . ' seconds + ' . $us . ' microseconds'); From b92ef972827846b86ffef283ed551a03c765effe Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:48:19 -0500 Subject: [PATCH 11/35] Prevent locking race conditions --- src/Tables/MerkleState.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index fa20af5..1f3c46a 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -461,8 +461,9 @@ protected function insertLeafInternal( $storedChallenge = $row['lock_challenge'] ?? null; break; case "sqlite": - $this->db->beginTransaction(); + $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); $this->db->exec("PRAGMA busy_timeout=5000"); + $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 5); $row = self::assertArray($this->db->row( "SELECT merkle_state, lock_challenge FROM pkd_merkle_state WHERE 1" From 7b46587cf9e6cf138d52379e716fb95bdbd5cc3f Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:48:31 -0500 Subject: [PATCH 12/35] Throw on error --- src/Protocol/Payload.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Protocol/Payload.php b/src/Protocol/Payload.php index 9a8c0d7..f3a71f5 100644 --- a/src/Protocol/Payload.php +++ b/src/Protocol/Payload.php @@ -9,7 +9,7 @@ ProtocolMessageInterface }; use FediE2EE\PKDServer\Traits\JsonTrait; -use function array_key_exists, json_decode; +use function array_key_exists; readonly class Payload { @@ -42,7 +42,7 @@ public function decode(): array */ public function getMerkleTreePayload(): string { - $decoded = json_decode($this->rawJson, true); + $decoded = self::jsonDecode($this->rawJson); if (array_key_exists('key-id', $decoded)) { unset($decoded['key-id']); } From 014d99a1eac7b0f541736a96ccbb1f38c5db2742 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 17:58:50 -0500 Subject: [PATCH 13/35] Fix BurnDown tests --- tests/ActivityPub/WebFingerTest.php | 1 - tests/Integration/VectorsTest.php | 25 ++++++++++++++++--- tests/ProtocolTest.php | 29 ++++++++++++++++------ tests/RequestHandlers/Api/BurnDownTest.php | 13 +++++++--- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/tests/ActivityPub/WebFingerTest.php b/tests/ActivityPub/WebFingerTest.php index c6fe79a..8d86946 100644 --- a/tests/ActivityPub/WebFingerTest.php +++ b/tests/ActivityPub/WebFingerTest.php @@ -170,7 +170,6 @@ public function testExceptionCodes(): void try { $method = new ReflectionMethod(WebFinger::class, 'getPublicKeyFromActivityPub'); - $method->setAccessible(true); $method->invoke($webFinger, 'https://example.com/users/alice'); $this->fail('Expected FetchException was not thrown'); } catch (FetchException $e) { diff --git a/tests/Integration/VectorsTest.php b/tests/Integration/VectorsTest.php index 68279f6..85cd4f5 100644 --- a/tests/Integration/VectorsTest.php +++ b/tests/Integration/VectorsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace FediE2EE\PKDServer\Tests\Integration; +use DateMalformedStringException; use FediE2EE\PKD\Crypto\ActivityPub\WebFinger as PKDCryptoWebFinger; use FediE2EE\PKD\Crypto\AttributeEncryption\AttributeKeyMap; use FediE2EE\PKD\Crypto\Exceptions\{ @@ -22,6 +23,8 @@ }; use FediE2EE\PKD\Crypto\Protocol\Handler; use GuzzleHttp\Exception\GuzzleException; +use ParagonIE\CipherSweet\Exception\CipherSweetException; +use Random\RandomException; use FediE2EE\PKD\Crypto\{ SecretKey, SymmetricKey @@ -42,10 +45,10 @@ }; use FediE2EE\PKDServer\Exceptions\{ CacheException, + ConcurrentException, DependencyException, ProtocolException, - TableException -}; + TableException}; use FediE2EE\PKDServer\Tables\{ Actors, AuxData, @@ -61,7 +64,10 @@ Peer }; use FediE2EE\PKDServer\Tests\HttpTestTrait; -use FediE2EE\PKDServer\Traits\ConfigTrait; +use FediE2EE\PKDServer\Traits\{ + ConfigTrait, + TOTPTrait +}; use GuzzleHttp\Psr7\Response; use JsonException; use ParagonIE\Certainty\Exception\CertaintyException; @@ -106,6 +112,7 @@ class VectorsTest extends TestCase { use ConfigTrait; use HttpTestTrait; + use TOTPTrait; private const string TEST_VECTORS_PATH = PKD_SERVER_ROOT . '/tests/TestVectors/test-vectors.json'; @@ -543,8 +550,11 @@ private function executeUndoFireproof( /** * @throws BundleException * @throws CacheException + * @throws CipherSweetException + * @throws ConcurrentException * @throws CryptoException * @throws CryptoJsonException + * @throws DateMalformedStringException * @throws DependencyException * @throws GuzzleException * @throws HPKEException @@ -552,6 +562,7 @@ private function executeUndoFireproof( * @throws NetworkException * @throws NotImplementedException * @throws ProtocolException + * @throws RandomException * @throws SodiumException * @throws TableException */ @@ -565,7 +576,13 @@ private function executeBurnDown( $operator = $this->extractOperatorFromDescription($step); $operatorKey = $this->identityKeys[$operator]; - $otp = '00000000'; + $actorDomain = parse_url($targetActor, PHP_URL_HOST); + $totpSecret = random_bytes(20); + /** @var TOTP $totpTable */ + $totpTable = $this->table('TOTP'); + $totpTable->saveSecret($actorDomain, $totpSecret); + $otp = self::generateTOTP($totpSecret); + $burnDown = new BurnDownAction($targetActor, $operator, null, $otp); $akm = (new AttributeKeyMap()) ->addKey('actor', SymmetricKey::generate()) diff --git a/tests/ProtocolTest.php b/tests/ProtocolTest.php index 1fdb6e1..20ad864 100644 --- a/tests/ProtocolTest.php +++ b/tests/ProtocolTest.php @@ -23,7 +23,6 @@ Actions\MoveIdentity, Actions\RevokeAuxData, Actions\RevokeKey, - Actions\RevokeKeyThirdParty, Actions\UndoFireproof, Handler }; @@ -37,7 +36,10 @@ SecretKey, SymmetricKey }; -use FediE2EE\PKDServer\Traits\ConfigTrait; +use FediE2EE\PKDServer\Traits\{ + ConfigTrait, + TOTPTrait +}; use FediE2EE\PKDServer\Exceptions\{ CacheException, ConcurrentException, @@ -117,6 +119,7 @@ class ProtocolTest extends TestCase { use ConfigTrait; use HttpTestTrait; + use TOTPTrait; protected Protocol $protocol; @@ -479,14 +482,19 @@ public function testBurnDown(): void $pkTable = $this->table('PublicKeys'); $this->assertCount(1, $pkTable->getPublicKeysFor($canonActor)); + $totpSecret = random_bytes(20); + /** @var TOTP $totpTable */ + $totpTable = $this->table('TOTP'); + $totpTable->saveSecret('example.com', $totpSecret); + $otp = self::generateTOTP($totpSecret); + // 3. BurnDown (plaintext - not HPKE encrypted, but with attribute encryption) $latestRoot3 = $merkleState->getLatestRoot(); - $burnDown = new BurnDown($canonActor, $canonOperator); + $burnDown = new BurnDown($canonActor, $canonOperator, null, $otp); $akm3 = new AttributeKeyMap() ->addKey('actor', SymmetricKey::generate()) ->addKey('operator', SymmetricKey::generate()); - $encryptedMsg3 = $burnDown->encrypt($akm3); - $bundle3 = $handler->handle($encryptedMsg3, $operatorKey, $akm3, $latestRoot3, $operatorKeyId); + $bundle3 = $handler->handle($burnDown, $operatorKey, $akm3, $latestRoot3); $this->assertNotInTransaction(); $this->assertTrue($this->protocol->burnDown($bundle3->toString(), $canonOperator)); $this->ensureMerkleStateUnlocked(); @@ -717,14 +725,19 @@ public function testUndoFireproof(): void $this->assertKeyRewrapped($latestRoot6, 'Key should be rewrapped after undoFireproof'); $this->ensureMerkleStateUnlocked(); + $totpSecret = random_bytes(20); + /** @var TOTP $totpTable */ + $totpTable = $this->table('TOTP'); + $totpTable->saveSecret('example.com', $totpSecret); + $otp = self::generateTOTP($totpSecret); + // 5. BurnDown (should succeed - plaintext with attribute encryption) $latestRoot7 = $merkleState->getLatestRoot(); - $burnDown = new BurnDown($canonicalActor, $canonicalOperator); + $burnDown = new BurnDown($canonicalActor, $canonicalOperator, null, $otp); $akm5 = new AttributeKeyMap() ->addKey('actor', SymmetricKey::generate()) ->addKey('operator', SymmetricKey::generate()); - $encryptedMsg5 = $burnDown->encrypt($akm5); - $bundle5 = $handler->handle($encryptedMsg5, $operatorKey, $akm5, $latestRoot7, $operatorKeyId); + $bundle5 = $handler->handle($burnDown, $operatorKey, $akm5, $latestRoot7); $this->assertNotInTransaction(); $this->assertTrue( $this->protocol->burnDown($bundle5->toString(), $canonicalOperator) diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index 719188e..190dde3 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -66,6 +66,7 @@ Peer }; use FediE2EE\PKDServer\Tests\HttpTestTrait; +use FediE2EE\PKDServer\Traits\TOTPTrait; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\{ Response, @@ -120,6 +121,7 @@ class BurnDownTest extends TestCase { use ConfigTrait; use HttpTestTrait; + use TOTPTrait; /** * @throws ArrayKeyException @@ -219,9 +221,14 @@ public function testHandle(): void ])) ); + $totpSecret = random_bytes(20); + /** @var TOTP $totpTable */ + $totpTable = $this->table('TOTP'); + $totpTable->saveSecret('example.com', $totpSecret); + $latestRoot3 = $merkleState->getLatestRoot(); // Note: BurnDownAction canonicalizes actor but NOT operator, so operator must be canonical URL - $otp = '12345678'; + $otp = self::generateTOTP($totpSecret); $now = (new DateTimeImmutable('NOW')); $burnDown = new BurnDownAction($actorHandle, $canonOperator, $now, $otp); $akm3 = (new AttributeKeyMap()) @@ -288,14 +295,14 @@ public function testHandle(): void // 8. Handle request and verify response $response = $burnDownHandler->handle($request); - $this->assertSame(400, $response->getStatusCode()); + $this->assertSame(200, $response->getStatusCode()); $body = json_decode($response->getBody()->getContents(), true); $this->assertSame('fedi-e2ee:v1/api/burndown', $body['!pkd-context']); // Verify time field is present and is a string $this->assertArrayHasKey('time', $body); $this->assertIsString($body['time']); - $this->assertFalse($body['status']); + $this->assertTrue($body['status']); // 9. Verify actor's keys were burned $this->assertCount(0, $pkTable->getPublicKeysFor($canonActor)); From ffecbff515990d21df7dd5fe226953401c9eb154 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:01:22 -0500 Subject: [PATCH 14/35] Specification compliance nit Now the correct array indices are used --- src/RequestHandlers/Api/HistoryCosign.php | 4 ++-- tests/RequestHandlers/Api/HistoryCosignTest.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/RequestHandlers/Api/HistoryCosign.php b/src/RequestHandlers/Api/HistoryCosign.php index 6bbfba0..dabf087 100644 --- a/src/RequestHandlers/Api/HistoryCosign.php +++ b/src/RequestHandlers/Api/HistoryCosign.php @@ -88,8 +88,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface ); return $this->json([ '!pkd-context' => 'fedi-e2ee:v1/api/history/cosign', - 'status' => $status, - 'current-time' => $this->time(), + 'success' => $status, + 'time' => $this->time(), ]); } catch (Throwable $ex) { $this->config()->getLogger()->error( diff --git a/tests/RequestHandlers/Api/HistoryCosignTest.php b/tests/RequestHandlers/Api/HistoryCosignTest.php index 847e9e7..cb13b77 100644 --- a/tests/RequestHandlers/Api/HistoryCosignTest.php +++ b/tests/RequestHandlers/Api/HistoryCosignTest.php @@ -313,10 +313,10 @@ public function testSuccessfulCosign(): void $this->assertSame(200, $response->getStatusCode()); $body = json_decode($response->getBody()->getContents(), true); $this->assertSame('fedi-e2ee:v1/api/history/cosign', $body['!pkd-context']); - $this->assertArrayHasKey('status', $body); - $this->assertTrue($body['status']); - $this->assertArrayHasKey('current-time', $body); - $this->assertIsString($body['current-time']); + $this->assertArrayHasKey('success', $body); + $this->assertTrue($body['success']); + $this->assertArrayHasKey('time', $body); + $this->assertIsString($body['time']); $this->assertNotInTransaction(); } } From 2431db121199f9953b55cdf652a263bbec13a237 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:05:35 -0500 Subject: [PATCH 15/35] Use hash_equals() here It isn't a security impact, but might as well be consistent --- src/Tables/MerkleState.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 1f3c46a..53f785e 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -125,10 +125,10 @@ public function addWitnessCosignature(string $origin, string $merkleRoot, string // Validate hostname: $hostname = $this->config()->getParams()->hostname; - if ($tmp['hostname'] !== $hostname) { + if (!hash_equals($tmp['hostname'], $hostname)) { // If hostname is formatted as a URL, just grab the hostname: $parsedHost = self::parseUrlHost($tmp['hostname']); - if ($parsedHost !== $hostname) { + if (!hash_equals($parsedHost, $hostname)) { // Both mismatched? Bail out. throw new ProtocolException('Hostname mismatch'); } From fa0b86905ccddff359de50629326386b7b0f9bd5 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:05:59 -0500 Subject: [PATCH 16/35] Remove dead constant --- src/Traits/ProtocolMethodTrait.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Traits/ProtocolMethodTrait.php b/src/Traits/ProtocolMethodTrait.php index 9f9d643..81ca8d3 100644 --- a/src/Traits/ProtocolMethodTrait.php +++ b/src/Traits/ProtocolMethodTrait.php @@ -39,7 +39,6 @@ trait ProtocolMethodTrait { protected const int ENCRYPTION_REQUIRED = 1; protected const int ENCRYPTION_DISALLOWED = 2; - protected const int ENCRYPTION_OPTIONAL = 3; /** * @throws ConcurrentException From 3395618967d15fd22aa7b61e4c54f6dc2c214ad2 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:06:59 -0500 Subject: [PATCH 17/35] Rate-limit filesystem backend: use exclusive locks --- src/RateLimit/Storage/Filesystem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RateLimit/Storage/Filesystem.php b/src/RateLimit/Storage/Filesystem.php index 9f36e28..7f423fa 100644 --- a/src/RateLimit/Storage/Filesystem.php +++ b/src/RateLimit/Storage/Filesystem.php @@ -93,7 +93,7 @@ public function set(string $type, string $identifier, RateLimitData $data): bool 'expires' => time() + $this->ttl, 'data' => self::jsonEncode($data), ]); - return file_put_contents($file, $bundled) !== false; + return file_put_contents($file, $bundled, LOCK_EX) !== false; } /** From 5cf21c4783abeca175ec7331abf1f9074ba5efd0 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:08:48 -0500 Subject: [PATCH 18/35] Throw on NULL hostnames --- src/Tables/MerkleState.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 53f785e..be41354 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -128,6 +128,9 @@ public function addWitnessCosignature(string $origin, string $merkleRoot, string if (!hash_equals($tmp['hostname'], $hostname)) { // If hostname is formatted as a URL, just grab the hostname: $parsedHost = self::parseUrlHost($tmp['hostname']); + if (!is_string($parsedHost)) { + throw new ProtocolException('Invalid hostname'); + } if (!hash_equals($parsedHost, $hostname)) { // Both mismatched? Bail out. throw new ProtocolException('Hostname mismatch'); From c8ba2ccba78ed50b5e2929f800e9470ada7482be Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:09:52 -0500 Subject: [PATCH 19/35] Update test --- tests/Integration/CosignLifecycleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/CosignLifecycleTest.php b/tests/Integration/CosignLifecycleTest.php index 72e3771..5300081 100644 --- a/tests/Integration/CosignLifecycleTest.php +++ b/tests/Integration/CosignLifecycleTest.php @@ -238,7 +238,7 @@ public function testCosignLifecycle(): void $response = $cosignHandler->handle($request); $body = json_decode($response->getBody()->getContents(), true); $this->assertSame(200, $response->getStatusCode()); - $this->assertTrue($body['status']); + $this->assertTrue($body['success']); $countAgain = $merkleState->countCosignatures($leaf->primaryKey); $this->assertNotSame($numCosigs, $countAgain, 'Number of cosignatures did not increase'); $tree = $cosign->getTree(); From 8c32d87757d2f4ce33dbc1de1b27871498362d13 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:11:20 -0500 Subject: [PATCH 20/35] Regenerate Reference Docs --- docs/reference/classes/meta.md | 22 ++++++++++++------- docs/reference/classes/ratelimit.md | 10 ++++----- docs/reference/classes/requesthandlers-api.md | 8 +++---- docs/reference/classes/tables.md | 20 ++++++++--------- docs/reference/classes/traits.md | 4 ++-- docs/reference/routes.md | 4 ++-- 6 files changed, 37 insertions(+), 31 deletions(-) diff --git a/docs/reference/classes/meta.md b/docs/reference/classes/meta.md index a873fff..947973b 100644 --- a/docs/reference/classes/meta.md +++ b/docs/reference/classes/meta.md @@ -28,10 +28,11 @@ Server configuration parameters | `$hostname` | `string` | (readonly) | | `$cacheKey` | `string` | (readonly) | | `$httpCacheTtl` | `int` | (readonly) | +| `$serverAllowsBurnDown` | `bool` | (readonly) | ### Methods -#### [`__construct`](../../../src/Meta/Params.php#L19-L45) +#### [`__construct`](../../../src/Meta/Params.php#L19-L46) Returns `void` @@ -45,34 +46,39 @@ These parameters MUST be public and MUST have a default value - `$hostname`: `string` = 'localhost' - `$cacheKey`: `string` = '' - `$httpCacheTtl`: `int` = 60 +- `$serverAllowsBurnDown`: `bool` = true **Throws:** `DependencyException` -#### [`getActorUsername`](../../../src/Meta/Params.php#L47-L50) +#### [`getActorUsername`](../../../src/Meta/Params.php#L48-L51) Returns `string` -#### [`getCacheKey`](../../../src/Meta/Params.php#L52-L55) +#### [`getBurnDownEnabled`](../../../src/Meta/Params.php#L53-L56) + +Returns `bool` + +#### [`getCacheKey`](../../../src/Meta/Params.php#L58-L61) Returns `string` -#### [`getHashFunction`](../../../src/Meta/Params.php#L57-L60) +#### [`getHashFunction`](../../../src/Meta/Params.php#L63-L66) Returns `string` -#### [`getHostname`](../../../src/Meta/Params.php#L62-L65) +#### [`getHostname`](../../../src/Meta/Params.php#L68-L71) Returns `string` -#### [`getHttpCacheTtl`](../../../src/Meta/Params.php#L67-L70) +#### [`getHttpCacheTtl`](../../../src/Meta/Params.php#L73-L76) Returns `int` -#### [`getOtpMaxLife`](../../../src/Meta/Params.php#L72-L75) +#### [`getOtpMaxLife`](../../../src/Meta/Params.php#L78-L81) Returns `int` -#### [`getEmptyTreeRoot`](../../../src/Meta/Params.php#L77-L80) +#### [`getEmptyTreeRoot`](../../../src/Meta/Params.php#L83-L86) Returns `string` diff --git a/docs/reference/classes/ratelimit.md b/docs/reference/classes/ratelimit.md index 1b2d738..61615ae 100644 --- a/docs/reference/classes/ratelimit.md +++ b/docs/reference/classes/ratelimit.md @@ -142,7 +142,7 @@ Returns `?DateTimeImmutable` **Throws:** `DateMalformedIntervalStringException` -#### [`getIntervalFromFailureCount`](../../../src/RateLimit/DefaultRateLimiting.php#L249-L262) +#### [`getIntervalFromFailureCount`](../../../src/RateLimit/DefaultRateLimiting.php#L249-L263) Returns `DateInterval` @@ -152,7 +152,7 @@ Returns `DateInterval` **Throws:** `DateMalformedIntervalStringException` -#### [`recordPenalty`](../../../src/RateLimit/DefaultRateLimiting.php#L268-L277) +#### [`recordPenalty`](../../../src/RateLimit/DefaultRateLimiting.php#L269-L278) Returns `void` @@ -165,7 +165,7 @@ Returns `void` **Throws:** `DateMalformedIntervalStringException` -#### [`increaseFailures`](../../../src/RateLimit/DefaultRateLimiting.php#L282-L296) +#### [`increaseFailures`](../../../src/RateLimit/DefaultRateLimiting.php#L283-L297) Returns `FediE2EE\PKDServer\RateLimit\RateLimitData` @@ -235,7 +235,7 @@ Returns `string` - `$array`: `array` -#### [`getRequestActor`](../../../src/RateLimit/DefaultRateLimiting.php#L155-L175) +#### [`getRequestActor`](../../../src/RateLimit/DefaultRateLimiting.php#L155-L173) Returns `?string` @@ -243,7 +243,7 @@ Returns `?string` - `$request`: `Psr\Http\Message\ServerRequestInterface` -#### [`getRequestDomain`](../../../src/RateLimit/DefaultRateLimiting.php#L177-L185) +#### [`getRequestDomain`](../../../src/RateLimit/DefaultRateLimiting.php#L175-L183) Returns `?string` diff --git a/docs/reference/classes/requesthandlers-api.md b/docs/reference/classes/requesthandlers-api.md index 6fbfbaa..6e4efa3 100644 --- a/docs/reference/classes/requesthandlers-api.md +++ b/docs/reference/classes/requesthandlers-api.md @@ -320,13 +320,13 @@ static · Returns `string` ### Methods -#### [`__construct`](../../../src/RequestHandlers/Api/BurnDown.php#L49-L52) +#### [`__construct`](../../../src/RequestHandlers/Api/BurnDown.php#L54-L57) Returns `void` **Throws:** `DependencyException` -#### [`handle`](../../../src/RequestHandlers/Api/BurnDown.php#L69-L91) +#### [`handle`](../../../src/RequestHandlers/Api/BurnDown.php#L79-L106) Returns `Psr\Http\Message\ResponseInterface` @@ -336,7 +336,7 @@ Returns `Psr\Http\Message\ResponseInterface` - `$request`: `Psr\Http\Message\ServerRequestInterface` -**Throws:** `CacheException`, `CertaintyException`, `CryptoException`, `DependencyException`, `HPKEException`, `JsonException`, `NotImplementedException`, `ParserException`, `SodiumException`, `TableException`, `InvalidArgumentException` +**Throws:** `BaseJsonException`, `BundleException`, `CacheException`, `CertaintyException`, `ConcurrentException`, `CryptoException`, `DateMalformedStringException`, `DependencyException`, `HPKEException`, `InvalidArgumentException`, `JsonException`, `NotImplementedException`, `ParserException`, `RandomException`, `SodiumException`, `TableException` #### [`getVerifiedStream`](../../../src/RequestHandlers/Api/BurnDown.php#L39-L62) @@ -2707,7 +2707,7 @@ static · Returns `string` ### Methods -#### [`handle`](../../../src/RequestHandlers/Api/Info.php#L34-L45) +#### [`handle`](../../../src/RequestHandlers/Api/Info.php#L34-L46) Returns `Psr\Http\Message\ResponseInterface` diff --git a/docs/reference/classes/tables.md b/docs/reference/classes/tables.md index d5180c2..bcbaa13 100644 --- a/docs/reference/classes/tables.md +++ b/docs/reference/classes/tables.md @@ -342,7 +342,7 @@ Return the witness data (including public key) for a given origin **Throws:** `TableException` -#### [`addWitnessCosignature`](../../../src/Tables/MerkleState.php#L112-L157) +#### [`addWitnessCosignature`](../../../src/Tables/MerkleState.php#L112-L160) **API** · Returns `bool` @@ -354,7 +354,7 @@ Return the witness data (including public key) for a given origin **Throws:** `CryptoException`, `DependencyException`, `JsonException`, `NotImplementedException`, `ProtocolException`, `SodiumException`, `TableException` -#### [`getCosignatures`](../../../src/Tables/MerkleState.php#L162-L180) +#### [`getCosignatures`](../../../src/Tables/MerkleState.php#L165-L183) Returns `array` @@ -362,7 +362,7 @@ Returns `array` - `$leafId`: `int` -#### [`countCosignatures`](../../../src/Tables/MerkleState.php#L182-L192) +#### [`countCosignatures`](../../../src/Tables/MerkleState.php#L185-L195) Returns `int` @@ -370,13 +370,13 @@ Returns `int` - `$leafId`: `int` -#### [`getLatestRoot`](../../../src/Tables/MerkleState.php#L200-L209) +#### [`getLatestRoot`](../../../src/Tables/MerkleState.php#L203-L212) **API** · Returns `string` **Throws:** `DependencyException`, `SodiumException` -#### [`insertLeaf`](../../../src/Tables/MerkleState.php#L228-L292) +#### [`insertLeaf`](../../../src/Tables/MerkleState.php#L231-L295) **API** · Returns `bool` @@ -390,7 +390,7 @@ Insert leaf with retry logic for deadlocks **Throws:** `ConcurrentException`, `CryptoException`, `DependencyException`, `NotImplementedException`, `RandomException`, `SodiumException` -#### [`getLeafByRoot`](../../../src/Tables/MerkleState.php#L311-L327) +#### [`getLeafByRoot`](../../../src/Tables/MerkleState.php#L314-L330) **API** · Returns `?FediE2EE\PKDServer\Tables\Records\MerkleLeaf` @@ -398,7 +398,7 @@ Insert leaf with retry logic for deadlocks - `$root`: `string` -#### [`getLeafByID`](../../../src/Tables/MerkleState.php#L332-L348) +#### [`getLeafByID`](../../../src/Tables/MerkleState.php#L335-L351) **API** · Returns `?FediE2EE\PKDServer\Tables\Records\MerkleLeaf` @@ -406,7 +406,7 @@ Insert leaf with retry logic for deadlocks - `$primaryKey`: `int` -#### [`getHashesSince`](../../../src/Tables/MerkleState.php#L388-L430) +#### [`getHashesSince`](../../../src/Tables/MerkleState.php#L391-L435) **API** · Returns `array` @@ -994,7 +994,7 @@ Returns `void` **Throws:** `JsonException`, `TableException` -#### [`getHistory`](../../../src/Tables/ReplicaHistory.php#L71-L82) +#### [`getHistory`](../../../src/Tables/ReplicaHistory.php#L71-L84) Returns `array` @@ -1006,7 +1006,7 @@ Returns `array` **Throws:** `JsonException` -#### [`getHistorySince`](../../../src/Tables/ReplicaHistory.php#L88-L108) +#### [`getHistorySince`](../../../src/Tables/ReplicaHistory.php#L90-L110) Returns `array` diff --git a/docs/reference/classes/traits.md b/docs/reference/classes/traits.md index 0efe5b6..e536cbd 100644 --- a/docs/reference/classes/traits.md +++ b/docs/reference/classes/traits.md @@ -873,7 +873,7 @@ Returns `string` - `$array`: `array` -#### [`getRequestActor`](../../../src/Traits/NetworkTrait.php#L155-L175) +#### [`getRequestActor`](../../../src/Traits/NetworkTrait.php#L155-L173) Returns `?string` @@ -881,7 +881,7 @@ Returns `?string` - `$request`: `Psr\Http\Message\ServerRequestInterface` -#### [`getRequestDomain`](../../../src/Traits/NetworkTrait.php#L177-L185) +#### [`getRequestDomain`](../../../src/Traits/NetworkTrait.php#L175-L183) Returns `?string` diff --git a/docs/reference/routes.md b/docs/reference/routes.md index a63a45a..42e632d 100644 --- a/docs/reference/routes.md +++ b/docs/reference/routes.md @@ -8,7 +8,7 @@ This document lists all API routes defined via `#[Route]` attributes. |---------------|---------------|--------| | `/` | [`RequestHandlers\IndexPage`](../../src/RequestHandlers/IndexPage.php) | `handle` | | `/.well-known/webfinger` | [`RequestHandlers\ActivityPub\Finger`](../../src/RequestHandlers/ActivityPub/Finger.php) | `handle` | -| `/api/burndown` | [`RequestHandlers\Api\Revoke`](../../src/RequestHandlers/Api/Revoke.php) | `handle` | +| `/api/burndown` | [`RequestHandlers\Api\BurnDown`](../../src/RequestHandlers/Api/BurnDown.php) | `handle` | | `/api/checkpoint` | [`RequestHandlers\Api\Checkpoint`](../../src/RequestHandlers/Api/Checkpoint.php) | `handle` | | `/api/extensions` | [`RequestHandlers\Api\Extensions`](../../src/RequestHandlers/Api/Extensions.php) | `handle` | | `/api/history` | [`RequestHandlers\Api\History`](../../src/RequestHandlers/Api/History.php) | `handle` | @@ -24,7 +24,7 @@ This document lists all API routes defined via `#[Route]` attributes. | `/api/replicas/{replica_id}/actor/{actor_id}/keys/key/{key_id}` | [`RequestHandlers\Api\ReplicaInfo`](../../src/RequestHandlers/Api/ReplicaInfo.php) | `actorKey` | | `/api/replicas/{replica_id}/history` | [`RequestHandlers\Api\ReplicaInfo`](../../src/RequestHandlers/Api/ReplicaInfo.php) | `history` | | `/api/replicas/{replica_id}/history/since/{hash}` | [`RequestHandlers\Api\ReplicaInfo`](../../src/RequestHandlers/Api/ReplicaInfo.php) | `historySince` | -| `/api/revoke` | [`RequestHandlers\Api\BurnDown`](../../src/RequestHandlers/Api/BurnDown.php) | `handle` | +| `/api/revoke` | [`RequestHandlers\Api\Revoke`](../../src/RequestHandlers/Api/Revoke.php) | `handle` | | `/api/server-public-key` | [`RequestHandlers\Api\ServerPublicKey`](../../src/RequestHandlers/Api/ServerPublicKey.php) | `handle` | | `/api/totp/disenroll` | [`RequestHandlers\Api\TotpDisenroll`](../../src/RequestHandlers/Api/TotpDisenroll.php) | `handle` | | `/api/totp/enroll` | [`RequestHandlers\Api\TotpEnroll`](../../src/RequestHandlers/Api/TotpEnroll.php) | `handle` | From da806c88cad5fe0ef2a8e0248c71af05c674059b Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:14:58 -0500 Subject: [PATCH 21/35] Fix sqlite --- src/Tables/MerkleState.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index be41354..5018fb8 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -568,6 +568,10 @@ protected function insertLeafInternal( $inTransaction(); // We only commit this transaction if all was successful: + if ($this->db->getDriver() === 'sqlite') { + $this->db->exec("END TRANSACTION"); + return true; + } return $this->db->commit(); } finally { // @phpstan-ignore-next-line From 9fbf8880bbc123f0f10b594a6e678dd2696f2f29 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:16:36 -0500 Subject: [PATCH 22/35] Add missing annotation --- tests/RequestHandlers/Api/BurnDownTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index 190dde3..90b3dc5 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -41,6 +41,7 @@ }; use FediE2EE\PKDServer\{ AppCache, + Meta\Params, Traits\ConfigTrait, Math, Protocol, @@ -117,6 +118,7 @@ #[UsesClass(Math::class)] #[UsesClass(RewrapConfig::class)] #[UsesClass(Peer::class)] +#[UsesClass(Params::class)] class BurnDownTest extends TestCase { use ConfigTrait; From c019d52024d9364ccd95db09a6915f4e1b87c1fa Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:18:56 -0500 Subject: [PATCH 23/35] Do we need to clear the transactions? --- tests/RequestHandlers/Api/BurnDownTest.php | 1 + tests/RequestHandlers/Api/TotpRotateTest.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index 90b3dc5..8e54a44 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -172,6 +172,7 @@ public function testHandle(): void $serverHpke = $config->getHPKE(); $handler = new Handler(); + $this->assertNotInTransaction(); // 1. Add key for the actor (victim) $latestRoot1 = $merkleState->getLatestRoot(); $addKey1 = new AddKey($canonActor, $actorKey->getPublicKey()); diff --git a/tests/RequestHandlers/Api/TotpRotateTest.php b/tests/RequestHandlers/Api/TotpRotateTest.php index a001063..7905c95 100644 --- a/tests/RequestHandlers/Api/TotpRotateTest.php +++ b/tests/RequestHandlers/Api/TotpRotateTest.php @@ -204,6 +204,7 @@ public function testHandle(int $timeOffset): void $serverHpke->encapsKey, $serverHpke->cs, ); + $this->assertNotInTransaction(); $addKeyResult = $protocol->addKey($encryptedForServer, $canonical); $keyId = $addKeyResult->keyID; @@ -636,6 +637,7 @@ public function testInvalidSignature(): void $serverHpke->encapsKey, $serverHpke->cs, ); + $this->assertNotInTransaction(); $addKeyResult = $protocol->addKey($encryptedForServer, $canonical); $keyId = $addKeyResult->keyID; From ffcffef687c4981db963c8b63deffb78a4e59959 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:22:10 -0500 Subject: [PATCH 24/35] Proposed fix --- src/Traits/ProtocolMethodTrait.php | 3 +++ tests/HttpTestTrait.php | 9 +++++++++ tests/RequestHandlers/Api/InfoTest.php | 2 ++ tests/RequestHandlers/Api/RevokeTest.php | 1 + 4 files changed, 15 insertions(+) diff --git a/src/Traits/ProtocolMethodTrait.php b/src/Traits/ProtocolMethodTrait.php index 81ca8d3..6d4621e 100644 --- a/src/Traits/ProtocolMethodTrait.php +++ b/src/Traits/ProtocolMethodTrait.php @@ -94,6 +94,9 @@ protected function protocolMethod( } // Before we do any insets, we should make sure we're not in a dangling transaction: if ($this->config()->getDb()->inTransaction()) { + if ($this->config()->getDb()->getDriver() === 'sqlite') { + $this->config()->getDb()->exec('END TRANSACTION'); + } $this->config()->getDb()->rollBack(); } throw new TableException('Could not insert new leaf'); diff --git a/tests/HttpTestTrait.php b/tests/HttpTestTrait.php index 5c1be66..05626c3 100644 --- a/tests/HttpTestTrait.php +++ b/tests/HttpTestTrait.php @@ -109,6 +109,12 @@ public function clearMerkleStateLock(): void public function assertNotInTransaction(): void { $db = $this->config()->getDb(); + if ($db->getDriver() === 'sqlite') { + try { + $db->exec('ROLLBACK'); + } catch (\PDOException $e) { + } + } $this->assertFalse($db->inTransaction(), 'we should not be in transaction'); } @@ -196,6 +202,9 @@ public function truncateTables(): void } else { $db = $GLOBALS['pkdConfig']->getDb(); } + if ($db->inTransaction()) { + $db->rollback(); + } $tables = [ 'pkd_merkle_witness_cosignatures', 'pkd_merkle_witnesses', diff --git a/tests/RequestHandlers/Api/InfoTest.php b/tests/RequestHandlers/Api/InfoTest.php index e08e464..b1d7b0f 100644 --- a/tests/RequestHandlers/Api/InfoTest.php +++ b/tests/RequestHandlers/Api/InfoTest.php @@ -5,6 +5,7 @@ use FediE2EE\PKD\Crypto\Exceptions\NotImplementedException; use FediE2EE\PKDServer\AppCache; use FediE2EE\PKDServer\Exceptions\DependencyException; +use FediE2EE\PKDServer\Meta\Params; use FediE2EE\PKDServer\RequestHandlers\Api\Info; use FediE2EE\PKDServer\ServerConfig; use FediE2EE\PKDServer\Tests\HttpTestTrait; @@ -19,6 +20,7 @@ #[CoversClass(Info::class)] #[UsesClass(AppCache::class)] +#[UsesClass(Params::class)] #[UsesClass(ServerConfig::class)] class InfoTest extends TestCase { diff --git a/tests/RequestHandlers/Api/RevokeTest.php b/tests/RequestHandlers/Api/RevokeTest.php index b7a2711..8ec9358 100644 --- a/tests/RequestHandlers/Api/RevokeTest.php +++ b/tests/RequestHandlers/Api/RevokeTest.php @@ -417,6 +417,7 @@ public function testHandleFlatFormat(): void $serverHpke->encapsKey, $serverHpke->cs ); + $this->assertNotInTransaction(); $protocol->addKey($encryptedForServer, $canonical); // Now, let's build a revocation token. From 189c715d370edf39d87d027286199e64884d7a2b Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:36:09 -0500 Subject: [PATCH 25/35] More rollbacks --- tests/HttpTestTrait.php | 8 +++++++- tests/Integration/ApiTest.php | 1 + tests/Integration/RewrapLifecycleTest.php | 1 + tests/RequestHandlers/Api/HistoryTest.php | 1 + tests/RequestHandlers/Api/ReplicasTest.php | 1 + tests/RequestHandlers/Api/TotpEnrollTest.php | 2 ++ tests/RequestHandlers/Api/TotpRotateTest.php | 4 ++++ 7 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/HttpTestTrait.php b/tests/HttpTestTrait.php index 05626c3..9962c37 100644 --- a/tests/HttpTestTrait.php +++ b/tests/HttpTestTrait.php @@ -80,6 +80,12 @@ public function getConfig(): ServerConfig public function clearOldTransaction(ServerConfig $config): void { $db = $config->getDb(); + if ($db->getDriver() === 'sqlite') { + try { + $db->exec('ROLLBACK'); + } catch (PDOException) { + } + } if ($db->inTransaction()) { $db->rollback(); } @@ -112,7 +118,7 @@ public function assertNotInTransaction(): void if ($db->getDriver() === 'sqlite') { try { $db->exec('ROLLBACK'); - } catch (\PDOException $e) { + } catch (PDOException) { } } $this->assertFalse($db->inTransaction(), 'we should not be in transaction'); diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 47c6160..9862122 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -125,6 +125,7 @@ class ApiTest extends TestCase public function setUp(): void { $this->config = $this->getConfig(); + $this->assertNotInTransaction(); $this->truncateTables(); } diff --git a/tests/Integration/RewrapLifecycleTest.php b/tests/Integration/RewrapLifecycleTest.php index 60c0243..0cc3b9e 100644 --- a/tests/Integration/RewrapLifecycleTest.php +++ b/tests/Integration/RewrapLifecycleTest.php @@ -113,6 +113,7 @@ class RewrapLifecycleTest extends TestCase public function setUp(): void { $this->config = $this->getConfig(); + $this->assertNotInTransaction(); $this->truncateTables(); } diff --git a/tests/RequestHandlers/Api/HistoryTest.php b/tests/RequestHandlers/Api/HistoryTest.php index e173429..50fc6f2 100644 --- a/tests/RequestHandlers/Api/HistoryTest.php +++ b/tests/RequestHandlers/Api/HistoryTest.php @@ -106,6 +106,7 @@ public function testHandle(): void $serverHpke->encapsKey, $serverHpke->cs ); + $this->assertNotInTransaction(); $protocol->addKey($encryptedForServer, $canonical); $newRoot = $merkleState->getLatestRoot(); diff --git a/tests/RequestHandlers/Api/ReplicasTest.php b/tests/RequestHandlers/Api/ReplicasTest.php index ee245f2..9cc464d 100644 --- a/tests/RequestHandlers/Api/ReplicasTest.php +++ b/tests/RequestHandlers/Api/ReplicasTest.php @@ -107,6 +107,7 @@ public function testHandle(): void if (!($peersTable instanceof Peers)) { $this->fail('peers table is not the right type'); } + $this->assertNotInTransaction(); /** @var Peer $newPeer */ $newPeer = $peersTable->create( $this->config->getSigningKeys()->publicKey, diff --git a/tests/RequestHandlers/Api/TotpEnrollTest.php b/tests/RequestHandlers/Api/TotpEnrollTest.php index da3d72c..b628d2d 100644 --- a/tests/RequestHandlers/Api/TotpEnrollTest.php +++ b/tests/RequestHandlers/Api/TotpEnrollTest.php @@ -586,6 +586,7 @@ public function testInvalidSignature(): void $this->clearOldTransaction($config); $protocol = new Protocol($config); + $this->assertNotInTransaction(); $result = $this->addKeyForActor($canonical, $keypair, $protocol, $config); $this->assertNotInTransaction(); $this->ensureMerkleStateUnlocked(); @@ -732,6 +733,7 @@ public function testInvalidTotpCodesIndividually(): void $this->clearOldTransaction($config); $protocol = new Protocol($config); + $this->assertNotInTransaction(); $result = $this->addKeyForActor($canonical, $keypair, $protocol, $config); $this->assertNotInTransaction(); $this->ensureMerkleStateUnlocked(); diff --git a/tests/RequestHandlers/Api/TotpRotateTest.php b/tests/RequestHandlers/Api/TotpRotateTest.php index 7905c95..71891c8 100644 --- a/tests/RequestHandlers/Api/TotpRotateTest.php +++ b/tests/RequestHandlers/Api/TotpRotateTest.php @@ -166,6 +166,7 @@ public static function timeOffsetProvider(): array #[DataProvider("timeOffsetProvider")] public function testHandle(int $timeOffset): void { + $this->assertNotInTransaction(); $sk = SecretKey::generate(); $pk = $sk->getPublicKey(); $hash = hash('sha256', pack('q', $timeOffset)); @@ -207,6 +208,7 @@ public function testHandle(int $timeOffset): void $this->assertNotInTransaction(); $addKeyResult = $protocol->addKey($encryptedForServer, $canonical); $keyId = $addKeyResult->keyID; + $this->assertNotInTransaction(); $domain = parse_url($canonical)['host']; $this->assertSame($rotatedomain, $domain); @@ -738,6 +740,7 @@ public function testEqualTimestepsRejected(): void $this->clearOldTransaction($config); $protocol = new Protocol($config); + $this->assertNotInTransaction(); $addKeyResult = $this->addKeyForActor($canonical, $sk, $protocol, $config); $keyId = $addKeyResult->keyID; @@ -808,6 +811,7 @@ public function testInvalidNewOtpCodes(): void $this->clearOldTransaction($config); $protocol = new Protocol($config); + $this->assertNotInTransaction(); $addKeyResult = $this->addKeyForActor($canonical, $sk, $protocol, $config); $keyId = $addKeyResult->keyID; From 32c028b459a013beebd4579ce09f3bb0ccf7f9ce Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:40:11 -0500 Subject: [PATCH 26/35] More retries --- src/Tables/MerkleState.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 5018fb8..f090041 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -228,7 +228,7 @@ public function getLatestRoot(): string * * @api */ - public function insertLeaf(MerkleLeaf $leaf, callable $inTransaction, int $maxRetries = 5): bool + public function insertLeaf(MerkleLeaf $leaf, callable $inTransaction, int $maxRetries = 20): bool { $attempt = 0; $lockChallenge = sodium_bin2hex(random_bytes(32)); @@ -243,7 +243,7 @@ public function insertLeaf(MerkleLeaf $leaf, callable $inTransaction, int $maxRe } catch (ConcurrentException $e) { if ($attempt < $maxRetries - 1) { $attempt++; - usleep(random_int(10000, 100000)); // Random backoff 10-100ms + usleep(random_int(10000, 1000000)); // Random backoff 10-1000ms continue; } throw $e; @@ -465,8 +465,8 @@ protected function insertLeafInternal( break; case "sqlite": $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); - $this->db->exec("PRAGMA busy_timeout=5000"); - $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 5); + $this->db->exec("PRAGMA busy_timeout=2000"); + $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 2); $row = self::assertArray($this->db->row( "SELECT merkle_state, lock_challenge FROM pkd_merkle_state WHERE 1" From fdc5314f2377a24af4c8fa13d07f56465a00dfab Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:42:29 -0500 Subject: [PATCH 27/35] Force rollback --- tests/HttpTestTrait.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/HttpTestTrait.php b/tests/HttpTestTrait.php index 9962c37..975f552 100644 --- a/tests/HttpTestTrait.php +++ b/tests/HttpTestTrait.php @@ -211,6 +211,12 @@ public function truncateTables(): void if ($db->inTransaction()) { $db->rollback(); } + if ($db->getDriver() === 'sqlite') { + try { + $db->exec('ROLLBACK'); + } catch (PDOException) { + } + } $tables = [ 'pkd_merkle_witness_cosignatures', 'pkd_merkle_witnesses', From 11612ecf1fe5b41d79f9594eb41761ff22b91c2d Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:45:39 -0500 Subject: [PATCH 28/35] Fix timeouts --- src/Tables/MerkleState.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index f090041..136e2b0 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -465,8 +465,8 @@ protected function insertLeafInternal( break; case "sqlite": $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); - $this->db->exec("PRAGMA busy_timeout=2000"); - $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 2); + $this->db->exec("PRAGMA busy_timeout=1000"); + $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 10); $row = self::assertArray($this->db->row( "SELECT merkle_state, lock_challenge FROM pkd_merkle_state WHERE 1" From 6c40a6619e1f621a0cfee143046fff766963bdaf Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:46:58 -0500 Subject: [PATCH 29/35] Regenerate docs --- docs/reference/classes/tables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/classes/tables.md b/docs/reference/classes/tables.md index bcbaa13..f850ee1 100644 --- a/docs/reference/classes/tables.md +++ b/docs/reference/classes/tables.md @@ -386,7 +386,7 @@ Insert leaf with retry logic for deadlocks - `$leaf`: `FediE2EE\PKDServer\Tables\Records\MerkleLeaf` - `$inTransaction`: `callable` -- `$maxRetries`: `int` = 5 +- `$maxRetries`: `int` = 20 **Throws:** `ConcurrentException`, `CryptoException`, `DependencyException`, `NotImplementedException`, `RandomException`, `SodiumException` From b4cb2016cead513e6355132441a46f2d6f7c4d29 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:50:05 -0500 Subject: [PATCH 30/35] Use different test domain --- tests/RequestHandlers/Api/BurnDownTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/RequestHandlers/Api/BurnDownTest.php b/tests/RequestHandlers/Api/BurnDownTest.php index 8e54a44..bae84a0 100644 --- a/tests/RequestHandlers/Api/BurnDownTest.php +++ b/tests/RequestHandlers/Api/BurnDownTest.php @@ -156,8 +156,8 @@ public function testHandle(): void { // Create two actors on the SAME domain: one to burn down, one as operator // Per spec, BurnDown must be from an operator on the same instance as the target - [$actorHandle, $canonActor] = $this->makeDummyActor(); - [$operatorHandle, $canonOperator] = $this->makeDummyActor(); + [$actorHandle, $canonActor] = $this->makeDummyActor('text-burndown.example.com'); + [$operatorHandle, $canonOperator] = $this->makeDummyActor('text-burndown.example.com'); $actorKey = SecretKey::generate(); $operatorKey = SecretKey::generate(); @@ -227,7 +227,7 @@ public function testHandle(): void $totpSecret = random_bytes(20); /** @var TOTP $totpTable */ $totpTable = $this->table('TOTP'); - $totpTable->saveSecret('example.com', $totpSecret); + $totpTable->saveSecret('text-burndown.example.com', $totpSecret); $latestRoot3 = $merkleState->getLatestRoot(); // Note: BurnDownAction canonicalizes actor but NOT operator, so operator must be canonical URL @@ -241,6 +241,7 @@ public function testHandle(): void // OTP is a top-level Bundle field (not part of the signed/encrypted message) $bundleData = json_decode($bundle3->toJson(), true); + $otp = self::generateTOTP($totpSecret); $bundleData['otp'] = $otp; $bundleJson = json_encode($bundleData, JSON_UNESCAPED_SLASHES); From 5c1f512a5f3400dd1f054e498f27c9e137ddaf22 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:53:49 -0500 Subject: [PATCH 31/35] Lock for shorter intervals --- src/Tables/MerkleState.php | 2 +- tests/HttpTestTrait.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 136e2b0..a21a899 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -466,7 +466,7 @@ protected function insertLeafInternal( case "sqlite": $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); $this->db->exec("PRAGMA busy_timeout=1000"); - $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 10); + $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 1); $row = self::assertArray($this->db->row( "SELECT merkle_state, lock_challenge FROM pkd_merkle_state WHERE 1" diff --git a/tests/HttpTestTrait.php b/tests/HttpTestTrait.php index 975f552..b314f86 100644 --- a/tests/HttpTestTrait.php +++ b/tests/HttpTestTrait.php @@ -85,6 +85,10 @@ public function clearOldTransaction(ServerConfig $config): void $db->exec('ROLLBACK'); } catch (PDOException) { } + try { + $db->exec('END TRANSACTION'); + } catch (PDOException) { + } } if ($db->inTransaction()) { $db->rollback(); @@ -216,6 +220,10 @@ public function truncateTables(): void $db->exec('ROLLBACK'); } catch (PDOException) { } + try { + $db->exec('END TRANSACTION'); + } catch (PDOException) { + } } $tables = [ 'pkd_merkle_witness_cosignatures', From 049aa0634147ec96727f2598fbc0aabb892ec1ad Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 18:59:16 -0500 Subject: [PATCH 32/35] END TRANSACTION in finally block --- src/Tables/MerkleState.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index a21a899..49cc601 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -568,12 +568,13 @@ protected function insertLeafInternal( $inTransaction(); // We only commit this transaction if all was successful: - if ($this->db->getDriver() === 'sqlite') { - $this->db->exec("END TRANSACTION"); - return true; - } return $this->db->commit(); } finally { + if ($this->db->getDriver() === 'sqlite') { + try { + $this->db->exec("END TRANSACTION"); + } catch (PDOException) {} + } // @phpstan-ignore-next-line $wrap = !$this->db->inTransaction(); // @phpstan-ignore-next-line From 694ba26a2798249987603f9f10bea6bc73d4c730 Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 19:00:41 -0500 Subject: [PATCH 33/35] Nits --- src/Tables/MerkleState.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index 49cc601..af423ac 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -573,7 +573,8 @@ protected function insertLeafInternal( if ($this->db->getDriver() === 'sqlite') { try { $this->db->exec("END TRANSACTION"); - } catch (PDOException) {} + } catch (PDOException) { + } } // @phpstan-ignore-next-line $wrap = !$this->db->inTransaction(); From 231190a6412107499525f83bef1b403257a2fa0b Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 19:24:26 -0500 Subject: [PATCH 34/35] Attempted fix: correct PRAGMA usage --- autoload-phpunit.php | 10 ++++++++-- src/Tables/MerkleState.php | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/autoload-phpunit.php b/autoload-phpunit.php index f91f973..9ffd259 100644 --- a/autoload-phpunit.php +++ b/autoload-phpunit.php @@ -59,11 +59,17 @@ function tableExists(EasyDB $db, string $tableName): bool mkdir(__DIR__ . '/tmp/db/', 0777, true); } $temp = __DIR__ . '/tmp/db/' . sodium_bin2hex(random_bytes(16)) . '-test.db'; - $pkdConfig->withDatabase(new EasyDBCache(new PDO('sqlite:' . $temp))); + $mainPDO = new PDO('sqlite:' . $temp); + $mainPDO->exec('PRAGMA jounral_mode=WAL'); + $mainPDO->exec('PRAGMA busy_timeout=5000'); + $pkdConfig->withDatabase(new EasyDBCache($mainPDO)); chmod($temp, 0777); // Create second DB connection for testing concurrency - $GLOBALS['PKD_PHPUNIT_DB'] = new EasyDBCache(new PDO('sqlite:' . $temp)); + $secondPDO = new PDO('sqlite:' . $temp); + $secondPDO->exec('PRAGMA jounral_mode=WAL'); + $secondPDO->exec('PRAGMA busy_timeout=5000'); + $GLOBALS['PKD_PHPUNIT_DB'] = new EasyDBCache($secondPDO); // Call cleanup-test-db.php to cleanup test file after phpunit is finished. if (getenv('AUTO_CLEANUP_TEST_DB')) { diff --git a/src/Tables/MerkleState.php b/src/Tables/MerkleState.php index af423ac..1a3b3f0 100644 --- a/src/Tables/MerkleState.php +++ b/src/Tables/MerkleState.php @@ -464,9 +464,9 @@ protected function insertLeafInternal( $storedChallenge = $row['lock_challenge'] ?? null; break; case "sqlite": - $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); $this->db->exec("PRAGMA busy_timeout=1000"); $this->db->getPdo()->setAttribute(PDO::ATTR_TIMEOUT, 1); + $this->db->exec("BEGIN EXCLUSIVE TRANSACTION"); $row = self::assertArray($this->db->row( "SELECT merkle_state, lock_challenge FROM pkd_merkle_state WHERE 1" @@ -568,11 +568,15 @@ protected function insertLeafInternal( $inTransaction(); // We only commit this transaction if all was successful: + if ($this->db->getDriver() === 'sqlite') { + $this->db->exec("END TRANSACTION"); + return true; + } return $this->db->commit(); } finally { if ($this->db->getDriver() === 'sqlite') { try { - $this->db->exec("END TRANSACTION"); + $this->db->exec("ROLLBACK"); } catch (PDOException) { } } From e5da38e24de584906cb0a24567688b40023d7fbd Mon Sep 17 00:00:00 2001 From: Soatok Dreamseeker Date: Sat, 21 Feb 2026 19:36:53 -0500 Subject: [PATCH 35/35] Remove kludge --- tests/HttpTestTrait.php | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/HttpTestTrait.php b/tests/HttpTestTrait.php index b314f86..22d056e 100644 --- a/tests/HttpTestTrait.php +++ b/tests/HttpTestTrait.php @@ -80,16 +80,6 @@ public function getConfig(): ServerConfig public function clearOldTransaction(ServerConfig $config): void { $db = $config->getDb(); - if ($db->getDriver() === 'sqlite') { - try { - $db->exec('ROLLBACK'); - } catch (PDOException) { - } - try { - $db->exec('END TRANSACTION'); - } catch (PDOException) { - } - } if ($db->inTransaction()) { $db->rollback(); } @@ -119,12 +109,6 @@ public function clearMerkleStateLock(): void public function assertNotInTransaction(): void { $db = $this->config()->getDb(); - if ($db->getDriver() === 'sqlite') { - try { - $db->exec('ROLLBACK'); - } catch (PDOException) { - } - } $this->assertFalse($db->inTransaction(), 'we should not be in transaction'); } @@ -215,16 +199,6 @@ public function truncateTables(): void if ($db->inTransaction()) { $db->rollback(); } - if ($db->getDriver() === 'sqlite') { - try { - $db->exec('ROLLBACK'); - } catch (PDOException) { - } - try { - $db->exec('END TRANSACTION'); - } catch (PDOException) { - } - } $tables = [ 'pkd_merkle_witness_cosignatures', 'pkd_merkle_witnesses',