mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-06 20:20:36 +00:00
slhdsa: the post-quantum signing path (deterministic, parameter-locked, additive)
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 <noreply@anthropic.com>
This commit is contained in:
parent
16040b79f5
commit
a03662438a
3 changed files with 372 additions and 0 deletions
232
src/pacta/slhdsa.py
Normal file
232
src/pacta/slhdsa.py
Normal file
|
|
@ -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 ["<empty>"])[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.",
|
||||||
|
}
|
||||||
|
|
@ -158,6 +158,8 @@ def make_signed_tree_head(
|
||||||
private_key_path: str | Path,
|
private_key_path: str | Path,
|
||||||
public_key_path: str | Path,
|
public_key_path: str | Path,
|
||||||
signing_provenance: dict[str, Any] | None = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
sth: dict[str, Any] = {
|
sth: dict[str, Any] = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
|
@ -170,6 +172,17 @@ def make_signed_tree_head(
|
||||||
}
|
}
|
||||||
payload = signed_tree_head_payload(sth)
|
payload = signed_tree_head_payload(sth)
|
||||||
signature_base64, signing_backend = sign_payload_ed25519_detailed(payload, private_key_path)
|
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"] = {
|
sth["signatures"] = {
|
||||||
"ed25519": {
|
"ed25519": {
|
||||||
"scheme": "openssl-ed25519",
|
"scheme": "openssl-ed25519",
|
||||||
|
|
@ -181,6 +194,7 @@ def make_signed_tree_head(
|
||||||
**({"signing_provenance": signing_provenance} if signing_provenance else {}),
|
**({"signing_provenance": signing_provenance} if signing_provenance else {}),
|
||||||
},
|
},
|
||||||
"ml_dsa": detect_ml_dsa().to_signature_slot(),
|
"ml_dsa": detect_ml_dsa().to_signature_slot(),
|
||||||
|
"slh_dsa": slh_block,
|
||||||
}
|
}
|
||||||
return sth
|
return sth
|
||||||
|
|
||||||
|
|
|
||||||
126
tests/test_slhdsa.py
Normal file
126
tests/test_slhdsa.py
Normal file
|
|
@ -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"}
|
||||||
Loading…
Reference in a new issue