Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor gpg import to use machine readable colon format #256

Open
wants to merge 1 commit into
base: 1.25.x
Choose a base branch
from
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
55 changes: 43 additions & 12 deletions src/Gpg/ImportGpgKeyFromStringViaTemporaryFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,60 @@

namespace Laminas\AutomaticReleases\Gpg;

use Laminas\AutomaticReleases\Gpg\Value\ColonFormattedKeyRecord;
use Psl;
use Psl\Env;
use Psl\Filesystem;
use Psl\Regex;
use Psl\Shell;
use Psl\Str;
use Psl\Vec;

use function array_shift;
use function count;
use function Psl\File\write;

final class ImportGpgKeyFromStringViaTemporaryFile implements ImportGpgKeyFromString
{
public function __invoke(string $keyContents): SecretKeyId
{
$keyFileName = Filesystem\create_temporary_file(Env\temp_dir(), 'imported-key');
write($keyFileName, $keyContents);

$output = Shell\execute('gpg', ['--import', $keyFileName], null, [], Shell\ErrorOutputBehavior::Append);

$matches = Regex\first_match($output, '/key\\s+([A-F0-9]+):\\s+secret\\s+key\\s+imported/im', Regex\capture_groups([1]));

Psl\invariant($matches !== null, 'unexpected output.');

Filesystem\delete_file($keyFileName);

return SecretKeyId::fromBase16String($matches[1]);
try {
write($keyFileName, $keyContents);

$output = Shell\execute(
'gpg',
['--import', '--import-options', 'import-show', '--with-colons', $keyFileName],
null,
[],
Shell\ErrorOutputBehavior::Discard,
);

$keyRecords = Vec\filter_nulls(Vec\map(
Str\split($output, "\n"),
static fn (string $record): ColonFormattedKeyRecord|null => ColonFormattedKeyRecord::fromRecordLine(
$record,
),
));

// Primary key secret is exported as unusable gnu-stub secret with --export-secret-subkeys.
// Consequently primary key secret is always present even when signing is done by subkey with actual secret.
$primaryKeyRecords = Vec\filter(
$keyRecords,
static fn (ColonFormattedKeyRecord $record): bool => $record->isPrimaryKey() && $record->isSecretKey(),
);

Psl\invariant(count($primaryKeyRecords) > 0, 'Imported GPG key material does not contain secret key');
// import can contain multiple keys. Sanity check to ensure no unexpected key usage.
Psl\invariant(
count($primaryKeyRecords) === 1,
'Imported GPG key material contains more than one primary key',
);

$primaryKeyRecord = array_shift($primaryKeyRecords);

return $primaryKeyRecord->keyId();
} finally {
Filesystem\delete_file($keyFileName);
}
}
}
67 changes: 67 additions & 0 deletions src/Gpg/Value/ColonFormattedKeyRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Laminas\AutomaticReleases\Gpg\Value;

use Laminas\AutomaticReleases\Gpg\SecretKeyId;
use Psl\Str;

use function in_array;
use function str_contains;

final readonly class ColonFormattedKeyRecord
{
private const FIELD_TYPE = 0;
private const FIELD_KEYID = 4;
private const FIELD_CAPABILITIES = 11;

private function __construct(
private bool $isSubkey,
private bool $isSecretKey,
private SecretKeyId $keyId,
private string $capabilities,
) {
}

public static function fromRecordLine(string $recordLine): self|null
{
$record = Str\split($recordLine, ':');
$type = $record[self::FIELD_TYPE] ?? '';
if (! in_array($type, ['pub', 'sec', 'sub', 'ssb'])) {
return null;
}

$isSubkey = in_array($type, ['sub', 'ssb']);
$isSecretKey = in_array($type, ['sec', 'ssb']);
$keyId = SecretKeyId::fromBase16String($record[self::FIELD_KEYID] ?? '');
$capabilities = $record[self::FIELD_CAPABILITIES] ?? '';

return new self($isSubkey, $isSecretKey, $keyId, $capabilities);
}

public function isPrimaryKey(): bool
{
return ! $this->isSubkey;
}

public function isSubkey(): bool
{
return $this->isSubkey;
}

public function isSecretKey(): bool
{
return $this->isSecretKey;
}

public function keyId(): SecretKeyId
{
return $this->keyId;
}

public function hasSignCapability(): bool
{
return str_contains($this->capabilities, 's');
}
}
36 changes: 36 additions & 0 deletions test/asset/dummy-gpg-key-no-secret.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

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
=qlLW
-----END PGP PUBLIC KEY BLOCK-----
50 changes: 50 additions & 0 deletions test/asset/dummy-gpg-only-subkey.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----

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
=VmJU
-----END PGP PRIVATE KEY BLOCK-----
25 changes: 25 additions & 0 deletions test/unit/Gpg/ImportGpgKeyFromStringViaTemporaryFileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Laminas\AutomaticReleases\Gpg\ImportGpgKeyFromStringViaTemporaryFile;
use Laminas\AutomaticReleases\Gpg\SecretKeyId;
use PHPUnit\Framework\TestCase;
use Psl\Exception\InvariantViolationException;
use Psl\Shell\Exception\FailedExecutionException;

use function Psl\File\read;

Expand All @@ -21,4 +23,27 @@ public function testWillImportValidGpgKey(): void
->__invoke(read(__DIR__ . '/../../asset/dummy-gpg-key.asc')),
);
}

