Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"examples/functions.php"
],
"psr-4": {
"Firehed\\WebAuthn\\": "tests"
"Firehed\\WebAuthn\\": "tests",
"Firehed\\WebAuthn\\Tools\\": "tools"
}
},
"require": {
Expand All @@ -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": [
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<!-- Default paths to cover -->
<file>src</file>
<file>tests</file>
<file>tools</file>

<rule ref="PSR12"/>
</ruleset>
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ parameters:
- examples
- src
- tests
- tools
106 changes: 106 additions & 0 deletions tools/FixtureWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn\Tools;

use Firehed\WebAuthn\BinaryString;
use UnexpectedValueException;

use function array_key_exists;
use function array_map;
use function is_array;
use function is_string;
use function json_decode;
use function json_encode;

use const JSON_PRETTY_PRINT;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_SLASHES;

/**
* Renders a Vector as the files IntegrationTest consumes. Every decision about
* the on-disk shape lives here.
*
* The spec publishes each value as hex; the wire format the response parsers
* expect is base64url, so that is the only transformation applied.
*/
class FixtureWriter
{
/**
* @return array<string, string> 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";
}
}
128 changes: 128 additions & 0 deletions tools/GenerateTestVectorsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn\Tools;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

use function count;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function is_dir;
use function is_file;
use function is_string;
use function mkdir;
use function sprintf;

#[AsCommand(
name: 'generate-test-vectors',
description: 'Regenerate the W3C integration fixtures from a WebAuthn spec checkout',
)]
class GenerateTestVectorsCommand extends Command
{
private const DEFAULT_OUTPUT = __DIR__ . '/../tests/integration';

protected function configure(): void
{
$this
->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);
}
}
Loading
Loading