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
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<extensionkey>/` (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 <extensionkey>
```

**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 <extensionkey>
```

#### 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 <version> [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 <extensionkey> <version>
```

#### 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 <extensionkey> alsoKnownAs '["did:web:extensions.typo3.org:my_ext"]'
```

## Author & License

Created by Benni Mack and Oliver Bartsch.
Expand Down
4 changes: 4 additions & 0 deletions bin/tailor
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/Command/Extension/UpdateExtensionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -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;
}
}
70 changes: 53 additions & 17 deletions src/Command/Extension/UploadExtensionVersionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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',
Expand All @@ -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];
Expand All @@ -96,37 +144,25 @@ 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).
// For now we just generate a description from the given version if non is given.
$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,
]);
}

Expand Down
Loading
Loading