Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions miners/checksums.sha256
Original file line number Diff line number Diff line change
@@ -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
35 changes: 27 additions & 8 deletions miners/linux/rustchain_linux_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions miners/signing_helpers.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion miners/windows/rustchain_miner_setup.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 33 additions & 13 deletions miners/windows/rustchain_windows_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions setup_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@
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",
"sha256": "163fafcf751d8fbd41bf936facaeb366c042f467fa34b79f2c4c0a45472ef70f",
},
"Windows": {
"url": "https://raw.githubusercontent.com/Scottcjn/Rustchain/main/miners/windows/rustchain_windows_miner.py",
"sha256": "7f663904031e5a4202be416682fd16ab51af2e96664d6db1567f716d8625f8e1",
"sha256": "eceba6529ab4df35761d6778c6f97032b69a70301b021ff2e217f9b73616f93e",
},
}

Expand Down
136 changes: 136 additions & 0 deletions tests/test_attestation_signing_6798.py
Original file line number Diff line number Diff line change
@@ -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()
Loading