diff --git a/config/services_ci.yaml b/config/services_ci.yaml index dc786dda76..58817f0368 100644 --- a/config/services_ci.yaml +++ b/config/services_ci.yaml @@ -73,6 +73,10 @@ services: $logFile: '/tmp/eb-fixtures/log-records.ndjson' tags: ['fob.context'] + OpenConext\EngineBlockFunctionalTestingBundle\Saml2\Recording\SamlTrafficRecorder: + + OpenConext\EngineBlockFunctionalTestingBundle\Command\NormalizeSamlRecordingCommand: + OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\SbsClientStateManager: arguments: - "@engineblock.functional_testing.data_store.sbs_client_state_mananger" diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php new file mode 100644 index 0000000000..2bbd5226f6 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php @@ -0,0 +1,92 @@ +addArgument('source', InputArgument::REQUIRED, 'Directory written by the recorder (EB_SAML_RECORD_DIR)') + ->addArgument('target', InputArgument::REQUIRED, 'Directory to write the normalized dumps to'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $source = rtrim((string) $input->getArgument('source'), '/'); + $target = rtrim((string) $input->getArgument('target'), '/'); + + if (!is_dir($source)) { + $output->writeln(sprintf('Source directory "%s" does not exist', $source)); + return Command::FAILURE; + } + + $scenarios = 0; + $messages = 0; + $failures = 0; + + foreach (glob($source . '/*', GLOB_ONLYDIR) ?: [] as $scenarioDirectory) { + $scenario = basename($scenarioDirectory); + $files = glob($scenarioDirectory . '/*.xml') ?: []; + sort($files); + + if (!is_dir($target . '/' . $scenario)) { + mkdir($target . '/' . $scenario, 0777, true); + } + + $normalizer = new SamlXmlNormalizer(); + $scenarios++; + + foreach ($files as $file) { + try { + $dump = $normalizer->normalize((string) file_get_contents($file)); + } catch (InvalidArgumentException $exception) { + $dump = '!! ' . $exception->getMessage() . "\n"; + $failures++; + } + + file_put_contents($target . '/' . $scenario . '/' . basename($file, '.xml') . '.txt', $dump); + $messages++; + } + } + + $output->writeln(sprintf( + 'Normalized %d messages from %d scenarios (%d unparsable)', + $messages, + $scenarios, + $failures + )); + + return Command::SUCCESS; + } +} diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php new file mode 100644 index 0000000000..c8ea35fd21 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php @@ -0,0 +1,71 @@ +getFeature()->getFile() ?? 'unknown', '.feature'), + $scope->getScenario()->getLine() + ); + + file_put_contents( + SamlTrafficRecorder::MARKER_FILE, + json_encode(['directory' => $directory, 'scenario' => $scenario], JSON_THROW_ON_ERROR) + ); + } + + /** + * @AfterSuite + */ + public static function stopRecording(): void + { + self::clearMarker(); + } + + private static function clearMarker(): void + { + if (is_file(SamlTrafficRecorder::MARKER_FILE)) { + unlink(SamlTrafficRecorder::MARKER_FILE); + } + } +} diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php new file mode 100644 index 0000000000..20f4907516 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php @@ -0,0 +1,199 @@ + ['onKernelResponse', -1024]]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + $recording = $this->readMarker(); + if ($recording === null) { + return; + } + + $messages = $this->extractMessages($event->getResponse()); + if ($messages === []) { + return; + } + + $request = $event->getRequest(); + $source = preg_replace('/[^A-Za-z0-9.]+/', '_', $request->getHost() . $request->getPathInfo()); + + foreach ($messages as $type => $xml) { + $this->write($recording['directory'], $recording['scenario'], $type, (string) $source, $xml); + } + } + + /** + * @return array SAML message type (SAMLRequest|SAMLResponse) => raw XML + */ + public function extractMessages(Response $response): array + { + return $this->extractFromRedirect($response) + $this->extractFromForm($response); + } + + /** + * @return array + */ + private function extractFromRedirect(Response $response): array + { + $location = $response->headers->get('Location'); + if ($location === null) { + return []; + } + + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + $messages = []; + foreach (self::MESSAGE_PARAMETERS as $parameter) { + $xml = is_string($query[$parameter] ?? null) ? $this->decode($query[$parameter], true) : null; + if ($xml !== null) { + $messages[$parameter] = $xml; + } + } + + return $messages; + } + + /** + * @return array + */ + private function extractFromForm(Response $response): array + { + $content = (string) $response->getContent(); + if (!str_contains($content, 'SAML') || !str_contains((string) $response->headers->get('Content-Type'), 'html')) { + return []; + } + + $messages = []; + foreach ($this->extractFormFields($content) as $parameter => $value) { + $xml = $this->decode($value, false); + if ($xml !== null) { + $messages[$parameter] = $xml; + } + } + + return $messages; + } + + /** + * @return array + */ + private function extractFormFields(string $html): array + { + $previous = libxml_use_internal_errors(true); + $document = new DOMDocument(); + $document->loadHTML($html); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $fields = []; + $xpath = new DOMXPath($document); + foreach ($xpath->query('//input[@name="SAMLRequest" or @name="SAMLResponse"]') as $input) { + $fields[$input->getAttribute('name')] = $input->getAttribute('value'); + } + + return $fields; + } + + private function decode(string $encoded, bool $deflated): ?string + { + $decoded = base64_decode($encoded, true); + if ($decoded === false) { + return null; + } + + if ($deflated) { + $decoded = $this->inflate($decoded) ?? $decoded; + } + + return str_starts_with(ltrim($decoded), '<') ? $decoded : null; + } + + private function inflate(string $data): ?string + { + // Recording must never break the request, so a corrupt payload is ignored instead of raising a warning. + set_error_handler(static fn(): bool => true); + try { + $inflated = gzinflate($data); + } finally { + restore_error_handler(); + } + + return $inflated === false ? null : $inflated; + } + + /** + * @return array{directory: string, scenario: string}|null + */ + private function readMarker(): ?array + { + if (!is_file($this->markerFile)) { + return null; + } + + $marker = json_decode((string) file_get_contents($this->markerFile), true); + if (!is_array($marker) || !is_string($marker['directory'] ?? null) || $marker['directory'] === '') { + return null; + } + + return [ + 'directory' => $marker['directory'], + 'scenario' => is_string($marker['scenario'] ?? null) && $marker['scenario'] !== '' + ? $marker['scenario'] + : 'unscoped', + ]; + } + + private function write(string $directory, string $scenario, string $type, string $source, string $xml): void + { + $scenarioDirectory = $directory . '/' . $scenario; + + if (!is_dir($scenarioDirectory)) { + mkdir($scenarioDirectory, 0777, true); + } + + $sequence = count(glob($scenarioDirectory . '/*.xml') ?: []) + 1; + + file_put_contents(sprintf('%s/%03d-%s-%s.xml', $scenarioDirectory, $sequence, $type, $source), $xml); + } +} diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php new file mode 100644 index 0000000000..7faa1e81c9 --- /dev/null +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php @@ -0,0 +1,240 @@ + 'samlp', + 'urn:oasis:names:tc:SAML:2.0:assertion' => 'saml', + 'urn:oasis:names:tc:SAML:2.0:metadata' => 'md', + 'urn:oasis:names:tc:SAML:metadata:ui' => 'mdui', + 'urn:oasis:names:tc:SAML:metadata:attribute' => 'mdattr', + 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol' => 'idpdisc', + 'urn:mace:shibboleth:metadata:1.0' => 'shibmd', + 'http://www.w3.org/2000/09/xmldsig#' => 'ds', + 'http://www.w3.org/2001/04/xmlenc#' => 'xenc', + 'http://www.w3.org/2001/XMLSchema' => 'xs', + 'http://www.w3.org/2001/XMLSchema-instance' => 'xsi', + 'http://www.w3.org/XML/1998/namespace' => 'xml', + ]; + + private const MASKED_ELEMENTS = [ + 'http://www.w3.org/2000/09/xmldsig#' => ['SignatureValue', 'DigestValue'], + 'http://www.w3.org/2001/04/xmlenc#' => ['CipherValue'], + ]; + + private const REFERENCE_ATTRIBUTES = ['ID', 'InResponseTo']; + + private const IDENTIFIER_PATTERN = + '/(? */ + private array $identifiers = []; + + public function normalize(string $xml): string + { + $previous = libxml_use_internal_errors(true); + $document = new DOMDocument(); + $loaded = $document->loadXML($xml); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!$loaded || $document->documentElement === null) { + throw new InvalidArgumentException( + 'Unable to parse SAML message: ' . ($errors[0]->message ?? 'unknown error') + ); + } + + $lines = []; + $this->dumpElement($document->documentElement, 0, $lines); + + return implode("\n", $lines) . "\n"; + } + + /** + * @param string[] $lines + */ + private function dumpElement(DOMElement $element, int $depth, array &$lines): void + { + $indent = str_repeat(' ', $depth); + $name = $this->qualifiedName($element->namespaceURI, $element->localName); + $attributes = $this->attributesOf($element); + $childElements = $this->childElementsOf($element); + $text = $childElements === [] ? $this->textOf($element) : ''; + + if ($attributes === [] && $childElements === []) { + $lines[] = $text === '' ? $indent . $name : $indent . $name . ': ' . $text; + return; + } + + $lines[] = $indent . $name; + foreach ($attributes as $attributeName => $value) { + $lines[] = $indent . ' @' . $attributeName . ': ' . $value; + } + if ($text !== '') { + $lines[] = $indent . ' #text: ' . $text; + } + foreach ($childElements as $child) { + $this->dumpElement($child, $depth + 1, $lines); + } + } + + /** + * @return array + */ + private function attributesOf(DOMElement $element): array + { + $attributes = []; + foreach ($element->attributes as $attribute) { + if ($attribute->namespaceURI === self::XMLNS) { + continue; + } + $attributeName = $this->qualifiedName($attribute->namespaceURI, $attribute->localName); + $attributes[$attributeName] = match (true) { + $attributeName === 'xsi:type' => $this->normalizeQName($element, $attribute->value), + in_array($attributeName, self::REFERENCE_ATTRIBUTES, true) => $this->identifier($attribute->value), + default => $this->maskVolatile($attribute->value), + }; + } + ksort($attributes); + + return $attributes; + } + + private function identifier(string $value): string + { + if ($value === '') { + return $value; + } + + return $this->identifiers[$value] ??= 'ID-' . (count($this->identifiers) + 1); + } + + /** + * @return DOMElement[] + */ + private function childElementsOf(DOMElement $element): array + { + $children = []; + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + $children[] = $child; + } + } + + return $children; + } + + private function textOf(DOMElement $element): string + { + $text = ''; + foreach ($element->childNodes as $child) { + if ($child instanceof DOMText) { + $text .= $child->nodeValue; + } + } + $text = trim($text); + + if ($this->isMasked($element)) { + return $text === '' ? '' : ''; + } + + if ($this->isCertificate($element)) { + return preg_replace('/\s+/', '', $text) ?? $text; + } + + return $this->maskVolatile($text); + } + + private function qualifiedName(?string $namespace, string $localName): string + { + if ($namespace === null || $namespace === '') { + return $localName; + } + + $prefix = self::NAMESPACE_PREFIXES[$namespace] ?? '{' . $namespace . '}'; + + return str_starts_with($prefix, '{') ? $prefix . $localName : $prefix . ':' . $localName; + } + + private function normalizeQName(DOMNode $context, string $value): string + { + if (!str_contains($value, ':')) { + return $value; + } + + [$prefix, $localName] = explode(':', $value, 2); + $namespace = $context->lookupNamespaceURI($prefix); + + return $namespace === null ? $value : $this->qualifiedName($namespace, $localName); + } + + private function isMasked(DOMElement $element): bool + { + return in_array($element->localName, self::MASKED_ELEMENTS[$element->namespaceURI ?? ''] ?? [], true); + } + + private function isCertificate(DOMElement $element): bool + { + return $element->localName === 'X509Certificate' + && $element->namespaceURI === 'http://www.w3.org/2000/09/xmldsig#'; + } + + private function maskVolatile(string $value): string + { + $value = preg_replace_callback( + self::TIMESTAMP_PATTERN, + static fn(array $match): string => sprintf( + '', + ($match[1] ?? '') !== '' ? '+fraction' : '', + $match[2] === 'Z' ? '' : '+offset' + ), + $value + ) ?? $value; + + foreach ([self::IDENTIFIER_PATTERN, self::UUID_PATTERN] as $pattern) { + $value = preg_replace_callback( + $pattern, + fn(array $match): string => $this->identifier($match[0]), + $value + ) ?? $value; + } + + return $value; + } +} diff --git a/tests/behat.yml b/tests/behat.yml index 18c453c452..53ce5a939f 100644 --- a/tests/behat.yml +++ b/tests/behat.yml @@ -46,6 +46,7 @@ default: - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\LoggingContext: logFile: '/tmp/eb-fixtures/log-records.ndjson' - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext: + - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\SamlRecordingContext: functional: mink_session: chrome mink_javascript_session: chrome diff --git a/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php new file mode 100644 index 0000000000..702e645338 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php @@ -0,0 +1,148 @@ +'; + + private SamlTrafficRecorder $recorder; + + private string $workDirectory; + + protected function setUp(): void + { + $this->workDirectory = sys_get_temp_dir() . '/saml-recorder-test-' . bin2hex(random_bytes(4)); + mkdir($this->workDirectory); + $this->recorder = new SamlTrafficRecorder($this->workDirectory . '/marker.json'); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->workDirectory); + } + + public function testRecordsMessagesIntoTheScenarioDirectoryFromTheMarker(): void + { + file_put_contents( + $this->workDirectory . '/marker.json', + json_encode(['directory' => $this->workDirectory . '/out', 'scenario' => 'Feature_L12']) + ); + + $this->recorder->onKernelResponse($this->event(new RedirectResponse( + 'https://idp.example.org/sso?' . http_build_query(['SAMLRequest' => base64_encode((string) gzdeflate(self::XML))]) + ))); + + $files = glob($this->workDirectory . '/out/Feature_L12/*.xml') ?: []; + $this->assertCount(1, $files); + $this->assertSame('001-SAMLRequest-engine.example.org_authn.xml', basename($files[0])); + $this->assertSame(self::XML, file_get_contents($files[0])); + } + + public function testRecordsNothingWithoutMarker(): void + { + $this->recorder->onKernelResponse($this->event(new RedirectResponse( + 'https://idp.example.org/sso?' . http_build_query(['SAMLRequest' => base64_encode((string) gzdeflate(self::XML))]) + ))); + + $this->assertSame([], glob($this->workDirectory . '/*') ?: []); + } + + private function event(Response $response): ResponseEvent + { + return new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + Request::create('https://engine.example.org/authn'), + HttpKernelInterface::MAIN_REQUEST, + $response + ); + } + + public function testExtractsMessageFromHttpRedirect(): void + { + $query = http_build_query([ + 'SAMLRequest' => base64_encode((string) gzdeflate(self::XML)), + 'RelayState' => 'abc', + ]); + + $messages = $this->recorder->extractMessages(new RedirectResponse('https://idp.example.org/sso?' . $query)); + + $this->assertSame(['SAMLRequest' => self::XML], $messages); + } + + public function testExtractsMessageFromHttpPostForm(): void + { + $html = sprintf( + '
' + . '' + . '
', + htmlspecialchars(base64_encode(self::XML)) + ); + + $messages = $this->recorder->extractMessages(new Response($html, 200, ['Content-Type' => 'text/html'])); + + $this->assertSame(['SAMLResponse' => self::XML], $messages); + } + + public function testIgnoresResponsesWithoutSamlMessage(): void + { + $this->assertSame([], $this->recorder->extractMessages(new RedirectResponse('https://example.org/?a=b'))); + $this->assertSame( + [], + $this->recorder->extractMessages(new Response('SAML is mentioned', 200, ['Content-Type' => 'text/html'])) + ); + } + + public function testIgnoresParametersThatAreNotXml(): void + { + $messages = $this->recorder->extractMessages( + new RedirectResponse('https://example.org/?SAMLRequest=' . base64_encode('not xml')) + ); + + $this->assertSame([], $messages); + } + + public function testCorruptDeflatedRedirectPayloadIsIgnoredWithoutWarning(): void + { + set_error_handler(static function (int $severity, string $message): never { + throw new ErrorException($message, 0, $severity); + }); + + try { + $messages = $this->recorder->extractMessages( + new RedirectResponse('https://example.org/?SAMLRequest=' . rawurlencode(base64_encode("\x01\x02garbage"))) + ); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $messages); + } +} diff --git a/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php new file mode 100644 index 0000000000..2f0ceda1df --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php @@ -0,0 +1,202 @@ + + https://engine.example.org/metadata + + + + %4$s + + + %4$s + MIIC +CERT + + + + + + jdoe + + + + +XML; + + public function testIgnoresNamespacePrefixesAndAttributeOrder(): void + { + $original = $this->response('_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'EB1111111111111111111111111111111111111111'); + $reprefixed = str_replace( + ['samlp:', 'xmlns:samlp', 'saml:', 'xmlns:saml=', 'xs:', 'xmlns:xs'], + ['p:', 'xmlns:p', 'a:', 'xmlns:a=', 'xsd:', 'xmlns:xsd'], + $original + ); + $reordered = str_replace( + 'Version="2.0" IssueInstant="2026-01-01T10:00:00Z" Destination="https://sp.example.org/acs"', + 'Destination="https://sp.example.org/acs" IssueInstant="2026-01-01T10:00:00Z" Version="2.0"', + $reprefixed + ); + + $this->assertSame($this->normalize($original), $this->normalize($reordered)); + } + + public function testMasksVolatileValuesButKeepsReferencesVisible(): void + { + $first = $this->normalize( + $this->response('_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'EB1111111111111111111111111111111111111111') + ); + $second = $this->normalize( + $this->response( + '_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'EB2222222222222222222222222222222222222222', + '2027-05-05T05:05:05Z', + 'different' + ) + ); + + $this->assertSame($first, $second); + $this->assertStringContainsString('@ID: ID-1', $first); + $this->assertStringContainsString('@InResponseTo: ID-2', $first); + $this->assertStringContainsString('@URI: #ID-1', $first); + $this->assertStringContainsString('@IssueInstant: ', $first); + $this->assertStringContainsString("ds:SignatureValue: ", $first); + $this->assertStringContainsString('ds:X509Certificate: MIICCERT', $first); + } + + public function testNormalizesXsiTypeIndependentlyOfPrefix(): void + { + $dump = $this->normalize( + str_replace(['xs:', 'xmlns:xs'], ['xsd:', 'xmlns:xsd'], $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40))) + ); + + $this->assertStringContainsString('@xsi:type: xs:string', $dump); + } + + public function testReportsSemanticDifferences(): void + { + $original = $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40)); + $changed = str_replace( + 'urn:oasis:names:tc:SAML:2.0:status:Success', + 'urn:oasis:names:tc:SAML:2.0:status:Requester', + $original + ); + + $this->assertNotSame($this->normalize($original), $this->normalize($changed)); + } + + public function testNumbersIdentifiersAcrossMessagesOfOneInstance(): void + { + $normalizer = new SamlXmlNormalizer(); + $request = ''; + + $normalizer->normalize($request); + $response = $normalizer->normalize( + $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40)) + ); + + $this->assertStringContainsString('@InResponseTo: ID-1', $response); + $this->assertStringContainsString('@ID: ID-2', $response); + } + + public function testMasksIdentifiersWithEngineBlockAndMockPrefixes(): void + { + $dump = $this->normalize( + '' + ); + + $this->assertStringContainsString('@ID: ID-1', $dump); + $this->assertStringContainsString('@InResponseTo: ID-2', $dump); + $this->assertStringContainsString('@Destination: https://sp.example.org/acs', $dump); + } + + public function testMasksReferenceAttributesRegardlessOfIdentifierFormat(): void + { + $dump = $this->normalize( + '' + ); + + $this->assertStringContainsString('@ID: ID-1', $dump); + $this->assertStringContainsString('@InResponseTo: ID-2', $dump); + $this->assertStringContainsString('@Destination: https://sp.example.org/acs', $dump); + } + + public function testKeepsReferenceBetweenRequestAndResponse(): void + { + $normalizer = new SamlXmlNormalizer(); + $normalizer->normalize(''); + $dump = $normalizer->normalize( + '' + ); + + $this->assertStringContainsString('@ID: ID-2', $dump); + $this->assertStringContainsString('@InResponseTo: ID-1', $dump); + } + + public function testMasksTimeBasedMockIdentityProviderFixtures(): void + { + $dump = $this->normalize( + '' + . 'ETS-MOCK-IDP-1791897603' + . 'urn:collab:person:example.org:test179189752994056' + . '' + ); + + $this->assertStringContainsString('saml:NameID: ID-1', $dump); + $this->assertStringContainsString('saml:AttributeValue: urn:collab:person:example.org:ID-2', $dump); + } + + public function testRejectsUnparsableXml(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SamlXmlNormalizer())->normalize('normalize($xml); + } + + private function response( + string $id, + string $inResponseTo, + string $instant = '2026-01-01T10:00:00Z', + string $digest = 'c2lnbmF0dXJl', + ): string { + return sprintf(self::RESPONSE, $id, $inResponseTo, $instant, $digest, '_' . str_repeat('c', 40)); + } +}