Skip to content

Commit 9d26e95

Browse files
committed
Fix add-signing-service failing for keys with subkeys
The previous implementation counted fpr: lines in GPG's colon output to verify that exactly one key matched the provided key ID. However, GPG emits a separate fpr: line for the primary key and each subkey, so any key with subkeys would be rejected with "There are N keys matching the key id." Count pub:/sec: lines instead, which represent actual distinct keys. Assisted-By: claude-opus-4.6
1 parent 3b1e161 commit 9d26e95

4 files changed

Lines changed: 150 additions & 76 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed `add-signing-service` management command failing with "There are N keys matching the key id" for PGP keys that have subkeys.

pulpcore/app/management/commands/add-signing-service.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,17 @@ def handle(self, *args, **options):
8282
if result.returncode != 0:
8383
raise CommandError(result.stderr.strip())
8484

85-
fpr_lines = [line for line in result.stdout.splitlines() if line.startswith("fpr:")]
86-
if len(fpr_lines) != 1:
87-
raise CommandError(_("There are {} keys matching the key id.").format(len(fpr_lines)))
88-
fingerprint = fpr_lines[0].split(":")[9]
85+
lines = result.stdout.splitlines()
86+
87+
# Count actual keys (pub:/sec: lines), not fingerprint lines. GPG emits
88+
# a separate fpr: line for the primary key and each subkey, so a single
89+
# key with subkeys produces multiple fpr: lines.
90+
key_lines = [l for l in lines if l.startswith(("pub:", "sec:"))] # noqa: E741
91+
if len(key_lines) != 1:
92+
raise CommandError(_("There are {} keys matching the key id.").format(len(key_lines)))
93+
94+
# Use the primary key fingerprint (first fpr: line in GPG's output).
95+
fingerprint = [l.split(":")[9] for l in lines if l.startswith("fpr:")][0] # noqa: E741
8996

