diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 968d6a739..b3fd83f06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,27 +13,28 @@ Required everywhere: - Git - ripgrep (`rg`) - ffmpeg for audio processing +- minisign 0.12 for transparency signing checks Linux is the primary development platform. macOS is supported. Source-checkout installs on Apple Silicon need Xcode command line tools to build the CoreML parakeet helper; packaged host installs (`uv tool install solstone-journal && uv tool install solstone`) on macOS 14 or newer ship the helper as a pre-built binary. Fedora/RHEL: ```bash -sudo dnf install python3 git ripgrep ffmpeg pipewire gstreamer1-plugins-base gstreamer1-plugin-pipewire pulseaudio-utils +sudo dnf install python3 git ripgrep ffmpeg minisign pipewire gstreamer1-plugins-base gstreamer1-plugin-pipewire pulseaudio-utils curl -LsSf https://astral.sh/uv/install.sh | sh ``` Ubuntu/Debian: ```bash -sudo apt install python3 git ripgrep ffmpeg pipewire gstreamer1.0-tools gstreamer1.0-pipewire pulseaudio-utils +sudo apt install python3 git ripgrep ffmpeg minisign pipewire gstreamer1.0-tools gstreamer1.0-pipewire pulseaudio-utils curl -LsSf https://astral.sh/uv/install.sh | sh ``` Arch: ```bash -sudo pacman -S python git ripgrep ffmpeg pipewire gstreamer gst-plugin-pipewire libpulse +sudo pacman -S python git ripgrep ffmpeg minisign pipewire gstreamer gst-plugin-pipewire libpulse curl -LsSf https://astral.sh/uv/install.sh | sh ``` @@ -41,7 +42,7 @@ macOS: ```bash xcode-select --install -brew install python git ripgrep ffmpeg uv +brew install python git ripgrep ffmpeg minisign uv ``` ## Source-checkout install diff --git a/Makefile b/Makefile index ce4c6e0a0..6aca8ac6e 100644 --- a/Makefile +++ b/Makefile @@ -783,7 +783,7 @@ release-test: ## Locked test-publication entrypoint .PHONY: check-transparency-minisign check-transparency-minisign: .installed - $(VENV_BIN)/python scripts/transparency_publish.py check-minisign + $(VENV_BIN)/python scripts/check_transparency_minisign.py .PHONY: publish-transparency resign-transparency-pointer publish-transparency: .installed diff --git a/scripts/check_transparency_minisign.py b/scripts/check_transparency_minisign.py new file mode 100644 index 000000000..90adfb8d9 --- /dev/null +++ b/scripts/check_transparency_minisign.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Exercise the real minisign transparency signing path.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Protocol, Sequence +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.release_candidate_driver import DriverError # noqa: E402 +from scripts.transparency_core import failure # noqa: E402 +from scripts.transparency_signing import ( # noqa: E402 + LocalMinisignSigner, + check_minisign_binary, +) + +FIXTURE_DIR = ROOT / "tests" / "fixtures" / "transparency" +ENTRY_FIXTURE = FIXTURE_DIR / "canonical-entry-v1.json" +ENTRY_TRUSTED_COMMENT = FIXTURE_DIR / "entry-trusted-comment.txt" + + +class _Verifier(Protocol): + def verify_file( + self, + message_path: Path, + signature_path: Path, + *, + expected_trusted_comment: str, + ) -> None: ... + + +def _print_failures(error: DriverError) -> None: + for item in error.failures: + print(f"ERROR: {item.error}", file=sys.stderr) + print(f" expected: {item.expected}", file=sys.stderr) + print(f" actual: {item.actual}", file=sys.stderr) + print(f" repair: {item.repair}", file=sys.stderr) + + +def _run_minisign(args: Sequence[str], *, input_text: str) -> None: + result = subprocess.run( + ["minisign", *args], + input=input_text, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise DriverError( + [ + failure( + "transparency minisign key generation failed", + expected="minisign -G exit 0", + actual=( + result.stderr or result.stdout or str(result.returncode) + ).strip(), + repair="retry after confirming the minisign binary works locally", + ) + ] + ) + + +def _generate_keypair(stage: Path, *, passphrase: str) -> tuple[Path, Path]: + secret_key = stage / "secret.key" + public_key = stage / "public.key" + _run_minisign( + ["-G", "-s", str(secret_key), "-p", str(public_key)], + input_text=f"{passphrase}\n{passphrase}\n", + ) + return secret_key, public_key + + +def _tamper_one_byte(payload: bytes) -> bytes: + if not payload: + raise DriverError( + [ + failure( + "transparency minisign tamper fixture is empty", + expected="non-empty message bytes", + actual="0 bytes", + repair="restore tests/fixtures/transparency/canonical-entry-v1.json", + ) + ] + ) + return bytes((payload[0] ^ 0x01,)) + payload[1:] + + +def _assert_tampered_verify_fails( + verifier: _Verifier, + message_path: Path, + signature_path: Path, + *, + expected_trusted_comment: str, +) -> None: + tampered_path = message_path.with_name(f"{message_path.name}.tampered") + tampered_path.write_bytes(_tamper_one_byte(message_path.read_bytes())) + try: + verifier.verify_file( + tampered_path, + signature_path, + expected_trusted_comment=expected_trusted_comment, + ) + except DriverError: + return + raise DriverError( + [ + failure( + "transparency minisign tampered message verified", + expected="tampered message verification fails", + actual="verification succeeded", + repair="inspect minisign verification and trusted-comment handling", + ) + ] + ) + + +def run_gate() -> None: + check_minisign_binary() + passphrase = "transparency-minisign-check" + with tempfile.TemporaryDirectory(prefix="transparency-minisign-") as tmp: + stage = Path(tmp) + message_path = stage / ENTRY_FIXTURE.name + comment_path = stage / ENTRY_TRUSTED_COMMENT.name + shutil.copy2(ENTRY_FIXTURE, message_path) + shutil.copy2(ENTRY_TRUSTED_COMMENT, comment_path) + trusted_comment = comment_path.read_text(encoding="utf-8").rstrip("\n") + secret_key, public_key = _generate_keypair(stage, passphrase=passphrase) + signature_path = stage / f"{message_path.name}.minisig" + signer = LocalMinisignSigner(secret_key=secret_key, public_key=public_key) + with patch("getpass.getpass", return_value=passphrase): + signer.sign_file( + message_path, + signature_path, + trusted_comment=trusted_comment, + ) + signer.verify_file( + message_path, + signature_path, + expected_trusted_comment=trusted_comment, + ) + extracted_comment = signer.trusted_comment(signature_path) + if extracted_comment != trusted_comment: + raise DriverError( + [ + failure( + "transparency minisign trusted comment extraction mismatch", + expected=trusted_comment, + actual=extracted_comment, + repair="inspect LocalMinisignSigner.trusted_comment", + ) + ] + ) + _assert_tampered_verify_fails( + signer, + message_path, + signature_path, + expected_trusted_comment=trusted_comment, + ) + + +def build_parser() -> argparse.ArgumentParser: + return argparse.ArgumentParser( + description="Check real transparency minisign signing." + ) + + +def main(argv: Sequence[str] | None = None) -> int: + build_parser().parse_args(list(argv) if argv is not None else None) + try: + run_gate() + except DriverError as exc: + _print_failures(exc) + return 1 + print("transparency minisign check ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/transparency_publish.py b/scripts/transparency_publish.py index 141ad4cdd..75afa3b19 100644 --- a/scripts/transparency_publish.py +++ b/scripts/transparency_publish.py @@ -79,10 +79,8 @@ from scripts.transparency_head_log import ( highest_seq, ) from scripts.transparency_signing import ( - MISSING_MINISIGN_MESSAGE, LocalMinisignSigner, TransparencySigner, - check_minisign_binary, ) from scripts.transparency_transport import ( CurlTransparencyTransport, @@ -1814,7 +1812,6 @@ def build_parser() -> argparse.ArgumentParser: resign_parser.add_argument("--root", default=".") resign_parser.add_argument("--version", default="resign") resign_parser.add_argument("--source-commit", default="0" * 40) - subparsers.add_parser("check-minisign") return parser @@ -1826,9 +1823,6 @@ def main( args = parser.parse_args(list(argv) if argv is not None else None) runtime_env = dict(os.environ if env is None else env) try: - if args.command == "check-minisign": - check_minisign_binary() - return 0 config = _config_from_args(args, runtime_env) transport = _transport_from_config(config) signer = _signer_from_config(config) @@ -1849,8 +1843,6 @@ def main( return 2 except DriverError as exc: _print_failures(exc) - if any(item.error == MISSING_MINISIGN_MESSAGE for item in exc.failures): - return 1 return 1 print(json.dumps(result.as_dict(), sort_keys=True)) return 0 diff --git a/tests/test_transparency_cli.py b/tests/test_transparency_cli.py index e19d2999d..8ed94c717 100644 --- a/tests/test_transparency_cli.py +++ b/tests/test_transparency_cli.py @@ -1,12 +1,12 @@ from __future__ import annotations import json -import logging from argparse import Namespace from pathlib import Path import pytest +import scripts.check_transparency_minisign as minisign_gate import scripts.transparency_publish as publisher from scripts.release_candidate_driver import DriverError from scripts.transparency_core import DEFAULT_BASE_URL, PRODUCT @@ -61,16 +61,11 @@ def test_config_from_args_derives_source_commit_from_retained_ledger( assert config.source_commit == "b" * 40 -def test_cli_check_minisign_success(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(publisher, "check_minisign_binary", lambda: "minisign 0.12") - assert publisher.main(["check-minisign"], env={}) == 0 - - -def test_cli_check_minisign_missing_logs_loud_message( +def test_check_transparency_minisign_missing_binary_fails_loudly( monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], ) -> None: - def fail() -> str: + def fail() -> None: raise DriverError( [ publisher.failure( @@ -82,10 +77,39 @@ def test_cli_check_minisign_missing_logs_loud_message( ] ) - monkeypatch.setattr(publisher, "check_minisign_binary", fail) - caplog.set_level(logging.ERROR) - assert publisher.main(["check-minisign"], env={}) == 1 - assert MISSING_MINISIGN_MESSAGE in caplog.text + monkeypatch.setattr(minisign_gate, "check_minisign_binary", fail) + assert minisign_gate.main([]) == 1 + captured = capsys.readouterr() + assert MISSING_MINISIGN_MESSAGE in captured.err + assert "sudo dnf install minisign" in captured.err + + +def test_check_transparency_minisign_tamper_must_fail(tmp_path: Path) -> None: + class AcceptingVerifier: + def verify_file( + self, + message_path: Path, + signature_path: Path, + *, + expected_trusted_comment: str, + ) -> None: + return None + + message = tmp_path / "message.json" + signature = tmp_path / "message.json.minisig" + message.write_bytes(b'{"ok":1}\n') + signature.write_text("placeholder\n", encoding="utf-8") + with pytest.raises(DriverError) as error: + minisign_gate._assert_tampered_verify_fails( + AcceptingVerifier(), + message, + signature, + expected_trusted_comment="trusted", + ) + assert ( + error.value.failures[0].error + == "transparency minisign tampered message verified" + ) def test_cli_publish_prints_operator_summary(