From 0174cd0eac37156d698b602c0cb9cc78d9625d3a Mon Sep 17 00:00:00 2001 From: Aleksei Khudiakov Date: Sun, 11 Feb 2024 19:24:57 +1000 Subject: [PATCH] Refactor gpg import to use machine readable colon format Signed-off-by: Aleksei Khudiakov --- ...ImportGpgKeyFromStringViaTemporaryFile.php | 55 ++++++++--- src/Gpg/Value/ColonFormattedKeyRecord.php | 67 +++++++++++++ test/asset/dummy-gpg-key-no-secret.asc | 36 +++++++ test/asset/dummy-gpg-only-subkey.asc | 50 ++++++++++ ...rtGpgKeyFromStringViaTemporaryFileTest.php | 25 +++++ .../Gpg/Value/ColonFormattedKeyRecordTest.php | 98 +++++++++++++++++++ 6 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 src/Gpg/Value/ColonFormattedKeyRecord.php create mode 100644 test/asset/dummy-gpg-key-no-secret.asc create mode 100644 test/asset/dummy-gpg-only-subkey.asc create mode 100644 test/unit/Gpg/Value/ColonFormattedKeyRecordTest.php diff --git a/src/Gpg/ImportGpgKeyFromStringViaTemporaryFile.php b/src/Gpg/ImportGpgKeyFromStringViaTemporaryFile.php index 55b7fda4..6168c5e1 100644 --- a/src/Gpg/ImportGpgKeyFromStringViaTemporaryFile.php +++ b/src/Gpg/ImportGpgKeyFromStringViaTemporaryFile.php @@ -4,12 +4,16 @@ 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 @@ -17,16 +21,43 @@ final class ImportGpgKeyFromStringViaTemporaryFile implements ImportGpgKeyFromSt 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); + } } } diff --git a/src/Gpg/Value/ColonFormattedKeyRecord.php b/src/Gpg/Value/ColonFormattedKeyRecord.php new file mode 100644 index 00000000..0712c835 --- /dev/null +++ b/src/Gpg/Value/ColonFormattedKeyRecord.php @@ -0,0 +1,67 @@ +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'); + } +} diff --git a/test/asset/dummy-gpg-key-no-secret.asc b/test/asset/dummy-gpg-key-no-secret.asc new file mode 100644 index 00000000..973261fd --- /dev/null +++ b/test/asset/dummy-gpg-key-no-secret.asc @@ -0,0 +1,36 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=qlLW +-----END PGP PUBLIC KEY BLOCK----- diff --git a/test/asset/dummy-gpg-only-subkey.asc b/test/asset/dummy-gpg-only-subkey.asc new file mode 100644 index 00000000..04b8a763 --- /dev/null +++ b/test/asset/dummy-gpg-only-subkey.asc @@ -0,0 +1,50 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +=VmJU +-----END PGP PRIVATE KEY BLOCK----- diff --git a/test/unit/Gpg/ImportGpgKeyFromStringViaTemporaryFileTest.php b/test/unit/Gpg/ImportGpgKeyFromStringViaTemporaryFileTest.php index 8edc3be8..a66c3413 100644 --- a/test/unit/Gpg/ImportGpgKeyFromStringViaTemporaryFileTest.php +++ b/test/unit/Gpg/ImportGpgKeyFromStringViaTemporaryFileTest.php @@ -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; @@ -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-----'); + } } diff --git a/test/unit/Gpg/Value/ColonFormattedKeyRecordTest.php b/test/unit/Gpg/Value/ColonFormattedKeyRecordTest.php new file mode 100644 index 00000000..4d759dcd --- /dev/null +++ b/test/unit/Gpg/Value/ColonFormattedKeyRecordTest.php @@ -0,0 +1,98 @@ + */ + 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 */ + 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 */ + 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); + } +}