9097
result = subprocess.run(
9198
gpg_cmd + ["--armor", "--export", key_id],

pulpcore/pytest_plugin.py

Lines changed: 115 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import asyncio
22
import json
33
import os
4-
import pathlib
54
import shutil
65
import socket
76
import ssl
@@ -1072,43 +1071,61 @@ def _dispatch_task_group(task_name, *args, **kwargs):
10721071

10731072
# GPG related fixtures
10741073

1074+
PULP_FIXTURES_SIGNING_KEYS_URL = (
1075+
"https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/signing_keys/"
1076+
)
1077+
KEY_V4_RSA2K_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-rsa2k.asc"
1078+
KEY_V4_RSA2K_PRIVATE = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-rsa2k.secret"
1079+
KEY_V4_RSA4K_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-rsa4k.asc"
1080+
KEY_V4_RSA4K_PRIVATE = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-rsa4k.secret"
1081+
KEY_V4_ED25519_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-ed25519.asc"
1082+
KEY_V4_ED25519_PRIVATE = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v4-ed25519.secret"
1083+
KEY_V6_RSA4K_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-rsa4k.asc"
1084+
KEY_V6_RSA4K_PRIVATE = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-rsa4k.secret"
1085+
KEY_V6_ED25519_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-ed25519.asc"
1086+
KEY_V6_ED25519_PRIVATE = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-ed25519.secret"
1087+
KEY_V6_MLDSA65_ED25519_PUBLIC = (
1088+
PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-mldsa65-ed25519.asc"
1089+
)
1090+
KEY_V6_MLDSA65_ED25519_PRIVATE = (
1091+
PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-mldsa65-ed25519.secret"
1092+
)
1093+
KEY_V6_MLDSA87_ED448_PUBLIC = PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-mldsa87-ed448.asc"
1094+
KEY_V6_MLDSA87_ED448_PRIVATE = (
1095+
PULP_FIXTURES_SIGNING_KEYS_URL + "pulp-testkey-v6-mldsa87-ed448.secret"
1096+
)
1097+
10751098

1076-
SIGNING_SCRIPT_STRING = r"""#!/usr/bin/env bash
1099+
SIGNING_SCRIPT_STRING = """#!/usr/bin/env bash
10771100
10781101
FILE_PATH=$1
10791102
SIGNATURE_PATH="$1.asc"
10801103
1081-
GPG_KEY_ID="pulp-fixture-signing-key"
1104+
GPG_KEY_ID="{gpg_key_id}"
10821105
10831106
# Create a detached signature
1084-
gpg --quiet --batch --homedir HOMEDIRHERE --detach-sign --local-user "${GPG_KEY_ID}" \
1085-
--armor --output ${SIGNATURE_PATH} ${FILE_PATH}
1107+
gpg --quiet --batch --homedir {gpg_home} --detach-sign --local-user "${{GPG_KEY_ID}}" \\
1108+
--armor --output ${{SIGNATURE_PATH}} ${{FILE_PATH}}
10861109
10871110
# Check the exit status
10881111
STATUS=$?
1089-
if [[ ${STATUS} -eq 0 ]]; then
1090-
echo {\"file\": \"${FILE_PATH}\", \"signature\": \"${SIGNATURE_PATH}\"}
1112+
if [[ ${{STATUS}} -eq 0 ]]; then
1113+
echo '{{"file": "'${{FILE_PATH}}'", "signature": "'${{SIGNATURE_PATH}}'"}}'
10911114
else
1092-
exit ${STATUS}
1115+
exit ${{STATUS}}
10931116
fi
10941117
"""
10951118

10961119

10971120
@pytest.fixture(scope="session")
1098-
def signing_script_path(signing_script_temp_dir, signing_gpg_homedir_path):
1099-
signing_script_file = signing_script_temp_dir / "sign-metadata.sh"
1100-
signing_script_file.write_text(
1101-
SIGNING_SCRIPT_STRING.replace("HOMEDIRHERE", str(signing_gpg_homedir_path))
1102-
)
1103-
1104-
signing_script_file.chmod(0o755)
1105-
1106-
return signing_script_file
1121+
def signing_script_path(signing_script_temp_dir, signing_gpg_homedir_path, signing_gpg_metadata):
1122+
_gpg, fingerprint, _keyid = signing_gpg_metadata
1123+
return make_signing_script(signing_gpg_homedir_path, fingerprint, signing_script_temp_dir)
11071124

11081125

11091126
@pytest.fixture(scope="session")
11101127
def signing_script_temp_dir(tmp_path_factory):
1111-
return tmp_path_factory.mktemp("sigining_script_dir")
1128+
return tmp_path_factory.mktemp("signing_script_dir")
11121129

11131130

11141131
@pytest.fixture(scope="session")
@@ -1149,30 +1166,7 @@ def _sign_with_ascii_armored_detached_signing_service(filename):
11491166
def signing_gpg_metadata(signing_gpg_homedir_path):
11501167
"""A fixture that returns a GPG instance and related metadata (i.e., fingerprint, keyid)."""
11511168
PRIVATE_KEY_URL = "https://raw.githubusercontent.com/pulp/pulp-fixtures/master/common/GPG-PRIVATE-KEY-fixture-signing" # noqa: E501
1152-
1153-
try:
1154-
import gnupg
1155-
except ImportError:
1156-
pytest.fail("python-gnupg is not installed, add to your functest_requirements.txt")
1157-
key_file = pathlib.Path(__file__).parent / "GPG-PRIVATE-KEY-fixture-signing"
1158-
if key_file.exists():
1159-
private_key_data = key_file.read_text()
1160-
else:
1161-
response = requests.get(PRIVATE_KEY_URL)
1162-
response.raise_for_status()
1163-
private_key_data = response.text
1164-
with suppress(FileNotFoundError, PermissionError):
1165-
key_file.write_text(private_key_data)
1166-
1167-
gpg = gnupg.GPG(gnupghome=signing_gpg_homedir_path)
1168-
gpg.import_keys(private_key_data)
1169-
1170-
key = gpg.list_keys()[0]
1171-
fingerprint = key["fingerprint"]
1172-
keyid = key["keyid"]
1173-
1174-
gpg.trust_keys(fingerprint, "TRUST_ULTIMATE")
1175-
return gpg, fingerprint, keyid
1169+
return import_signing_key(PRIVATE_KEY_URL, signing_gpg_homedir_path)
11761170

11771171

11781172
@pytest.fixture(scope="session")
@@ -1194,53 +1188,102 @@ def _ascii_armored_detached_signing_service_name(
11941188
signing_gpg_metadata,
11951189
signing_gpg_homedir_path,
11961190
):
1197-
service_name = str(uuid.uuid4())
11981191
_gpg, fingerprint, _keyid = signing_gpg_metadata
1192+
service_name = create_signing_service(
1193+
signing_gpg_homedir_path, fingerprint, signing_script_path
1194+
)
1195+
1196+
yield service_name
1197+
1198+
remove_signing_service(service_name)
1199+
1200+
1201+
@pytest.fixture(scope="session")
1202+
def ascii_armored_detached_signing_service(
1203+
_ascii_armored_detached_signing_service_name, pulpcore_bindings
1204+
):
1205+
return pulpcore_bindings.SigningServicesApi.list(
1206+
name=_ascii_armored_detached_signing_service_name
1207+
).results[0]
1208+
11991209

1210+
def import_signing_key(key_url, gpg_home):
1211+
"""Import a PGP key into a GPG home directory and trust it.
1212+
1213+
Returns ``(gpg, fingerprint, keyid)``.
1214+
"""
1215+
try:
1216+
import gnupg
1217+
except ImportError:
1218+
pytest.skip("python-gnupg not installed")
1219+
1220+
gpg = gnupg.GPG(gnupghome=gpg_home)
1221+
1222+
response = requests.get(key_url)
1223+
response.raise_for_status()
1224+
result = gpg.import_keys(response.content)
1225+
assert result.count >= 1, f"Failed to import key from {key_url}"
1226+
1227+
key_info = gpg.list_keys()[0]
1228+
fingerprint = key_info["fingerprint"]
1229+
keyid = key_info["keyid"]
1230+
gpg.trust_keys(fingerprint, "TRUST_ULTIMATE")
1231+
1232+
return gpg, fingerprint, keyid
1233+
1234+
1235+
def make_signing_script(gpg_home, fingerprint, script_dir=None):
1236+
"""Create a detached-signature signing script.
1237+
1238+
Returns the script path.
1239+
"""
1240+
if script_dir is None:
1241+
script_dir = gpg_home
1242+
script_path = script_dir / "sign.sh"
1243+
script_path.write_text(SIGNING_SCRIPT_STRING.format(gpg_home=gpg_home, gpg_key_id=fingerprint))
1244+
script_path.chmod(0o755)
1245+
return script_path
1246+
1247+
1248+
def create_signing_service(
1249+
gpg_home, fingerprint, script_path, *, service_class="core:AsciiArmoredDetachedSigningService"
1250+
):
1251+
"""Register a signing service via pulpcore-manager.
1252+
1253+
Returns the service name.
1254+
"""
1255+
service_name = str(uuid.uuid4())
12001256
cmd = (
12011257
"pulpcore-manager",
12021258
"add-signing-service",
12031259
service_name,
1204-
str(signing_script_path),
1260+
str(script_path),
12051261
fingerprint,
12061262
"--class",
1207-
"core:AsciiArmoredDetachedSigningService",
1263+
service_class,
12081264
"--gnupghome",
1209-
str(signing_gpg_homedir_path),
1210-
)
1211-
completed_process = subprocess.run(
1212-
cmd,
1213-
stdout=subprocess.PIPE,
1214-
stderr=subprocess.PIPE,
1265+
str(gpg_home),
12151266
)
1267+
completed = subprocess.run(cmd, capture_output=True, text=True)
1268+
assert completed.returncode == 0, completed.stderr
12161269

1217-
assert completed_process.returncode == 0
1270+
return service_name
12181271

1219-
yield service_name
12201272

1221-
cmd = (
1222-
"pulpcore-manager",
1223-
"remove-signing-service",
1224-
service_name,
1225-
"--class",
1226-
"core:AsciiArmoredDetachedSigningService",
1227-
)
1273+
def remove_signing_service(service_name, service_class="core:AsciiArmoredDetachedSigningService"):
1274+
"""Remove a signing service created by ``create_signing_service``."""
12281275
subprocess.run(
1229-
cmd,
1230-
stdout=subprocess.PIPE,
1231-
stderr=subprocess.PIPE,
1276+
(
1277+
"pulpcore-manager",
1278+
"remove-signing-service",
1279+
service_name,
1280+
"--class",
1281+
service_class,
1282+
),
1283+
capture_output=True,
12321284
)
12331285

12341286

1235-
@pytest.fixture(scope="session")
1236-
def ascii_armored_detached_signing_service(
1237-
_ascii_armored_detached_signing_service_name, pulpcore_bindings
1238-
):
1239-
return pulpcore_bindings.SigningServicesApi.list(
1240-
name=_ascii_armored_detached_signing_service_name
1241-
).results[0]
1242-
1243-
12441287
# if content_origin == None, base_url will return the relative path and
12451288
# we need to add the hostname to run the tests
12461289
@pytest.fixture(scope="session")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import pytest
22

3+
from pulpcore.pytest_plugin import (
4+
KEY_V4_RSA4K_PRIVATE,
5+
create_signing_service,
6+
import_signing_key,
7+
make_signing_script,
8+
remove_signing_service,
9+
)
10+
311

412
@pytest.mark.parallel
513
def test_crud_signing_service(ascii_armored_detached_signing_service):
614
service = ascii_armored_detached_signing_service
715
assert "/api/v3/signing-services/" in service.pulp_href
16+
17+
18+
def test_add_signing_service_key_with_subkeys(tmp_path_factory):
19+
"""Verify that add-signing-service works with a PGP key that has subkeys.
20+
21+
Keys with signing subkeys produce multiple fpr: lines in GPG's colon
22+
output, which previously caused add-signing-service to fail.
23+
"""
24+
gpg_home = tmp_path_factory.mktemp("gpghome_subkey_test")
25+
_gpg, fingerprint, _keyid = import_signing_key(KEY_V4_RSA4K_PRIVATE, gpg_home)
26+
script_path = make_signing_script(gpg_home, fingerprint)
27+
service_name = create_signing_service(gpg_home, fingerprint, script_path)
28+
assert len(fingerprint) == 40
29+
30+
remove_signing_service(service_name)

0 commit comments

Comments
 (0)