* @copyright 2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf-sign * * This file is part of tc-lib-pdf-sign software library. */ namespace Test\Timestamp; use Com\Tecnick\Pdf\Sign\Cms\Asn1; use Com\Tecnick\Pdf\Sign\Exception; use Com\Tecnick\Pdf\Sign\Timestamp\Client; use Com\Tecnick\Pdf\Sign\Timestamp\Config; use PHPUnit\Framework\TestCase; /** * Timestamp Client Test * * @since 2026-07-15 * @category Library * @package PdfSign * @author Nicola Asuni * @copyright 2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf-sign */ class ClientTest extends TestCase { private Asn1 $asn1; protected function setUp(): void { $this->asn1 = new Asn1(); } private function client(bool $nonce = false, string $policyOid = '', string $hash = 'sha256'): Client { return new Client(new Config( host: 'https://tsa.example.org', hashAlgorithm: $hash, policyOid: $policyOid, nonceEnabled: $nonce, )); } /** * Build a minimal valid TimeStampResp wrapping the given content. * * @param int<0, max> $statusCode PKIStatus value. */ private function response(int $statusCode, string $content): string { return $this->asn1->encodeSequence( $this->asn1->encodeSequence($this->asn1->encodeInteger($statusCode)) . $content, ); } private function sampleToken(): string { return $this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier('1.2.840.113549.1.7.2')); } public function testHashAlgorithmOid(): void { $client = $this->client(); $this->assertSame('2.16.840.1.101.3.4.2.1', $client->hashAlgorithmOid('sha256')); $this->assertSame('2.16.840.1.101.3.4.2.2', $client->hashAlgorithmOid('sha384')); $this->assertSame('2.16.840.1.101.3.4.2.3', $client->hashAlgorithmOid('sha512')); } public function testHashAlgorithmOidRejectsUnknown(): void { $this->expectException(Exception::class); $this->client()->hashAlgorithmOid('sha1'); } public function testBuildRequestStructure(): void { $req = $this->client()->buildRequest('payload'); $offset = 0; $root = $this->asn1->readTlv($req, $offset); $this->assertSame(0x30, $root['tag']); $this->assertSame(\strlen($req), $offset); $inner = 0; $version = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x02, $version['tag']); $this->assertSame(1, $this->asn1->decodeInteger($version['value'])); $messageImprint = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x30, $messageImprint['tag']); $certReq = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x01, $certReq['tag']); $this->assertSame("\xFF", $certReq['value']); // Nothing follows certReq when no policy and no nonce are present. $this->assertSame(\strlen($root['value']), $inner); // The message imprint carries the SHA-256 digest of the input. $miOffset = 0; $algId = $this->asn1->readTlv($messageImprint['value'], $miOffset); $this->assertSame(0x30, $algId['tag']); $digest = $this->asn1->readTlv($messageImprint['value'], $miOffset); $this->assertSame(0x04, $digest['tag']); $this->assertSame(\hash('sha256', 'payload', true), $digest['value']); } public function testBuildRequestIncludesPolicyOid(): void { $req = $this->client(policyOid: '1.2.3.4')->buildRequest('x'); $offset = 0; $root = $this->asn1->readTlv($req, $offset); $inner = 0; $this->asn1->readTlv($root['value'], $inner); // version $this->asn1->readTlv($root['value'], $inner); // messageImprint $policy = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x06, $policy['tag']); $this->assertSame($this->asn1->encodeObjectIdentifier('1.2.3.4'), $policy['raw']); } public function testBuildRequestIncludesNonce(): void { $req = $this->client(nonce: true)->buildRequest('x'); $offset = 0; $root = $this->asn1->readTlv($req, $offset); $inner = 0; $this->asn1->readTlv($root['value'], $inner); // version $this->asn1->readTlv($root['value'], $inner); // messageImprint $nonce = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x02, $nonce['tag']); $certReq = $this->asn1->readTlv($root['value'], $inner); $this->assertSame(0x01, $certReq['tag']); } public function testParseResponseReturnsToken(): void { $token = $this->sampleToken(); $this->assertSame($token, $this->client()->parseResponse($this->response(0, $token))); // status 1 (granted with mods) is also accepted $this->assertSame($token, $this->client()->parseResponse($this->response(1, $token))); } public function testParseResponseRejectsEmpty(): void { $this->expectException(Exception::class); $this->client()->parseResponse(''); } public function testParseResponseRejectsNonSequenceRoot(): void { $this->expectException(Exception::class); $this->client()->parseResponse($this->asn1->encodeInteger(0)); } public function testParseResponseRejectsInvalidStatusStructure(): void { $bad = $this->asn1->encodeSequence($this->asn1->encodeInteger(0) . $this->sampleToken()); $this->expectException(Exception::class); $this->client()->parseResponse($bad); } public function testParseResponseRejectsNonIntegerStatus(): void { $bad = $this->asn1->encodeSequence( $this->asn1->encodeSequence($this->asn1->encodeOctetString('x')) . $this->sampleToken(), ); $this->expectException(Exception::class); $this->client()->parseResponse($bad); } public function testParseResponseRejectsRejectedStatus(): void { $this->expectException(Exception::class); $this->client()->parseResponse($this->response(2, $this->sampleToken())); } public function testParseResponseRejectsMissingToken(): void { $noToken = $this->asn1->encodeSequence($this->asn1->encodeSequence($this->asn1->encodeInteger(0))); $this->expectException(Exception::class); $this->client()->parseResponse($noToken); } public function testParseResponseRejectsNonSequenceToken(): void { $bad = $this->asn1->encodeSequence( $this->asn1->encodeSequence($this->asn1->encodeInteger(0)) . $this->asn1->encodeInteger(5), ); $this->expectException(Exception::class); $this->client()->parseResponse($bad); } public function testRequestTokenUsesTransport(): void { $token = $this->sampleToken(); $response = $this->response(0, $token); $captured = ''; $transport = static function (string $request) use (&$captured, $response): string { $captured = $request; return $response; }; $result = $this->client()->requestToken('payload', $transport); $this->assertSame($token, $result); // The transport received a well-formed DER request. $offset = 0; $root = $this->asn1->readTlv($captured, $offset); $this->assertSame(0x30, $root['tag']); } public function testRequestTokenRejectsNonStringTransportResult(): void { $transport = static fn(string $request): int => \strlen($request); $this->expectException(Exception::class); $this->client()->requestToken('payload', $transport); } }