Skip to content

Commit

Permalink
Refactor gpg import to use machine readable colon format
Browse files Browse the repository at this point in the history
Signed-off-by: Aleksei Khudiakov <aleksey@xerkus.pro>
  • Loading branch information
Xerkus committed Feb 12, 2024
1 parent 3f7565f commit 0174cd0
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 12 deletions.
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);
}
}

0 comments on commit 0174cd0

Please sign in to comment.