From 521f2de717a9a33d18fcca33239bc7639538f77d Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 15 May 2026 09:09:39 -0500 Subject: [PATCH 1/4] metamask: add external-signer transaction test harness End-to-end test that a Wire transaction signed by an external EIP-191 personal_sign wallet (MetaMask) is accepted by nodeop, exercising both the HTTP /v1/chain/send_transaction2 path and clio push transaction. Components under tests/metamask/: - metamask-sign.html: browser page, signs a 32-byte trx digest via personal_sign and surfaces the Wire SIG_EM_/PUB_EM_ forms. - metamask_sim.py: headless eth_account substitute, byte-identical to MetaMask for the same digest; drives unattended CI. - verify_em_sig.py: offline recovery check using the same EIP-191 envelope nodeop applies. - em_sig_to_wire.py: bidirectional converter (Wire EM form is hex, not base58+ripemd160 like K1/R1/BLS). - push_metamask_trx.py: TestHarness orchestrator; expandauth-registers the EM key, builds an unsigned transfer, signs, pushes both ways. Registered as the nonparallelizable ctest metamask_trx_signing_test, which runs the simulator (--simulate) so CI needs no browser. The manual (real-MetaMask) path is hardened against two issues the simulator never hits: an offline pre-flight recovers the pasted signature and checks it against the registered PUB_EM before pushing, so a stale or wrong paste is rejected locally with recovered-vs-expected instead of as an opaque unsatisfied_authorization; and --trx-expiration (default 600s) builds the transfer with a window wide enough for human browser signing, since clio's 30s default expires first. --- tests/CMakeLists.txt | 8 + tests/metamask/.gitignore | 2 + tests/metamask/README.md | 149 +++++++ tests/metamask/em_sig_to_wire.py | 172 ++++++++ tests/metamask/metamask-sign.html | 201 +++++++++ tests/metamask/metamask_sim.py | 116 +++++ tests/metamask/push_metamask_trx.py | 653 ++++++++++++++++++++++++++++ tests/metamask/verify_em_sig.py | 136 ++++++ 8 files changed, 1437 insertions(+) create mode 100644 tests/metamask/.gitignore create mode 100644 tests/metamask/README.md create mode 100644 tests/metamask/em_sig_to_wire.py create mode 100644 tests/metamask/metamask-sign.html create mode 100644 tests/metamask/metamask_sim.py create mode 100755 tests/metamask/push_metamask_trx.py create mode 100644 tests/metamask/verify_em_sig.py diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f4cadd008f..0c2cdcbd9c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,12 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/cluster_launcher.py ${CMAKE_CURRENT_B configure_file(${CMAKE_CURRENT_SOURCE_DIR}/distributed-transactions-test.py ${CMAKE_CURRENT_BINARY_DIR}/distributed-transactions-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_bls_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_bls_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_snapshot_info_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_snapshot_info_test.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/em_sig_to_wire.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/em_sig_to_wire.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/metamask_sim.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/metamask_sim.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/verify_em_sig.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/verify_em_sig.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/push_metamask_trx.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/push_metamask_trx.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/metamask-sign.html ${CMAKE_CURRENT_BINARY_DIR}/metamask/metamask-sign.html COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/README.md ${CMAKE_CURRENT_BINARY_DIR}/metamask/README.md COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sample-cluster-map.json ${CMAKE_CURRENT_BINARY_DIR}/sample-cluster-map.json COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/restart-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/restart-scenarios-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/terminate-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/terminate-scenarios-test.py COPYONLY) @@ -233,6 +239,8 @@ add_np_test(NAME nodeop_chainbase_allocation_test COMMAND tests/nodeop_chainbase add_np_test(NAME nodeop_signal_throw_test COMMAND tests/nodeop_signal_throw_test.py -v ${UNSHARE}) +add_np_test(NAME metamask_trx_signing_test COMMAND tests/metamask/push_metamask_trx.py --simulate -v ${UNSHARE}) + add_lr_test(NAME nodeop_startup_catchup_lr_test COMMAND tests/nodeop_startup_catchup.py -v ${UNSHARE}) add_np_test(NAME nodeop_short_fork_take_over_test COMMAND tests/nodeop_short_fork_take_over_test.py -v --wallet-port 9905 ${UNSHARE}) diff --git a/tests/metamask/.gitignore b/tests/metamask/.gitignore new file mode 100644 index 0000000000..7a60b85e14 --- /dev/null +++ b/tests/metamask/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/tests/metamask/README.md b/tests/metamask/README.md new file mode 100644 index 0000000000..87c3f56d8b --- /dev/null +++ b/tests/metamask/README.md @@ -0,0 +1,149 @@ +# Metamask transaction-signing harness + +End-to-end test that a Wire transaction signed by **MetaMask** (or any wallet that +exposes EIP-191 `personal_sign` over secp256k1) is accepted by `nodeop`. + +Wire already supports this in C++ -- `signature_shim::recover()` applies the +EIP-191 prefix to a `sha256` transaction digest and recovers an `em::public_key`, +and the `expandauth_sign_with_eth_key` unit test proves the full in-process flow. +This directory adds the **external-signer** half: real MetaMask in a browser, +signing a real trx that gets pushed to nodeop. + + +## What's here + +| File | Role | +| ---- | ---- | +| `metamask-sign.html` | Static page that talks to MetaMask via `window.ethereum`. Calls `personal_sign` on a 32-byte digest, locally ecRecovers the public key, and displays both raw hex (Metamask format) and Wire's `SIG_EM_` / `PUB_EM_` forms ready to paste into the push helper. | +| `em_sig_to_wire.py` | Bidirectional converter between Metamask `0x...` hex and Wire `SIG_EM_` / `PUB_EM_` forms. (Unlike K1/R1/BLS keys, Wire's EM form is plain hex, not base58 + ripemd160: `SIG_EM_0x<130hex>` and `PUB_EM_<130hex>`.) Importable; also runs as a CLI. | +| `verify_em_sig.py` | Offline check: given `(digest, signature, expected-address-or-pub)`, recover the pubkey using the same EIP-191 envelope nodeop applies, and assert it matches. Uses `eth_account`. | +| `metamask_sim.py` | Headless substitute for MetaMask. Generates an EM key and signs an arbitrary digest the same way `personal_sign` would. Used by automated test runs. | +| `push_metamask_trx.py` | TestHarness-driven end-to-end test. Stands up a single-node cluster, creates an account, calls `expandauth` to add the EM key, seeds it with SYS transferred from `sysio`, builds two unsigned `sysio.token::transfer` trxs, signs each digest (via simulator or pasted-from-browser), and pushes one via HTTP `send_transaction2` and one via `clio push transaction`. | + + +## Python deps + +The eth\_account stack is required. From repo root: + +```bash +.venv/bin/python -m pip install eth-account eth-keys eth-utils pycryptodome +``` + +(or rely on the project's `requirements.txt` install.) + +The manual-path pre-flight and the simulator run as subprocesses that import +the eth_account stack. Invoking the harness with `.venv/bin/python` is the +simplest path. If you launch it with a bare `python3` that lacks those deps, +the harness auto-detects this and falls back to `$VIRTUAL_ENV` or the first +`.venv` found walking up from the script (so it works from the source tree or +the build-dir copy); it errors early, before launching the cluster, only if no +suitable interpreter exists. + + +## Quick offline sanity check + +```bash +.venv/bin/python tests/metamask/metamask_sim.py keygen > /tmp/k.json +PRIV=$(jq -r .private_key /tmp/k.json) +WIRE_PUB=$(jq -r .wire_pub_em /tmp/k.json) +ADDR=$(jq -r .eth_address /tmp/k.json) + +SIG=$(.venv/bin/python tests/metamask/metamask_sim.py sign \ + --private-key "$PRIV" --digest 0xdeadbeef --raw) + +.venv/bin/python tests/metamask/verify_em_sig.py \ + --digest 0xdeadbeef \ + --signature "$SIG" \ + --expect-addr "$ADDR" \ + --expect-pub "$WIRE_PUB" +``` + +Exit 0 means the EIP-191 envelope, the secp256k1 recovery, and Wire's plain-hex +`SIG_EM_` / `PUB_EM_` framing all round-trip. + + +## Full end-to-end (automated) + +This is the path `ctest -R metamask_trx_signing_test` runs. + +```bash +cd $BUILD_DIR +.venv/bin/python tests/metamask/push_metamask_trx.py --simulate -v +``` + +The script: + +1. Launches a single-node cluster via TestHarness. +2. Creates `mmtest11`, gives it K1 keys. +3. Generates an EM keypair in the simulator and adds the `PUB_EM_` to + `mmtest11@active` via `sysio::expandauth`. +4. Transfers `100.0000 SYS` from `sysio` to `mmtest11` (bootstrap issued the + full max_supply to `sysio`, so just transfer instead of `issue`). +5. **Round 1 - HTTP path:** builds an unsigned `sysio.token::transfer + mmtest11 -> defproducera` via `clio push action -s -d --return-packed -j`, + computes `sig_digest = sha256(chain_id || packed_trx || zero_32)`, signs + via the simulator, and `POST`s the packed_transaction directly to + `/v1/chain/send_transaction2`. +6. **Round 2 - clio path:** builds a second unsigned transfer (different + memo so the trx id is unique), computes its digest, signs, and pushes via + `clio convert unpack_transaction ... | clio push transaction --signature SIG_EM_... -s`. +7. After each round, fetches `mmtest11`'s SYS balance and asserts it dropped by + exactly the transfer amount (integer minimal units, no float), and that the + trx reports `processed.except == null` and `processed.error_code == null`. + + +## Full end-to-end (real MetaMask) + +```bash +cd $BUILD_DIR +.venv/bin/python tests/metamask/push_metamask_trx.py -v +``` + +Open `metamask-sign.html` in a browser with MetaMask. If the browser can open +the local file directly, that works with no server. Under WSL (browser on the +Windows side) serve it instead, e.g. from `tests/metamask/`: +`python3 -m http.server 8866 --bind 0.0.0.0`, then open +`http://localhost:8866/metamask-sign.html`. The script will: + +1. Spin up the cluster as above. +2. Prompt for your ETH address + uncompressed pubkey + Wire `PUB_EM_` -- + the HTML page shows all three once you sign any test message. The three + pasted values are cross-checked offline for mutual consistency before use. +3. Add the `PUB_EM_` to the test account via `expandauth`. +4. Print the trx `sig_digest`. Paste that into the HTML page's "Digest to sign" + field, click "Sign with Metamask", copy the `SIG_EM_` value. +5. Paste the `SIG_EM_` back into the script. It is recovered offline and + checked against the registered `PUB_EM_` before being sent; a stale or + wrong-digest signature is rejected locally and re-prompted. +6. Push, check execution. Repeated for Round 2 (different digest). + + +## Notes / gotchas + +- MetaMask's `personal_sign` accepts the message either as a UTF-8 string or as + `0x`-prefixed hex. **Always pass `0x` + 64 hex chars** for a 32-byte digest: + if you pass a UTF-8 string by mistake, MetaMask prefix-wraps the UTF-8 bytes + of the *string*, not the underlying digest, and the chain rejects the + signature. +- EIP-191 v-byte is `27` or `28` (recovery_id + 27). Some wallets emit raw `0/1`. + Both `metamask_sig_to_wire` and `verify_em_sig` normalize to `27/28` before + encoding. +- ECDSA signatures must be canonical (`s < n/2`). MetaMask and `eth_account` + produce canonical signatures, but ad-hoc third-party signers may not; Wire's + `em::public_key::is_canonical()` rejects them. +- The Wire EM form is hex, not base58 + ripemd160 like K1/R1/BLS: + - `SIG_EM_0x<130hex>`: `to_hex()` always prefixes 0x, so the 0x lives + between `SIG_EM_` and the hex chars. + - `PUB_EM_<130hex>`: no `0x` in the middle; first two hex chars are the + SEC1 `04` uncompressed prefix. +- The Wire send_transaction2 reply's success indicator is + `processed.except == null` and `processed.error_code == null`; the trx-level + `receipt` field can be `null` while the trx still executes successfully. +- `--trx-expiration` (default 600s) must be in `(0, 3600]` -- the chain's + `max_transaction_lifetime` under TestHarness. Manual signing needs the wide + window because the expiration is baked into the bytes the signer hashes and + cannot be changed after signing. +- This test pushes through `sysio.token::transfer` because it requires + authentication to succeed and visibly mutates state (balance change). The + same path works for any action; swap the body of `push_metamask_trx.py` if + you need to exercise something else. diff --git a/tests/metamask/em_sig_to_wire.py b/tests/metamask/em_sig_to_wire.py new file mode 100644 index 0000000000..afe3981c67 --- /dev/null +++ b/tests/metamask/em_sig_to_wire.py @@ -0,0 +1,172 @@ +"""Bidirectional converter between Metamask-format secp256k1 signatures / +uncompressed public keys and Wire's SIG_EM_/PUB_EM_ string forms. + +The Wire EM format is hex, NOT base58. From libfc: + - em::public_key_shim::to_string(no_prefix=true) -> 130 hex chars (no 0x) + so PUB_EM_ form is: "PUB_EM_" + 130 hex chars (65-byte uncompressed, SEC1 0x04 prefix in first 2 hex chars) + - em::signature_shim::to_string() -> "0x" + 130 hex chars + so SIG_EM_ form is: "SIG_EM_" + "0x" + 130 hex chars (65 bytes: r||s||v) + +Metamask format: + - Signature: "0x" + 130 hex chars (r(32) || s(32) || v(1)) + - Public key (uncompressed): 64 raw bytes (X || Y), or 65 with 0x04 SEC1 prefix + - Address: "0x" + last 40 hex chars of keccak256(uncompressed_pub[1:]) + +Run as a CLI: + python em_sig_to_wire.py sig-to-wire 0x<130-hex> + python em_sig_to_wire.py wire-to-sig SIG_EM_0x<130-hex> + python em_sig_to_wire.py pub-to-wire 0x<128-hex or 130-hex> + python em_sig_to_wire.py wire-to-pub PUB_EM_<130-hex> + python em_sig_to_wire.py addr-from-pub 0x<128-hex> +""" + +from __future__ import annotations + +import sys + + +def _hex_to_bytes(s: str) -> bytes: + s = s[2:] if s.startswith(("0x", "0X")) else s + return bytes.fromhex(s) + + +def _bytes_to_0x_hex(b: bytes) -> str: + return "0x" + b.hex() + + +# --- signature conversions ------------------------------------------------- + +def metamask_sig_to_wire(sig_hex: str) -> str: + """Convert a 65-byte Metamask hex signature (r||s||v) to Wire SIG_EM_ form. + + Wire form is `SIG_EM_0x<130hex>`. Normalizes v to 27/28. + """ + sig = _hex_to_bytes(sig_hex) + if len(sig) != 65: + raise ValueError(f"signature must be 65 bytes, got {len(sig)}") + v = sig[64] + if v in (0, 1): + sig = sig[:64] + bytes([v + 27]) + elif v not in (27, 28): + raise ValueError(f"unexpected v byte: {v}; expected 27, 28, 0, or 1") + return "SIG_EM_" + _bytes_to_0x_hex(sig) + + +def wire_sig_to_metamask(sig_wire: str) -> str: + """Convert SIG_EM_0x... back to a 65-byte 0x-prefixed hex signature.""" + if not sig_wire.startswith("SIG_EM_"): + raise ValueError(f"expected SIG_EM_... prefix, got {sig_wire[:10]}") + tail = sig_wire[len("SIG_EM_"):] + # tail starts with "0x" because em::signature_shim::to_string always adds it. + if not tail.startswith(("0x", "0X")): + # Accept the variant without "0x" too, just in case. + tail = "0x" + tail + raw = _hex_to_bytes(tail) + if len(raw) != 65: + raise ValueError(f"decoded signature length {len(raw)} != 65") + return _bytes_to_0x_hex(raw) + + +# --- public key conversions ------------------------------------------------ + +def metamask_pub_to_wire(pub_hex: str) -> str: + """Convert an uncompressed Ethereum-style hex pub (128 or 130 chars) to PUB_EM_...""" + raw = _hex_to_bytes(pub_hex) + if len(raw) == 64: + raw = b"\x04" + raw + elif len(raw) == 65: + if raw[0] != 0x04: + raise ValueError(f"uncompressed pubkey prefix must be 0x04, got 0x{raw[0]:02x}") + else: + raise ValueError(f"uncompressed pubkey must be 64 or 65 bytes, got {len(raw)}") + # Wire form: PUB_EM_<130 hex chars, no 0x in middle> + return "PUB_EM_" + raw.hex() + + +def wire_pub_to_metamask(pub_wire: str) -> str: + """Convert PUB_EM_... to a 0x-prefixed uncompressed-pub hex string (130 chars including 04).""" + if not pub_wire.startswith("PUB_EM_"): + raise ValueError(f"expected PUB_EM_... prefix, got {pub_wire[:10]}") + tail = pub_wire[len("PUB_EM_"):] + # PUB_EM_ form has no "0x" in the middle, but accept it gracefully. + if tail.startswith(("0x", "0X")): + tail = tail[2:] + raw = bytes.fromhex(tail) + if len(raw) == 64: + raw = b"\x04" + raw + elif len(raw) != 65: + raise ValueError(f"unexpected pubkey hex length {len(tail)}; want 128 or 130") + if raw[0] != 0x04: + raise ValueError("uncompressed pubkey prefix must be 0x04") + return _bytes_to_0x_hex(raw) + + +def eth_address_from_pub(pub_hex: str) -> str: + """Derive the 0x... Ethereum address from an uncompressed pubkey hex (128 or 130 chars).""" + raw = _hex_to_bytes(pub_hex) + if len(raw) == 65: + if raw[0] != 0x04: + raise ValueError("uncompressed pubkey prefix must be 0x04") + xy = raw[1:] + elif len(raw) == 64: + xy = raw + else: + raise ValueError(f"uncompressed pubkey must be 64 or 65 bytes, got {len(raw)}") + digest = _keccak256(xy) + return _bytes_to_0x_hex(digest[-20:]) + + +def _keccak256(data: bytes) -> bytes: + try: + from eth_utils import keccak # type: ignore + return keccak(data) + except ImportError: + pass + try: + from Crypto.Hash import keccak # type: ignore + h = keccak.new(digest_bits=256) + h.update(data) + return h.digest() + except ImportError: + pass + try: + import sha3 # type: ignore (pysha3 / safe-pysha3) + h = sha3.keccak_256() + h.update(data) + return h.digest() + except ImportError as exc: + raise RuntimeError( + "no keccak256 backend found; pip install eth-utils, pycryptodome, or pysha3" + ) from exc + + +# --- CLI ------------------------------------------------------------------- + +def _main(argv: list[str]) -> int: + if len(argv) < 2: + print(__doc__) + return 2 + cmd = argv[1] + args = argv[2:] + try: + if cmd == "sig-to-wire" and len(args) == 1: + print(metamask_sig_to_wire(args[0])) + elif cmd == "wire-to-sig" and len(args) == 1: + print(wire_sig_to_metamask(args[0])) + elif cmd == "pub-to-wire" and len(args) == 1: + print(metamask_pub_to_wire(args[0])) + elif cmd == "wire-to-pub" and len(args) == 1: + print(wire_pub_to_metamask(args[0])) + elif cmd == "addr-from-pub" and len(args) == 1: + print(eth_address_from_pub(args[0])) + else: + print(__doc__) + return 2 + except Exception as exc: # noqa: BLE001 + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv)) diff --git a/tests/metamask/metamask-sign.html b/tests/metamask/metamask-sign.html new file mode 100644 index 0000000000..c1757a9161 --- /dev/null +++ b/tests/metamask/metamask-sign.html @@ -0,0 +1,201 @@ + + + + +Wire: Metamask signing harness + + + +

