diff --git a/infection.json.dist b/infection.json.dist index b9e6c36..782b599 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -20,7 +20,9 @@ "FediE2EE\\PKD\\Features\\FetchTrait::fetchUnverifiedAuxData", "FediE2EE\\PKD\\Features\\FetchTrait::fetchUnverifiedAuxDataByID", "FediE2EE\\PKD\\Features\\FetchTrait::fetchRecentMerkleRoot", + "FediE2EE\\PKD\\Features\\PublishTrait::preamble", "FediE2EE\\PKD\\Features\\VerifyTrait::fetchPublicKeys", + "FediE2EE\\PKD\\Features\\VerifyTrait::fetchAuxDataByID", "FediE2EE\\PKD\\Features\\VerifyTrait::fetchAuxData" ] }, @@ -72,17 +74,25 @@ }, "IncrementInteger": { "ignore": [ + "FediE2EE\\PKD\\Features\\VerifyTrait", "FediE2EE\\PKD\\Features\\APTrait::ensureHttpClientConfigured" ] }, "DecrementInteger": { "ignore": [ + "FediE2EE\\PKD\\Features\\VerifyTrait", "FediE2EE\\PKD\\Features\\APTrait::ensureHttpClientConfigured", "FediE2EE\\PKD\\Features\\VerifyTrait::verifyInclusionProofInternal" ] }, + "Continue_": { + "ignore": [ + "FediE2EE\\PKD\\Features\\VerifyTrait" + ], + }, "MatchArmRemoval": { "ignore": [ + "FediE2EE\\PKD\\Features\\VerifyTrait", "FediE2EE\\PKD\\Features\\PublishTrait::getInternalHpke" ] }, diff --git a/src/Features/FetchTrait.php b/src/Features/FetchTrait.php index 6329e22..f548a9a 100644 --- a/src/Features/FetchTrait.php +++ b/src/Features/FetchTrait.php @@ -7,12 +7,13 @@ use FediE2EE\PKD\Extensions\ExtensionException; use FediE2EE\PKD\Values\AuxData; use FediE2EE\PKD\Crypto\Exceptions\{ + CryptoException, HttpSignatureException, JsonException, NetworkException, - NotImplementedException -}; + NotImplementedException}; use GuzzleHttp\Exception\GuzzleException; +use ParagonIE\Certainty\Exception\CertaintyException; use SodiumException; use function is_array, is_null, is_string, urlencode; @@ -32,7 +33,9 @@ trait FetchTrait * * @return PublicKey[] * + * @throws CertaintyException * @throws ClientException + * @throws CryptoException * @throws GuzzleException * @throws HttpSignatureException * @throws JsonException diff --git a/src/Features/VerifyTrait.php b/src/Features/VerifyTrait.php index 9065f2b..f46255c 100644 --- a/src/Features/VerifyTrait.php +++ b/src/Features/VerifyTrait.php @@ -392,7 +392,7 @@ protected function parseInclusionProof(array $row): InclusionProof * @throws ClientException If the format is invalid * Supported $hashFunction 'sha256', 'sha384', 'sha512', 'blake2b' */ - protected function decodeMerkleRoot(string $merkleRoot, string $hashFunction): string + public function decodeMerkleRoot(string $merkleRoot, string $hashFunction): string { $prefix = 'pkd-mr-v1:'; if (!str_starts_with($merkleRoot, $prefix)) { @@ -403,11 +403,10 @@ protected function decodeMerkleRoot(string $merkleRoot, string $hashFunction): s $decoded = Base64UrlSafe::decodeNoPadding($encoded); $expectedByteLen = match ($hashFunction) { - 'sha256' => 32, + 'blake2b', 'sha256' => 32, 'sha384' => 48, 'sha512' => 64, - 'blake2b' => 32, // variable-length 8 to 512 bits (1 to 64 bytes) but 32 minimum for safety. - default => 32, // Fallback to 32 bytes as a minimum but $hashFunc should never be null. + default => throw new ClientException("Hash function not in allow list"), }; if (strlen($decoded) < $expectedByteLen) { diff --git a/tests/unit/Features/VerifyTraitTest.php b/tests/unit/Features/VerifyTraitTest.php index 22de298..84fa93d 100644 --- a/tests/unit/Features/VerifyTraitTest.php +++ b/tests/unit/Features/VerifyTraitTest.php @@ -21,6 +21,7 @@ use GuzzleHttp\HandlerStack; use ParagonIE\ConstantTime\Base64UrlSafe; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Random\RandomException; @@ -677,10 +678,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -749,10 +752,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -812,10 +817,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -884,10 +891,12 @@ public function getAuxDataType(): string { return 'wanted-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -972,10 +981,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -1182,7 +1193,7 @@ public function testFetchPublicKeysTreeSizeCoercion(): void 'leaf-index' => $proof->index, ]], 'merkle-root' => $merkleRoot, - 'tree-size' => (string) $tree->getSize(), + 'tree-size' => (string)$tree->getSize(), ], 'fedi-e2ee:v1/api/actor/get-keys' ); @@ -1343,10 +1354,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -1398,10 +1411,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -1454,10 +1469,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -1499,7 +1516,7 @@ public function isValid(string $auxData): bool 'leaf-index' => $proof->index ]], 'merkle-root' => $merkleRoot, - 'tree-size' => (string) $tree->getSize() + 'tree-size' => (string)$tree->getSize() ], 'fedi-e2ee:v1/api/actor/aux-info' ); @@ -1563,10 +1580,12 @@ public function getAuxDataType(): string { return 'test-type'; } + public function getRejectionReason(): string { return 'Invalid'; } + public function isValid(string $auxData): bool { return true; @@ -1602,4 +1621,74 @@ public function isValid(string $auxData): bool $client->fetchAuxData('alice@example.com', 'test-type'); } + + public static function hashFunctionProvider(): array + { + return [ + ['blake2b'], + ['sha256'], + ['sha384'], + ['sha512'], + ]; + } + + /** + * @throws ClientException + * @throws CryptoException + * @throws NotImplementedException + * @throws SodiumException + */ + #[DataProvider("hashFunctionProvider")] + public function testHardCodedHashLengths(string $hashFunc): void + { + if ($hashFunc === 'blake2b') { + $expectedHashLength = 32; + } else { + $expectedHashLength = strlen(hash($hashFunc, '', true)); + } + // strlen("pkd-mr-v1:") == 10 + $expectedEncodedLength = 10 + (int) ceil($expectedHashLength * 4 / 3); + $serverPk = $this->serverKey->getPublicKey(); + $client = new ReadOnlyClient('http://pkd.test', $serverPk); + + // Build a simple tree with known leaves + $leaves = ['leaf1', 'leaf2', 'leaf3', 'leaf4']; + $tree = new Tree($leaves, $hashFunc); + $rawRoot = $tree->getRoot(); + $this->assertNotNull($rawRoot); + $this->assertSame($expectedHashLength, strlen($rawRoot)); + $merkleRoot = $tree->getEncodedRoot(); + $this->assertSame($expectedEncodedLength, strlen($merkleRoot)); + + // We need to actually fail if the code is mutated: + $proof = $tree->getInclusionProof('leaf1'); + $result = $client->verifyInclusionProof( + $hashFunc, + $merkleRoot, + 'leaf1', + $proof, + $tree->getSize() + ); + $this->assertTrue($result, 'Inclusion proof verification failed'); + } + + public static function merkleRootProvider(): array + { + return [ + ['blake2b', 32], + ['sha256', 32], + ['sha384', 48], + ['sha512', 64], + ]; + } + + #[DataProvider("merkleRootProvider")] + public function testDecodeMerkleRoot(string $hashFunc, int $zeroes): void + { + $serverPk = $this->serverKey->getPublicKey(); + $client = new ReadOnlyClient('http://pkd.test', $serverPk); + $encoded = (new Tree([], $hashFunc))->getEncodedRoot(); + $out = $client->decodeMerkleRoot($encoded, $hashFunc); + $this->assertSame($zeroes, strlen($out)); + } } diff --git a/tests/unit/ReadOnlyClientTest.php b/tests/unit/ReadOnlyClientTest.php index bf38a73..d2ec432 100644 --- a/tests/unit/ReadOnlyClientTest.php +++ b/tests/unit/ReadOnlyClientTest.php @@ -2,27 +2,36 @@ declare(strict_types=1); namespace FediE2EE\PKD\Tests; +use FediE2EE\PKD\Crypto\Exceptions\CryptoException; +use FediE2EE\PKD\Crypto\Merkle\Tree; use FediE2EE\PKD\Crypto\SecretKey; +use FediE2EE\PKD\Exceptions\ClientException; use FediE2EE\PKD\Extensions\Registry; use FediE2EE\PKD\ReadOnlyClient; use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use SodiumException; #[CoversClass(ReadOnlyClient::class)] #[Group('unit')] class ReadOnlyClientTest extends TestCase { - private function createMockClient(array $responses): HttpClient + private function createMockClient(array $responses = []): HttpClient { $mock = new MockHandler($responses); $handlerStack = HandlerStack::create($mock); return new HttpClient(['handler' => $handlerStack]); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testConstructorWithMinimalArguments(): void { $sk = SecretKey::generate(); @@ -33,6 +42,10 @@ public function testConstructorWithMinimalArguments(): void $this->assertInstanceOf(ReadOnlyClient::class, $client); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testConstructorWithRegistry(): void { $sk = SecretKey::generate(); @@ -44,6 +57,10 @@ public function testConstructorWithRegistry(): void $this->assertInstanceOf(ReadOnlyClient::class, $client); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testConstructorWithNullRegistry(): void { $sk = SecretKey::generate(); @@ -54,6 +71,10 @@ public function testConstructorWithNullRegistry(): void $this->assertInstanceOf(ReadOnlyClient::class, $client); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testMethodsExist(): void { $sk = SecretKey::generate(); @@ -70,18 +91,26 @@ public function testMethodsExist(): void $this->assertTrue(method_exists($client, 'setHttpClient')); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testSetHttpClient(): void { $sk = SecretKey::generate(); $pk = $sk->getPublicKey(); $client = new ReadOnlyClient('https://pkd.example.com', $pk); - $mockClient = $this->createMockClient([]); + $mockClient = $this->createMockClient(); $result = $client->setHttpClient($mockClient); $this->assertSame($client, $result); } + /** + * @throws CryptoException + * @throws SodiumException + */ public function testDoesNotHaveWriteMethods(): void { $sk = SecretKey::generate(); @@ -94,4 +123,30 @@ public function testDoesNotHaveWriteMethods(): void $this->assertFalse(method_exists($client, 'burnDown')); $this->assertFalse(method_exists($client, 'fireproof')); } + + public static function merkleRootProvider(): array + { + return [ + ['blake2b', 32], + ['sha256', 32], + ['sha384', 48], + ['sha512', 64], + ]; + } + + /** + * @throws ClientException + * @throws CryptoException + * @throws SodiumException + */ + #[DataProvider("merkleRootProvider")] + public function testDecodeMerkleRoot(string $hashFunc, int $zeroes): void + { + $sk = SecretKey::generate(); + $serverPk = $sk->getPublicKey(); + $client = new ReadOnlyClient('http://pkd.test', $serverPk); + $encoded = (new Tree([], $hashFunc))->getEncodedRoot(); + $out = $client->decodeMerkleRoot($encoded, $hashFunc); + $this->assertSame($zeroes, strlen($out)); + } }