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();