From a03662438a13cd1eba2fff3c35aded5f45093b06 Mon Sep 17 00:00:00 2001 From: mrwulf Date: Thu, 6 Aug 2026 22:03:39 +0200 Subject: [PATCH] slhdsa: the post-quantum signing path (deterministic, parameter-locked, additive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2b+3 of the step-3 rehearsal, under the four operator decisions of 2026-08-06: deterministic signing, separate slh_dsa block, additive posture, keygen executed same day (key in provider state, 0600, git-ignored — verified before generation, not after). src/pacta/slhdsa.py — the module that did not exist (register: pq-slot-names-unproven-algorithm). Parameter set LOCKED to SLH-DSA-SHA2-128s: every entry point asserts the key's reported algorithm and refuses anything else, because any other set sits outside all eleven certificates while looking like dogfood. Deterministic via -pkeyopt deterministic:1, so the byte-level reproducibility check that caught a real defect on the Ed25519 side survives here. Verification runs two ways: OpenSSL, and pacta-verify-slhdsa built from the pinned proven source — the one signature check in the estate performed by code whose verify path the certificates cover. The proven-verifier path is package-anchored, not cwd-relative: the lesson of signer-backend-depends-on-cwd applied on day one, not retrofitted. make_signed_tree_head grows optional slhdsa key parameters. With them, the head carries a signed slh_dsa block; without, an honest not-configured slot exactly as ml_dsa always has. ml_dsa itself is untouched. Signatures stay outside the signed payload for both algorithms — tested by asserting the payload is byte-identical with and without the slh_dsa key. Honesty carried in the artifact: signing_backend says "openssl" because no proven signer exists for any algorithm; the module docstring states that nothing here is Lean-proven and that the certificates cover the verify path of the extracted model only. Tests: 7 new, suite 152 passed, 0 failed, 0 skipped — including determinism (two signings, identical bytes), the foreign-key refusal (Ed25519 key raises), corruption rejected by both verifiers, and the proven/OpenSSL agreement. Co-Authored-By: Claude Opus 4.8 --- src/pacta/slhdsa.py | 232 ++++++++++++++++++++++++++++++++++++++ src/pacta/transparency.py | 14 +++ tests/test_slhdsa.py | 126 +++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/pacta/slhdsa.py create mode 100644 tests/test_slhdsa.py diff --git a/src/pacta/slhdsa.py b/src/pacta/slhdsa.py new file mode 100644 index 0000000..7584e6a --- /dev/null +++ b/src/pacta/slhdsa.py @@ -0,0 +1,232 @@ +"""SLH-DSA-SHA2-128s signing and verification for transparency-log heads. + +This module is the post-quantum signing path that did NOT exist before +2026-08-06 (register: pq-slot-names-unproven-algorithm). Scope discipline, +stated up front because the estate has measured what silence costs: + + * The parameter set is LOCKED to SLH-DSA-SHA2-128s — the only set the + eleven fips205 certificates cover. Every entry point asserts the key's + algorithm and refuses anything else rather than producing a signature + outside every proof the estate holds. + * Signing is DETERMINISTIC (operator decision 2026-08-06): FIPS 205's + optional deterministic variant, selected via OpenSSL's + `-pkeyopt deterministic:1`. Chosen so the byte-level reproducibility + check that caught a real defect on the Ed25519 side survives for this + algorithm too. The trade is documented: fault-attack hardening from + hedged signing is forgone, for a key that signs a public log. + * NOTHING here is Lean-proven. The certificates cover the VERIFY path of + the extracted model; signing and key generation are outside every proof + (fips205 TRUSTED-BASE item 2). Verification below can be cross-checked + against the proven-source binary (pacta-verify-slhdsa); signing cannot + be cross-checked against anything proven, and no field this module + emits claims otherwise. +""" +from __future__ import annotations + +import base64 +import hashlib +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +SLH_SCHEME = "openssl-slh-dsa-sha2-128s" +SLH_STANDARD = "FIPS 205" +SLH_PARAMETER_SET = "SLH-DSA-SHA2-128s" +SLH_SIGNATURE_BYTES = 7856 +SLH_PUBLIC_KEY_BYTES = 32 + +# Package-anchored, NOT cwd-relative. The Ed25519 twin of this constant was a +# relative path and which implementation signed the log became an accident of +# the launch directory (register: signer-backend-depends-on-cwd). parents[2] +# of src/pacta/slhdsa.py is the repository root. +_REPO_ROOT = Path(__file__).resolve().parents[2] +PROVEN_VERIFIER = (_REPO_ROOT / "dogfood" / "quorum" / "verify-slhdsa" + / "target" / "release" / "pacta-verify-slhdsa") +SLHDSA_VERIFIER_ENV = "PACTA_SLHDSA_VERIFIER" + + +class SlhDsaError(RuntimeError): + pass + + +def _openssl() -> str: + import shutil + exe = shutil.which("openssl") + if not exe: + raise SlhDsaError("openssl binary not found; SLH-DSA operations unavailable") + return exe + + +def _assert_128s_key(key_path: str | Path, public: bool) -> None: + """Refuse any key that is not SLH-DSA-SHA2-128s. + + The check is on the PROPERTY (the algorithm OpenSSL reports for the key), + not on a filename. A signature under any other parameter set would sit + outside all eleven certificates while looking exactly like dogfood. + """ + args = [_openssl(), "pkey", "-in", str(key_path), "-noout", "-text"] + if public: + args.insert(2, "-pubin") + result = subprocess.run(args, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise SlhDsaError(f"cannot read key {key_path}: {(result.stderr or '').strip()[:120]}") + if SLH_PARAMETER_SET not in result.stdout: + first = (result.stdout.strip().splitlines() or [""])[0] + raise SlhDsaError( + f"key {key_path} is not {SLH_PARAMETER_SET} (openssl reports: {first!r}). " + f"The certificates cover {SLH_PARAMETER_SET} only; refusing.") + + +def generate_slhdsa_keypair(private_key_path: str | Path, public_key_path: str | Path) -> None: + """Generate an SLH-DSA-SHA2-128s key pair. Private key mode 0600. + + Key generation is NOT covered by any certificate; this is OpenSSL's + generator, trusted base, and recorded as such wherever the key is used. + """ + openssl = _openssl() + private_path = Path(private_key_path) + public_path = Path(public_key_path) + private_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run([openssl, "genpkey", "-algorithm", SLH_PARAMETER_SET, + "-out", str(private_path)], check=True, timeout=60) + os.chmod(private_path, 0o600) + subprocess.run([openssl, "pkey", "-in", str(private_path), "-pubout", + "-out", str(public_path)], check=True, timeout=30) + _assert_128s_key(private_path, public=False) + _assert_128s_key(public_path, public=True) + + +def sign_payload_slhdsa(payload: bytes, private_key_path: str | Path) -> str: + """Deterministically sign; returns base64. Same payload + key => same bytes.""" + _assert_128s_key(private_key_path, public=False) + openssl = _openssl() + with tempfile.TemporaryDirectory(prefix="pacta-slhdsa-sign-") as tmp: + payload_path = Path(tmp) / "payload.bin" + signature_path = Path(tmp) / "payload.sig" + payload_path.write_bytes(payload) + completed = subprocess.run( + [openssl, "pkeyutl", "-sign", "-inkey", str(private_key_path), "-rawin", + "-pkeyopt", "deterministic:1", + "-in", str(payload_path), "-out", str(signature_path)], + check=False, capture_output=True, text=True, timeout=120) + if completed.returncode != 0: + raise SlhDsaError((completed.stderr or "slh-dsa signing failed").strip()) + signature = signature_path.read_bytes() + if len(signature) != SLH_SIGNATURE_BYTES: + raise SlhDsaError( + f"signature is {len(signature)} bytes, expected {SLH_SIGNATURE_BYTES} " + f"for {SLH_PARAMETER_SET} — wrong parameter set slipped through?") + return base64.b64encode(signature).decode("ascii") + + +def verify_payload_slhdsa(payload: bytes, signature_base64: str, + public_key_path: str | Path) -> tuple[bool, str | None]: + """Verify with OpenSSL. For the proven-source cross-check, see + verify_payload_slhdsa_proven — callers wanting both run both.""" + _assert_128s_key(public_key_path, public=True) + try: + signature = base64.b64decode(signature_base64) + except Exception as exc: + return False, f"signature_base64 undecodable: {exc}" + if len(signature) != SLH_SIGNATURE_BYTES: + return False, f"signature is {len(signature)} bytes, expected {SLH_SIGNATURE_BYTES}" + openssl = _openssl() + with tempfile.TemporaryDirectory(prefix="pacta-slhdsa-verify-") as tmp: + payload_path = Path(tmp) / "payload.bin" + signature_path = Path(tmp) / "payload.sig" + payload_path.write_bytes(payload) + signature_path.write_bytes(signature) + completed = subprocess.run( + [openssl, "pkeyutl", "-verify", "-pubin", "-inkey", str(public_key_path), + "-rawin", "-in", str(payload_path), "-sigfile", str(signature_path)], + capture_output=True, timeout=120) + if completed.returncode == 0: + return True, None + return False, "OpenSSL rejected the SLH-DSA signature" + + +def locate_proven_verifier() -> Path | None: + env = os.environ.get(SLHDSA_VERIFIER_ENV) + if env: + path = Path(env) + return path if path.exists() else None + return PROVEN_VERIFIER if PROVEN_VERIFIER.exists() else None + + +def raw_public_key(public_key_path: str | Path) -> bytes: + der = subprocess.run([_openssl(), "pkey", "-pubin", "-in", str(public_key_path), + "-outform", "DER"], capture_output=True, timeout=30).stdout + if len(der) < SLH_PUBLIC_KEY_BYTES: + raise SlhDsaError(f"cannot extract raw public key from {public_key_path}") + return der[-SLH_PUBLIC_KEY_BYTES:] + + +def verify_payload_slhdsa_proven(payload: bytes, signature_base64: str, + public_key_path: str | Path) -> tuple[bool, str | None]: + """Verify with pacta-verify-slhdsa, built from the PINNED proven source. + + This is the one place in the estate where a log signature is checked by + the implementation whose verify path the certificates actually cover. + Honest residue: the binary also assembles M' and does IO, which no + certificate reaches; and it is a compiled binary, while the proofs are + about the extracted model (the estate's standing R5 gap). + """ + binary = locate_proven_verifier() + if binary is None: + return False, ("proven verifier not built (dogfood/quorum/build-verify-slhdsa.sh); " + "refusing to report a proven-path verdict without it") + try: + signature = base64.b64decode(signature_base64) + except Exception as exc: + return False, f"signature_base64 undecodable: {exc}" + if len(signature) != SLH_SIGNATURE_BYTES: + return False, f"signature is {len(signature)} bytes, expected {SLH_SIGNATURE_BYTES}" + with tempfile.TemporaryDirectory(prefix="pacta-slhdsa-proven-") as tmp: + payload_path = Path(tmp) / "payload.bin" + payload_path.write_bytes(payload) + completed = subprocess.run( + [str(binary), raw_public_key(public_key_path).hex(), signature.hex(), + str(payload_path)], capture_output=True, text=True, timeout=120) + if completed.returncode == 0: + return True, None + if completed.returncode == 1: + return False, "proven verifier rejected the signature" + return False, f"proven verifier input error: {(completed.stderr or '').strip()[:120]}" + + +def public_key_fingerprint(public_key_path: str | Path) -> str: + return hashlib.sha256(Path(public_key_path).read_bytes()).hexdigest() + + +def slh_dsa_signature_block(payload: bytes, private_key_path: str | Path, + public_key_path: str | Path) -> dict[str, Any]: + """The `signatures.slh_dsa` block for a signed tree head. + + A SEPARATE block by operator decision 2026-08-06: the ml_dsa slot keeps + saying, truthfully, that ML-DSA was never configured; no algorithm is + swapped inside a field that names a different one. + """ + signature_base64 = sign_payload_slhdsa(payload, private_key_path) + return { + "scheme": SLH_SCHEME, + "standard": SLH_STANDARD, + "parameter_set": SLH_PARAMETER_SET, + "mode": "deterministic", + "status": "signed", + "signing_backend": "openssl", # honest: no proven signer exists, for any algorithm + "payload_digest_sha256": hashlib.sha256(payload).hexdigest(), + "signature_base64": signature_base64, + "public_key_fingerprint_sha256": public_key_fingerprint(public_key_path), + } + + +def slh_dsa_not_configured_block() -> dict[str, Any]: + return { + "scheme": SLH_SCHEME, + "standard": SLH_STANDARD, + "parameter_set": SLH_PARAMETER_SET, + "status": "not_configured", + "reason": "No SLH-DSA signing key was configured for this log.", + } diff --git a/src/pacta/transparency.py b/src/pacta/transparency.py index 9fb2cf8..fa90699 100644 --- a/src/pacta/transparency.py +++ b/src/pacta/transparency.py @@ -158,6 +158,8 @@ def make_signed_tree_head( private_key_path: str | Path, public_key_path: str | Path, signing_provenance: dict[str, Any] | None = None, + slhdsa_private_key_path: str | Path | None = None, + slhdsa_public_key_path: str | Path | None = None, ) -> dict[str, Any]: sth: dict[str, Any] = { "schema_version": 1, @@ -170,6 +172,17 @@ def make_signed_tree_head( } payload = signed_tree_head_payload(sth) signature_base64, signing_backend = sign_payload_ed25519_detailed(payload, private_key_path) + # slh_dsa is a SEPARATE block (operator decision 2026-08-06): ml_dsa keeps + # its truthful not-configured disclosure; no algorithm is swapped inside a + # field that names a different one. ADDITIVE: ed25519 remains the signature + # consumers must check; a head without an SLH-DSA key carries the honest + # not-configured slot, exactly as ml_dsa always has. + from .slhdsa import slh_dsa_not_configured_block, slh_dsa_signature_block + + if slhdsa_private_key_path and slhdsa_public_key_path: + slh_block = slh_dsa_signature_block(payload, slhdsa_private_key_path, slhdsa_public_key_path) + else: + slh_block = slh_dsa_not_configured_block() sth["signatures"] = { "ed25519": { "scheme": "openssl-ed25519", @@ -181,6 +194,7 @@ def make_signed_tree_head( **({"signing_provenance": signing_provenance} if signing_provenance else {}), }, "ml_dsa": detect_ml_dsa().to_signature_slot(), + "slh_dsa": slh_block, } return sth diff --git a/tests/test_slhdsa.py b/tests/test_slhdsa.py new file mode 100644 index 0000000..eb14de0 --- /dev/null +++ b/tests/test_slhdsa.py @@ -0,0 +1,126 @@ +"""SLH-DSA signing path: deterministic, parameter-locked, two-verifier checked. + +Every test uses THROWAWAY keys generated into tmp_path. No test touches the +provider state directory or any long-lived key. +""" +from __future__ import annotations + +import base64 +import json + + +def _keypair(tmp_path): + from pacta.slhdsa import generate_slhdsa_keypair + priv, pub = tmp_path / "t.key", tmp_path / "t.pub" + generate_slhdsa_keypair(priv, pub) + return priv, pub + + +def test_keygen_shape_and_permissions(tmp_path): + priv, pub = _keypair(tmp_path) + assert priv.exists() and pub.exists() + assert (priv.stat().st_mode & 0o777) == 0o600 + + +def test_deterministic_signing_reproduces_bytes(tmp_path): + """Operator decision 2026-08-06: same payload + key => identical bytes. + This is the property the Ed25519 reproducibility check relies on, and the + reason the deterministic variant was chosen over the FIPS 205 default.""" + from pacta.slhdsa import sign_payload_slhdsa + priv, _pub = _keypair(tmp_path) + payload = b"the same head payload" + assert sign_payload_slhdsa(payload, priv) == sign_payload_slhdsa(payload, priv) + + +def test_sign_verify_roundtrip_both_verifiers(tmp_path): + from pacta.slhdsa import (locate_proven_verifier, sign_payload_slhdsa, + verify_payload_slhdsa, verify_payload_slhdsa_proven) + priv, pub = _keypair(tmp_path) + payload = b"a transparency log head payload" + sig = sign_payload_slhdsa(payload, priv) + ok, err = verify_payload_slhdsa(payload, sig, pub) + assert ok, err + if locate_proven_verifier() is None: + import pytest + pytest.skip("pacta-verify-slhdsa not built on this host") + ok, err = verify_payload_slhdsa_proven(payload, sig, pub) + assert ok, f"proven-source verifier disagrees with OpenSSL: {err}" + + +def test_corruption_rejected_by_both(tmp_path): + from pacta.slhdsa import (locate_proven_verifier, sign_payload_slhdsa, + verify_payload_slhdsa, verify_payload_slhdsa_proven) + priv, pub = _keypair(tmp_path) + payload = b"payload" + raw = bytearray(base64.b64decode(sign_payload_slhdsa(payload, priv))) + raw[0] ^= 1 + bad = base64.b64encode(bytes(raw)).decode() + ok, _ = verify_payload_slhdsa(payload, bad, pub) + assert not ok + if locate_proven_verifier() is not None: + ok, _ = verify_payload_slhdsa_proven(payload, bad, pub) + assert not ok + + +def test_parameter_set_lock_refuses_foreign_key(tmp_path): + """An Ed25519 key must be refused outright — a signature under any other + algorithm would look like dogfood while sitting outside every proof.""" + import pytest + from pacta.signing import generate_ed25519_keypair + from pacta.slhdsa import SlhDsaError, sign_payload_slhdsa + priv, pub = tmp_path / "ed.key", tmp_path / "ed.pub" + generate_ed25519_keypair(priv, pub) + with pytest.raises(SlhDsaError): + sign_payload_slhdsa(b"x", priv) + + +def test_head_carries_separate_slh_dsa_block(tmp_path): + """make_signed_tree_head with an SLH-DSA key: both signatures verify, the + ml_dsa slot is UNTOUCHED, and without a key the slot degrades honestly.""" + from pacta.signing import generate_ed25519_keypair, verify_payload_ed25519_detailed + from pacta.slhdsa import verify_payload_slhdsa + from pacta.transparency import make_signed_tree_head, signed_tree_head_payload + + ed_priv, ed_pub = tmp_path / "ed.key", tmp_path / "ed.pub" + generate_ed25519_keypair(ed_priv, ed_pub) + slh_priv, slh_pub = _keypair(tmp_path) + + sth = make_signed_tree_head("00" * 32, 19, "11" * 32, "2026-08-06T00:00:00Z", + ed_priv, ed_pub, + slhdsa_private_key_path=slh_priv, + slhdsa_public_key_path=slh_pub) + payload = signed_tree_head_payload(sth) + + ed = sth["signatures"]["ed25519"] + ok, err, _backend = verify_payload_ed25519_detailed(payload, ed["signature_base64"], ed_pub) + assert ok, err + + slh = sth["signatures"]["slh_dsa"] + assert slh["status"] == "signed" + assert slh["parameter_set"] == "SLH-DSA-SHA2-128s" + assert slh["mode"] == "deterministic" + ok, err = verify_payload_slhdsa(payload, slh["signature_base64"], slh_pub) + assert ok, err + + # ml_dsa stays exactly the honest disclosure it always was + assert sth["signatures"]["ml_dsa"]["status"] in {"not_configured", "unavailable"} + assert "signature_base64" not in sth["signatures"]["ml_dsa"] + + # additive: no key => honest not-configured slot, never an error + bare = make_signed_tree_head("00" * 32, 19, "11" * 32, "2026-08-06T00:00:00Z", + ed_priv, ed_pub) + assert bare["signatures"]["slh_dsa"]["status"] == "not_configured" + + # and the payload is unchanged by the slh_dsa presence: signatures are + # outside the signed bytes for BOTH algorithms + assert signed_tree_head_payload(bare) == payload + + +def test_block_is_json_serialisable(tmp_path): + from pacta.slhdsa import slh_dsa_signature_block + priv, pub = _keypair(tmp_path) + block = slh_dsa_signature_block(b"payload", priv, pub) + json.dumps(block) + assert set(block) >= {"scheme", "standard", "parameter_set", "mode", "status", + "payload_digest_sha256", "signature_base64", + "public_key_fingerprint_sha256"}