diff --git a/setup_cert.py b/setup_cert.py index 24f4abb..b7802ed 100644 --- a/setup_cert.py +++ b/setup_cert.py @@ -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)") +# 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): - 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): @@ -229,11 +267,23 @@ DNS.1 = {uuid} run(['openssl', 'req', '-new', '-key', str(paths['key']), '-out', str(paths['csr']), '-subj', subject]) - run(['openssl', 'x509', '-req', '-in', str(paths['csr']), - '-CA', str(ac14k_cert), '-CAkey', str(ac14k_key), - '-CAcreateserial', '-CAserial', str(paths['srl']), - '-out', str(paths['leaf']), '-days', '3650', - '-extfile', str(paths['ext']), '-sha1']) + sign_cmd = ['openssl', 'x509', '-req', '-in', str(paths['csr']), + '-CA', str(ac14k_cert), '-CAkey', str(ac14k_key), + '-CAcreateserial', '-CAserial', str(paths['srl']), + '-out', str(paths['leaf']), '-days', '3650', + '-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()] for p in chain_files: @@ -459,7 +509,20 @@ def main(): print("=" * 60) print(f"Phase 3: mint client cert with UUID {uuid}") 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" leaf: {paths['leaf']}") print(f" fullchain: {paths['fullchain']}") diff --git a/tests/test_setup_cert_signing.py b/tests/test_setup_cert_signing.py new file mode 100644 index 0000000..655eb20 --- /dev/null +++ b/tests/test_setup_cert_signing.py @@ -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)