diff --git a/README.md b/README.md index 6c976e4..8230087 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,77 @@ path to your custom configuration file to the environment variable - ``--ansi`` Force ANSI output - ``--no-ansi`` Disable ANSI output +### Commands specific to the FAIR Project + +FAIR integration gives each extension a cryptographic identity (`did:plc`) anchored at +[plc.directory](https://plc.directory). Published versions are signed with an Ed25519 key so +that consumers can verify artefact integrity without trusting the registry alone. + +Local state is stored in `~/.config/fairpm//` (mode `0700`): + +| File | Contents | +|------|----------| +| `keys.json` | Rotation key pair, Ed25519 verification key pair, recovery salt (mode `0600`) | +| `did.json` | The `did:plc:…` identifier and the signed genesis operation | + +#### Initial setup (one-time per extension) + +**1. `fair:did:create`** — Generate and publish a `did:plc` identity for the extension. + +Generates a fresh Ed25519 verification key pair and a secp256k1 rotation key pair, derives a static +recovery key from `TYPO3_API_USERNAME` + `TYPO3_API_PASSWORD` via HKDF-SHA256, builds the genesis +PLC operation, submits it to `plc.directory`, and writes `keys.json` + `did.json` locally. +Fails safely if a DID already exists. + +```bash +bin/tailor fair:did:create +``` + +**2. `ter:update`** — Register the DID with the TYPO3 Extension Repository. + +When a local DID is found in `~/.config/fairpm/`, `ter:update` automatically appends the +`did` field to the form payload. Run this once after `fair:did:create` to associate the DID +with the extension record in TER. + +```bash +bin/tailor ter:update +``` + +#### Publishing a new version (regular workflow) + +**3. `ter:publish`** — Publish a new version, optionally with FAIR signatures. + +If a local DID and verification key exist for the extension, `ter:publish` automatically computes +SHA-256/384/512 hashes of the ZIP artefact, signs the SHA-384 with the Ed25519 key, and includes +the hashes and signature in the upload payload — no extra step needed. + +```bash +bin/tailor ter:publish [extensionkey] +``` + +#### Signing an already-published version + +**4. `fair:extension:sign`** — Retroactively add FAIR signatures to an existing TER version. + +Downloads the published ZIP from `extensions.typo3.org`, verifies its MD5 checksum against the TER +API, computes SHA hashes, creates an Ed25519 signature, and submits the metadata via `PATCH` — +without re-uploading the binary. + +```bash +bin/tailor fair:extension:sign +``` + +#### Rare maintenance + +**5. `fair:did:update`** — Update a field in the published `did:plc` document. + +Currently supports updating `alsoKnownAs` (the list of `did:web` aliases). Fetches the previous +CID from `plc.directory`, builds a signed update operation, submits it, and refreshes `did.json`. + +```bash +bin/tailor fair:did:update alsoKnownAs '["did:web:extensions.typo3.org:my_ext"]' +``` + ## Author & License Created by Benni Mack and Oliver Bartsch. diff --git a/bin/tailor b/bin/tailor index 818fd70..b75a2ad 100755 --- a/bin/tailor +++ b/bin/tailor @@ -43,5 +43,9 @@ foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../autoload.php $application->add(new Command\Extension\UpdateExtensionCommand('ter:update')); $application->add(new Command\Extension\UploadExtensionVersionCommand('ter:publish')); $application->add(new Command\Extension\VersionDetailsCommand('ter:version')); + $application->add(new Command\Fair\CreateDidCommand('fair:did:create')); + $application->add(new Command\Fair\UpdateDidCommand('fair:did:update')); + $application->add(new Command\Fair\SignExtensionVersionCommand('fair:extension:sign')); + $application->add(new Command\Fair\MigrateSignCommand('fair:migrate:sign')); $application->run(); }); diff --git a/composer.json b/composer.json index a931f75..b9c6e5c 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": "^7.2 || ^8.0", "ext-json": "*", "ext-zip": "*", + "fairpm/did-manager": "^0.0.3", "symfony/console": "^5.4 || ^6.4 || ^7.0", "symfony/dotenv": "^5.4 || ^6.4 || ^7.0", "symfony/http-client": "^5.4 || ^6.4 || ^7.0", diff --git a/src/Command/Extension/UpdateExtensionCommand.php b/src/Command/Extension/UpdateExtensionCommand.php index ab70fdf..d20a9a9 100644 --- a/src/Command/Extension/UpdateExtensionCommand.php +++ b/src/Command/Extension/UpdateExtensionCommand.php @@ -21,6 +21,7 @@ use TYPO3\Tailor\Dto\RequestConfiguration; use TYPO3\Tailor\Formatter\ConsoleFormatter; use TYPO3\Tailor\Helper\CommandHelper; +use TYPO3\Tailor\Service\FairConfigurationService; /** * Command for TER REST endpoint `PUT /extension/{key}` @@ -91,6 +92,14 @@ private function getFormData(): array } } + $config = new FairConfigurationService(); + if ($config->didExists($this->extensionKey)) { + $did = $config->getDid($this->extensionKey); + if ($did !== null) { + $formData['did'] = $did; + } + } + return $formData; } } diff --git a/src/Command/Extension/UploadExtensionVersionCommand.php b/src/Command/Extension/UploadExtensionVersionCommand.php index 3c06f10..b6da417 100644 --- a/src/Command/Extension/UploadExtensionVersionCommand.php +++ b/src/Command/Extension/UploadExtensionVersionCommand.php @@ -12,6 +12,7 @@ namespace TYPO3\Tailor\Command\Extension; +use FAIR\DID\Keys\EdDsaKey; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -25,6 +26,7 @@ use TYPO3\Tailor\Formatter\ConsoleFormatter; use TYPO3\Tailor\Helper\CommandHelper; use TYPO3\Tailor\HttpClientFactory; +use TYPO3\Tailor\Service\FairConfigurationService; use TYPO3\Tailor\Service\VersionService; /** @@ -70,7 +72,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int protected function getRequestConfiguration(): RequestConfiguration { - $formDataPart = $this->getFormDataPart($this->input->getOptions()); + $versionService = $this->prepareVersionService($this->input->getOptions()); + $fairFields = $this->getFairpmFields($versionService->getVersionFilePath()); + $formDataPart = $this->getFormDataPart($this->input->getOptions(), $versionService, $fairFields); return new RequestConfiguration( 'POST', @@ -84,6 +88,50 @@ protected function getRequestConfiguration(): RequestConfiguration ); } + private function prepareVersionService(array $options): VersionService + { + $versionService = new VersionService($this->version, $this->extensionKey, $this->transactionPath); + + if ($options['path'] !== null) { + $versionService->createZipArchiveFromPath((string)$options['path']); + } elseif ($options['artefact'] !== null) { + $versionService->createZipArchiveFromArtefact(trim((string)$options['artefact'])); + } else { + $versionService->createZipArchiveFromPath(getcwd() ?: './'); + } + + return $versionService; + } + + private function getFairpmFields(string $zipFilePath): array + { + $config = new FairConfigurationService(); + + if (!$config->didExists($this->extensionKey)) { + return []; + } + + $keysData = $config->loadKeysData($this->extensionKey); + $privateMultibase = $keysData['verificationKey']['private'] ?? null; + + if ($privateMultibase === null) { + return []; + } + + $zipFileContents = file_get_contents($zipFilePath); + $sha256 = hash('sha256', $zipFileContents); + $sha384 = hash('sha384', $zipFileContents); + $sha512 = hash('sha512', $zipFileContents); + $signature = EdDsaKey::from_private($privateMultibase)->sign($sha384); + + return [ + 'sha256' => $sha256, + 'sha384' => $sha384, + 'sha512' => $sha512, + 'didSignature' => $signature, + ]; + } + protected function getMessages(): Messages { $variables = [$this->version, $this->extensionKey]; @@ -96,14 +144,13 @@ protected function getMessages(): Messages } /** - * Create FormDataPart from given options. - * This also creates a proper DataPart (containing the version as ZipArchive) - * from either a given path or an existing ZipArchive (local or remote). + * Create FormDataPart from given options and a prepared VersionService. * * @param array $options + * @param VersionService $versionService * @return FormDataPart */ - protected function getFormDataPart(array $options): FormDataPart + protected function getFormDataPart(array $options, VersionService $versionService, array $fairFields = []): FormDataPart { if ($options['comment'] === null) { // The REST API requires a description to be set (just like the GUI does). @@ -111,22 +158,11 @@ protected function getFormDataPart(array $options): FormDataPart $options['comment'] = 'Updated extension to ' . $this->version; } - $versionService = new VersionService($this->version, $this->extensionKey, $this->transactionPath); - - if ($options['path'] !== null) { - $versionService->createZipArchiveFromPath((string)$options['path']); - } elseif ($options['artefact'] !== null) { - $versionService->createZipArchiveFromArtefact(trim((string)$options['artefact'])); - } else { - // If neither `path` nor `artefact` is defined, we just - // create the ZipArchive from the current directory. - $versionService->createZipArchiveFromPath(getcwd() ?: './'); - } - return new FormDataPart([ 'description' => (string)$options['comment'], 'gplCompliant' => '1', 'file' => DataPart::fromPath($versionService->getVersionFilePath()), + ...$fairFields, ]); } diff --git a/src/Command/Fair/CreateDidCommand.php b/src/Command/Fair/CreateDidCommand.php new file mode 100644 index 0000000..129f0e1 --- /dev/null +++ b/src/Command/Fair/CreateDidCommand.php @@ -0,0 +1,238 @@ +setDescription('Generate a new did:plc for a TYPO3 extension') + ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $fairService = new FairService(); + + $extensionKey = CommandHelper::getExtensionKeyFromInput($input); + $config = new FairConfigurationService(); + $plcClient = new PlcClient('https://plc.directory'); + + if ($config->didExists($extensionKey)) { + $didData = $config->loadDidData($extensionKey); + $did = $didData['did'] ?? ''; + $plcUrl = 'https://plc.directory/' . $did; + + try { + $plcClient->resolve_did($did); + } catch (\Exception) { + $io->warning('DID exists locally but has not been published to plc.directory.'); + $io->table([], [ + ['DID', $did], + ['Local file', $config->getDidFile($extensionKey)], + ]); + return Command::FAILURE; + } + + // DID is published — regenerate alsoKnownAs and services without touching keys. + $keysData = $config->loadKeysData($extensionKey); + $rotationKeys = array_map( + fn(string $k) => KeyFactory::decode_did_key($k), + $didData['rotationKeys'], + ); + $verificationMethods = array_map( + fn(string $k) => KeyFactory::decode_did_key($k), + $didData['verificationMethods'], + ); + $signingKey = KeyFactory::decode_private_key($keysData['rotationKey']['private']); + + try { + $this->publishUpdate( + $plcClient, + $config, + $extensionKey, + $did, + $rotationKeys, + $verificationMethods, + [$fairService->resolveDidWeb($extensionKey)], + $this->buildServicesArray($did), + $signingKey, + ); + } catch (\Exception $e) { + $io->error('Failed to update DID on plc.directory: ' . $e->getMessage()); + return Command::FAILURE; + } + + $io->success('DID updated successfully!'); + $io->table([], [ + ['DID', $did], + ['plc.directory', $plcUrl], + ['DID file', $config->getDidFile($extensionKey)], + ]); + + return Command::SUCCESS; + } + + // Load or initialise keys.json + $keysData = $config->loadKeysData($extensionKey); + $config->ensureSalt($keysData); + + // Derive the static rotation key (recovery key) using HKDF + $staticRotationKey = $fairService->deriveRecoveryKey( + Variables::get('TYPO3_API_USERNAME'), + Variables::get('TYPO3_API_PASSWORD'), + $keysData['recovery']['salt'], + ); + + // Generate fresh keys + $rotationKey = DidCodec::generate_key_pair(); + $verificationKey = DidCodec::generate_ed25519_key_pair(); + + // Build genesis PlcOperation (no services — DID not yet known) + $keyId = substr(hash('sha256', $verificationKey->encode_public()), 0, 6); + $operation = new PlcOperation( + type: 'plc_operation', + rotation_keys: [$rotationKey, $staticRotationKey], + verification_methods: ['fair_' . $keyId => $verificationKey], + also_known_as: [$fairService->resolveDidWeb($extensionKey)], + services: [], + ); + + // Sign & generate DID + $signed = DidCodec::sign_plc_operation($operation, $rotationKey); + $did = DidCodec::generate_plc_did($signed); + + // Submit genesis to plc.directory + try { + $plcClient->create_did($did, $signed->jsonSerialize()); + } catch (\Exception $e) { + $io->error('Failed to submit DID to plc.directory: ' . $e->getMessage()); + return Command::FAILURE; + } + + // Persist config + $config->ensureConfigDir($extensionKey); + + $keysData['rotationKey'] = [ + 'private' => $rotationKey->encode_private(), + 'public' => $rotationKey->encode_public(), + ]; + $keysData['verificationKey'] = [ + 'private' => $verificationKey->encode_private(), + 'public' => $verificationKey->encode_public(), + ]; + $config->writeKeysData($extensionKey, $keysData); + $config->writeDidData($extensionKey, array_merge(['did' => $did], $signed->jsonSerialize())); + + // Submit follow-up update to add alsoKnownAs + services (DID is now known) + $rotationKeys = [$rotationKey, $staticRotationKey]; + $verificationMethods = ['fair_' . $keyId => $verificationKey]; + + try { + $this->publishUpdate( + $plcClient, + $config, + $extensionKey, + $did, + $rotationKeys, + $verificationMethods, + [$fairService->resolveDidWeb($extensionKey)], + $this->buildServicesArray($did), + $rotationKey, + ); + } catch (\Exception $e) { + $io->warning('DID created but service endpoint could not be published: ' . $e->getMessage() . ' Re-run this command to retry.'); + } + + $io->success('DID created successfully!'); + $io->table([], [ + ['DID', $did], + ['Keys file', $config->getKeysFile($extensionKey)], + ['DID file', $config->getDidFile($extensionKey)], + ]); + + return Command::SUCCESS; + } + + /** + * Build the standard FAIR package management service array for a given DID. + */ + private function buildServicesArray(string $did): array + { + return [ + 'fairpm_repo' => [ + 'type' => 'FairPackageManagementRepo', + 'endpoint' => 'https://extensions.typo3.org/fair/v1/packages/' . $did, + ], + ]; + } + + /** + * Submit a plc_operation update to plc.directory and persist the result locally. + * + * @param PlcClient $plcClient + * @param FairConfigurationService $config + * @param string $extensionKey + * @param string $did + * @param Key[] $rotationKeys + * @param array $verificationMethods + * @param string[] $alsoKnownAs + * @param array $services + * @param Key $signingKey + */ + private function publishUpdate( + PlcClient $plcClient, + FairConfigurationService $config, + string $extensionKey, + string $did, + array $rotationKeys, + array $verificationMethods, + array $alsoKnownAs, + array $services, + Key $signingKey, + ): void { + $prev = $plcClient->get_previous_cid($did); + + $operation = new PlcOperation( + type: 'plc_operation', + rotation_keys: $rotationKeys, + verification_methods: $verificationMethods, + also_known_as: $alsoKnownAs, + services: $services, + prev: $prev, + ); + + $signed = DidCodec::sign_plc_operation($operation, $signingKey); + $plcClient->update_did($did, $signed->jsonSerialize()); + $config->writeDidData($extensionKey, array_merge(['did' => $did], $signed->jsonSerialize())); + } +} diff --git a/src/Command/Fair/MigrateSignCommand.php b/src/Command/Fair/MigrateSignCommand.php new file mode 100644 index 0000000..af53d18 --- /dev/null +++ b/src/Command/Fair/MigrateSignCommand.php @@ -0,0 +1,291 @@ +setDescription('Sign an extension version with a local Ed25519 PEM key and write FAIR metadata to a JSON file') + ->addArgument('extensionkey', InputArgument::REQUIRED, 'The extension key (e.g. news)') + ->addArgument('version', InputArgument::REQUIRED, 'The version to sign (e.g. 14.0.1)') + ->addOption( + 'out', + null, + InputOption::VALUE_REQUIRED, + 'Path to the JSON output file (created or merged into)', + 'releases.json' + ) + ->addOption( + 'key', + null, + InputOption::VALUE_REQUIRED, + 'Path to the Ed25519 private key in PEM format' + ) + ->addOption( + 'did', + null, + InputOption::VALUE_REQUIRED, + 'Path to write the DID document JSON (e.g. did.json). If omitted, no DID document is written.' + ) + ->setHelp('bin/tailor fair:migrate:sign news 14.0.0 --key ~/.config/private.pem --out news.releases.json --did did.json'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $extensionKey = (string)$input->getArgument('extensionkey'); + $version = (string)$input->getArgument('version'); + $outFile = (string)$input->getOption('out'); + $keyFile = (string)$input->getOption('key'); + + // 1. Validate --key option + if ($keyFile === '') { + $io->error('Option --key is required. Provide a path to an Ed25519 PEM private key.'); + return Command::FAILURE; + } + + if (!is_readable($keyFile)) { + $io->error(sprintf('Key file "%s" does not exist or is not readable.', $keyFile)); + return Command::FAILURE; + } + + $privateMultibase = $this->loadEd25519SecretKeyFromPem((string)file_get_contents($keyFile)); + if ($privateMultibase === null) { + $io->error(sprintf('Failed to load Ed25519 private key from "%s". Expected a PKCS#8 PEM file (BEGIN PRIVATE KEY).', $keyFile)); + return Command::FAILURE; + } + + $edDsaKey = EdDsaKey::from_private($privateMultibase); + $didFile = (string)$input->getOption('did'); + + // 2. Resolve did:web: identifier (deterministic, no local config needed) + $fairService = new FairService(); + $did = $fairService->resolveDidWeb($extensionKey); + + // 3. Build ZIP URL using the fileadmin path convention + $baseUri = rtrim(Variables::get('TYPO3_REMOTE_BASE_URI') ?: self::DEFAULT_BASE_URI, '/'); + $a = $extensionKey[0]; + $b = $extensionKey[1] ?? $a; + $zipUrl = sprintf('%s/fileadmin/ter/%s/%s/%s_%s.zip', $baseUri, $a, $b, $extensionKey, $version); + + // 4. Download the extension ZIP + $io->writeln(sprintf('Downloading ZIP from %s...', $zipUrl)); + $client = HttpClient::create(['max_redirects' => 5]); + + try { + $response = $client->request('GET', $zipUrl, [ + 'headers' => ['User-Agent' => 'Tailor - Your TYPO3 Extension Helper'], + ]); + $zipContents = $response->getContent(); + } catch (\Throwable $e) { + $io->error('Failed to download extension ZIP: ' . $e->getMessage()); + return Command::FAILURE; + } + + // 5. Compute SHA hashes + $sha256 = hash('sha256', $zipContents); + $sha384 = hash('sha384', $zipContents); + $sha512 = hash('sha512', $zipContents); + + // 6. Build the artifact operation (all fields to be signed — no signature field) + $operation = [ + 'content-type' => 'application/zip', + 'sha256' => $sha256, + 'signer' => $did, + 'url' => $zipUrl, + ]; + + // 7. DAG-CBOR encode and sign: SHA-256 of CBOR bytes → Ed25519 → base64url + $cbor = $this->encodeOperationCbor($operation); + $signature = rtrim(strtr(base64_encode(hex2bin( + $edDsaKey->sign(hash('sha256', $cbor, false)) + )), '+/', '-_'), '='); + + // 8. Build the artifact record + $artifactRecord = [ + 'url' => $zipUrl, + 'content-type' => 'application/zip', + 'signer' => $did, + 'sha256' => $sha256, + 'signature' => $signature, + ]; + + // 9. Merge into the output JSON file + $this->mergeReleaseRecord($outFile, $version, $zipUrl, $artifactRecord); + + // 10. Optionally write DID document + if ($didFile !== '') { + $siteDid = $fairService->resolveDidWeb(null); + $didDocument = [ + '@context' => [ + 'https://www.w3.org/ns/did/v1', + 'https://w3id.org/security/multikey/v1', + ], + 'id' => $siteDid, + 'verificationMethod' => [ + [ + 'id' => $siteDid . '#fair_signing', + 'type' => 'Multikey', + 'controller' => $siteDid, + 'publicKeyMultibase' => $edDsaKey->encode_public(), + ], + ], + ]; + file_put_contents( + $didFile, + json_encode($didDocument, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); + $io->writeln(sprintf('DID document written to %s.', $didFile)); + } + + $io->success(sprintf( + 'Version %s of extension %s signed and written to %s.', + $version, + $extensionKey, + $outFile + )); + $io->table([], [ + ['DID (signer)', $did], + ['ZIP URL', $zipUrl], + ['SHA-256', $sha256], + ['SHA-384', $sha384], + ['SHA-512', $sha512], + ['Signature', $signature], + ['Output file', $outFile], + ]); + + return Command::SUCCESS; + } + + /** + * DAG-CBOR encode a flat string→string map using RFC 8949 §4.2 canonical ordering. + */ + private function encodeOperationCbor(array $operation): string + { + $items = []; + foreach ($operation as $key => $value) { + $items[] = MapItem::create( + TextStringObject::create($key), + TextStringObject::create($value), + ); + } + return (string) CanonicalMapObject::create($items); + } + + /** + * Parse a PKCS#8 Ed25519 PEM private key and return a multibase-encoded private key + * string suitable for use with EdDsaKey::from_private(), or null on failure. + * + * A PKCS#8 Ed25519 DER blob is always 48 bytes: + * 30 2e 30 05 06 03 2b 65 70 04 22 04 20 [32-byte seed] + * The seed occupies the final 32 bytes. + */ + private function loadEd25519SecretKeyFromPem(string $pem): ?string + { + // Strip PEM envelope and decode + $der = base64_decode( + preg_replace('/-----[^-]+-----|[\r\n\s]+/', '', $pem) ?? '', + strict: true + ); + + if ($der === false || strlen($der) < 32) { + return null; + } + + // The 32-byte Ed25519 seed is always the last 32 bytes of the PKCS#8 blob + $seed = substr($der, -32); + + return Multibase::encode(Multibase::BASE58BTC, Key::PREFIX_ED25519_PRIV . $seed); + } + + private function mergeReleaseRecord( + string $outFile, + string $version, + string $zipUrl, + array $artifactRecord + ): void { + $data = ['releases' => []]; + if (file_exists($outFile)) { + $decoded = json_decode((string)file_get_contents($outFile), true); + if (is_array($decoded)) { + $data = $decoded; + } + } + + $releaseIndex = null; + foreach ($data['releases'] as $i => $release) { + if (($release['version'] ?? '') === $version) { + $releaseIndex = $i; + break; + } + } + + if ($releaseIndex !== null) { + $packages = $data['releases'][$releaseIndex]['artifacts']['package'] ?? []; + $artIndex = null; + foreach ($packages as $j => $art) { + if (($art['url'] ?? '') === $zipUrl) { + $artIndex = $j; + break; + } + } + + if ($artIndex !== null) { + $data['releases'][$releaseIndex]['artifacts']['package'][$artIndex] = $artifactRecord; + } else { + $data['releases'][$releaseIndex]['artifacts']['package'][] = $artifactRecord; + } + } else { + $data['releases'][] = [ + 'version' => $version, + 'artifacts' => [ + 'package' => [$artifactRecord], + ], + ]; + } + + usort($data['releases'], static fn(array $a, array $b) => version_compare($a['version'] ?? '', $b['version'] ?? '')); + + file_put_contents( + $outFile, + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); + } +} diff --git a/src/Command/Fair/SignExtensionVersionCommand.php b/src/Command/Fair/SignExtensionVersionCommand.php new file mode 100644 index 0000000..f2b029e --- /dev/null +++ b/src/Command/Fair/SignExtensionVersionCommand.php @@ -0,0 +1,164 @@ +setDescription('Sign an already-published extension version on TER with a local FAIR DID') + ->addArgument('extensionkey', InputArgument::REQUIRED, 'The extension key') + ->addArgument('version', InputArgument::REQUIRED, 'The version to sign, e.g. 1.2.3'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $extensionKey = (string)$input->getArgument('extensionkey'); + $version = (string)$input->getArgument('version'); + + // 1. Validate a local DID exists for this extension + $config = new FairConfigurationService(); + if (!$config->didExists($extensionKey)) { + $io->error(sprintf( + 'No local DID found for extension "%s". Run fair:did:create first.', + $extensionKey + )); + return Command::FAILURE; + } + + // 2. Load DID identifier and private signing key + $didData = $config->loadDidData($extensionKey); + $did = $didData['did'] ?? null; + if ($did === null) { + $io->error('DID data is corrupt: missing "did" field.'); + return Command::FAILURE; + } + + $keysData = $config->loadKeysData($extensionKey); + $privateMultibase = $keysData['verificationKey']['private'] ?? null; + if ($privateMultibase === null) { + $io->error('No private verification key found in keys.json. Cannot sign.'); + return Command::FAILURE; + } + + // 3. Download the extension ZIP from extensions.typo3.org + $baseUri = rtrim(Variables::get('TYPO3_REMOTE_BASE_URI') ?: self::DEFAULT_BASE_URI, '/'); + $zipUrl = sprintf('%s/extension/download/%s/%s/zip/', $baseUri, $extensionKey, $version); + + $io->writeln(sprintf('Downloading ZIP from %s...', $zipUrl)); + $downloadClient = HttpClient::create(['max_redirects' => 5]); + + try { + $zipResponse = $downloadClient->request('GET', $zipUrl, [ + 'headers' => ['User-Agent' => 'Tailor - Your TYPO3 Extension Helper'], + ]); + $zipContents = $zipResponse->getContent(); + } catch (\Throwable $e) { + $io->error('Failed to download extension ZIP: ' . $e->getMessage()); + return Command::FAILURE; + } + + // 4. Compute SHA hashes of the ZIP + $sha256 = hash('sha256', $zipContents); + $sha384 = hash('sha384', $zipContents); + $sha512 = hash('sha512', $zipContents); + + // 5. Create Ed25519 signature over the hex-encoded SHA-384 + $signature = EdDsaKey::from_private($privateMultibase)->sign($sha384); + + // 6. Submit FAIR metadata via PATCH (no binary re-upload) + $io->writeln('Submitting FAIR signature and hashes to TER...'); + $terClient = $this->createTerApiClient(); + + try { + $patchResponse = $terClient->request('PATCH', 'extension/' . $extensionKey . '/' . $version, [ + 'body' => [ + 'sha256' => $sha256, + 'sha384' => $sha384, + 'sha512' => $sha512, + 'didSignature' => $signature, + ], + ]); + $patchStatus = $patchResponse->getStatusCode(); + $patchContent = (array)(json_decode($patchResponse->getContent(false), true) ?? []); + } catch (\Throwable $e) { + $io->error('Failed to submit FAIR metadata to TER: ' . $e->getMessage()); + return Command::FAILURE; + } + + if ($patchStatus >= 200 && $patchStatus < 300) { + $io->success(sprintf( + 'Version %s of extension %s successfully signed and registered with FAIR metadata.', + $version, + $extensionKey + )); + return Command::SUCCESS; + } + + $errorMessage = $patchContent['error_description'] ?? $patchContent['message'] ?? 'Unknown error (Status ' . $patchStatus . ')'; + $io->error('Could not submit FAIR metadata to TER: ' . $errorMessage); + return Command::FAILURE; + } + + /** + * Creates an authenticated Symfony HTTP client for the TER REST API. + */ + private function createTerApiClient(): \Symfony\Contracts\HttpClient\HttpClientInterface + { + $remoteBaseUri = Variables::get('TYPO3_REMOTE_BASE_URI') ?: self::DEFAULT_BASE_URI; + $apiVersion = Variables::get('TYPO3_API_VERSION') ?: self::DEFAULT_API_VERSION; + $baseUri = rtrim($remoteBaseUri, '/') . self::API_ENTRY_POINT . trim($apiVersion, '/') . '/'; + + $options = [ + 'base_uri' => $baseUri, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Tailor - Your TYPO3 Extension Helper', + ], + 'max_redirects' => 0, + ]; + + if (Variables::has('TYPO3_API_TOKEN')) { + $options['auth_bearer'] = Variables::get('TYPO3_API_TOKEN'); + } elseif (Variables::has('TYPO3_API_USERNAME') && Variables::has('TYPO3_API_PASSWORD')) { + $options['auth_basic'] = [Variables::get('TYPO3_API_USERNAME'), Variables::get('TYPO3_API_PASSWORD')]; + } else { + throw new \InvalidArgumentException('No authentication credentials are defined.', 1606995339); + } + + return HttpClient::create($options); + } +} diff --git a/src/Command/Fair/UpdateDidCommand.php b/src/Command/Fair/UpdateDidCommand.php new file mode 100644 index 0000000..3b85adb --- /dev/null +++ b/src/Command/Fair/UpdateDidCommand.php @@ -0,0 +1,151 @@ +setDescription('Update a field in a published did:plc for a TYPO3 extension') + ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key') + ->addArgument('field', InputArgument::OPTIONAL, 'The DID document field to update (e.g. alsoKnownAs)') + ->addArgument('value', InputArgument::OPTIONAL, 'The new value as a JSON string (e.g. \'["did:web:…"]\')') + ->setHelp('bin/tailor fair:did:update fairy_tale alsoKnownAs \'["did:web:extensions.typo3.org:fairy_tale"]\''); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $config = new FairConfigurationService(); + + $extensionKey = CommandHelper::getExtensionKeyFromInput($input); + $field = (string)($input->getArgument('field') ?? ''); + $rawValue = (string)($input->getArgument('value') ?? ''); + + if ($field === '' || $rawValue === '') { + $io->error('Arguments and are required.'); + return Command::FAILURE; + } + + // Guard: local DID must exist + if (!$config->didExists($extensionKey)) { + $io->error(sprintf( + 'No local DID found for "%s". Run fair:did:create first.', + $extensionKey, + )); + return Command::FAILURE; + } + + // Validate field + if (!in_array($field, self::ALLOWED_FIELDS, true)) { + $io->error(sprintf( + 'Unsupported field "%s". Allowed fields: %s.', + $field, + implode(', ', self::ALLOWED_FIELDS), + )); + return Command::FAILURE; + } + + // Parse JSON value + $parsedValue = json_decode($rawValue, true); + if ($parsedValue === null) { + $io->error('Value must be valid JSON (e.g. \'["did:web:extensions.typo3.org:my_ext"]\').'); + return Command::FAILURE; + } + + $didData = $config->loadDidData($extensionKey); + $keysData = $config->loadKeysData($extensionKey); + $did = $didData['did']; + + // Reconstruct Key objects from did.json + $rotationKeys = array_map( + fn(string $k) => KeyFactory::decode_did_key($k), + $didData['rotationKeys'], + ); + $verificationMethods = array_map( + fn(string $k) => KeyFactory::decode_did_key($k), + $didData['verificationMethods'], + ); + + // Apply field update + $alsoKnownAs = $didData['alsoKnownAs'] ?? []; + switch ($field) { + case 'alsoKnownAs': + $alsoKnownAs = $parsedValue; + break; + } + + $plcClient = new PlcClient('https://plc.directory'); + + // Fetch prev CID required by PLC spec + try { + $prev = $plcClient->get_previous_cid($did); + } catch (\Exception $e) { + $io->error('Failed to fetch previous CID from plc.directory: ' . $e->getMessage()); + return Command::FAILURE; + } + + // Reconstruct signing key from keys.json + $signingKey = KeyFactory::decode_private_key($keysData['rotationKey']['private']); + + // Build update operation + $operation = new PlcOperation( + type: 'plc_operation', + rotation_keys: $rotationKeys, + verification_methods: $verificationMethods, + also_known_as: $alsoKnownAs, + services: $didData['services'] ?? [], + prev: $prev, + ); + + $signed = DidCodec::sign_plc_operation($operation, $signingKey); + + // Submit to plc.directory + try { + $plcClient->update_did($did, $signed->jsonSerialize()); + } catch (\Exception $e) { + $io->error('Failed to submit update to plc.directory: ' . $e->getMessage()); + return Command::FAILURE; + } + + // Persist updated did.json + $config->writeDidData($extensionKey, array_merge(['did' => $did], $signed->jsonSerialize())); + + $io->success('DID updated successfully!'); + $io->table([], [ + ['DID', $did], + ['Updated field', $field], + ['New value', json_encode($parsedValue, JSON_UNESCAPED_SLASHES)], + ['plc.directory', 'https://plc.directory/' . $did], + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Service/FairConfigurationService.php b/src/Service/FairConfigurationService.php new file mode 100644 index 0000000..2245e87 --- /dev/null +++ b/src/Service/FairConfigurationService.php @@ -0,0 +1,137 @@ +/). + * + * Responsible for path resolution, reading and writing keys.json / did.json, + * and ensuring the directory is created with the correct permissions. + */ +class FairConfigurationService +{ + private string $baseDir; + + public function __construct(?string $baseDir = null) + { + $this->baseDir = $baseDir ?? (($_SERVER['HOME'] ?? '') . '/.config/fairpm'); + } + + public function getConfigDir(string $extensionKey): string + { + return $this->baseDir . '/' . $extensionKey; + } + + public function getKeysFile(string $extensionKey): string + { + return $this->getConfigDir($extensionKey) . '/keys.json'; + } + + public function getDidFile(string $extensionKey): string + { + return $this->getConfigDir($extensionKey) . '/did.json'; + } + + /** + * @return string|null `did:plc:...` value (if available) + */ + public function getDid(string $extensionKey): ?string + { + if (!$this->didExists($extensionKey)) { + return null; + } + $didFile = $this->getDidFile($extensionKey); + $didData = json_decode((string)file_get_contents($didFile), true) ?? []; + return $didData['did'] ?? null; + } + + public function didExists(string $extensionKey): bool + { + return file_exists($this->getDidFile($extensionKey)); + } + + /** + * Load keys.json for the given extension key. + * Returns an empty array if the file does not yet exist. + * + * @return array + */ + public function loadKeysData(string $extensionKey): array + { + $file = $this->getKeysFile($extensionKey); + if (!file_exists($file)) { + return []; + } + return json_decode((string)file_get_contents($file), true) ?? []; + } + + /** + * Load did.json for the given extension key. + * Returns an empty array if the file does not yet exist. + * + * @return array + */ + public function loadDidData(string $extensionKey): array + { + $file = $this->getDidFile($extensionKey); + if (!file_exists($file)) { + return []; + } + return json_decode((string)file_get_contents($file), true) ?? []; + } + + /** + * Ensure recovery.salt is present in $keysData, generating one if absent. + * + * @param array $keysData + */ + public function ensureSalt(array &$keysData): void + { + if (empty($keysData['recovery']['salt'])) { + $keysData['recovery']['salt'] = bin2hex(random_bytes(32)); + } + } + + /** + * Create the config directory for the extension (mode 0700) if it does not exist. + */ + public function ensureConfigDir(string $extensionKey): void + { + $dir = $this->getConfigDir($extensionKey); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + } + + /** + * Write keys.json, then restrict permissions to 0600. + * + * @param array $keysData + */ + public function writeKeysData(string $extensionKey, array $keysData): void + { + $file = $this->getKeysFile($extensionKey); + file_put_contents($file, json_encode($keysData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + chmod($file, 0600); + } + + /** + * Write did.json for the given extension. + * + * @param array $didData + */ + public function writeDidData(string $extensionKey, array $didData): void + { + $file = $this->getDidFile($extensionKey); + file_put_contents($file, json_encode($didData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/src/Service/FairService.php b/src/Service/FairService.php new file mode 100644 index 0000000..b5bc76f --- /dev/null +++ b/src/Service/FairService.php @@ -0,0 +1,46 @@ + $username, 'pass' => $password]); + $derivedBytes = hash_hkdf('sha256', $ikm, 32, 'did-plc-rotation-key', $salt); + $ec = new EC(Key::CURVE_K256); + return new EcKey($ec->keyFromPrivate(bin2hex($derivedBytes), 'hex'), Key::CURVE_K256); + } +}