diff --git a/miners/checksums.sha256 b/miners/checksums.sha256 index 69cf98f36..2ec30741e 100644 --- a/miners/checksums.sha256 +++ b/miners/checksums.sha256 @@ -1,5 +1,4 @@ -c7af612bb2630d5fe6576bb132bdeb7a00ba0be042ec168887ab767a1f16c9f9 linux/rustchain_linux_miner.py +96c1656a82bdeed7c386c189782d2b638653aad7d040c635f9f18cb4f4d8789b linux/rustchain_linux_miner.py cdfca6e63ecd24f53b30140dd44df42415a3254c68aad95b1fca3c1557e15f7b linux/fingerprint_checks.py 603d9a3b3ebfe1a0ca56a60988db4b5d4a80ab57cb5feb1c0b563a1d4020fcd7 macos/rustchain_mac_miner_v2.4.py 163fafcf751d8fbd41bf936facaeb366c042f467fa34b79f2c4c0a45472ef70f macos/rustchain_mac_miner_v2.5.py -c2257dbe3b64a183bd2fda44631fffdf6c91ad8543b28f8e9c23a280e946a6e5 macos/fingerprint_checks.py diff --git a/miners/linux/rustchain_linux_miner.py b/miners/linux/rustchain_linux_miner.py index 4bcabbc4f..13a606e96 100755 --- a/miners/linux/rustchain_linux_miner.py +++ b/miners/linux/rustchain_linux_miner.py @@ -22,6 +22,17 @@ except ImportError: CRYPTO_AVAILABLE = False +# Shared pipe-message builder (PR #6839 review) +try: + from miners.signing_helpers import build_pipe_sign_message + _SIGNING_HELPERS = True +except ImportError: + try: + from signing_helpers import build_pipe_sign_message + _SIGNING_HELPERS = True + except ImportError: + _SIGNING_HELPERS = False + # Import fingerprint checks try: from fingerprint_checks import validate_all_checks @@ -546,17 +557,25 @@ def attest(self): "warthog": self.warthog.collect_proof() if self.warthog else None } - # ── Ed25519 signature (GPT-5.4 audit finding #2) ── - # Sign canonical JSON of the full attestation BEFORE adding signature - # fields. Server (PR #6426) strips signature/public_key/signature_type - # before re-canonicalizing for verification. + # ── Ed25519 signature ── + # Sign the pipe-delimited message that the node verifier reconstructs + # (miner_id|miner|nonce|commitment). Previous code signed the canonical + # JSON of the full attestation, but the server verifies the pipe-string, + # causing every signed attestation to fail with INVALID_SIGNATURE. + # See issue #6798. if CRYPTO_AVAILABLE and self.keypair: try: - payload_bytes = json.dumps( - attestation, sort_keys=True, separators=(",", ":") - ).encode() + if _SIGNING_HELPERS: + sign_msg = build_pipe_sign_message(attestation) + else: + sign_msg = "{}|{}|{}|{}".format( + attestation["miner_id"], + attestation["miner"], + attestation["nonce"], + attestation["report"]["commitment"], + ).encode("utf-8") attestation["signature"] = sign_payload( - payload_bytes, self.keypair["private_key"] + sign_msg, self.keypair["private_key"] ) attestation["public_key"] = self.public_key attestation["signature_type"] = "ed25519" diff --git a/miners/signing_helpers.py b/miners/signing_helpers.py new file mode 100644 index 000000000..a56ba01f4 --- /dev/null +++ b/miners/signing_helpers.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 RustChain Contributors +""" +Shared signing helpers for RustChain miners. + +The node verifier (rustchain_v2_integrated_v2.2.1_rip200.py:3949) reconstructs +a pipe-delimited string miner_id|miner|nonce|commitment from the attestation +payload and verifies the Ed25519 signature over *that* byte sequence — NOT over +canonical JSON. Both miners must build the exact same byte string so the +round-trip verifies on the server. + +Binding analysis (tri-brain review, PR #6839): + The pipe message covers miner_id, miner (wallet), nonce, and commitment. + The commitment itself is SHA-256(nonce + wallet + json.dumps(entropy, sort_keys=True)) + so the signature transitively covers nonce, wallet, and entropy through the + commitment hash. Device / signals / fingerprint fields are carried alongside + the attestation but are NOT covered by the Ed25519 signature — the server + validates them via separate checks. This is unchanged from the pre-fix code; + the old canonical-JSON signature *did* cover them, but the server never + verified canonical-JSON signatures (it verified the pipe string), so the + effective binding surface is the same. +""" + + +def build_pipe_sign_message(attestation): + """Build the pipe-delimited signing message from an attestation dict. + + Returns the UTF-8 bytes of miner_id|miner|nonce|commitment. + + Raises ValueError if any field contains the pipe delimiter ``|`` (which + would make the message ambiguous on the server side) or if any required + field is missing. + """ + try: + miner_id = attestation["miner_id"] + miner = attestation["miner"] + nonce = attestation["nonce"] + commitment = attestation["report"]["commitment"] + except (KeyError, TypeError) as exc: + raise ValueError(f"attestation missing required field: {exc}") from exc + + # Delimiter safety — none of the four fields may contain '|' + for name, value in (("miner_id", miner_id), ("miner", miner), + ("nonce", nonce), ("commitment", commitment)): + if isinstance(value, str) and "|" in value: + raise ValueError( + f"attestation field '{name}' contains pipe delimiter: {value!r}" + ) + + msg = f"{miner_id}|{miner}|{nonce}|{commitment}" + return msg.encode("utf-8") diff --git a/miners/windows/rustchain_miner_setup.bat b/miners/windows/rustchain_miner_setup.bat index 3f5a4f546..4aa406181 100755 --- a/miners/windows/rustchain_miner_setup.bat +++ b/miners/windows/rustchain_miner_setup.bat @@ -6,7 +6,7 @@ set "PYTHON_URL=https://www.python.org/ftp/python/3.11.5/python-3.11.5-amd64.exe set "PYTHON_INSTALLER=%SCRIPT_DIR%python-3.11.5-amd64.exe" set "MINER_URL=https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/windows/rustchain_windows_miner.py" set "MINER_SCRIPT=%SCRIPT_DIR%rustchain_windows_miner.py" -set "MINER_SHA256=7f663904031e5a4202be416682fd16ab51af2e96664d6db1567f716d8625f8e1" +set "MINER_SHA256=eceba6529ab4df35761d6778c6f97032b69a70301b021ff2e217f9b73616f93e" set "CRYPTO_URL=https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/windows/miner_crypto.py" set "CRYPTO_SCRIPT=%SCRIPT_DIR%miner_crypto.py" set "CRYPTO_SHA256=a987e2f0caaf75723c568de67ec848daec5e323cc5673cc17964da04eee07242" diff --git a/miners/windows/rustchain_windows_miner.py b/miners/windows/rustchain_windows_miner.py index 6c807e9e5..65ac0a038 100644 --- a/miners/windows/rustchain_windows_miner.py +++ b/miners/windows/rustchain_windows_miner.py @@ -62,6 +62,17 @@ except ImportError: CRYPTO_AVAILABLE = False +# Shared pipe-message builder (PR #6839 review) +try: + from miners.signing_helpers import build_pipe_sign_message + _SIGNING_HELPERS = True +except ImportError: + try: + from signing_helpers import build_pipe_sign_message + _SIGNING_HELPERS = True + except ImportError: + _SIGNING_HELPERS = False + # Configuration RUSTCHAIN_API = "http://50.28.86.131:8088" WALLET_DIR = Path.home() / ".rustchain" @@ -536,20 +547,29 @@ def attest(self): if self._pow_proof: attestation["pow_proof"] = self._pow_proof - # ── Ed25519 signature (GPT-5.4 audit finding #2) ── - # Sign canonical JSON of the full attestation BEFORE adding the - # signature/public_key/signature_type fields. Server reproduces the - # same canonical bytes by stripping those three fields and verifying. - # Legacy sha512 fallback for installs without PyNaCl — server flags - # it but still accepts (see PR #6426 server-side handling). + # ── Ed25519 signature ── + # Sign the pipe-delimited message that the node verifier reconstructs + # (miner_id|miner|nonce|commitment). Previous code signed the canonical + # JSON of the full attestation, but the server verifies the pipe-string, + # causing every signed attestation to fail with INVALID_SIGNATURE. + # See issue #6798. if CRYPTO_AVAILABLE and self.keypair: - payload_bytes = json.dumps( - attestation, sort_keys=True, separators=(",", ":") - ).encode() - signature = sign_payload(payload_bytes, self.keypair["private_key"]) - attestation["signature"] = signature - attestation["public_key"] = self.public_key - attestation["signature_type"] = "ed25519" + try: + if _SIGNING_HELPERS: + sign_msg = build_pipe_sign_message(attestation) + else: + sign_msg = "{}|{}|{}|{}".format( + attestation["miner_id"], + attestation["miner"], + attestation["nonce"], + attestation["report"]["commitment"], + ).encode("utf-8") + signature = sign_payload(sign_msg, self.keypair["private_key"]) + attestation["signature"] = signature + attestation["public_key"] = self.public_key + attestation["signature_type"] = "ed25519" + except Exception: + pass # Fall through unsigned; server accepts with warning else: # Legacy fallback — sha512 pseudo-signature. Server accepts but # logs a warning. Real wallet-hijack protection requires PyNaCl. diff --git a/setup_miner.py b/setup_miner.py index ada898628..0b93a8776 100644 --- a/setup_miner.py +++ b/setup_miner.py @@ -20,7 +20,7 @@ MINER_ARTIFACTS = { "Linux": { "url": "https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/linux/rustchain_linux_miner.py", - "sha256": "c7af612bb2630d5fe6576bb132bdeb7a00ba0be042ec168887ab767a1f16c9f9", + "sha256": "96c1656a82bdeed7c386c189782d2b638653aad7d040c635f9f18cb4f4d8789b", }, "Darwin": { "url": "https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/macos/rustchain_mac_miner_v2.5.py", @@ -28,7 +28,7 @@ }, "Windows": { "url": "https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/windows/rustchain_windows_miner.py", - "sha256": "7f663904031e5a4202be416682fd16ab51af2e96664d6db1567f716d8625f8e1", + "sha256": "eceba6529ab4df35761d6778c6f97032b69a70301b021ff2e217f9b73616f93e", }, } diff --git a/tests/test_attestation_signing_6798.py b/tests/test_attestation_signing_6798.py new file mode 100644 index 000000000..750d72aaa --- /dev/null +++ b/tests/test_attestation_signing_6798.py @@ -0,0 +1,136 @@ +""" +Regression test for issue #6798: +Miners must sign the pipe-delimited message (miner_id|miner|nonce|commitment) +that the node verifier reconstructs, NOT the canonical JSON of the full attestation. + +Before the fix, both miners signed canonical JSON, but the server verified +a pipe-delimited string, causing every signed attestation to fail with +INVALID_SIGNATURE. + +This test imports the actual signing helper used by both miners so that a +regression in the miner code would be caught here (tri-brain review feedback +on PR #6839). +""" +import json +import hashlib +import sys +import os +import unittest + +# Add miners/ to path so we can import signing_helpers +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "miners")) + +from signing_helpers import build_pipe_sign_message + + +class TestAttestationSigningMessage(unittest.TestCase): + """Verify the signing message matches what the node verifier expects.""" + + def _node_verifier_reconstruction(self, attestation): + """Reproduce the server-side 4-part pipe reconstruction + (node/rustchain_v2_integrated_v2.2.1_rip200.py:3949).""" + return "{}|{}|{}|{}".format( + attestation["miner_id"], + attestation["miner"], + attestation["nonce"], + attestation["report"]["commitment"], + ) + + def _build_sample_attestation(self): + nonce = "test-nonce-abc123" + wallet = "RTC1EXAMPLEWALLETADDR" + miner_id = "min-001" + entropy = {"variance_ns": 42.5, "source": "timer_jitter"} + commitment = hashlib.sha256( + (nonce + wallet + json.dumps(entropy, sort_keys=True)).encode() + ).hexdigest() + return { + "miner": wallet, + "miner_id": miner_id, + "nonce": nonce, + "report": { + "nonce": nonce, + "commitment": commitment, + "derived": entropy, + "entropy_score": entropy.get("variance_ns", 0.0), + }, + "device": { + "family": "x86", + "arch": "x86_64", + "model": "Test CPU", + "cpu": "Test CPU", + "cores": 4, + "memory_gb": 16.0, + "serial": None, + "machine": "x86_64", + }, + "signals": {"macs": ["00:00:00:00:00:00"], "hostname": "test-host"}, + "fingerprint": None, + "warthog": None, + } + + def test_shared_helper_matches_node_verifier(self): + """The shared build_pipe_sign_message must produce the same bytes the + node verifier reconstructs from the 4-part pipe split.""" + att = self._build_sample_attestation() + miner_bytes = build_pipe_sign_message(att) + verifier_str = self._node_verifier_reconstruction(att) + self.assertEqual(miner_bytes, verifier_str.encode("utf-8")) + + def test_round_trip_four_parts(self): + """Round-trip: the signed bytes split on '|' yield exactly the four + fields the node verifier extracts.""" + att = self._build_sample_attestation() + signed_bytes = build_pipe_sign_message(att) + parts = signed_bytes.decode("utf-8").split("|") + self.assertEqual(len(parts), 4) + self.assertEqual(parts[0], att["miner_id"]) + self.assertEqual(parts[1], att["miner"]) + self.assertEqual(parts[2], att["nonce"]) + self.assertEqual(parts[3], att["report"]["commitment"]) + + def test_pipe_message_differs_from_canonical_json(self): + """Confirm the old canonical-JSON approach produces different bytes + than the pipe-string — this is the root cause of issue #6798.""" + att = self._build_sample_attestation() + pipe_msg = build_pipe_sign_message(att) + canonical_bytes = json.dumps( + att, sort_keys=True, separators=(",", ":") + ).encode() + self.assertNotEqual(pipe_msg, canonical_bytes) + + def test_pipe_message_deterministic(self): + """Same attestation fields always produce the same signing message.""" + att = self._build_sample_attestation() + msg1 = build_pipe_sign_message(att) + msg2 = build_pipe_sign_message(att) + self.assertEqual(msg1, msg2) + + def test_different_nonce_changes_message(self): + """Different nonce produces a different signing message.""" + att1 = self._build_sample_attestation() + att2 = self._build_sample_attestation() + att2["nonce"] = "different-nonce" + att2["report"]["nonce"] = "different-nonce" + att2["report"]["commitment"] = hashlib.sha256( + (att2["nonce"] + att2["miner"] + json.dumps({"variance_ns": 42.5, "source": "timer_jitter"}, sort_keys=True)).encode() + ).hexdigest() + msg1 = build_pipe_sign_message(att1) + msg2 = build_pipe_sign_message(att2) + self.assertNotEqual(msg1, msg2) + + def test_pipe_delimiter_in_field_raises(self): + """If any field contains a pipe character the builder must reject it.""" + att = self._build_sample_attestation() + att["nonce"] = "bad|nonce" + with self.assertRaises(ValueError): + build_pipe_sign_message(att) + + def test_missing_field_raises(self): + """Missing required fields must raise ValueError.""" + with self.assertRaises(ValueError): + build_pipe_sign_message({"miner_id": "x", "miner": "y"}) + + +if __name__ == "__main__": + unittest.main()