Wire transaction signing harness for Metamask

+

+Connect Metamask, paste a 32-byte transaction sig-digest (sha256 of the trx), +and click Sign. The signature is produced via personal_sign which +applies the EIP-191 prefix exactly the way Wire's +signature_shim::recover() expects. +The page also outputs the Wire-formatted SIG_EM_... / +PUB_EM_... values you'll paste into push_metamask_trx.py. +

+ +
+
Wallet
+ +
not connected
+
+ +
+
Digest to sign (32-byte trx digest as 0x-prefixed 64 hex chars)
+ +
+ +
+ + +
+ +

Signature

+
+
Raw Metamask signature (0x + r||s||v hex, 132 chars)
+
+
+ +
+
+
+
Wire SIG_EM_
+
+
+ +
+
+ +

Signer identity (recovered locally)

+
+
Ethereum address
+
+
+ +
+
+
+
Uncompressed pubkey (0x04 + X || Y, 130 hex chars)
+
+
+ +
+
+
+
Wire PUB_EM_
+
+
+ +
+
+ +

+Uses ethers.js v6 (pinned 6.13.4, loaded from cdnjs with a Subresource +Integrity hash) for personal_sign + ecRecover + keccak256. Wire's EM signature +and pubkey forms are plain hex (SIG_EM_0x... / PUB_EM_...), not the base58 + +ripemd160 checksum used by K1/R1/BLS keys, so no extra encoding is applied. +

