fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy (#19)
* fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy The signing step forces -sha1 (the AC14K_M chain requires SHA-1-signed leaves), which Fedora/RHEL's default crypto policy rejects on OpenSSL 3.x. run() also swallowed stderr, so the failure surfaced as an opaque non-zero-exit traceback with no diagnostic. - run() now raises CommandError carrying the command and openssl stderr - mint_cert retries signing with a scoped OPENSSL_CONF enabling rh-allow-sha1-signatures when the first attempt fails - main() prints the update-crypto-policies fallback on failure Fixes #15 * test(setup_cert): cover SHA-1 signing, error surfacing, and crypto-policy retry Regression tests for the #15 fix: - full mint_cert flow (SHA-1 leaf, UUID SAN, custom OIDs, chain assembly) - CommandError surfaces openssl stderr on a genuine signing failure - signing retries via the SHA-1 override when the plain attempt is blocked - run() raises CommandError with detail
This commit is contained in:
+70
-7
@@ -184,8 +184,46 @@ def verify_cert_key_pair(cert_path, key_path):
|
|||||||
f"AC14K_M cert and key do not pair (cert modulus != key modulus)")
|
f"AC14K_M cert and key do not pair (cert modulus != key modulus)")
|
||||||
|
|
||||||
|
|
||||||
|
# OpenSSL config that force-enables SHA-1 signatures. Fedora/RHEL (and some
|
||||||
|
# other hardened OpenSSL 3.x builds) reject SHA-1 signing under the default
|
||||||
|
# crypto policy, but the AC14K_M trust chain requires a SHA-1-signed leaf, so
|
||||||
|
# we re-enable it just for the signing step via a scoped OPENSSL_CONF.
|
||||||
|
SHA1_OVERRIDE_CONF = """\
|
||||||
|
openssl_conf = openssl_init
|
||||||
|
|
||||||
|
[openssl_init]
|
||||||
|
alg_section = evp_properties
|
||||||
|
|
||||||
|
[evp_properties]
|
||||||
|
rh-allow-sha1-signatures = yes
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(RuntimeError):
|
||||||
|
"""A subprocess exited non-zero; carries the command and its output."""
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, **kw):
|
def run(cmd, **kw):
|
||||||
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw)
|
proc = subprocess.run(cmd, capture_output=True, text=True, **kw)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or '').strip()
|
||||||
|
raise CommandError(
|
||||||
|
f"command failed (exit {proc.returncode}): {' '.join(cmd)}"
|
||||||
|
+ (f"\n{detail}" if detail else ""))
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def run_allow_sha1(cmd):
|
||||||
|
"""Run an openssl command with SHA-1 signatures force-enabled, for
|
||||||
|
distros whose crypto policy otherwise blocks SHA-1 signing."""
|
||||||
|
conf = tempfile.NamedTemporaryFile(
|
||||||
|
'w', suffix='.cnf', prefix='sha1_ok_', delete=False)
|
||||||
|
conf.write(SHA1_OVERRIDE_CONF)
|
||||||
|
conf.close()
|
||||||
|
try:
|
||||||
|
return run(cmd, env=dict(os.environ, OPENSSL_CONF=conf.name))
|
||||||
|
finally:
|
||||||
|
os.unlink(conf.name)
|
||||||
|
|
||||||
|
|
||||||
def mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir):
|
def mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir):
|
||||||
@@ -229,11 +267,23 @@ DNS.1 = {uuid}
|
|||||||
run(['openssl', 'req', '-new', '-key', str(paths['key']),
|
run(['openssl', 'req', '-new', '-key', str(paths['key']),
|
||||||
'-out', str(paths['csr']), '-subj', subject])
|
'-out', str(paths['csr']), '-subj', subject])
|
||||||
|
|
||||||
run(['openssl', 'x509', '-req', '-in', str(paths['csr']),
|
sign_cmd = ['openssl', 'x509', '-req', '-in', str(paths['csr']),
|
||||||
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
|
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
|
||||||
'-CAcreateserial', '-CAserial', str(paths['srl']),
|
'-CAcreateserial', '-CAserial', str(paths['srl']),
|
||||||
'-out', str(paths['leaf']), '-days', '3650',
|
'-out', str(paths['leaf']), '-days', '3650',
|
||||||
'-extfile', str(paths['ext']), '-sha1'])
|
'-extfile', str(paths['ext']), '-sha1']
|
||||||
|
try:
|
||||||
|
run(sign_cmd)
|
||||||
|
except CommandError as first:
|
||||||
|
# Most likely the local crypto policy blocks SHA-1 signing
|
||||||
|
# (common on Fedora/RHEL). Retry once with SHA-1 force-enabled;
|
||||||
|
# if that still fails, surface the original error.
|
||||||
|
print(" SHA-1 signing was rejected by the local OpenSSL policy; "
|
||||||
|
"retrying with a SHA-1 override...")
|
||||||
|
try:
|
||||||
|
run_allow_sha1(sign_cmd)
|
||||||
|
except CommandError:
|
||||||
|
raise first
|
||||||
|
|
||||||
parts = [paths['leaf'].read_text()]
|
parts = [paths['leaf'].read_text()]
|
||||||
for p in chain_files:
|
for p in chain_files:
|
||||||
@@ -459,7 +509,20 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"Phase 3: mint client cert with UUID {uuid}")
|
print(f"Phase 3: mint client cert with UUID {uuid}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
|
try:
|
||||||
|
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
|
||||||
|
except CommandError as e:
|
||||||
|
print(f"\n[!] Failed to mint the client cert:\n{e}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"\n If the failure mentions SHA-1 / disabled digests, your "
|
||||||
|
"OpenSSL build blocks SHA-1 signing (common on Fedora/RHEL).\n"
|
||||||
|
" The AC14K_M chain requires SHA-1, so allow it and re-run:\n"
|
||||||
|
" sudo update-crypto-policies --set DEFAULT:SHA1\n"
|
||||||
|
" (or LEGACY). Undo afterwards with: "
|
||||||
|
"sudo update-crypto-policies --set DEFAULT",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
|
||||||
print(f" key: {paths['key']}")
|
print(f" key: {paths['key']}")
|
||||||
print(f" leaf: {paths['leaf']}")
|
print(f" leaf: {paths['leaf']}")
|
||||||
print(f" fullchain: {paths['fullchain']}")
|
print(f" fullchain: {paths['fullchain']}")
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import setup_cert
|
||||||
|
|
||||||
|
# All of these drive the real `openssl` CLI the way setup_cert does.
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
shutil.which("openssl") is None, reason="openssl CLI not available")
|
||||||
|
|
||||||
|
UUID = "04700f20-1111-2222-3333-444455556666"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ca(dir_path):
|
||||||
|
"""A throwaway self-signed CA standing in for the AC14K_M signer."""
|
||||||
|
cert = dir_path / "ca.pem"
|
||||||
|
key = dir_path / "ca.key"
|
||||||
|
subprocess.run(
|
||||||
|
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", str(key), "-out", str(cert), "-days", "1",
|
||||||
|
"-subj", "/CN=AC14K_M"],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_produces_sha1_leaf_with_uuid(tmp_path):
|
||||||
|
ca_cert, ca_key = _make_ca(tmp_path)
|
||||||
|
paths = setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
|
||||||
|
|
||||||
|
for name in ("key", "leaf", "fullchain"):
|
||||||
|
assert paths[name].exists() and paths[name].stat().st_size > 0
|
||||||
|
|
||||||
|
text = subprocess.run(
|
||||||
|
["openssl", "x509", "-in", str(paths["leaf"]), "-noout", "-text"],
|
||||||
|
check=True, capture_output=True, text=True).stdout
|
||||||
|
assert "sha1WithRSAEncryption" in text # SHA-1 signed leaf
|
||||||
|
assert f"URI:urn:uuid:{UUID}" in text # UUID in the SAN
|
||||||
|
assert "1.3.6.1.4.1.51414" in text # custom OIDs parsed
|
||||||
|
# fullchain is leaf + supplied chain
|
||||||
|
assert paths["fullchain"].read_text().count("BEGIN CERTIFICATE") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_surfaces_openssl_error(tmp_path):
|
||||||
|
"""A genuine signing failure raises CommandError carrying openssl's
|
||||||
|
output, instead of a bare non-zero-exit traceback."""
|
||||||
|
ca_cert, _ = _make_ca(tmp_path)
|
||||||
|
with pytest.raises(setup_cert.CommandError) as exc:
|
||||||
|
setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, tmp_path / "missing.key", [ca_cert],
|
||||||
|
tmp_path / "out")
|
||||||
|
assert "command failed" in str(exc.value)
|
||||||
|
assert len(str(exc.value)) > 40 # includes detail, not just an exit code
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_cert_retries_when_sha1_signing_blocked(tmp_path, monkeypatch):
|
||||||
|
"""Simulate a Fedora/RHEL crypto policy rejecting SHA-1: the first
|
||||||
|
(plain) signing attempt fails, and the SHA-1-override retry recovers."""
|
||||||
|
ca_cert, ca_key = _make_ca(tmp_path)
|
||||||
|
real_run = setup_cert.run
|
||||||
|
attempts = {"plain": 0}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kw):
|
||||||
|
# Only the plain attempt has no OPENSSL_CONF override in its env.
|
||||||
|
if cmd[:3] == ["openssl", "x509", "-req"] and "env" not in kw:
|
||||||
|
attempts["plain"] += 1
|
||||||
|
raise setup_cert.CommandError(
|
||||||
|
"error: sha1 signature disabled by crypto policy")
|
||||||
|
return real_run(cmd, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(setup_cert, "run", fake_run)
|
||||||
|
paths = setup_cert.mint_cert(
|
||||||
|
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
|
||||||
|
|
||||||
|
assert attempts["plain"] == 1 # the plain path was exercised
|
||||||
|
assert paths["leaf"].exists() # the override retry recovered
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_error_includes_stderr():
|
||||||
|
with pytest.raises(setup_cert.CommandError) as exc:
|
||||||
|
setup_cert.run(["openssl", "x509", "-in", "/no/such/file"])
|
||||||
|
assert "command failed" in str(exc.value)
|
||||||
Reference in New Issue
Block a user