public function testWillImportGpgKeyWithValidSubkey(): void
{
self::assertEquals(
SecretKeyId::fromBase16String('8CA5C026AE941316'),
(new ImportGpgKeyFromStringViaTemporaryFile())
->__invoke(read(__DIR__ . '/../../asset/dummy-gpg-only-subkey.asc')),
);
}

public function testWillFailOnNoSecretKey(): void
{
$this->expectException(InvariantViolationException::class);
$this->expectExceptionMessage('Imported GPG key material does not contain secret key');
(new ImportGpgKeyFromStringViaTemporaryFile())
->__invoke(read(__DIR__ . '/../../asset/dummy-gpg-key-no-secret.asc'));
}

public function testWillFailOnInvalidGpgKey(): void
{
$this->expectException(FailedExecutionException::class);
(new ImportGpgKeyFromStringViaTemporaryFile())->__invoke('-----BEGIN PGP PRIVATE KEY BLOCK-----');
}
}
98 changes: 98 additions & 0 deletions test/unit/Gpg/Value/ColonFormattedKeyRecordTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Laminas\AutomaticReleases\Test\Unit\Gpg\Value;

use Laminas\AutomaticReleases\Gpg\SecretKeyId;
use Laminas\AutomaticReleases\Gpg\Value\ColonFormattedKeyRecord;
use PHPUnit\Framework\TestCase;
use Psl\Exception\InvariantViolationException;

/** @covers \Laminas\AutomaticReleases\Gpg\Value\ColonFormattedKeyRecord */
class ColonFormattedKeyRecordTest extends TestCase
{
/** @return array<string, array{string, bool, bool}> */
public static function keyRecordLineProvider(): array
{
return [
'primary key' => ['pub:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true, false],
'primary secret key' => ['sec:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true, true],
'subkey' => ['sub:-:2048:1:8CA5C026AE941316:1594306790:::-:::esca::::::23::0:', false, false],
'secret subkey' => ['ssb:-:2048:1:8CA5C026AE941316:1594306790:::-:::esca::::::23::0:', false, true],
];
}

/** @dataProvider keyRecordLineProvider */
public function testFromRecordLine(string $recordLine, bool $isPrimary, bool $isSecret): void
{
$record = ColonFormattedKeyRecord::fromRecordLine($recordLine);

self::assertNotNull($record);
self::assertSame($isPrimary, $record->isPrimaryKey());
self::assertSame(! $isPrimary, $record->isSubkey());
self::assertSame($isSecret, $record->isSecretKey());
self::assertEquals(
SecretKeyId::fromBase16String('8CA5C026AE941316'),
$record->keyId(),
);
}

/** @return array<string, array{string, bool}> */
public static function recordLineCapabilitiesProvider(): array
{
return [
'primary with sign' => ['pub:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true],
'primary no sign' => ['pub:-:2048:1:8CA5C026AE941316:1594306790:::-:::ecaESCA::::::23::0:', false],
'primary no capabilities' => ['pub:-:2048:1:8CA5C026AE941316:1594306790:::-:::::::::23::0:', false],
'primary no capabilities field' => ['pub:-:2048:1:8CA5C026AE941316', false],
'primary secret with sign' => ['sec:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true],
'primary secret no sign' => ['sec:-:2048:1:8CA5C026AE941316:1594306790:::-:::ecaESCA::::::23::0:', false],
'subkey with sign' => ['sub:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true],
'subkey no sign' => ['sub:-:2048:1:8CA5C026AE941316:1594306790:::-:::ecaESCA::::::23::0:', false],
'subkey no capabilities' => ['sub:-:2048:1:8CA5C026AE941316:1594306790:::-:::::::::23::0:', false],
'secret subkey with sign' => ['ssb:-:2048:1:8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:', true],
'secret subkey no sign' => ['ssb:-:2048:1:8CA5C026AE941316:1594306790:::-:::ecaESCA::::::23::0:', false],
];
}

/** @dataProvider recordLineCapabilitiesProvider */
public function testFromRecordLineSignCapability(string $recordLine, bool $hasSign): void
{
$record = ColonFormattedKeyRecord::fromRecordLine($recordLine);

self::assertNotNull($record);
self::assertSame($hasSign, $record->hasSignCapability());
}

public function testMalformedKeyIdInvariant(): void
{
$this->expectException(InvariantViolationException::class);
ColonFormattedKeyRecord::fromRecordLine('pub:-:2048:1:0X8CA5C026AE941316:1594306790:::-:::escaESCA::::::23::0:');
}

public function testMissingKeyIdInvariant(): void
{
$this->expectException(InvariantViolationException::class);
ColonFormattedKeyRecord::fromRecordLine('pub:-:2048:1::1594306790:::-:::escaESCA::::::23::0:');
}

/** @return array<string, array{string}> */
public static function unrelatedRecordLineProvider(): array
{
return [
'fingerprint' => ['fpr:::::::::3F548E613B430AAA0040513E8CA5C026AE941316:'],
'keygrip' => ['grp:::::::::6541B11573E0968A3C6F831350B04B6336DE6BDF:'],
'empty' => [''],
'empty with delimiters' => ['::'],
'unknown' => ['unknown::::::::::::::::::::'],
];
}

/** @dataProvider unrelatedRecordLineProvider */
public function testFromRecordLineIgnoresNonKeyTypes(string $recordLine): void
{
$record = ColonFormattedKeyRecord::fromRecordLine($recordLine);
self::assertNull($record);
}
}
Loading