+ + + + + diff --git a/tests/metamask/metamask_sim.py b/tests/metamask/metamask_sim.py new file mode 100644 index 0000000000..4409f83d8b --- /dev/null +++ b/tests/metamask/metamask_sim.py @@ -0,0 +1,116 @@ +"""Headless substitute for Metamask's personal_sign. + +Mimics MetaMask's `personal_sign` on an arbitrary 0x-hex payload using +`eth_account`. The signature produced by this script is byte-identical to what +Metamask would emit if a user signed the same digest in the browser, because +both go through the EIP-191 envelope: + + keccak256("\\x19Ethereum Signed Message:\\n" + len(payload) + payload) + +then deterministic RFC-6979 secp256k1 with low-s normalization. + +Run: + python metamask_sim.py keygen + -> emits {private_key, eth_address, wire_pub_em} as JSON + + python metamask_sim.py sign --private-key 0x --digest 0x + -> emits {signature_hex, signature_wire, eth_address, wire_pub_em} as JSON + + python metamask_sim.py sign --private-key 0x --digest 0x --raw + -> emits the SIG_EM_... value alone on stdout (script-friendly) +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +from em_sig_to_wire import metamask_pub_to_wire, metamask_sig_to_wire + + +def _load_key(priv_hex: str): + from eth_keys import keys + raw = bytes.fromhex(priv_hex[2:] if priv_hex.startswith("0x") else priv_hex) + if len(raw) != 32: + raise ValueError(f"private key must be 32 bytes, got {len(raw)}") + return keys.PrivateKey(raw) + + +def _generate_key() -> str: + return "0x" + os.urandom(32).hex() + + +def _key_info(priv) -> dict[str, str]: + pub_hex = "0x04" + priv.public_key.to_bytes().hex() + from eth_utils import keccak + addr = "0x" + keccak(priv.public_key.to_bytes())[-20:].hex() + return { + "eth_address": addr, + "uncompressed_pub_hex": pub_hex, + "wire_pub_em": metamask_pub_to_wire(pub_hex), + } + + +def _sign_digest(priv, digest_hex: str) -> dict[str, Any]: + """Sign `digest_hex` (raw payload, will be EIP-191 wrapped + keccak256-ed). + + `digest_hex` is what Metamask receives as the message argument to + personal_sign when called as `personal_sign('0x', address)`. + """ + from eth_account.messages import encode_defunct + from eth_account import Account + + msg_bytes = bytes.fromhex(digest_hex[2:] if digest_hex.startswith("0x") else digest_hex) + msg = encode_defunct(primitive=msg_bytes) + signed = Account.sign_message(msg, priv.to_hex()) + sig_hex = "0x" + signed.signature.hex() + return { + "signature_hex": sig_hex, + "signature_wire": metamask_sig_to_wire(sig_hex), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_keygen = sub.add_parser("keygen", help="Generate a fresh EM key") + p_keygen.add_argument("--private-key", default=None, + help="Use this private key instead of generating one") + + p_sign = sub.add_parser("sign", help="Sign a 0x-hex digest as Metamask would") + p_sign.add_argument("--private-key", required=True, help="0x-prefixed 32-byte hex") + p_sign.add_argument("--digest", required=True, help="0x-prefixed hex message to sign") + p_sign.add_argument("--raw", action="store_true", + help="Print only the SIG_EM_... line (no JSON)") + + args = parser.parse_args(argv) + + try: + if args.cmd == "keygen": + priv_hex = args.private_key or _generate_key() + priv = _load_key(priv_hex) + info = {"private_key": priv_hex, **_key_info(priv)} + print(json.dumps(info, indent=2)) + return 0 + + # The only other subcommand; argparse's required subparser guarantees + # args.cmd is "keygen" or "sign", so this needs no further dispatch. + priv = _load_key(args.private_key) + sig = _sign_digest(priv, args.digest) + if args.raw: + print(sig["signature_wire"]) + else: + print(json.dumps({**sig, **_key_info(priv)}, indent=2)) + return 0 + except Exception as exc: # noqa: BLE001 + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/metamask/push_metamask_trx.py b/tests/metamask/push_metamask_trx.py new file mode 100755 index 0000000000..e8bfd6d718 --- /dev/null +++ b/tests/metamask/push_metamask_trx.py @@ -0,0 +1,653 @@ +#!/usr/bin/env python3 +"""End-to-end Metamask-signed transaction test. + +What this exercises: + 1. Stand up a single-node TestHarness cluster. + 2. Create a test account with K1 keys on its active permission. + 3. Append a Metamask-style EM pubkey to the active permission via + sysio::expandauth. + 4. Seed the test account by transferring SYS from `sysio` (the bootstrap + issues the full max_supply to `sysio`, so issuing more would exceed it). + 5. For each of two rounds, build an unsigned `sysio.token::transfer`, + compute its sig_digest (sha256(chain_id || packed_trx || cfd_digest)), + sign that digest, push it, and assert the sender balance dropped by + exactly the transfer amount: + Round 1 - POST the packed_transaction to /v1/chain/send_transaction2. + Round 2 - clio convert unpack_transaction | clio push transaction + --signature SIG_EM_... -s. + 6. Signing has two modes: + --simulate : metamask_sim.py drives the signing automatically. This is + what the `metamask_trx_signing_test` ctest runs, so CI + needs no browser. + (default) : print the digest and wait for the user to paste the + SIG_EM_... value from metamask-sign.html. The pasted + identity and every signature are verified offline before + anything reaches the chain, so a stale or wrong paste is + rejected locally with a clear message instead of as an + opaque unsatisfied_authorization. + +Run from your build directory (so $BUILD_DIR/programs/clio resolves): + + cd $BUILD_DIR + .venv/bin/python tests/metamask/push_metamask_trx.py --simulate + .venv/bin/python tests/metamask/push_metamask_trx.py # manual + +Use `--leave-running` to leave the cluster up after the test. +""" + +from __future__ import annotations + +import functools +import hashlib +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parent)) + +from em_sig_to_wire import ( # noqa: E402 + eth_address_from_pub, + metamask_pub_to_wire, + wire_pub_to_metamask, +) + +# TestHarness shaping +from TestHarness import ( # noqa: E402 + Cluster, + TestHelper, + Utils, + WalletMgr, + createAccountKeys, +) +from TestHarness.TestHelper import AppArgs # noqa: E402 + + +Print = Utils.Print +errorExit = Utils.errorExit + +# Mirrors the max_transaction_lifetime the TestHarness launcher configures +# (tests/TestHarness/launcher.py). A built transfer's expiration cannot exceed +# this, so an out-of-range --trx-expiration is rejected up front with a clear +# message rather than failing opaquely inside clio. +MAX_TRX_EXPIRATION_SECONDS = 3600 + +# metamask_sim.py (simulate) and verify_em_sig.py (manual pre-flight) run as +# subprocesses and import these. Probed once at startup against a chosen +# interpreter so a deps-less launcher fails fast with guidance instead of +# rejecting every signature deep in the manual flow. +_REQUIRED_DEPS = ("eth_account", "eth_keys", "eth_utils") + + +@functools.lru_cache(maxsize=1) +def _dep_python() -> str: + """Return a Python interpreter that can import the eth_account stack. + + Relying on sys.executable breaks when the harness is launched with a bare + `python3` that lacks the deps: the pre-flight subprocess then fails with an + opaque "No module named 'eth_account'" and the manual loop rejects every + paste forever. Prefer the launching interpreter if it already has the deps; + otherwise fall back to $VIRTUAL_ENV or the first `.venv` found walking up + from this script (works whether run from the source tree or the build-dir + copy). errorExit with actionable guidance if nothing qualifies. + """ + probe = "import " + ", ".join(_REQUIRED_DEPS) + + def has_deps(py: str) -> bool: + try: + return subprocess.run([py, "-c", probe], + capture_output=True, text=True).returncode == 0 + except OSError: + return False + + if has_deps(sys.executable): + return sys.executable + + candidates: list[str] = [] + venv_env = os.environ.get("VIRTUAL_ENV") + if venv_env: + candidates.append(str(Path(venv_env) / "bin" / "python")) + for parent in [HERE, *HERE.parents]: + cand = parent / ".venv" / "bin" / "python" + if cand.is_file(): + candidates.append(str(cand)) + + tried: list[str] = [] + for cand in candidates: + if cand in tried: + continue + tried.append(cand) + if has_deps(cand): + return cand + + errorExit( + "no Python with the eth_account stack " + f"({', '.join(_REQUIRED_DEPS)}) could be found.\n" + f" launched with : {sys.executable}\n" + f" also tried : {tried or '(none)'}\n" + " fix: run the harness with the repo virtualenv, e.g.\n" + " /.venv/bin/python tests/metamask/push_metamask_trx.py ...\n" + " or: /bin/python -m pip install " + "eth-account eth-keys eth-utils pycryptodome") + + +def _prompt(label: str) -> str: + """input() that turns EOF / Ctrl-C into a clean TestHarness exit. + + Manual mode is interactive; if stdin is closed (piped, no TTY) a bare + input() raises EOFError and dumps a traceback. Fail with a clear message + instead so the cluster still shuts down via main()'s finally. + """ + try: + return input(label).strip() + except (EOFError, KeyboardInterrupt): + errorExit("aborted: no input on stdin (manual mode needs an interactive terminal)") + + +def _run_clio(node, *args, capture_json=True): + """Run clio against `node` and return stdout (json-decoded if requested). + + A non-zero clio exit is fatal via errorExit, matching how the rest of this + test reports failures (clean TestHarness shutdown, no raw traceback). + """ + cmd = [Utils.SysClientPath, *node.sysClientArgs().split(), *args] + if Utils.Debug: + Print(f"cmd: {' '.join(cmd)}") + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + errorExit(f"clio failed: {' '.join(cmd)}\nstderr: {res.stderr}\nstdout: {res.stdout}") + out = res.stdout.strip() + if not capture_json: + return out + return json.loads(out) if out else {} + + +def _parse_asset(asset: str) -> tuple[int, int, str]: + """Parse a Wire asset string ("100.0000 SYS") into (units, precision, symbol). + + `units` is the amount in integer minimal units (here 1000000 for the + example) so balance math never touches floating point. + """ + asset = (asset or "").strip() + if not asset: + raise ValueError("empty asset string") + amount_str, _, symbol = asset.partition(" ") + symbol = symbol.strip() + if not symbol: + raise ValueError(f"asset missing symbol: {asset!r}") + whole, dot, frac = amount_str.partition(".") + precision = len(frac) if dot else 0 + units = int(whole or "0") * (10 ** precision) + (int(frac) if frac else 0) + return units, precision, symbol + + +def _assert_balance_decreased(label: str, before: str, after: str, expected_delta: str): + """Assert `before - after == expected_delta` exactly (integer units, same symbol).""" + b_units, _, b_sym = _parse_asset(before) + a_units, _, a_sym = _parse_asset(after) + d_units, _, d_sym = _parse_asset(expected_delta) + if not (b_sym == a_sym == d_sym): + errorExit(f"{label}: asset symbol mismatch: " + f"before={before!r} after={after!r} delta={expected_delta!r}") + if b_units - a_units != d_units: + errorExit(f"{label}: balance did not drop by {expected_delta}: " + f"before={before} after={after} " + f"(observed delta {b_units - a_units} minimal units, expected {d_units})") + Print(f"{label}: balance {before} -> {after} (-{expected_delta}) OK") + + +def _validate_pasted_identity(em_info: dict[str, str]): + """Offline check of the three values pasted from metamask-sign.html. + + The signature gets a recovery pre-flight; the identity it must recover to + deserves the same treatment. Confirm the pasted uncompressed pubkey, the + pasted Ethereum address, and the pasted PUB_EM_ are mutually consistent so + a typo fails here with a clear message instead of opaquely at expandauth. + """ + eth_address = em_info["eth_address"] + pub_hex = em_info["uncompressed_pub_hex"] + wire_pub_em = em_info["wire_pub_em"] + if not wire_pub_em.startswith("PUB_EM_"): + errorExit(f"pasted Wire pubkey is not a PUB_EM_ value: {wire_pub_em!r}") + try: + # wire_pub_to_metamask validates the PUB_EM_ framing/length/0x04 prefix. + wire_pub_to_metamask(wire_pub_em) + derived_wire = metamask_pub_to_wire(pub_hex) + derived_addr = eth_address_from_pub(pub_hex) + except Exception as exc: # noqa: BLE001 + errorExit(f"pasted identity is malformed: {exc}") + if derived_wire.lower() != wire_pub_em.lower(): + errorExit("pasted uncompressed pubkey does not match the pasted PUB_EM_:\n" + f" from pubkey : {derived_wire}\n" + f" pasted PUB : {wire_pub_em}") + if derived_addr.lower() != eth_address.lower(): + errorExit("pasted ETH address does not match the pasted pubkey:\n" + f" from pubkey : {derived_addr}\n" + f" pasted addr : {eth_address}") + + +def _create_em_key(simulate: bool, + private_key_hex: str | None) -> tuple[dict[str, str], str | None]: + """Obtain the EM identity, either from metamask_sim keygen or by paste. + + Returns (em_info, sim_private_key). `em_info` always has exactly + {eth_address, uncompressed_pub_hex, wire_pub_em}; `sim_private_key` is the + simulator's hex private key in --simulate mode (used to sign later) and + None in manual mode. Keeping the shape identical across both modes avoids + the previous branch-dependent KeyError footgun. + """ + if simulate: + cmd = [_dep_python(), str(HERE / "metamask_sim.py"), "keygen"] + if private_key_hex: + cmd += ["--private-key", private_key_hex] + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + raw = json.loads(res.stdout) + em_info = { + "eth_address": raw["eth_address"], + "uncompressed_pub_hex": raw["uncompressed_pub_hex"], + "wire_pub_em": raw["wire_pub_em"], + } + return em_info, raw["private_key"] + + Print("\nOpen tests/metamask/metamask-sign.html in a browser with Metamask") + Print("installed. Click 'Connect Metamask' and then sign any test message so") + Print("the page can ecRecover and surface your uncompressed pubkey.") + Print("Paste the values shown on the page below:\n") + em_info = { + "eth_address": _prompt("ETH address (0x...): "), + "uncompressed_pub_hex": _prompt("uncompressed pubkey (0x04...): "), + "wire_pub_em": _prompt("Wire PUB_EM_ pubkey: "), + } + _validate_pasted_identity(em_info) + Print("pasted identity is self-consistent (pubkey <-> address <-> PUB_EM_)") + return em_info, None + + +def _verify_sig_locally(digest_hex: str, sig_em: str, + expected_wire_pub: str) -> tuple[int, str]: + """Offline pre-flight before pushing: recover the signer pubkey from + (digest, sig) using the SAME EIP-191 envelope nodeop applies, and confirm it + matches the EM key registered on the account. + + Delegates to verify_em_sig.py (its documented CLI) rather than reaching into + its internals, and mirrors how metamask_sim.py is driven elsewhere here. + + Returns (returncode, detail) where returncode follows verify_em_sig.py's + contract: 0 ok, 1 recovery failed (the pasted text is not a recoverable + signature), 2 recovered-key mismatch (wrong/stale digest signed), 3 CLI + error. `detail` carries its recovered-vs-expected output so a bad paste is + diagnosable HERE instead of as an opaque unsatisfied_authorization. + """ + cmd = [ + _dep_python(), str(HERE / "verify_em_sig.py"), + "--digest", digest_hex, + "--signature", sig_em, + "--expect-pub", expected_wire_pub, + ] + res = subprocess.run(cmd, capture_output=True, text=True) + parts = [res.stdout.strip(), res.stderr.strip()] + detail = "\n".join(p for p in parts if p) + return res.returncode, detail + + +def _sign_with_metamask(simulate: bool, private_key_hex: str | None, + digest_hex: str, expected_wire_pub: str) -> str: + """Get a SIG_EM_... signature for `digest_hex` either from sim or from human paste. + + Manual mode runs an offline recovery pre-flight on the pasted signature before + returning it: anything that does not recover to the account's registered EM + key is rejected locally with a clear diagnostic and the user is re-prompted, + rather than letting nodeop reject it later with an opaque + unsatisfied_authorization. (The simulate path is unchanged.) + """ + if simulate: + assert private_key_hex, "simulator mode requires the simulator's private key" + cmd = [ + _dep_python(), str(HERE / "metamask_sim.py"), + "sign", + "--private-key", private_key_hex, + "--digest", digest_hex, + "--raw", + ] + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + return res.stdout.strip() + + Print("\n" + "=" * 72) + Print("Paste THIS EXACT digest into the HTML page's 'Digest to sign' field:") + Print(f" {digest_hex}") + Print("Click 'Sign with Metamask', then copy the SIG_EM_... value back here.") + Print("(blank input aborts the test)") + Print("=" * 72) + while True: + sig = _prompt("SIG_EM_...: ") + if not sig: + errorExit("aborted: no signature entered") + rc, detail = _verify_sig_locally(digest_hex, sig, expected_wire_pub) + if rc == 0: + Print("pre-flight OK: signature recovers to the registered EM key; pushing") + return sig + # A missing dependency / CLI failure is not something re-pasting can fix; + # fail loudly rather than spinning the prompt forever. (_dep_python makes + # the missing-module case unreachable, but guard it regardless.) + if "No module named" in detail or rc not in (1, 2): + errorExit(f"pre-flight could not run (verify_em_sig exit {rc}); " + f"this is an environment problem, not a bad signature:\n{detail}") + Print(f"\n*** REJECTED locally (NOT sent to chain):\n{detail}\n") + if rc == 2: + Print("The signature recovers to a DIFFERENT key than the one registered.") + Print("Almost always a stale signature from an earlier run, or the") + Print("pubkey-reveal signature instead of this transaction's digest.") + Print(f"The digest to sign is still: {digest_hex}") + Print("Re-sign THAT exact digest in the browser and paste again.\n") + else: # rc == 1 + Print("That is not a recoverable signature. Paste the full") + Print("SIG_EM_0x... value exactly as the page's 'Wire SIG_EM_' box") + Print(f"shows it, signed over digest: {digest_hex}\n") + + +def _compute_sig_digest(chain_id_hex: str, packed_trx_hex: str) -> str: + """Wire trx sig_digest: sha256(chain_id || packed_trx || cfd_digest). + + No context-free data: cfd_digest is 32 zero bytes. + """ + chain_id = bytes.fromhex(chain_id_hex) + if len(chain_id) != 32: + raise ValueError(f"chain_id must be 32 bytes, got {len(chain_id)}") + packed_trx = bytes.fromhex(packed_trx_hex) + cfd_digest = b"\x00" * 32 + return "0x" + hashlib.sha256(chain_id + packed_trx + cfd_digest).hexdigest() + + +def _build_unsigned_transfer(node, sender: str, recipient: str, quantity: str, + memo: str, expiration_seconds: int) -> dict: + """Use `clio push action -s -d --return-packed` to build an unsigned trx. + + Returns the {signatures, compression, packed_context_free_data, packed_trx} + object from clio. Signatures is always []. + + `expiration_seconds` is baked into the packed bytes the signer hashes, so it + cannot be changed after signing. Manual MetaMask signing has a human in the + loop (open page, paste digest, approve in the extension, maybe re-sign after + a pre-flight rejection); clio's 30s default expires long before that. The + caller passes a generous window (still <= chain max_transaction_lifetime). + """ + transfer_data = json.dumps({ + "from": sender, "to": recipient, + "quantity": quantity, "memo": memo, + }) + return _run_clio( + node, + "push", "action", "sysio.token", "transfer", transfer_data, + "-p", f"{sender}@active", + "-x", str(expiration_seconds), + "-s", "-d", "-j", "--return-packed", + ) + + +def _sign_unsigned_packed(packed, chain_id_hex: str, simulate: bool, + sim_private_key: str | None, + expected_wire_pub: str) -> tuple[str, str]: + """Compute sig_digest and obtain a SIG_EM_... over it. Returns (sig_em, digest_hex). + + `expected_wire_pub` is the PUB_EM_ registered on the account; the manual path + pre-flights the pasted signature against it before returning. + """ + sig_digest = _compute_sig_digest(chain_id_hex, packed["packed_trx"]) + sig_em = _sign_with_metamask(simulate, sim_private_key, sig_digest, expected_wire_pub) + return sig_em, sig_digest + + +def _push_via_http(node, packed, sig_em: str) -> dict: + """POST a packed_transaction directly to /v1/chain/send_transaction2.""" + return _post_send_transaction2(node, packed["packed_trx"], sig_em) + + +def _push_via_clio(node, packed, sig_em: str) -> dict: + """Round-trip the packed trx through `clio convert unpack_transaction` to recover + the JSON form, then hand to `clio push transaction --signature SIG_EM_... -s`. + + fc::raw::pack is deterministic, so the unpack -> repack on the send path yields + bytes identical to those we used to compute the sig_digest. If that invariant + ever breaks the test will fail with a recovered-key mismatch. + + `compression` is hardcoded "none": `clio push action -d --return-packed` + returns an uncompressed packed_trx, and the digest was computed over those + raw bytes. If clio ever returned a compressed form, repacking here would + change the bytes and the test would fail loudly at the pre-flight / chain. + """ + fake_packed = { + "signatures": [], + "compression": "none", + "packed_context_free_data": "", + "packed_trx": packed["packed_trx"], + } + unsigned_trx_obj = _run_clio(node, "convert", "unpack_transaction", json.dumps(fake_packed)) + return _run_clio( + node, + "push", "transaction", json.dumps(unsigned_trx_obj), + "--signature", sig_em, + "-s", "-j", + ) + + +def _post_send_transaction2(node, packed_trx_hex: str, sig_em: str) -> dict: + """POST a packed_transaction directly to /v1/chain/send_transaction2. + + Sending the packed form preserves the exact bytes used to compute the + sig_digest. Going through `clio push transaction` would re-pack from JSON + and (in principle) could produce different bytes than the ones we hashed. + + Returns the decoded JSON reply, or a {"http_error": ...} / {"net_error": ...} + marker so the caller's _assert_executed reports a clean message instead of a + raw traceback for an HTTP-status error or a connection/timeout failure. + """ + body = { + "return_failure_trace": True, + "retry_trx": False, + "transaction": { + "signatures": [sig_em], + "compression": "none", + "packed_context_free_data": "", + "packed_trx": packed_trx_hex, + }, + } + url = f"http://{node.host}:{node.port}/v1/chain/send_transaction2" + req = urllib.request.Request( + url, data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return {"http_error": exc.code, "body": exc.read().decode("utf-8")} + except (urllib.error.URLError, TimeoutError) as exc: + return {"net_error": str(getattr(exc, "reason", exc)), "url": url} + + +def _assert_executed(label: str, result: dict): + """Success criterion shared by both push paths. + + send_transaction2 and `clio push transaction -j` return the same trace + shape: success means processed.except is null and processed.error_code is + null. The trx-level `receipt` can be null even on success, so it is not + checked. http_error / net_error markers from _post_send_transaction2 are + surfaced as clean failures. + """ + if "http_error" in result: + errorExit(f"{label}: HTTP error {result['http_error']}: {result['body']}") + if "net_error" in result: + errorExit(f"{label}: could not reach {result['url']}: {result['net_error']}") + processed = result.get("processed", {}) + exc = processed.get("except") + if exc: + errorExit(f"{label}: trx raised: {exc.get('name')}: {exc.get('message')}") + err_code = processed.get("error_code") + if err_code: + errorExit(f"{label}: trx error_code={err_code}") + trx_id = result.get("transaction_id") or processed.get("id") + block = processed.get("block_num") + Print(f"{label}: executed, trx_id={trx_id}, block_num={block}") + + +def main() -> int: + app_args = AppArgs() + app_args.add_bool("--simulate", + help="Drive metamask_sim.py to sign instead of asking the user to paste") + app_args.add("--sim-private-key", type=str, default=None, + help="If --simulate, use this hex private key instead of a fresh one") + app_args.add("--transfer-amount", type=str, default="1.0000 SYS", + help="Asset string to transfer (default: '1.0000 SYS')") + app_args.add("--trx-expiration", type=int, default=600, + help="Seconds before each built transfer expires. Manual signing " + "needs a wide window; default 600s, must be in " + f"(0, {MAX_TRX_EXPIRATION_SECONDS}] (chain max_transaction_lifetime).") + args = TestHelper.parse_args({ + "--keep-logs", "--dump-error-details", "-v", "--leave-running", "--unshared", + "--host", "--port", + }, applicationSpecificArgs=app_args) + + if not 0 < args.trx_expiration <= MAX_TRX_EXPIRATION_SECONDS: + errorExit(f"--trx-expiration must be in (0, {MAX_TRX_EXPIRATION_SECONDS}] " + f"seconds, got {args.trx_expiration}") + + # Resolve the eth_account interpreter up front: a deps-less launcher errors + # here in ~1s with guidance, instead of after a multi-minute cluster launch + # and (manual mode) a paste that the pre-flight could never accept. + dep_py = _dep_python() + if dep_py != sys.executable: + Print(f"note: {sys.executable} lacks the eth_account stack; " + f"using {dep_py} for sim / pre-flight subprocesses") + + Utils.Debug = args.v + cluster = Cluster( + host=args.host, port=args.port, + unshared=args.unshared, + keepRunning=args.leave_running, keepLogs=args.keep_logs, + ) + wallet_mgr = WalletMgr(True, keepRunning=args.leave_running, keepLogs=args.keep_logs) + cluster.setWalletMgr(wallet_mgr) + + test_ok = False + try: + TestHelper.printSystemInfo("BEGIN metamask trx signing test") + + Print("launching single-node cluster") + if not cluster.launch(pnodes=1, totalNodes=1, prodCount=1, dontBootstrap=False): + errorExit("cluster launch failed") + node = cluster.getNode(0) + + Print("creating wallet and test account") + wallet = wallet_mgr.create("mm_test") + + # Import the bootstrap accounts we need to sign as: + # sysio -> calls expandauth, issue/transfer of test tokens. + # defproducera -> creator of the test account. + defproducera = cluster.defproduceraAccount + sysio_account = cluster.sysioAccount + wallet_mgr.importKey(defproducera, wallet, ignoreDupKeyWarning=True) + wallet_mgr.importKey(sysio_account, wallet, ignoreDupKeyWarning=True) + + test = createAccountKeys(1)[0] + test.name = "mmtest11" + wallet_mgr.importKey(test, wallet, ignoreDupKeyWarning=True) + + Print(f"creating account {test.name}") + # Wire post-ROA: new accounts created by sysio with carl as nodeOwner. + trans = cluster.createAccountAndVerify( + test, cluster.sysioAccount, + nodeOwner=cluster.carlAccount, + stakedDeposit=0, buyRAM=200000, + ) + if not trans: + errorExit("createAccountAndVerify failed") + + Print("setting up Metamask-style EM key on the test account") + em_info, sim_private_key = _create_em_key(args.simulate, args.sim_private_key) + Print(json.dumps(em_info, indent=2)) + + Print("calling sysio::expandauth to add the EM key to active permission") + expand_action_data = { + "account": test.name, + "permission": "active", + "keys": [{"key": em_info["wire_pub_em"], "weight": 1}], + "accounts": [], + } + success, _ = node.pushMessage("sysio", "expandauth", + json.dumps(expand_action_data), + opts="-p sysio@active") + if not success: + errorExit("expandauth failed") + + # The bootstrap issues the full max_supply to `sysio`, so just transfer + # from sysio to the test account. Issuing more would exceed max_supply. + seed_qty = "100.0000 SYS" + Print(f"transferring {seed_qty} from sysio to {test.name}") + seed_ok, _ = node.pushMessage("sysio.token", "transfer", + json.dumps({"from": "sysio", "to": test.name, + "quantity": seed_qty, "memo": "seed"}), + opts="-p sysio@active", silentErrors=False) + if not seed_ok: + errorExit(f"seed transfer of {seed_qty} from sysio to {test.name} failed") + + info = _run_clio(node, "get", "info") + chain_id_hex = info["chain_id"] + Print(f"chain_id : {chain_id_hex}") + + balance_initial = node.getCurrencyBalance("sysio.token", test.name) + Print(f"balance before any transfer: {balance_initial}") + + recipient = defproducera.name + + # --- Round 1: push via HTTP /v1/chain/send_transaction2 --- + Print("\n=== Round 1: Metamask-signed transfer via HTTP send_transaction2 ===") + packed_http = _build_unsigned_transfer(node, test.name, recipient, + args.transfer_amount, + "metamask http transfer", + args.trx_expiration) + sig_em_http, digest_http = _sign_unsigned_packed( + packed_http, chain_id_hex, args.simulate, sim_private_key, + em_info["wire_pub_em"]) + Print(f"sig_digest: {digest_http}") + Print(f"signature : {sig_em_http}") + http_result = _push_via_http(node, packed_http, sig_em_http) + _assert_executed("HTTP path", http_result) + balance_after_http = node.getCurrencyBalance("sysio.token", test.name) + _assert_balance_decreased("HTTP path", balance_initial, balance_after_http, + args.transfer_amount) + + # --- Round 2: push via clio push transaction --signature --- + Print("\n=== Round 2: Metamask-signed transfer via clio push transaction ===") + packed_clio = _build_unsigned_transfer(node, test.name, recipient, + args.transfer_amount, + "metamask clio transfer", + args.trx_expiration) + sig_em_clio, digest_clio = _sign_unsigned_packed( + packed_clio, chain_id_hex, args.simulate, sim_private_key, + em_info["wire_pub_em"]) + Print(f"sig_digest: {digest_clio}") + Print(f"signature : {sig_em_clio}") + clio_result = _push_via_clio(node, packed_clio, sig_em_clio) + _assert_executed("clio path", clio_result) + balance_after_clio = node.getCurrencyBalance("sysio.token", test.name) + _assert_balance_decreased("clio path", balance_after_http, balance_after_clio, + args.transfer_amount) + + Print("\nOK: Metamask-signed transactions executed via BOTH HTTP and clio paths") + test_ok = True + return 0 + finally: + TestHelper.shutdown( + cluster, wallet_mgr, + testSuccessful=test_ok, + dumpErrorDetails=args.dump_error_details, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/metamask/verify_em_sig.py b/tests/metamask/verify_em_sig.py new file mode 100644 index 0000000000..b847ee991a --- /dev/null +++ b/tests/metamask/verify_em_sig.py @@ -0,0 +1,136 @@ +"""Verify a Metamask-style EIP-191 signature against an expected signer. + +Given: + --digest Hex of the message that Metamask was asked to sign. This is + treated EXACTLY the way personal_sign treats a 0x-prefixed + hex string: the bytes are decoded from hex, then the EIP-191 + prefix and length are prepended, then keccak256, then ECDSA + recovery on the supplied signature. + --signature Either an 0x-prefixed 130-hex-char signature (r||s||v) or + Wire's SIG_EM_... form. Both are accepted. + --expect-addr Optional. 0x-prefixed Ethereum address (40 hex chars) the + recovered pubkey must hash to. + --expect-pub Optional. Either an 0x-prefixed uncompressed hex pubkey or + Wire's PUB_EM_... form. The recovered pubkey must match. + +If neither --expect-addr nor --expect-pub is provided, the recovered values are +printed and the script exits 0. + +Exit codes: + 0 recovery succeeded and (if specified) matched all expectations + 1 signature recovery failed + 2 recovery succeeded but expectations did not match + 3 CLI / input error +""" + +from __future__ import annotations + +import argparse +import sys + +from em_sig_to_wire import ( + eth_address_from_pub, + metamask_pub_to_wire, + wire_pub_to_metamask, + wire_sig_to_metamask, +) + + +def _normalize_sig(s: str) -> str: + return wire_sig_to_metamask(s) if s.startswith("SIG_EM_") else s + + +def _normalize_pub(s: str) -> str: + return wire_pub_to_metamask(s) if s.startswith("PUB_EM_") else s + + +def _recover_pub(digest_hex: str, sig_hex: str) -> str: + """Run eth_account-style EIP-191 personal_sign recovery. + + Returns the recovered uncompressed pubkey as 0x-prefixed hex (130 chars + including the SEC1 0x04 prefix). + """ + from eth_account.messages import _hash_eip191_message, encode_defunct + from eth_keys import keys + + sig_bytes = bytes.fromhex(sig_hex[2:] if sig_hex.startswith("0x") else sig_hex) + if len(sig_bytes) != 65: + raise ValueError(f"signature must be 65 bytes, got {len(sig_bytes)}") + + digest_hex_clean = digest_hex[2:] if digest_hex.startswith("0x") else digest_hex + message_bytes = bytes.fromhex(digest_hex_clean) + + # Apply the EIP-191 envelope (0x19 + "E" + "thereum Signed Message:\n" + len + payload) + # and keccak256 it. This matches what Wire's signature_shim::recover() does internally + # for a sha256 trx digest, and what Metamask's personal_sign does in the browser. + h = _hash_eip191_message(encode_defunct(primitive=message_bytes)) + + r = int.from_bytes(sig_bytes[0:32], "big") + s = int.from_bytes(sig_bytes[32:64], "big") + v = sig_bytes[64] + if v in (27, 28): + recovery_id = v - 27 + elif v in (0, 1): + recovery_id = v + else: + raise ValueError(f"unexpected v byte: {v}") + + sig = keys.Signature(vrs=(recovery_id, r, s)) + pub = sig.recover_public_key_from_msg_hash(h) + return "0x04" + pub.to_bytes().hex() + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--digest", required=True, + help="Hex-encoded payload Metamask signed (0x-prefixed or bare hex)") + p.add_argument("--signature", required=True, + help="0x-hex signature (130 chars) or SIG_EM_... form") + p.add_argument("--expect-addr", default=None, + help="Optional. 0x... Ethereum address the recovered pubkey must hash to") + p.add_argument("--expect-pub", default=None, + help="Optional. 0x-uncompressed hex or PUB_EM_... form") + p.add_argument("--quiet", action="store_true", help="Suppress info output") + args = p.parse_args(argv) + + try: + sig_hex = _normalize_sig(args.signature) + recovered_pub = _recover_pub(args.digest, sig_hex) + except Exception as exc: # noqa: BLE001 + print(f"recovery failed: {exc}", file=sys.stderr) + return 1 + + recovered_addr = eth_address_from_pub(recovered_pub) + recovered_wire_pub = metamask_pub_to_wire(recovered_pub) + + if not args.quiet: + print(f"recovered address: {recovered_addr}") + print(f"recovered pub (hex): {recovered_pub}") + print(f"recovered pub (wire): {recovered_wire_pub}") + + failures: list[str] = [] + if args.expect_addr: + if args.expect_addr.lower() != recovered_addr.lower(): + failures.append(f"address mismatch: got {recovered_addr}, expected {args.expect_addr}") + if args.expect_pub: + try: + want = _normalize_pub(args.expect_pub).lower() + except Exception as exc: # noqa: BLE001 + print(f"could not parse --expect-pub: {exc}", file=sys.stderr) + return 3 + if recovered_pub.lower() != want: + failures.append(f"pubkey mismatch: got {recovered_pub}, expected {want}") + + if failures: + for line in failures: + print(f"FAIL: {line}", file=sys.stderr) + return 2 + + if not args.quiet and (args.expect_addr or args.expect_pub): + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From da6e9aff0b54204dea4f043ec12c6818662ae6da Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 15 May 2026 15:29:31 -0500 Subject: [PATCH 2/4] clio: add em/sol key support and offline em sign/recover First-class CLI support for Wire's external-signer key types, built on libfc's own crypto (the same code nodeop runs to validate these signatures), all offline (no node, no wallet): create key --em EM key (Ethereum-style secp256k1, PVT_EM_/PUB_EM_) -- the form a MetaMask / EIP-191 personal_sign wallet uses to sign Wire transactions --sol Solana key (ed25519, PVT_ED_/PUB_ED_) --r1/--em/--sol are mutually exclusive via CLI11 ->excludes() convert em_private_key import a raw Ethereum hex secret (what MetaMask/eth tooling exports) or a PVT_EM_ string, print the Wire PVT_EM_/PUB_EM_ forms em_sign sign a 32-byte sha256 digest with an EM key; dispatches to em::sign_sha256, which applies the EIP-191 personal_sign envelope, byte-identical to MetaMask and to what nodeop recovers; prints SIG_EM_ em_recover recover the PUB_EM_ from a SIG_EM_ over a digest These mirror the existing convert k1_private_key/k1_public_key idiom and share two local parse helpers. clio_em_key_test (offline, parallelizable) covers create-key round-trips and a frozen known-answer vector minted from eth_account, the Ethereum-ecosystem reference that is byte-identical to MetaMask personal_sign. The vector pins libfc's EIP-191 envelope, low-s normalization and recovery-id handling against an independent implementation with no pip/apt/browser dependency; any divergence fails hermetically. Provenance is documented in the test for deliberate, reviewed regeneration. --- programs/clio/main.cpp | 117 ++++++++++++++++++++++++-- tests/CMakeLists.txt | 2 + tests/clio_em_key_test.py | 170 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 5 deletions(-) create mode 100755 tests/clio_em_key_test.py diff --git a/programs/clio/main.cpp b/programs/clio/main.cpp index b01a0b35c2..52b6c797fb 100644 --- a/programs/clio/main.cpp +++ b/programs/clio/main.cpp @@ -1904,18 +1904,33 @@ int main( int argc, char** argv ) { bool r1 = false; bool k1 = false; + bool em = false; + bool sol = false; string key_file; bool print_console = false; // create key - auto create_key_cmd = create_cmd->add_subcommand("key", localized("Create a new keypair and print the public and private keys"))->callback( [&r1, &k1, &key_file, &print_console](){ + auto create_key_cmd = create_cmd->add_subcommand("key", localized("Create a new keypair and print the public and private keys"))->callback( [&r1, &k1, &em, &sol, &key_file, &print_console](){ if (key_file.empty() && !print_console) { std::cerr << "ERROR: Either indicate a file using \"--file\" or pass \"--to-console\"" << std::endl; return; } - auto pk = r1 ? private_key_type::generate(crypto::private_key::key_type::r1) : private_key_type::generate(); - auto privs = pk.to_string({}, k1); - auto pubs = pk.get_public_key().to_string({}, k1); + // --r1/--em/--sol select a curve and are mutually exclusive (enforced by CLI11 + // ->excludes() below, so a bad combination is a non-zero parse error). --k1 is + // not a curve switch: it selects the prefixed PVT_K1_/PUB_K1_ form of the + // default K1 key instead of the legacy unprefixed form. + auto kt = crypto::private_key::key_type::k1; + if (sol) kt = crypto::private_key::key_type::ed; // Solana ed25519 + else if (em) kt = crypto::private_key::key_type::em; // Ethereum-style secp256k1 (MetaMask personal_sign) + else if (r1) kt = crypto::private_key::key_type::r1; + + // K1 has a legacy unprefixed form; --k1 requests the prefixed PVT_K1_/PUB_K1_ + // form. r1/em/ed are only ever emitted in their prefixed (PVT_*_/PUB_*_) form. + const bool include_prefix = (kt != crypto::private_key::key_type::k1) || k1; + + auto pk = private_key_type::generate(kt); + auto privs = pk.to_string({}, include_prefix); + auto pubs = pk.get_public_key().to_string({}, include_prefix); if (print_console) { std::cout << localized("Private key: ${key}", ("key", privs) ) << std::endl; std::cout << localized("Public key: ${key}", ("key", pubs ) ) << std::endl; @@ -1927,7 +1942,12 @@ int main( int argc, char** argv ) { } }); create_key_cmd->add_flag( "--k1", k1, "Generate a key using the K1 curve (Bitcoin) with PUB_K1_ & PVT_K1_ prefix instead of legacy" ); - create_key_cmd->add_flag( "--r1", r1, "Generate a key using the R1 curve (iPhone), instead of the K1 curve (Bitcoin)" ); + auto r1_flag = create_key_cmd->add_flag( "--r1", r1, "Generate a key using the R1 curve (iPhone), instead of the K1 curve (Bitcoin)" ); + auto em_flag = create_key_cmd->add_flag( "--em", em, "Generate an EM key (Ethereum-style secp256k1, PUB_EM_/PVT_EM_) for MetaMask/external personal_sign" ); + auto sol_flag = create_key_cmd->add_flag( "--sol", sol, "Generate a Solana key (ed25519, PUB_ED_/PVT_ED_) for external Solana signers" ); + // --r1/--em/--sol pick different curves; selecting more than one is a usage error. + r1_flag->excludes(em_flag)->excludes(sol_flag); + em_flag->excludes(sol_flag); create_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); create_key_cmd->add_flag( "--to-console", print_console, localized("Print private/public keys to console.")); @@ -2087,6 +2107,93 @@ int main( int argc, char** argv ) { std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true) ) ) << std::endl; }); + // EM (Ethereum-style secp256k1) key utilities. These let an external Ethereum signer (MetaMask personal_sign) + // interoperate with Wire offline: import a raw Ethereum secret as a Wire PVT_EM_ key, and sign/recover a Wire + // transaction sig_digest exactly as nodeop validates it, using libfc's own em path (the same code the chain runs). + + /// Parse a Wire PVT_EM_ string or a raw 0x-prefixed Ethereum hex secret into a unified em private key. + /// One helper, used by every em_* subcommand below. + auto parse_em_private_key = [](const std::string& s) -> fc::crypto::private_key { + if (s.rfind("PVT_EM_", 0) == 0) + return fc::crypto::private_key::from_string(s, fc::crypto::private_key::key_type::em); + // Raw Ethereum form (what MetaMask / eth tooling exports): 0x<64hex> or 64hex. + auto em_priv = fc::em::private_key::from_native_string(s); + return fc::crypto::private_key::regenerate(em_priv.get_secret()); + }; + + /// Parse a 32-byte sha256 digest given as 64 hex chars (optional 0x prefix). + auto parse_sha256_hex = [](std::string s) -> fc::sha256 { + if (s.rfind("0x", 0) == 0 || s.rfind("0X", 0) == 0) + s = s.substr(2); + SYSC_ASSERT(s.size() == 64, "ERROR: digest must be a 32-byte sha256 (64 hex chars, 0x optional)"); + return fc::sha256(s); + }; + + string em_private_key; + auto em_private_key_cmd = convert_cmd->add_subcommand("em_private_key", localized("Convert a raw Ethereum secret (or PVT_EM_) to Wire PVT_EM_/PUB_EM_ key forms")); + em_private_key_cmd->add_option("--private-key", em_private_key, localized("PVT_EM_... or a raw 0x Ethereum hex secret; prompts if not provided"))->expected(0, 1); + em_private_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); + em_private_key_cmd->add_flag("--to-console", print_console, localized("Print private/public keys to console.")); + em_private_key_cmd->callback([&] { + if (key_file.empty() && !print_console) { + std::cerr << "ERROR: Either indicate a file using \"--file\" or pass \"--to-console\"" << std::endl; + return; + } + if (em_private_key.empty()) { + std::cout << localized("private key: "); + fc::set_console_echo(false); + std::getline(std::cin, em_private_key, '\n'); + fc::set_console_echo(true); + std::cout << std::endl; + } + auto privk = parse_em_private_key(em_private_key); + auto pubk = privk.get_public_key(); + if (print_console) { + std::cout << localized("Private key: ${key}", ("key", privk.to_string({}, true)) ) << std::endl; + std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + } else { + std::cerr << localized("saving keys to ${filename}", ("filename", key_file)) << std::endl; + std::ofstream out( key_file.c_str() ); + out << localized("Private key: ${key}", ("key", privk.to_string({}, true)) ) << std::endl; + out << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + } + }); + + string em_sign_digest; + string em_sign_priv; + auto em_sign_cmd = convert_cmd->add_subcommand("em_sign", localized("Sign a 32-byte sha256 digest with an EM key (EIP-191 personal_sign), printing SIG_EM_")); + em_sign_cmd->add_option("digest", em_sign_digest, localized("32-byte sha256 digest, 64 hex chars (0x optional)"))->required(); + em_sign_cmd->add_option("--private-key", em_sign_priv, localized("PVT_EM_... or a raw 0x Ethereum hex secret; prompts if not provided"))->expected(0, 1); + em_sign_cmd->callback([&] { + if (em_sign_priv.empty()) { + std::cout << localized("private key: "); + fc::set_console_echo(false); + std::getline(std::cin, em_sign_priv, '\n'); + fc::set_console_echo(true); + std::cout << std::endl; + } + auto privk = parse_em_private_key(em_sign_priv); + auto digest = parse_sha256_hex(em_sign_digest); + // private_key::sign dispatches to em::sign_sha256, which wraps the digest in the EIP-191 personal_sign + // envelope before secp256k1 -- identical to what MetaMask produces and to what nodeop recovers. Emit the + // prefixed SIG_EM_ form, which is what `clio push transaction --signature` and send_transaction2 expect. + std::cout << localized("Signature: ${sig}", ("sig", privk.sign(digest).to_string({}, true)) ) << std::endl; + }); + + string em_recover_sig; + string em_recover_digest; + auto em_recover_cmd = convert_cmd->add_subcommand("em_recover", localized("Recover the PUB_EM_ from a SIG_EM_ over a 32-byte sha256 digest (EIP-191)")); + em_recover_cmd->add_option("signature", em_recover_sig, localized("SIG_EM_... signature to recover from"))->required(); + em_recover_cmd->add_option("digest", em_recover_digest, localized("32-byte sha256 digest, 64 hex chars (0x optional)"))->required(); + em_recover_cmd->callback([&] { + auto sig = fc::crypto::signature::from_string(em_recover_sig, fc::crypto::signature::sig_type::em); + auto digest = parse_sha256_hex(em_recover_digest); + // public_key::recover dispatches to em::recover, which applies the same EIP-191 envelope before recovery. + // Emit the prefixed PUB_EM_ form so it compares directly against an expandauth-registered key. + auto pubk = fc::crypto::public_key::recover(sig, digest); + std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + }); + string name_input; auto name_cmd = convert_cmd->add_subcommand("name", localized("Convert between sysio::name and uint64_t, printing both interpretations")); name_cmd->add_option("input", name_input, localized("A sysio name or uint64_t value (decimal, or 0x-prefixed hex)"))->required(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f4cadd008f..eac3612033 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,7 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/cluster_launcher.py ${CMAKE_CURRENT_B configure_file(${CMAKE_CURRENT_SOURCE_DIR}/distributed-transactions-test.py ${CMAKE_CURRENT_BINARY_DIR}/distributed-transactions-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_bls_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_bls_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_snapshot_info_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_snapshot_info_test.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/clio_em_key_test.py ${CMAKE_CURRENT_BINARY_DIR}/clio_em_key_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sample-cluster-map.json ${CMAKE_CURRENT_BINARY_DIR}/sample-cluster-map.json COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/restart-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/restart-scenarios-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/terminate-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/terminate-scenarios-test.py COPYONLY) @@ -252,6 +253,7 @@ add_np_test(NAME lib_advance_test COMMAND tests/lib_advance_test.py -v ${UNSHARE add_np_test(NAME producer_rank_test COMMAND tests/producer_rank_test.py -v ${UNSHARE}) add_p_test(NAME sysio_util_bls_test COMMAND tests/sysio_util_bls_test.py) +add_p_test(NAME clio_em_key_test COMMAND tests/clio_em_key_test.py) add_p_test(NAME sysio_util_snapshot_info_test COMMAND tests/sysio_util_snapshot_info_test.py) add_np_test(NAME http_plugin_test COMMAND tests/http_plugin_test.py ${UNSHARE} TIMEOUT 200) diff --git a/tests/clio_em_key_test.py b/tests/clio_em_key_test.py new file mode 100755 index 0000000000..1dd842543b --- /dev/null +++ b/tests/clio_em_key_test.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +"""Offline test for clio's EM (Ethereum-style secp256k1) and SOL (ed25519) key support. + +Exercises, with no running node: + + * ``clio create key --em`` -> PVT_EM_/PUB_EM_ keypair, round-tripped through + ``clio convert em_private_key`` / ``em_sign`` / ``em_recover``. + * ``clio create key --sol`` -> PVT_ED_/PUB_ED_ (Solana ed25519) keypair. + * A frozen known-answer vector proving libfc's EM path is byte-for-byte + identical to the Ethereum ecosystem reference (eth_account / MetaMask + ``personal_sign``). This is the cross-implementation anchor: a regression in + Wire's EIP-191 envelope, low-s normalization, or recovery-id handling fails + here, hermetically, with no pip/apt/browser dependency. + * Flag mutual-exclusion and input-validation error paths. + +Known-answer vector provenance (regenerate only by a deliberate, reviewed act): + + Reference implementation : eth_account 0.13.7 (eth-keys/eth-utils stack), + byte-identical to MetaMask personal_sign for the + same key and message. + Ethereum private key : 0x46 * 32 (the well-known EIP-155 example key) + Message / digest : sha256(b"wire-sysio metamask external-signer KAT v1") + = 7358248b67726c147e6b100d188dec3b5be0bdc08735378b6146ec0207cf58c4 + Recipe : personal_sign over the 32 raw digest bytes + (EIP-191: keccak256("\\x19Ethereum Signed Message:\\n32" + digest)), + deterministic RFC-6979 secp256k1, low-s. + +The expected PUB_EM_/SIG_EM_ below were produced by that reference and MUST NOT +be edited to match a code change -- a mismatch means clio/libfc diverged from +the Ethereum ecosystem, which is a bug in libfc, not in this vector. +""" + +import re +import subprocess +import tempfile + +from TestHarness import Utils + +Print = Utils.Print + +# --- frozen known-answer vector (see module docstring for provenance) --- +KAT_ETH_SECRET = "0x4646464646464646464646464646464646464646464646464646464646464646" +KAT_PVT_EM = "PVT_EM_0x4646464646464646464646464646464646464646464646464646464646464646" +KAT_DIGEST = "7358248b67726c147e6b100d188dec3b5be0bdc08735378b6146ec0207cf58c4" +KAT_PUB_EM = ("PUB_EM_044bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb629742" + "47bb493382ce28cab79ad7119ee1ad3ebcdb98a16805211530ecc6cfefa1b88e6dff99232a") +KAT_SIG_EM = ("SIG_EM_0xea0847dc6f2c9a189b85f01e9a1d51931ac92ee381b67641384b523" + "14a803fd5064a69f67b5967f2cac3fa3b9cf3f4f0be9f08e282e0e71ed0ce13d202a892b71b") + +testSuccessful = False + + +def clio(*args, expect_fail=False, raw=False): + """Run the clio binary offline with `args`. + + Default: assert exit 0 and return a dict of the ``Label: value`` lines clio + prints. `expect_fail=True`: assert a non-zero exit (CLI11 parse error or a + thrown libfc exception) and return None. `raw=True`: return the + ``subprocess.CompletedProcess`` for callers that assert on stderr / exit code + directly (used for clio's soft-validation paths, which by long-standing + convention print ``ERROR:`` and emit nothing but still exit 0). + """ + cmd = [Utils.SysClientPath, *args] + if Utils.Debug: + Print("cmd: %s" % " ".join(cmd)) + res = subprocess.run(cmd, capture_output=True, text=True) + if raw: + return res + if expect_fail: + assert res.returncode != 0, "expected failure but clio succeeded: %s\n%s" % (cmd, res.stdout) + return None + assert res.returncode == 0, "clio failed: %s\nstderr: %s\nstdout: %s" % (cmd, res.stderr, res.stdout) + return {k: v for k, v in re.findall(r'(\w[^:\n]*): ([^\n]+)', res.stdout)} + + +def test_create_key_em_roundtrip(): + """create key --em yields a PVT_EM_/PUB_EM_ pair that round-trips through convert.""" + r = clio("create", "key", "--em", "--to-console") + priv, pub = r["Private key"], r["Public key"] + assert priv.startswith("PVT_EM_"), priv + assert pub.startswith("PUB_EM_"), pub + + # PVT_EM_ -> same PUB_EM_ via convert em_private_key + assert clio("convert", "em_private_key", "--private-key", priv, "--to-console")["Public key"] == pub + + # sign an arbitrary digest with it, then recover -> must yield the same PUB_EM_ + sig = clio("convert", "em_sign", KAT_DIGEST, "--private-key", priv)["Signature"] + assert sig.startswith("SIG_EM_"), sig + assert clio("convert", "em_recover", sig, KAT_DIGEST)["Public key"] == pub + + # two fresh keys must differ (sanity that generation is not fixed) + assert clio("create", "key", "--em", "--to-console")["Private key"] != priv + + +def test_create_key_sol(): + """create key --sol yields a Solana ed25519 PVT_ED_/PUB_ED_ pair.""" + r = clio("create", "key", "--sol", "--to-console") + assert r["Private key"].startswith("PVT_ED_"), r["Private key"] + assert r["Public key"].startswith("PUB_ED_"), r["Public key"] + assert clio("create", "key", "--sol", "--to-console")["Private key"] != r["Private key"] + + +def test_create_key_to_file(): + """--file persists the same forms --to-console prints.""" + with tempfile.NamedTemporaryFile(mode="w+") as f: + clio("create", "key", "--em", "--file", f.name) + f.seek(0) + body = f.read() + assert "PVT_EM_" in body and "PUB_EM_" in body, body + + +def test_known_answer_vector(): + """libfc's EM path is byte-for-byte identical to eth_account / MetaMask. + + Both the raw-Ethereum-secret import path and the PVT_EM_ path must derive + the reference public key; em_sign must reproduce the reference signature + (RFC-6979 deterministic, so this is exact); em_recover must invert it. + """ + # raw Ethereum secret import -> reference PUB_EM_ + assert clio("convert", "em_private_key", "--private-key", KAT_ETH_SECRET, + "--to-console")["Public key"] == KAT_PUB_EM + # PVT_EM_ import -> same + assert clio("convert", "em_private_key", "--private-key", KAT_PVT_EM, + "--to-console")["Public key"] == KAT_PUB_EM + + # signature is deterministic and must match the reference, from either key form + assert clio("convert", "em_sign", KAT_DIGEST, "--private-key", KAT_ETH_SECRET)["Signature"] == KAT_SIG_EM + assert clio("convert", "em_sign", KAT_DIGEST, "--private-key", KAT_PVT_EM)["Signature"] == KAT_SIG_EM + # 0x-prefixed digest is accepted and equivalent + assert clio("convert", "em_sign", "0x" + KAT_DIGEST, "--private-key", KAT_PVT_EM)["Signature"] == KAT_SIG_EM + + # recover the reference signature back to the reference public key + assert clio("convert", "em_recover", KAT_SIG_EM, KAT_DIGEST)["Public key"] == KAT_PUB_EM + + +def test_error_handling(): + """Mutually exclusive curve flags and malformed crypto inputs are hard errors. + + The missing-output-sink case is clio's long-standing soft-validation + convention (shared by ``create key`` / ``convert k1_private_key``): print + ``ERROR:`` and emit no key, exit 0. Assert that contract rather than an exit + code so this test does not silently encode a behavior change to it. + """ + # mutually exclusive curve flags -> CLI11 parse error (non-zero) + clio("create", "key", "--em", "--sol", "--to-console", expect_fail=True) + clio("create", "key", "--em", "--r1", "--to-console", expect_fail=True) + + # neither --file nor --to-console: soft validation -> ERROR, no key, exit 0 + res = clio("create", "key", "--em", raw=True) + assert "ERROR" in res.stderr, res.stderr + assert "PVT_EM_" not in res.stdout, res.stdout + + # malformed crypto inputs throw in libfc -> non-zero + clio("convert", "em_sign", "deadbeef", "--private-key", KAT_PVT_EM, expect_fail=True) # 8 hex != 64 + clio("convert", "em_recover", "SIG_EM_0xdeadbeef", KAT_DIGEST, expect_fail=True) # malformed sig + + +try: + test_create_key_em_roundtrip() + test_create_key_sol() + test_create_key_to_file() + test_known_answer_vector() + test_error_handling() + testSuccessful = True +except Exception as e: + Print(e) + Utils.errorExit("exception during processing") + +exit(0 if testSuccessful else 1) From 07166e181b3dfad1688272e6469302bbb478dada Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 15 May 2026 16:33:28 -0500 Subject: [PATCH 3/4] metamask: rewrite signing harness onto clio (drop eth_account) The metamask_trx_signing_test ctest failed in CI: it required the Python eth_account stack (eth_account/eth_keys/eth_utils), which the CI test images do not provide -- they have python3-numpy via apt for the performance tests, but the eth stack is not in apt for Ubuntu noble and was never pip-installed. The harness now drives clio's own EM tooling, so its only dependency is the clio binary the build already produces: no pip, no apt, no browser, no .venv. - --simulate keygen: clio create key --em (or convert em_private_key for a fixed --sim-private-key, which now accepts a PVT_EM_ or a raw 0x Ethereum secret) - --simulate sign: clio convert em_sign over the same sha256 sig_digest - manual real-MetaMask pre-flight: clio convert em_recover, compared to the registered PUB_EM_ (replaces verify_em_sig.py) clio's EM path is libfc's own crypto -- the exact code nodeop runs to validate these signatures. Its byte-for-byte equivalence to the Ethereum-ecosystem reference (eth_account / MetaMask personal_sign) is pinned hermetically by the separate clio_em_key_test ctest, so the cross-implementation assurance the eth_account simulator used to provide is preserved without the runtime dependency. Removes metamask_sim.py, verify_em_sig.py, em_sig_to_wire.py and the _dep_python interpreter-resolution machinery. The build/push/balance-assertion flow and both push paths (HTTP send_transaction2, clio push transaction) are unchanged. README updated; metamask-sign.html (real MetaMask manual path) kept. Manual pre-flight now hints that a persistent recover-mismatch may mean the pasted PUB_EM_ is not this wallet's key; drop a dead sys.path entry; README documents the --private-key argv exposure and uses a copy-pasteable digest example. --- tests/CMakeLists.txt | 3 - tests/metamask/README.md | 103 +++++----- tests/metamask/em_sig_to_wire.py | 172 ---------------- tests/metamask/metamask_sim.py | 116 ----------- tests/metamask/push_metamask_trx.py | 291 ++++++++++------------------ tests/metamask/verify_em_sig.py | 136 ------------- 6 files changed, 142 insertions(+), 679 deletions(-) delete mode 100644 tests/metamask/em_sig_to_wire.py delete mode 100644 tests/metamask/metamask_sim.py delete mode 100644 tests/metamask/verify_em_sig.py diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7b9c6bc197..449f4cd30e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,9 +23,6 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/cluster_launcher.py ${CMAKE_CURRENT_B configure_file(${CMAKE_CURRENT_SOURCE_DIR}/distributed-transactions-test.py ${CMAKE_CURRENT_BINARY_DIR}/distributed-transactions-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_bls_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_bls_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_snapshot_info_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_snapshot_info_test.py COPYONLY) -configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/em_sig_to_wire.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/em_sig_to_wire.py COPYONLY) -configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/metamask_sim.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/metamask_sim.py COPYONLY) -configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/verify_em_sig.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/verify_em_sig.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/push_metamask_trx.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/push_metamask_trx.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/metamask-sign.html ${CMAKE_CURRENT_BINARY_DIR}/metamask/metamask-sign.html COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/README.md ${CMAKE_CURRENT_BINARY_DIR}/metamask/README.md COPYONLY) diff --git a/tests/metamask/README.md b/tests/metamask/README.md index 87c3f56d8b..869e670790 100644 --- a/tests/metamask/README.md +++ b/tests/metamask/README.md @@ -3,63 +3,46 @@ End-to-end test that a Wire transaction signed by **MetaMask** (or any wallet that exposes EIP-191 `personal_sign` over secp256k1) is accepted by `nodeop`. -Wire already supports this in C++ -- `signature_shim::recover()` applies the -EIP-191 prefix to a `sha256` transaction digest and recovers an `em::public_key`, -and the `expandauth_sign_with_eth_key` unit test proves the full in-process flow. +Wire supports this in C++ -- `signature_shim::recover()` applies the EIP-191 +prefix to a `sha256` transaction digest and recovers an `em::public_key`, and +the `expandauth_sign_with_eth_key` unit test proves the full in-process flow. This directory adds the **external-signer** half: real MetaMask in a browser, signing a real trx that gets pushed to nodeop. +The automated path drives `clio`'s own EM tooling (`clio create key --em`, +`clio convert em_sign` / `em_recover`), which is libfc's own crypto -- the exact +code nodeop runs to validate these signatures. There is **no Python crypto +dependency**: the only thing this harness needs is the `clio` binary the build +already produces. clio's EM path is pinned byte-for-byte against the +Ethereum-ecosystem reference (`eth_account` / MetaMask `personal_sign`) by the +separate, hermetic `clio_em_key_test` ctest; this harness exercises the full +chain round-trip. + ## What's here | File | Role | | ---- | ---- | -| `metamask-sign.html` | Static page that talks to MetaMask via `window.ethereum`. Calls `personal_sign` on a 32-byte digest, locally ecRecovers the public key, and displays both raw hex (Metamask format) and Wire's `SIG_EM_` / `PUB_EM_` forms ready to paste into the push helper. | -| `em_sig_to_wire.py` | Bidirectional converter between Metamask `0x...` hex and Wire `SIG_EM_` / `PUB_EM_` forms. (Unlike K1/R1/BLS keys, Wire's EM form is plain hex, not base58 + ripemd160: `SIG_EM_0x<130hex>` and `PUB_EM_<130hex>`.) Importable; also runs as a CLI. | -| `verify_em_sig.py` | Offline check: given `(digest, signature, expected-address-or-pub)`, recover the pubkey using the same EIP-191 envelope nodeop applies, and assert it matches. Uses `eth_account`. | -| `metamask_sim.py` | Headless substitute for MetaMask. Generates an EM key and signs an arbitrary digest the same way `personal_sign` would. Used by automated test runs. | -| `push_metamask_trx.py` | TestHarness-driven end-to-end test. Stands up a single-node cluster, creates an account, calls `expandauth` to add the EM key, seeds it with SYS transferred from `sysio`, builds two unsigned `sysio.token::transfer` trxs, signs each digest (via simulator or pasted-from-browser), and pushes one via HTTP `send_transaction2` and one via `clio push transaction`. | - - -## Python deps - -The eth\_account stack is required. From repo root: - -```bash -.venv/bin/python -m pip install eth-account eth-keys eth-utils pycryptodome -``` - -(or rely on the project's `requirements.txt` install.) - -The manual-path pre-flight and the simulator run as subprocesses that import -the eth_account stack. Invoking the harness with `.venv/bin/python` is the -simplest path. If you launch it with a bare `python3` that lacks those deps, -the harness auto-detects this and falls back to `$VIRTUAL_ENV` or the first -`.venv` found walking up from the script (so it works from the source tree or -the build-dir copy); it errors early, before launching the cluster, only if no -suitable interpreter exists. +| `metamask-sign.html` | Static page that talks to MetaMask via `window.ethereum`. Calls `personal_sign` on a 32-byte digest, locally ecRecovers the public key, and displays both raw hex (MetaMask format) and Wire's `SIG_EM_` / `PUB_EM_` forms ready to paste into the push helper. | +| `push_metamask_trx.py` | TestHarness-driven end-to-end test. Stands up a single-node cluster, creates an account, calls `expandauth` to add the EM key, seeds it with SYS transferred from `sysio`, builds two unsigned `sysio.token::transfer` trxs, signs each digest (via clio in `--simulate`, or pasted-from-browser in manual mode), and pushes one via HTTP `send_transaction2` and one via `clio push transaction`. | ## Quick offline sanity check +No node or Python deps -- just clio: + ```bash -.venv/bin/python tests/metamask/metamask_sim.py keygen > /tmp/k.json -PRIV=$(jq -r .private_key /tmp/k.json) -WIRE_PUB=$(jq -r .wire_pub_em /tmp/k.json) -ADDR=$(jq -r .eth_address /tmp/k.json) - -SIG=$(.venv/bin/python tests/metamask/metamask_sim.py sign \ - --private-key "$PRIV" --digest 0xdeadbeef --raw) - -.venv/bin/python tests/metamask/verify_em_sig.py \ - --digest 0xdeadbeef \ - --signature "$SIG" \ - --expect-addr "$ADDR" \ - --expect-pub "$WIRE_PUB" +cd $BUILD_DIR +eval "$(bin/clio create key --em --to-console | sed 's/Private key: /PRIV=/;s/Public key: /PUB=/')" +DIGEST=$(printf 'wire metamask sanity' | sha256sum | cut -d' ' -f1) # any 32-byte sha256 +SIG=$(bin/clio convert em_sign "$DIGEST" --private-key "$PRIV" | sed 's/Signature: //') +bin/clio convert em_recover "$SIG" "$DIGEST" # -> Public key: $PUB ``` -Exit 0 means the EIP-191 envelope, the secp256k1 recovery, and Wire's plain-hex -`SIG_EM_` / `PUB_EM_` framing all round-trip. +The recovered `PUB_EM_` equals the generated one when the EIP-191 envelope, the +secp256k1 recovery, and Wire's plain-hex `SIG_EM_` / `PUB_EM_` framing all +round-trip. The exhaustive version of this check, including the frozen +known-answer vector minted from `eth_account`, is `ctest -R clio_em_key_test`. ## Full end-to-end (automated) @@ -68,21 +51,22 @@ This is the path `ctest -R metamask_trx_signing_test` runs. ```bash cd $BUILD_DIR -.venv/bin/python tests/metamask/push_metamask_trx.py --simulate -v +python3 tests/metamask/push_metamask_trx.py --simulate -v ``` The script: 1. Launches a single-node cluster via TestHarness. 2. Creates `mmtest11`, gives it K1 keys. -3. Generates an EM keypair in the simulator and adds the `PUB_EM_` to - `mmtest11@active` via `sysio::expandauth`. +3. Generates an EM keypair with `clio create key --em` and adds the `PUB_EM_` + to `mmtest11@active` via `sysio::expandauth`. (`--sim-private-key` pins a + fixed `PVT_EM_` or raw `0x` Ethereum secret for reproducibility.) 4. Transfers `100.0000 SYS` from `sysio` to `mmtest11` (bootstrap issued the full max_supply to `sysio`, so just transfer instead of `issue`). 5. **Round 1 - HTTP path:** builds an unsigned `sysio.token::transfer mmtest11 -> defproducera` via `clio push action -s -d --return-packed -j`, computes `sig_digest = sha256(chain_id || packed_trx || zero_32)`, signs - via the simulator, and `POST`s the packed_transaction directly to + via `clio convert em_sign`, and `POST`s the packed_transaction directly to `/v1/chain/send_transaction2`. 6. **Round 2 - clio path:** builds a second unsigned transfer (different memo so the trx id is unique), computes its digest, signs, and pushes via @@ -96,7 +80,7 @@ The script: ```bash cd $BUILD_DIR -.venv/bin/python tests/metamask/push_metamask_trx.py -v +python3 tests/metamask/push_metamask_trx.py -v ``` Open `metamask-sign.html` in a browser with MetaMask. If the browser can open @@ -106,15 +90,15 @@ Windows side) serve it instead, e.g. from `tests/metamask/`: `http://localhost:8866/metamask-sign.html`. The script will: 1. Spin up the cluster as above. -2. Prompt for your ETH address + uncompressed pubkey + Wire `PUB_EM_` -- - the HTML page shows all three once you sign any test message. The three - pasted values are cross-checked offline for mutual consistency before use. +2. Prompt for your Wire `PUB_EM_` -- the HTML page shows it once you sign any + test message. 3. Add the `PUB_EM_` to the test account via `expandauth`. 4. Print the trx `sig_digest`. Paste that into the HTML page's "Digest to sign" field, click "Sign with Metamask", copy the `SIG_EM_` value. -5. Paste the `SIG_EM_` back into the script. It is recovered offline and - checked against the registered `PUB_EM_` before being sent; a stale or - wrong-digest signature is rejected locally and re-prompted. +5. Paste the `SIG_EM_` back into the script. It is recovered offline via + `clio convert em_recover` and checked against the registered `PUB_EM_` + before being sent; a stale or wrong-digest signature is rejected locally and + re-prompted. 6. Push, check execution. Repeated for Round 2 (different digest). @@ -125,11 +109,10 @@ Windows side) serve it instead, e.g. from `tests/metamask/`: if you pass a UTF-8 string by mistake, MetaMask prefix-wraps the UTF-8 bytes of the *string*, not the underlying digest, and the chain rejects the signature. -- EIP-191 v-byte is `27` or `28` (recovery_id + 27). Some wallets emit raw `0/1`. - Both `metamask_sig_to_wire` and `verify_em_sig` normalize to `27/28` before - encoding. -- ECDSA signatures must be canonical (`s < n/2`). MetaMask and `eth_account` - produce canonical signatures, but ad-hoc third-party signers may not; Wire's +- EIP-191 v-byte is `27` or `28` (recovery_id + 27). libfc's `em::sign_sha256` + emits `27/28`; recovery accepts either. +- ECDSA signatures must be canonical (`s < n/2`). MetaMask and libfc produce + canonical signatures, but ad-hoc third-party signers may not; Wire's `em::public_key::is_canonical()` rejects them. - The Wire EM form is hex, not base58 + ripemd160 like K1/R1/BLS: - `SIG_EM_0x<130hex>`: `to_hex()` always prefixes 0x, so the 0x lives @@ -147,3 +130,7 @@ Windows side) serve it instead, e.g. from `tests/metamask/`: authentication to succeed and visibly mutates state (balance change). The same path works for any action; swap the body of `push_metamask_trx.py` if you need to exercise something else. +- `--sim-private-key` and `clio convert em_private_key/em_sign --private-key` + put a secret on the command line, which is visible in `ps` and shell history. + These are test/throwaway keys, so it does not matter here; for real keys omit + the option and enter it at clio's interactive prompt instead. diff --git a/tests/metamask/em_sig_to_wire.py b/tests/metamask/em_sig_to_wire.py deleted file mode 100644 index afe3981c67..0000000000 --- a/tests/metamask/em_sig_to_wire.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Bidirectional converter between Metamask-format secp256k1 signatures / -uncompressed public keys and Wire's SIG_EM_/PUB_EM_ string forms. - -The Wire EM format is hex, NOT base58. From libfc: - - em::public_key_shim::to_string(no_prefix=true) -> 130 hex chars (no 0x) - so PUB_EM_ form is: "PUB_EM_" + 130 hex chars (65-byte uncompressed, SEC1 0x04 prefix in first 2 hex chars) - - em::signature_shim::to_string() -> "0x" + 130 hex chars - so SIG_EM_ form is: "SIG_EM_" + "0x" + 130 hex chars (65 bytes: r||s||v) - -Metamask format: - - Signature: "0x" + 130 hex chars (r(32) || s(32) || v(1)) - - Public key (uncompressed): 64 raw bytes (X || Y), or 65 with 0x04 SEC1 prefix - - Address: "0x" + last 40 hex chars of keccak256(uncompressed_pub[1:]) - -Run as a CLI: - python em_sig_to_wire.py sig-to-wire 0x<130-hex> - python em_sig_to_wire.py wire-to-sig SIG_EM_0x<130-hex> - python em_sig_to_wire.py pub-to-wire 0x<128-hex or 130-hex> - python em_sig_to_wire.py wire-to-pub PUB_EM_<130-hex> - python em_sig_to_wire.py addr-from-pub 0x<128-hex> -""" - -from __future__ import annotations - -import sys - - -def _hex_to_bytes(s: str) -> bytes: - s = s[2:] if s.startswith(("0x", "0X")) else s - return bytes.fromhex(s) - - -def _bytes_to_0x_hex(b: bytes) -> str: - return "0x" + b.hex() - - -# --- signature conversions ------------------------------------------------- - -def metamask_sig_to_wire(sig_hex: str) -> str: - """Convert a 65-byte Metamask hex signature (r||s||v) to Wire SIG_EM_ form. - - Wire form is `SIG_EM_0x<130hex>`. Normalizes v to 27/28. - """ - sig = _hex_to_bytes(sig_hex) - if len(sig) != 65: - raise ValueError(f"signature must be 65 bytes, got {len(sig)}") - v = sig[64] - if v in (0, 1): - sig = sig[:64] + bytes([v + 27]) - elif v not in (27, 28): - raise ValueError(f"unexpected v byte: {v}; expected 27, 28, 0, or 1") - return "SIG_EM_" + _bytes_to_0x_hex(sig) - - -def wire_sig_to_metamask(sig_wire: str) -> str: - """Convert SIG_EM_0x... back to a 65-byte 0x-prefixed hex signature.""" - if not sig_wire.startswith("SIG_EM_"): - raise ValueError(f"expected SIG_EM_... prefix, got {sig_wire[:10]}") - tail = sig_wire[len("SIG_EM_"):] - # tail starts with "0x" because em::signature_shim::to_string always adds it. - if not tail.startswith(("0x", "0X")): - # Accept the variant without "0x" too, just in case. - tail = "0x" + tail - raw = _hex_to_bytes(tail) - if len(raw) != 65: - raise ValueError(f"decoded signature length {len(raw)} != 65") - return _bytes_to_0x_hex(raw) - - -# --- public key conversions ------------------------------------------------ - -def metamask_pub_to_wire(pub_hex: str) -> str: - """Convert an uncompressed Ethereum-style hex pub (128 or 130 chars) to PUB_EM_...""" - raw = _hex_to_bytes(pub_hex) - if len(raw) == 64: - raw = b"\x04" + raw - elif len(raw) == 65: - if raw[0] != 0x04: - raise ValueError(f"uncompressed pubkey prefix must be 0x04, got 0x{raw[0]:02x}") - else: - raise ValueError(f"uncompressed pubkey must be 64 or 65 bytes, got {len(raw)}") - # Wire form: PUB_EM_<130 hex chars, no 0x in middle> - return "PUB_EM_" + raw.hex() - - -def wire_pub_to_metamask(pub_wire: str) -> str: - """Convert PUB_EM_... to a 0x-prefixed uncompressed-pub hex string (130 chars including 04).""" - if not pub_wire.startswith("PUB_EM_"): - raise ValueError(f"expected PUB_EM_... prefix, got {pub_wire[:10]}") - tail = pub_wire[len("PUB_EM_"):] - # PUB_EM_ form has no "0x" in the middle, but accept it gracefully. - if tail.startswith(("0x", "0X")): - tail = tail[2:] - raw = bytes.fromhex(tail) - if len(raw) == 64: - raw = b"\x04" + raw - elif len(raw) != 65: - raise ValueError(f"unexpected pubkey hex length {len(tail)}; want 128 or 130") - if raw[0] != 0x04: - raise ValueError("uncompressed pubkey prefix must be 0x04") - return _bytes_to_0x_hex(raw) - - -def eth_address_from_pub(pub_hex: str) -> str: - """Derive the 0x... Ethereum address from an uncompressed pubkey hex (128 or 130 chars).""" - raw = _hex_to_bytes(pub_hex) - if len(raw) == 65: - if raw[0] != 0x04: - raise ValueError("uncompressed pubkey prefix must be 0x04") - xy = raw[1:] - elif len(raw) == 64: - xy = raw - else: - raise ValueError(f"uncompressed pubkey must be 64 or 65 bytes, got {len(raw)}") - digest = _keccak256(xy) - return _bytes_to_0x_hex(digest[-20:]) - - -def _keccak256(data: bytes) -> bytes: - try: - from eth_utils import keccak # type: ignore - return keccak(data) - except ImportError: - pass - try: - from Crypto.Hash import keccak # type: ignore - h = keccak.new(digest_bits=256) - h.update(data) - return h.digest() - except ImportError: - pass - try: - import sha3 # type: ignore (pysha3 / safe-pysha3) - h = sha3.keccak_256() - h.update(data) - return h.digest() - except ImportError as exc: - raise RuntimeError( - "no keccak256 backend found; pip install eth-utils, pycryptodome, or pysha3" - ) from exc - - -# --- CLI ------------------------------------------------------------------- - -def _main(argv: list[str]) -> int: - if len(argv) < 2: - print(__doc__) - return 2 - cmd = argv[1] - args = argv[2:] - try: - if cmd == "sig-to-wire" and len(args) == 1: - print(metamask_sig_to_wire(args[0])) - elif cmd == "wire-to-sig" and len(args) == 1: - print(wire_sig_to_metamask(args[0])) - elif cmd == "pub-to-wire" and len(args) == 1: - print(metamask_pub_to_wire(args[0])) - elif cmd == "wire-to-pub" and len(args) == 1: - print(wire_pub_to_metamask(args[0])) - elif cmd == "addr-from-pub" and len(args) == 1: - print(eth_address_from_pub(args[0])) - else: - print(__doc__) - return 2 - except Exception as exc: # noqa: BLE001 - print(f"error: {exc}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(_main(sys.argv)) diff --git a/tests/metamask/metamask_sim.py b/tests/metamask/metamask_sim.py deleted file mode 100644 index 4409f83d8b..0000000000 --- a/tests/metamask/metamask_sim.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Headless substitute for Metamask's personal_sign. - -Mimics MetaMask's `personal_sign` on an arbitrary 0x-hex payload using -`eth_account`. The signature produced by this script is byte-identical to what -Metamask would emit if a user signed the same digest in the browser, because -both go through the EIP-191 envelope: - - keccak256("\\x19Ethereum Signed Message:\\n" + len(payload) + payload) - -then deterministic RFC-6979 secp256k1 with low-s normalization. - -Run: - python metamask_sim.py keygen - -> emits {private_key, eth_address, wire_pub_em} as JSON - - python metamask_sim.py sign --private-key 0x --digest 0x - -> emits {signature_hex, signature_wire, eth_address, wire_pub_em} as JSON - - python metamask_sim.py sign --private-key 0x --digest 0x --raw - -> emits the SIG_EM_... value alone on stdout (script-friendly) -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -from typing import Any - -from em_sig_to_wire import metamask_pub_to_wire, metamask_sig_to_wire - - -def _load_key(priv_hex: str): - from eth_keys import keys - raw = bytes.fromhex(priv_hex[2:] if priv_hex.startswith("0x") else priv_hex) - if len(raw) != 32: - raise ValueError(f"private key must be 32 bytes, got {len(raw)}") - return keys.PrivateKey(raw) - - -def _generate_key() -> str: - return "0x" + os.urandom(32).hex() - - -def _key_info(priv) -> dict[str, str]: - pub_hex = "0x04" + priv.public_key.to_bytes().hex() - from eth_utils import keccak - addr = "0x" + keccak(priv.public_key.to_bytes())[-20:].hex() - return { - "eth_address": addr, - "uncompressed_pub_hex": pub_hex, - "wire_pub_em": metamask_pub_to_wire(pub_hex), - } - - -def _sign_digest(priv, digest_hex: str) -> dict[str, Any]: - """Sign `digest_hex` (raw payload, will be EIP-191 wrapped + keccak256-ed). - - `digest_hex` is what Metamask receives as the message argument to - personal_sign when called as `personal_sign('0x', address)`. - """ - from eth_account.messages import encode_defunct - from eth_account import Account - - msg_bytes = bytes.fromhex(digest_hex[2:] if digest_hex.startswith("0x") else digest_hex) - msg = encode_defunct(primitive=msg_bytes) - signed = Account.sign_message(msg, priv.to_hex()) - sig_hex = "0x" + signed.signature.hex() - return { - "signature_hex": sig_hex, - "signature_wire": metamask_sig_to_wire(sig_hex), - } - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - sub = parser.add_subparsers(dest="cmd", required=True) - - p_keygen = sub.add_parser("keygen", help="Generate a fresh EM key") - p_keygen.add_argument("--private-key", default=None, - help="Use this private key instead of generating one") - - p_sign = sub.add_parser("sign", help="Sign a 0x-hex digest as Metamask would") - p_sign.add_argument("--private-key", required=True, help="0x-prefixed 32-byte hex") - p_sign.add_argument("--digest", required=True, help="0x-prefixed hex message to sign") - p_sign.add_argument("--raw", action="store_true", - help="Print only the SIG_EM_... line (no JSON)") - - args = parser.parse_args(argv) - - try: - if args.cmd == "keygen": - priv_hex = args.private_key or _generate_key() - priv = _load_key(priv_hex) - info = {"private_key": priv_hex, **_key_info(priv)} - print(json.dumps(info, indent=2)) - return 0 - - # The only other subcommand; argparse's required subparser guarantees - # args.cmd is "keygen" or "sign", so this needs no further dispatch. - priv = _load_key(args.private_key) - sig = _sign_digest(priv, args.digest) - if args.raw: - print(sig["signature_wire"]) - else: - print(json.dumps({**sig, **_key_info(priv)}, indent=2)) - return 0 - except Exception as exc: # noqa: BLE001 - print(f"error: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/metamask/push_metamask_trx.py b/tests/metamask/push_metamask_trx.py index e8bfd6d718..bf1f0213a1 100755 --- a/tests/metamask/push_metamask_trx.py +++ b/tests/metamask/push_metamask_trx.py @@ -16,31 +16,40 @@ Round 2 - clio convert unpack_transaction | clio push transaction --signature SIG_EM_... -s. 6. Signing has two modes: - --simulate : metamask_sim.py drives the signing automatically. This is - what the `metamask_trx_signing_test` ctest runs, so CI - needs no browser. + --simulate : clio's own EM tooling drives the signing automatically + (`clio create key --em` for the identity, + `clio convert em_sign` for the signature). This is what + the `metamask_trx_signing_test` ctest runs, so CI needs + no browser and no Python eth stack -- the only dependency + is the clio binary the build already produces. (default) : print the digest and wait for the user to paste the - SIG_EM_... value from metamask-sign.html. The pasted - identity and every signature are verified offline before - anything reaches the chain, so a stale or wrong paste is - rejected locally with a clear message instead of as an - opaque unsatisfied_authorization. + SIG_EM_... value from metamask-sign.html (real MetaMask). + Every pasted signature is recovered offline via + `clio convert em_recover` and checked against the EM key + registered on the account before anything reaches the + chain, so a stale or wrong paste is rejected locally with + a clear message instead of as an opaque + unsatisfied_authorization. + +clio's EM path is libfc's own crypto -- the exact code nodeop runs to validate +these signatures -- and is pinned byte-for-byte against the Ethereum-ecosystem +reference (eth_account / MetaMask personal_sign) by the separate, hermetic +`clio_em_key_test` ctest. This harness exercises the full chain round-trip. Run from your build directory (so $BUILD_DIR/programs/clio resolves): cd $BUILD_DIR - .venv/bin/python tests/metamask/push_metamask_trx.py --simulate - .venv/bin/python tests/metamask/push_metamask_trx.py # manual + python3 tests/metamask/push_metamask_trx.py --simulate + python3 tests/metamask/push_metamask_trx.py # manual (real MetaMask) Use `--leave-running` to leave the cluster up after the test. """ from __future__ import annotations -import functools import hashlib import json -import os +import re import subprocess import sys import urllib.error @@ -48,14 +57,7 @@ from pathlib import Path HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE)) -sys.path.insert(0, str(HERE.parent)) - -from em_sig_to_wire import ( # noqa: E402 - eth_address_from_pub, - metamask_pub_to_wire, - wire_pub_to_metamask, -) +sys.path.insert(0, str(HERE.parent)) # tests/ -- so `import TestHarness` resolves # TestHarness shaping from TestHarness import ( # noqa: E402 @@ -77,65 +79,6 @@ # message rather than failing opaquely inside clio. MAX_TRX_EXPIRATION_SECONDS = 3600 -# metamask_sim.py (simulate) and verify_em_sig.py (manual pre-flight) run as -# subprocesses and import these. Probed once at startup against a chosen -# interpreter so a deps-less launcher fails fast with guidance instead of -# rejecting every signature deep in the manual flow. -_REQUIRED_DEPS = ("eth_account", "eth_keys", "eth_utils") - - -@functools.lru_cache(maxsize=1) -def _dep_python() -> str: - """Return a Python interpreter that can import the eth_account stack. - - Relying on sys.executable breaks when the harness is launched with a bare - `python3` that lacks the deps: the pre-flight subprocess then fails with an - opaque "No module named 'eth_account'" and the manual loop rejects every - paste forever. Prefer the launching interpreter if it already has the deps; - otherwise fall back to $VIRTUAL_ENV or the first `.venv` found walking up - from this script (works whether run from the source tree or the build-dir - copy). errorExit with actionable guidance if nothing qualifies. - """ - probe = "import " + ", ".join(_REQUIRED_DEPS) - - def has_deps(py: str) -> bool: - try: - return subprocess.run([py, "-c", probe], - capture_output=True, text=True).returncode == 0 - except OSError: - return False - - if has_deps(sys.executable): - return sys.executable - - candidates: list[str] = [] - venv_env = os.environ.get("VIRTUAL_ENV") - if venv_env: - candidates.append(str(Path(venv_env) / "bin" / "python")) - for parent in [HERE, *HERE.parents]: - cand = parent / ".venv" / "bin" / "python" - if cand.is_file(): - candidates.append(str(cand)) - - tried: list[str] = [] - for cand in candidates: - if cand in tried: - continue - tried.append(cand) - if has_deps(cand): - return cand - - errorExit( - "no Python with the eth_account stack " - f"({', '.join(_REQUIRED_DEPS)}) could be found.\n" - f" launched with : {sys.executable}\n" - f" also tried : {tried or '(none)'}\n" - " fix: run the harness with the repo virtualenv, e.g.\n" - " /.venv/bin/python tests/metamask/push_metamask_trx.py ...\n" - " or: /bin/python -m pip install " - "eth-account eth-keys eth-utils pycryptodome") - - def _prompt(label: str) -> str: """input() that turns EOF / Ctrl-C into a clean TestHarness exit. @@ -167,6 +110,25 @@ def _run_clio(node, *args, capture_json=True): return json.loads(out) if out else {} +def _clio_offline(*args, expect_fail=False) -> dict[str, str] | None: + """Run clio for an offline subcommand (create key / convert em_*); no node. + + These subcommands print `Label: value` lines and never touch a wallet or + nodeop, so they take no node args. Returns the parsed lines as a dict, or + None when `expect_fail` and clio exited non-zero (a malformed signature in + the manual pre-flight, recovered locally rather than at the chain). + """ + cmd = [Utils.SysClientPath, *args] + if Utils.Debug: + Print(f"cmd: {' '.join(cmd)}") + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + if expect_fail: + return None + errorExit(f"clio failed: {' '.join(cmd)}\nstderr: {res.stderr}\nstdout: {res.stdout}") + return {k: v for k, v in re.findall(r'(\w[^:\n]*): ([^\n]+)', res.stdout)} + + def _parse_asset(asset: str) -> tuple[int, int, str]: """Parse a Wire asset string ("100.0000 SYS") into (units, precision, symbol). @@ -201,121 +163,73 @@ def _assert_balance_decreased(label: str, before: str, after: str, expected_delt Print(f"{label}: balance {before} -> {after} (-{expected_delta}) OK") -def _validate_pasted_identity(em_info: dict[str, str]): - """Offline check of the three values pasted from metamask-sign.html. - - The signature gets a recovery pre-flight; the identity it must recover to - deserves the same treatment. Confirm the pasted uncompressed pubkey, the - pasted Ethereum address, and the pasted PUB_EM_ are mutually consistent so - a typo fails here with a clear message instead of opaquely at expandauth. - """ - eth_address = em_info["eth_address"] - pub_hex = em_info["uncompressed_pub_hex"] - wire_pub_em = em_info["wire_pub_em"] - if not wire_pub_em.startswith("PUB_EM_"): - errorExit(f"pasted Wire pubkey is not a PUB_EM_ value: {wire_pub_em!r}") - try: - # wire_pub_to_metamask validates the PUB_EM_ framing/length/0x04 prefix. - wire_pub_to_metamask(wire_pub_em) - derived_wire = metamask_pub_to_wire(pub_hex) - derived_addr = eth_address_from_pub(pub_hex) - except Exception as exc: # noqa: BLE001 - errorExit(f"pasted identity is malformed: {exc}") - if derived_wire.lower() != wire_pub_em.lower(): - errorExit("pasted uncompressed pubkey does not match the pasted PUB_EM_:\n" - f" from pubkey : {derived_wire}\n" - f" pasted PUB : {wire_pub_em}") - if derived_addr.lower() != eth_address.lower(): - errorExit("pasted ETH address does not match the pasted pubkey:\n" - f" from pubkey : {derived_addr}\n" - f" pasted addr : {eth_address}") - - def _create_em_key(simulate: bool, - private_key_hex: str | None) -> tuple[dict[str, str], str | None]: - """Obtain the EM identity, either from metamask_sim keygen or by paste. - - Returns (em_info, sim_private_key). `em_info` always has exactly - {eth_address, uncompressed_pub_hex, wire_pub_em}; `sim_private_key` is the - simulator's hex private key in --simulate mode (used to sign later) and - None in manual mode. Keeping the shape identical across both modes avoids - the previous branch-dependent KeyError footgun. + sim_priv_arg: str | None) -> tuple[dict[str, str], str | None]: + """Obtain the EM identity via clio (simulate) or by paste (manual). + + Returns (em_info, sim_priv_em). `em_info` always has exactly {wire_pub_em}. + `sim_priv_em` is the PVT_EM_ to sign with in --simulate mode and None in + manual mode (the private key never leaves the browser there). Keeping the + shape identical across both modes avoids a branch-dependent KeyError. """ if simulate: - cmd = [_dep_python(), str(HERE / "metamask_sim.py"), "keygen"] - if private_key_hex: - cmd += ["--private-key", private_key_hex] - res = subprocess.run(cmd, capture_output=True, text=True, check=True) - raw = json.loads(res.stdout) - em_info = { - "eth_address": raw["eth_address"], - "uncompressed_pub_hex": raw["uncompressed_pub_hex"], - "wire_pub_em": raw["wire_pub_em"], - } - return em_info, raw["private_key"] + if sim_priv_arg: + # Reproducible: a fixed PVT_EM_ or raw 0x Ethereum hex secret. + r = _clio_offline("convert", "em_private_key", + "--private-key", sim_priv_arg, "--to-console") + else: + r = _clio_offline("create", "key", "--em", "--to-console") + return {"wire_pub_em": r["Public key"]}, r["Private key"] Print("\nOpen tests/metamask/metamask-sign.html in a browser with Metamask") Print("installed. Click 'Connect Metamask' and then sign any test message so") - Print("the page can ecRecover and surface your uncompressed pubkey.") - Print("Paste the values shown on the page below:\n") - em_info = { - "eth_address": _prompt("ETH address (0x...): "), - "uncompressed_pub_hex": _prompt("uncompressed pubkey (0x04...): "), - "wire_pub_em": _prompt("Wire PUB_EM_ pubkey: "), - } - _validate_pasted_identity(em_info) - Print("pasted identity is self-consistent (pubkey <-> address <-> PUB_EM_)") - return em_info, None + Print("the page can ecRecover and surface your Wire PUB_EM_ pubkey.") + Print("Paste it below (the per-signature pre-flight checks every signature") + Print("recovers to THIS key before anything reaches the chain):\n") + wire_pub_em = _prompt("Wire PUB_EM_ pubkey: ") + if not wire_pub_em.startswith("PUB_EM_"): + errorExit(f"pasted Wire pubkey is not a PUB_EM_ value: {wire_pub_em!r}") + return {"wire_pub_em": wire_pub_em}, None def _verify_sig_locally(digest_hex: str, sig_em: str, expected_wire_pub: str) -> tuple[int, str]: """Offline pre-flight before pushing: recover the signer pubkey from - (digest, sig) using the SAME EIP-191 envelope nodeop applies, and confirm it - matches the EM key registered on the account. - - Delegates to verify_em_sig.py (its documented CLI) rather than reaching into - its internals, and mirrors how metamask_sim.py is driven elsewhere here. - - Returns (returncode, detail) where returncode follows verify_em_sig.py's - contract: 0 ok, 1 recovery failed (the pasted text is not a recoverable - signature), 2 recovered-key mismatch (wrong/stale digest signed), 3 CLI - error. `detail` carries its recovered-vs-expected output so a bad paste is - diagnosable HERE instead of as an opaque unsatisfied_authorization. + (digest, sig) via `clio convert em_recover` -- the SAME libfc EIP-191 path + nodeop applies -- and confirm it matches the EM key registered on the + account. + + Returns (status, detail): 0 ok; 1 not a recoverable signature (clio could + not recover, e.g. malformed paste); 2 recovered-key mismatch (wrong/stale + digest signed). `detail` carries the recovered-vs-expected text so a bad + paste is diagnosable HERE instead of as an opaque unsatisfied_authorization. """ - cmd = [ - _dep_python(), str(HERE / "verify_em_sig.py"), - "--digest", digest_hex, - "--signature", sig_em, - "--expect-pub", expected_wire_pub, - ] - res = subprocess.run(cmd, capture_output=True, text=True) - parts = [res.stdout.strip(), res.stderr.strip()] - detail = "\n".join(p for p in parts if p) - return res.returncode, detail + r = _clio_offline("convert", "em_recover", sig_em, digest_hex, expect_fail=True) + if r is None or "Public key" not in r: + return 1, "clio could not recover a public key from the pasted signature" + recovered = r["Public key"] + if recovered == expected_wire_pub: + return 0, recovered + return 2, (f" recovered : {recovered}\n" + f" expected : {expected_wire_pub}") -def _sign_with_metamask(simulate: bool, private_key_hex: str | None, +def _sign_with_metamask(simulate: bool, sim_priv_em: str | None, digest_hex: str, expected_wire_pub: str) -> str: - """Get a SIG_EM_... signature for `digest_hex` either from sim or from human paste. + """Get a SIG_EM_... signature for `digest_hex` from clio (sim) or human paste. Manual mode runs an offline recovery pre-flight on the pasted signature before returning it: anything that does not recover to the account's registered EM key is rejected locally with a clear diagnostic and the user is re-prompted, rather than letting nodeop reject it later with an opaque - unsatisfied_authorization. (The simulate path is unchanged.) + unsatisfied_authorization. """ if simulate: - assert private_key_hex, "simulator mode requires the simulator's private key" - cmd = [ - _dep_python(), str(HERE / "metamask_sim.py"), - "sign", - "--private-key", private_key_hex, - "--digest", digest_hex, - "--raw", - ] - res = subprocess.run(cmd, capture_output=True, text=True, check=True) - return res.stdout.strip() + assert sim_priv_em, "simulator mode requires the simulator's PVT_EM_ key" + # clio convert em_sign applies the same EIP-191 envelope nodeop recovers + # with; pinned byte-for-byte to MetaMask by the clio_em_key_test ctest. + return _clio_offline("convert", "em_sign", digest_hex, + "--private-key", sim_priv_em)["Signature"] Print("\n" + "=" * 72) Print("Paste THIS EXACT digest into the HTML page's 'Digest to sign' field:") @@ -331,19 +245,15 @@ def _sign_with_metamask(simulate: bool, private_key_hex: str | None, if rc == 0: Print("pre-flight OK: signature recovers to the registered EM key; pushing") return sig - # A missing dependency / CLI failure is not something re-pasting can fix; - # fail loudly rather than spinning the prompt forever. (_dep_python makes - # the missing-module case unreachable, but guard it regardless.) - if "No module named" in detail or rc not in (1, 2): - errorExit(f"pre-flight could not run (verify_em_sig exit {rc}); " - f"this is an environment problem, not a bad signature:\n{detail}") Print(f"\n*** REJECTED locally (NOT sent to chain):\n{detail}\n") if rc == 2: Print("The signature recovers to a DIFFERENT key than the one registered.") Print("Almost always a stale signature from an earlier run, or the") Print("pubkey-reveal signature instead of this transaction's digest.") Print(f"The digest to sign is still: {digest_hex}") - Print("Re-sign THAT exact digest in the browser and paste again.\n") + Print("Re-sign THAT exact digest in the browser and paste again.") + Print("If this persists across a fresh re-sign, the PUB_EM_ you") + Print("pasted earlier may not be this wallet's key.\n") else: # rc == 1 Print("That is not a recoverable signature. Paste the full") Print("SIG_EM_0x... value exactly as the page's 'Wire SIG_EM_' box") @@ -390,7 +300,7 @@ def _build_unsigned_transfer(node, sender: str, recipient: str, quantity: str, def _sign_unsigned_packed(packed, chain_id_hex: str, simulate: bool, - sim_private_key: str | None, + sim_priv_em: str | None, expected_wire_pub: str) -> tuple[str, str]: """Compute sig_digest and obtain a SIG_EM_... over it. Returns (sig_em, digest_hex). @@ -398,7 +308,7 @@ def _sign_unsigned_packed(packed, chain_id_hex: str, simulate: bool, pre-flights the pasted signature against it before returning. """ sig_digest = _compute_sig_digest(chain_id_hex, packed["packed_trx"]) - sig_em = _sign_with_metamask(simulate, sim_private_key, sig_digest, expected_wire_pub) + sig_em = _sign_with_metamask(simulate, sim_priv_em, sig_digest, expected_wire_pub) return sig_em, sig_digest @@ -498,9 +408,10 @@ def _assert_executed(label: str, result: dict): def main() -> int: app_args = AppArgs() app_args.add_bool("--simulate", - help="Drive metamask_sim.py to sign instead of asking the user to paste") + help="Sign automatically via clio's EM tooling instead of asking the user to paste") app_args.add("--sim-private-key", type=str, default=None, - help="If --simulate, use this hex private key instead of a fresh one") + help="If --simulate, sign with this fixed key (PVT_EM_... or a raw 0x " + "Ethereum hex secret) instead of a freshly generated one") app_args.add("--transfer-amount", type=str, default="1.0000 SYS", help="Asset string to transfer (default: '1.0000 SYS')") app_args.add("--trx-expiration", type=int, default=600, @@ -516,14 +427,6 @@ def main() -> int: errorExit(f"--trx-expiration must be in (0, {MAX_TRX_EXPIRATION_SECONDS}] " f"seconds, got {args.trx_expiration}") - # Resolve the eth_account interpreter up front: a deps-less launcher errors - # here in ~1s with guidance, instead of after a multi-minute cluster launch - # and (manual mode) a paste that the pre-flight could never accept. - dep_py = _dep_python() - if dep_py != sys.executable: - Print(f"note: {sys.executable} lacks the eth_account stack; " - f"using {dep_py} for sim / pre-flight subprocesses") - Utils.Debug = args.v cluster = Cluster( host=args.host, port=args.port, @@ -568,7 +471,7 @@ def main() -> int: errorExit("createAccountAndVerify failed") Print("setting up Metamask-style EM key on the test account") - em_info, sim_private_key = _create_em_key(args.simulate, args.sim_private_key) + em_info, sim_priv_em = _create_em_key(args.simulate, args.sim_private_key) Print(json.dumps(em_info, indent=2)) Print("calling sysio::expandauth to add the EM key to active permission") @@ -611,7 +514,7 @@ def main() -> int: "metamask http transfer", args.trx_expiration) sig_em_http, digest_http = _sign_unsigned_packed( - packed_http, chain_id_hex, args.simulate, sim_private_key, + packed_http, chain_id_hex, args.simulate, sim_priv_em, em_info["wire_pub_em"]) Print(f"sig_digest: {digest_http}") Print(f"signature : {sig_em_http}") @@ -628,7 +531,7 @@ def main() -> int: "metamask clio transfer", args.trx_expiration) sig_em_clio, digest_clio = _sign_unsigned_packed( - packed_clio, chain_id_hex, args.simulate, sim_private_key, + packed_clio, chain_id_hex, args.simulate, sim_priv_em, em_info["wire_pub_em"]) Print(f"sig_digest: {digest_clio}") Print(f"signature : {sig_em_clio}") diff --git a/tests/metamask/verify_em_sig.py b/tests/metamask/verify_em_sig.py deleted file mode 100644 index b847ee991a..0000000000 --- a/tests/metamask/verify_em_sig.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Verify a Metamask-style EIP-191 signature against an expected signer. - -Given: - --digest Hex of the message that Metamask was asked to sign. This is - treated EXACTLY the way personal_sign treats a 0x-prefixed - hex string: the bytes are decoded from hex, then the EIP-191 - prefix and length are prepended, then keccak256, then ECDSA - recovery on the supplied signature. - --signature Either an 0x-prefixed 130-hex-char signature (r||s||v) or - Wire's SIG_EM_... form. Both are accepted. - --expect-addr Optional. 0x-prefixed Ethereum address (40 hex chars) the - recovered pubkey must hash to. - --expect-pub Optional. Either an 0x-prefixed uncompressed hex pubkey or - Wire's PUB_EM_... form. The recovered pubkey must match. - -If neither --expect-addr nor --expect-pub is provided, the recovered values are -printed and the script exits 0. - -Exit codes: - 0 recovery succeeded and (if specified) matched all expectations - 1 signature recovery failed - 2 recovery succeeded but expectations did not match - 3 CLI / input error -""" - -from __future__ import annotations - -import argparse -import sys - -from em_sig_to_wire import ( - eth_address_from_pub, - metamask_pub_to_wire, - wire_pub_to_metamask, - wire_sig_to_metamask, -) - - -def _normalize_sig(s: str) -> str: - return wire_sig_to_metamask(s) if s.startswith("SIG_EM_") else s - - -def _normalize_pub(s: str) -> str: - return wire_pub_to_metamask(s) if s.startswith("PUB_EM_") else s - - -def _recover_pub(digest_hex: str, sig_hex: str) -> str: - """Run eth_account-style EIP-191 personal_sign recovery. - - Returns the recovered uncompressed pubkey as 0x-prefixed hex (130 chars - including the SEC1 0x04 prefix). - """ - from eth_account.messages import _hash_eip191_message, encode_defunct - from eth_keys import keys - - sig_bytes = bytes.fromhex(sig_hex[2:] if sig_hex.startswith("0x") else sig_hex) - if len(sig_bytes) != 65: - raise ValueError(f"signature must be 65 bytes, got {len(sig_bytes)}") - - digest_hex_clean = digest_hex[2:] if digest_hex.startswith("0x") else digest_hex - message_bytes = bytes.fromhex(digest_hex_clean) - - # Apply the EIP-191 envelope (0x19 + "E" + "thereum Signed Message:\n" + len + payload) - # and keccak256 it. This matches what Wire's signature_shim::recover() does internally - # for a sha256 trx digest, and what Metamask's personal_sign does in the browser. - h = _hash_eip191_message(encode_defunct(primitive=message_bytes)) - - r = int.from_bytes(sig_bytes[0:32], "big") - s = int.from_bytes(sig_bytes[32:64], "big") - v = sig_bytes[64] - if v in (27, 28): - recovery_id = v - 27 - elif v in (0, 1): - recovery_id = v - else: - raise ValueError(f"unexpected v byte: {v}") - - sig = keys.Signature(vrs=(recovery_id, r, s)) - pub = sig.recover_public_key_from_msg_hash(h) - return "0x04" + pub.to_bytes().hex() - - -def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--digest", required=True, - help="Hex-encoded payload Metamask signed (0x-prefixed or bare hex)") - p.add_argument("--signature", required=True, - help="0x-hex signature (130 chars) or SIG_EM_... form") - p.add_argument("--expect-addr", default=None, - help="Optional. 0x... Ethereum address the recovered pubkey must hash to") - p.add_argument("--expect-pub", default=None, - help="Optional. 0x-uncompressed hex or PUB_EM_... form") - p.add_argument("--quiet", action="store_true", help="Suppress info output") - args = p.parse_args(argv) - - try: - sig_hex = _normalize_sig(args.signature) - recovered_pub = _recover_pub(args.digest, sig_hex) - except Exception as exc: # noqa: BLE001 - print(f"recovery failed: {exc}", file=sys.stderr) - return 1 - - recovered_addr = eth_address_from_pub(recovered_pub) - recovered_wire_pub = metamask_pub_to_wire(recovered_pub) - - if not args.quiet: - print(f"recovered address: {recovered_addr}") - print(f"recovered pub (hex): {recovered_pub}") - print(f"recovered pub (wire): {recovered_wire_pub}") - - failures: list[str] = [] - if args.expect_addr: - if args.expect_addr.lower() != recovered_addr.lower(): - failures.append(f"address mismatch: got {recovered_addr}, expected {args.expect_addr}") - if args.expect_pub: - try: - want = _normalize_pub(args.expect_pub).lower() - except Exception as exc: # noqa: BLE001 - print(f"could not parse --expect-pub: {exc}", file=sys.stderr) - return 3 - if recovered_pub.lower() != want: - failures.append(f"pubkey mismatch: got {recovered_pub}, expected {want}") - - if failures: - for line in failures: - print(f"FAIL: {line}", file=sys.stderr) - return 2 - - if not args.quiet and (args.expect_addr or args.expect_pub): - print("OK") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From 634c6ab3c001658df7202095eddf8236e5cf4088 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 15 May 2026 16:37:48 -0500 Subject: [PATCH 4/4] clio: harden em_* validation, mutually-exclude key-curve flags create key: --k1/--r1/--em/--sol are now mutually exclusive (CLI11 ->excludes()), so any contradictory combination is a non-zero parse error instead of being silently resolved by precedence. em_private_key/em_sign --private-key help: note the secret is visible in ps and shell history; omit the option to enter it at the interactive prompt instead. clio_em_key_test: add a digest-binding negative (the reference signature recovered against a different well-formed digest must yield a different key -- the property the external-signer pre-flight depends on), a 64-char non-hex digest rejection, and the new --k1 exclusion pairs. elliptic_em.hpp: fix the from_native_string doc comment, which described a public key; it takes the raw Ethereum private-key hex. --- .../libfc/include/fc/crypto/elliptic_em.hpp | 6 ++--- programs/clio/main.cpp | 19 ++++++++------ tests/clio_em_key_test.py | 26 ++++++++++++++++++- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/libraries/libfc/include/fc/crypto/elliptic_em.hpp b/libraries/libfc/include/fc/crypto/elliptic_em.hpp index c876beb65e..47991398b7 100644 --- a/libraries/libfc/include/fc/crypto/elliptic_em.hpp +++ b/libraries/libfc/include/fc/crypto/elliptic_em.hpp @@ -93,9 +93,9 @@ namespace fc { static private_key regenerate( const private_key_secret& secret ); /** - * @brief - * @param pub_key_str Public key in ethereum format 0x<128 hex chars> - * @return public key + * @brief Parse an Ethereum-native private key (the raw secret a wallet exports). + * @param priv_key_str 32-byte secret as hex, with or without a 0x prefix. + * @return the corresponding em private key */ static private_key from_native_string( const std::string& priv_key_str ); diff --git a/programs/clio/main.cpp b/programs/clio/main.cpp index 52b6c797fb..42fd4ed430 100644 --- a/programs/clio/main.cpp +++ b/programs/clio/main.cpp @@ -1915,10 +1915,10 @@ int main( int argc, char** argv ) { return; } - // --r1/--em/--sol select a curve and are mutually exclusive (enforced by CLI11 - // ->excludes() below, so a bad combination is a non-zero parse error). --k1 is - // not a curve switch: it selects the prefixed PVT_K1_/PUB_K1_ form of the - // default K1 key instead of the legacy unprefixed form. + // --k1/--r1/--em/--sol are mutually exclusive (enforced by CLI11 ->excludes() + // below, so any combination is a non-zero parse error). No flag => the default + // K1 key in its legacy unprefixed form; --k1 => the same K1 key in the prefixed + // PVT_K1_/PUB_K1_ form; --r1/--em/--sol => that curve (always prefixed). auto kt = crypto::private_key::key_type::k1; if (sol) kt = crypto::private_key::key_type::ed; // Solana ed25519 else if (em) kt = crypto::private_key::key_type::em; // Ethereum-style secp256k1 (MetaMask personal_sign) @@ -1941,11 +1941,12 @@ int main( int argc, char** argv ) { out << localized("Public key: ${key}", ("key", pubs ) ) << std::endl; } }); - create_key_cmd->add_flag( "--k1", k1, "Generate a key using the K1 curve (Bitcoin) with PUB_K1_ & PVT_K1_ prefix instead of legacy" ); + auto k1_flag = create_key_cmd->add_flag( "--k1", k1, "Generate a key using the K1 curve (Bitcoin) with PUB_K1_ & PVT_K1_ prefix instead of legacy" ); auto r1_flag = create_key_cmd->add_flag( "--r1", r1, "Generate a key using the R1 curve (iPhone), instead of the K1 curve (Bitcoin)" ); auto em_flag = create_key_cmd->add_flag( "--em", em, "Generate an EM key (Ethereum-style secp256k1, PUB_EM_/PVT_EM_) for MetaMask/external personal_sign" ); auto sol_flag = create_key_cmd->add_flag( "--sol", sol, "Generate a Solana key (ed25519, PUB_ED_/PVT_ED_) for external Solana signers" ); - // --r1/--em/--sol pick different curves; selecting more than one is a usage error. + // --k1/--r1/--em/--sol are mutually exclusive; selecting more than one is a usage error. + k1_flag->excludes(r1_flag)->excludes(em_flag)->excludes(sol_flag); r1_flag->excludes(em_flag)->excludes(sol_flag); em_flag->excludes(sol_flag); create_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); @@ -2131,7 +2132,8 @@ int main( int argc, char** argv ) { string em_private_key; auto em_private_key_cmd = convert_cmd->add_subcommand("em_private_key", localized("Convert a raw Ethereum secret (or PVT_EM_) to Wire PVT_EM_/PUB_EM_ key forms")); - em_private_key_cmd->add_option("--private-key", em_private_key, localized("PVT_EM_... or a raw 0x Ethereum hex secret; prompts if not provided"))->expected(0, 1); + em_private_key_cmd->add_option("--private-key", em_private_key, localized("PVT_EM_... or a raw 0x Ethereum hex secret. Omit to enter it at the prompt; " + "passing it here exposes the secret in ps/shell history"))->expected(0, 1); em_private_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); em_private_key_cmd->add_flag("--to-console", print_console, localized("Print private/public keys to console.")); em_private_key_cmd->callback([&] { @@ -2163,7 +2165,8 @@ int main( int argc, char** argv ) { string em_sign_priv; auto em_sign_cmd = convert_cmd->add_subcommand("em_sign", localized("Sign a 32-byte sha256 digest with an EM key (EIP-191 personal_sign), printing SIG_EM_")); em_sign_cmd->add_option("digest", em_sign_digest, localized("32-byte sha256 digest, 64 hex chars (0x optional)"))->required(); - em_sign_cmd->add_option("--private-key", em_sign_priv, localized("PVT_EM_... or a raw 0x Ethereum hex secret; prompts if not provided"))->expected(0, 1); + em_sign_cmd->add_option("--private-key", em_sign_priv, localized("PVT_EM_... or a raw 0x Ethereum hex secret. Omit to enter it at the prompt; " + "passing it here exposes the secret in ps/shell history"))->expected(0, 1); em_sign_cmd->callback([&] { if (em_sign_priv.empty()) { std::cout << localized("private key: "); diff --git a/tests/clio_em_key_test.py b/tests/clio_em_key_test.py index 1dd842543b..1314ff36b7 100755 --- a/tests/clio_em_key_test.py +++ b/tests/clio_em_key_test.py @@ -47,6 +47,11 @@ "47bb493382ce28cab79ad7119ee1ad3ebcdb98a16805211530ecc6cfefa1b88e6dff99232a") KAT_SIG_EM = ("SIG_EM_0xea0847dc6f2c9a189b85f01e9a1d51931ac92ee381b67641384b523" "14a803fd5064a69f67b5967f2cac3fa3b9cf3f4f0be9f08e282e0e71ed0ce13d202a892b71b") +# KAT_DIGEST with the last hex nibble flipped: a valid but different 32-byte +# digest. Recovering KAT_SIG_EM against this must yield a *different* key -- +# proving recovery is digest-bound (the property the stale-signature pre-flight +# in the metamask harness relies on). +KAT_WRONG_DIGEST = KAT_DIGEST[:-1] + ("5" if KAT_DIGEST[-1] != "5" else "6") testSuccessful = False @@ -134,6 +139,20 @@ def test_known_answer_vector(): assert clio("convert", "em_recover", KAT_SIG_EM, KAT_DIGEST)["Public key"] == KAT_PUB_EM +def test_recover_is_digest_bound(): + """Recovery is bound to the exact digest signed. + + Recovering the reference signature against a *different* (but well-formed) + digest still succeeds -- ECDSA recovery yields some point for almost any + digest -- but must yield a DIFFERENT public key, never the reference one. + This is precisely what lets the metamask harness reject a stale or + wrong-digest paste offline instead of as an opaque unsatisfied_authorization. + """ + recovered = clio("convert", "em_recover", KAT_SIG_EM, KAT_WRONG_DIGEST)["Public key"] + assert recovered.startswith("PUB_EM_"), recovered + assert recovered != KAT_PUB_EM, "recovery is not digest-bound: wrong digest recovered the reference key" + + def test_error_handling(): """Mutually exclusive curve flags and malformed crypto inputs are hard errors. @@ -142,9 +161,12 @@ def test_error_handling(): ``ERROR:`` and emit no key, exit 0. Assert that contract rather than an exit code so this test does not silently encode a behavior change to it. """ - # mutually exclusive curve flags -> CLI11 parse error (non-zero) + # mutually exclusive curve flags -> CLI11 parse error (non-zero); all of + # --k1/--r1/--em/--sol exclude each other clio("create", "key", "--em", "--sol", "--to-console", expect_fail=True) clio("create", "key", "--em", "--r1", "--to-console", expect_fail=True) + clio("create", "key", "--k1", "--em", "--to-console", expect_fail=True) + clio("create", "key", "--k1", "--r1", "--to-console", expect_fail=True) # neither --file nor --to-console: soft validation -> ERROR, no key, exit 0 res = clio("create", "key", "--em", raw=True) @@ -153,6 +175,7 @@ def test_error_handling(): # malformed crypto inputs throw in libfc -> non-zero clio("convert", "em_sign", "deadbeef", "--private-key", KAT_PVT_EM, expect_fail=True) # 8 hex != 64 + clio("convert", "em_sign", "z" * 64, "--private-key", KAT_PVT_EM, expect_fail=True) # 64 chars, not hex clio("convert", "em_recover", "SIG_EM_0xdeadbeef", KAT_DIGEST, expect_fail=True) # malformed sig @@ -161,6 +184,7 @@ def test_error_handling(): test_create_key_sol() test_create_key_to_file() test_known_answer_vector() + test_recover_is_digest_bound() test_error_handling() testSuccessful = True except Exception as e: