diff --git a/composer.json b/composer.json index 26663a0..3d00243 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "examples/functions.php" ], "psr-4": { - "Firehed\\WebAuthn\\": "tests" + "Firehed\\WebAuthn\\": "tests", + "Firehed\\WebAuthn\\Tools\\": "tools" } }, "require": { @@ -49,11 +50,12 @@ }, "require-dev": { "mheap/phpunit-github-actions-printer": "^1.5", + "phpstan/phpstan": "^2.1.50", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan": "^2.1.50", "phpunit/phpunit": "^11", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^3.5", + "symfony/console": "^7.0" }, "scripts": { "test": [ @@ -62,6 +64,7 @@ "@phpcs" ], "autofix": "phpcbf", + "generate-test-vectors": "@php tools/console.php", "phpunit": "phpunit", "phpstan": "phpstan analyse --memory-limit=1G", "phpstan-baseline": "phpstan analyse --generate-baseline", diff --git a/phpcs.xml b/phpcs.xml index 07fca84..d184199 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,7 @@ src tests + tools diff --git a/phpstan.neon b/phpstan.neon index 6b6d822..e705640 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,3 +9,4 @@ parameters: - examples - src - tests + - tools diff --git a/tools/FixtureWriter.php b/tools/FixtureWriter.php new file mode 100644 index 0000000..cdaf3a3 --- /dev/null +++ b/tools/FixtureWriter.php @@ -0,0 +1,106 @@ + file name => contents + */ + public function render(Vector $vector): array + { + $reg = $vector->registration; + $credentialId = self::encode($reg['credential_id']); + + $files = [ + // No spec counterpart; supplies what the harness needs but neither + // response carries. + 'metadata.json' => [ + 'id' => $credentialId, + 'origin' => self::origin($reg['clientDataJSON']), + ], + 'reg-req.json' => [ + 'publicKey' => [ + 'challenge' => self::encode($reg['challenge']), + ], + ], + 'reg-res.json' => [ + 'id' => $credentialId, + 'rawId' => $credentialId, + 'response' => [ + 'clientDataJSON' => self::encode($reg['clientDataJSON']), + 'attestationObject' => self::encode($reg['attestationObject']), + // JsonResponseParser rejects the response outright if this + // is absent, and the vectors carry no transport hints. + 'transports' => [], + ], + 'type' => 'public-key', + ], + ]; + + $auth = $vector->authentication; + if ($auth !== null) { + $files['auth-req.json'] = [ + 'publicKey' => [ + 'challenge' => self::encode($auth['challenge']), + ], + ]; + $files['auth-res.json'] = [ + 'rawId' => $credentialId, + 'response' => [ + 'authenticatorData' => self::encode($auth['authenticatorData']), + 'signature' => self::encode($auth['signature']), + 'clientDataJSON' => self::encode($auth['clientDataJSON']), + ], + 'type' => 'public-key', + ]; + } + + return array_map(self::toJson(...), $files); + } + + private static function encode(string $hex): string + { + return BinaryString::fromHex($hex)->toBase64Url(); + } + + private static function origin(string $clientDataJsonHex): string + { + $decoded = json_decode(BinaryString::fromHex($clientDataJsonHex)->unwrap(), true); + if (!is_array($decoded) || !array_key_exists('origin', $decoded) || !is_string($decoded['origin'])) { + throw new UnexpectedValueException('clientDataJSON does not contain a usable origin'); + } + return $decoded['origin']; + } + + /** + * @param mixed[] $data + */ + private static function toJson(array $data): string + { + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + } +} diff --git a/tools/GenerateTestVectorsCommand.php b/tools/GenerateTestVectorsCommand.php new file mode 100644 index 0000000..8e077bc --- /dev/null +++ b/tools/GenerateTestVectorsCommand.php @@ -0,0 +1,128 @@ +addOption( + 'spec', + 's', + InputOption::VALUE_REQUIRED, + 'Path to index.bs from a w3c/webauthn checkout', + ) + ->addOption( + 'output', + 'o', + InputOption::VALUE_REQUIRED, + 'Directory to write vector directories into', + self::DEFAULT_OUTPUT, + ) + ->addOption( + 'prefix', + null, + InputOption::VALUE_REQUIRED, + 'Prefix applied to each vector directory name', + 'w3c-', + ) + ->addOption( + 'check', + null, + InputOption::VALUE_NONE, + 'Report differences without writing, exiting non-zero if any are found', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $specPath = $input->getOption('spec'); + if (!is_string($specPath)) { + $io->error('--spec is required; point it at index.bs in a w3c/webauthn checkout.'); + return Command::INVALID; + } + $spec = @file_get_contents($specPath); + if ($spec === false) { + $io->error(sprintf('Could not read %s', $specPath)); + return Command::INVALID; + } + + $outputDir = $input->getOption('output'); + $prefix = $input->getOption('prefix'); + if (!is_string($outputDir) || !is_string($prefix)) { + $io->error('--output and --prefix must be strings.'); + return Command::INVALID; + } + $check = $input->getOption('check') === true; + + $vectors = (new SpecParser())->parse($spec); + $writer = new FixtureWriter(); + + $changed = []; + foreach ($vectors as $vector) { + $dir = sprintf('%s/%s%s', $outputDir, $prefix, $vector->slug); + foreach ($writer->render($vector) as $name => $contents) { + $path = sprintf('%s/%s', $dir, $name); + if (is_file($path) && file_get_contents($path) === $contents) { + continue; + } + $changed[] = $path; + if (!$check) { + self::write($path, $contents); + } + } + } + + $io->text(sprintf('Parsed %d vectors from %s', count($vectors), $specPath)); + + if ($changed === []) { + $io->success('Fixtures are up to date.'); + return Command::SUCCESS; + } + + $io->listing($changed); + if ($check) { + $io->error(sprintf('%d file(s) differ from the generated output.', count($changed))); + return Command::FAILURE; + } + $io->success(sprintf('Wrote %d file(s).', count($changed))); + return Command::SUCCESS; + } + + private static function write(string $path, string $contents): void + { + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, recursive: true); + } + file_put_contents($path, $contents); + } +} diff --git a/tools/SpecParser.php b/tools/SpecParser.php new file mode 100644 index 0000000..837ad32 --- /dev/null +++ b/tools/SpecParser.php @@ -0,0 +1,161 @@ +'; + private const END = ''; + + /** + * §16.1 is the shared attestation trust root. It describes no credential, + * and the harness does no chain validation, so it has no fixture. + */ + private const SKIPPED_SLUG = 'attestation-root-cert'; + + private const REQUIRED_REGISTRATION = [ + 'challenge', + 'credential_id', + 'clientDataJSON', + 'attestationObject', + ]; + + private const REQUIRED_AUTHENTICATION = [ + 'challenge', + 'authenticatorData', + 'clientDataJSON', + 'signature', + ]; + + /** + * @return Vector[] + */ + public function parse(string $spec): array + { + $start = strpos($spec, self::BEGIN); + $end = strpos($spec, self::END); + if ($start === false || $end === false) { + throw new UnexpectedValueException( + 'Could not locate the generated test vector block. Is this a WebAuthn index.bs?' + ); + } + $block = substr($spec, $start, $end - $start); + + // Yields [preamble, slug, body, slug, body, ...] + $sections = preg_split( + '/^## .+ ## \{#sctn-test-vectors-([A-Za-z0-9-]+)\}$/m', + $block, + flags: PREG_SPLIT_DELIM_CAPTURE, + ); + if ($sections === false) { + throw new UnexpectedValueException('Could not split the vector block into sections'); + } + array_shift($sections); + + $vectors = []; + foreach (array_chunk($sections, 2) as $pair) { + if (count($pair) !== 2) { + throw new UnexpectedValueException('Found a vector heading with no body'); + } + [$slug, $body] = $pair; + if ($slug === self::SKIPPED_SLUG) { + continue; + } + + $registration = self::extractCeremony($body, 'Registration'); + if ($registration === null) { + throw new UnexpectedValueException(sprintf('%s has no registration ceremony', $slug)); + } + self::requireFields($registration, self::REQUIRED_REGISTRATION, "$slug registration"); + + $authentication = self::extractCeremony($body, 'Authentication'); + if ($authentication !== null) { + self::requireFields($authentication, self::REQUIRED_AUTHENTICATION, "$slug authentication"); + } + + $vectors[] = new Vector( + slug: $slug, + registration: $registration, + authentication: $authentication, + ); + } + + if ($vectors === []) { + throw new UnexpectedValueException('The vector block contained no credential vectors'); + } + + return $vectors; + } + + /** + * @return ?array + */ + private static function extractCeremony(string $body, string $label): ?array + { + $pattern = sprintf('/%s=\]:\s*]*>(.*?)<\/xmp>/s', preg_quote($label, '/')); + if (preg_match($pattern, $body, $matches) !== 1) { + return null; + } + return self::parseAssignments($matches[1]); + } + + /** + * Reads `name = h'hex'` lines, ignoring `;` comments and the alternate + * `= b64'...'` rendering that some values carry. + * + * @return array + */ + private static function parseAssignments(string $block): array + { + preg_match_all("/^([A-Za-z_][A-Za-z0-9_]*) = h'([0-9a-f]*)'/m", $block, $matches, PREG_SET_ORDER); + + $values = []; + foreach ($matches as $match) { + $values[$match[1]] ??= $match[2]; + } + return $values; + } + + /** + * @param array $ceremony + * @param string[] $required + */ + private static function requireFields(array $ceremony, array $required, string $context): void + { + $missing = array_diff($required, array_keys($ceremony)); + if ($missing !== []) { + throw new UnexpectedValueException(sprintf( + '%s is missing expected field(s): %s', + $context, + implode(', ', $missing), + )); + } + } +} diff --git a/tools/Vector.php b/tools/Vector.php new file mode 100644 index 0000000..084c36f --- /dev/null +++ b/tools/Vector.php @@ -0,0 +1,28 @@ + + */ +class Vector +{ + /** + * @param Ceremony $registration + * @param ?Ceremony $authentication + */ + public function __construct( + public readonly string $slug, + public readonly array $registration, + public readonly ?array $authentication, + ) { + } +} diff --git a/tools/console.php b/tools/console.php new file mode 100644 index 0000000..2d4e1ec --- /dev/null +++ b/tools/console.php @@ -0,0 +1,17 @@ +#!/usr/bin/env php +add($command); +$application->setDefaultCommand((string) $command->getName(), true); +$application->run();