gabriel / muse public
test_msign_dual_sig.py python
422 lines 17.5 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
1 """Tests for extended MPay dual-signature (Ed25519 + secp256k1 / AVAX).
2
3 Verifies that :func:`build_payment_claim` correctly:
4
5 1. Produces required Ed25519 MSign signature in all cases (backward compat).
6 2. Produces ``payer_avax_address``, ``eth_sig`` when ``avax_private_key`` is set.
7 3. Produces ``recipient_avax_address`` when that argument is passed.
8 4. Leaves AVAX fields absent when no secp256k1 key is supplied.
9 5. ``eth_sig`` is a valid EIP-191 signature verifiable with ``eip191_verify``.
10 6. ``PaymentClaim`` TypedDict shape is correct (required + optional fields).
11 7. Dual-signed claims are deterministic for the same inputs (given fixed ``ts``).
12 8. Large payloads and extreme argument values don't break signing.
13
14 Test categories
15 ---------------
16 - unit : PaymentClaim field structure
17 - integration : dual-sig claim round-trip (Ed25519 + secp256k1)
18 - e2e : verify eth_sig with eip191_verify against derived address
19 - stress : 50 consecutive dual-sig claims
20 - data-integrity : canonical message matches both Ed25519 and EIP-191 signer
21 - performance : dual-sig claim under 200 ms per claim
22 - security : AVAX fields absent when no key supplied; no key leakage
23 - docstrings : public API has docstrings
24 """
25
26 from __future__ import annotations
27
28 import hashlib
29 import time
30 from typing import TYPE_CHECKING
31 from unittest.mock import MagicMock
32
33 import pytest
34
35 from muse.core.types import b64url_decode
36
37 if TYPE_CHECKING:
38 from eth_keys.keys import PrivateKey as _AvaxKey
39
40
41 # ---------------------------------------------------------------------------
42 # Shared fixtures
43 # ---------------------------------------------------------------------------
44
45 BIP39_MNEMONIC = (
46 "abandon abandon abandon abandon abandon abandon "
47 "abandon abandon abandon abandon abandon about"
48 )
49
50
51 @pytest.fixture(scope="module")
52 def seed() -> bytes:
53 from muse.core.bip39 import mnemonic_to_seed
54 return mnemonic_to_seed(BIP39_MNEMONIC)
55
56
57 @pytest.fixture(scope="module")
58 def avax_key(seed: bytes) -> "_AvaxKey":
59 from muse.core.secp256k1_sign import derive_avax_key
60 return derive_avax_key(seed)
61
62
63 @pytest.fixture(scope="module")
64 def ed25519_signing(seed: bytes) -> MagicMock:
65 """A minimal SigningIdentity-compatible object with a real Ed25519 key."""
66 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
67 dk = derive_identity_key(seed)
68 private_key = dk_to_ed25519(dk)
69
70 signing = MagicMock()
71 signing.private_key = private_key
72 signing.handle = "gabriel"
73 return signing
74
75
76 @pytest.fixture(scope="module")
77 def avax_address(avax_key: "_AvaxKey") -> str:
78 from muse.core.secp256k1_sign import avax_c_chain_address
79 return avax_c_chain_address(avax_key.public_key)
80
81
82 # ---------------------------------------------------------------------------
83 # Unit: PaymentClaim TypedDict shape
84 # ---------------------------------------------------------------------------
85
86
87 class TestPaymentClaimShape:
88 """PaymentClaim TypedDict structure — required and optional fields."""
89
90 def test_required_fields_present_without_avax(self, ed25519_signing: MagicMock) -> None:
91 """Required fields are always present even without AVAX key."""
92 from muse.core.msign import build_payment_claim
93
94 claim = build_payment_claim(
95 ed25519_signing, "gabriel", "alice",
96 1_000_000, "nanoMUSE", "a" * 64, "test memo", ts=1_700_000_000,
97 )
98 assert claim["from_handle"] == "gabriel"
99 assert claim["to_handle"] == "alice"
100 assert claim["amount_nano"] == 1_000_000
101 assert claim["currency"] == "nanoMUSE"
102 assert claim["nonce_hex"] == "a" * 64
103 assert claim["memo"] == "test memo"
104 assert claim["ts"] == 1_700_000_000
105 assert "signature_b64" in claim
106 assert "canonical_message" in claim
107
108 def test_avax_fields_absent_without_key(self, ed25519_signing: MagicMock) -> None:
109 """AVAX fields must be absent when ``avax_private_key`` is not supplied."""
110 from muse.core.msign import build_payment_claim
111
112 claim = build_payment_claim(
113 ed25519_signing, "gabriel", "alice",
114 1_000_000, "nanoMUSE", "a" * 64, "memo", ts=1,
115 )
116 assert "payer_avax_address" not in claim
117 assert "eth_sig" not in claim
118 assert "recipient_avax_address" not in claim
119
120 def test_avax_fields_present_with_key(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
121 """AVAX fields appear when ``avax_private_key`` is supplied."""
122 from muse.core.msign import build_payment_claim
123
124 claim = build_payment_claim(
125 ed25519_signing, "gabriel", "alice",
126 1_000_000, "nanoMUSE", "b" * 64, "memo", ts=1,
127 avax_private_key=avax_key,
128 )
129 assert "payer_avax_address" in claim
130 assert "eth_sig" in claim
131 assert "recipient_avax_address" not in claim
132
133 def test_recipient_avax_address_stored(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
134 """``recipient_avax_address`` is stored verbatim when provided."""
135 from muse.core.msign import build_payment_claim
136
137 recipient = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
138 claim = build_payment_claim(
139 ed25519_signing, "gabriel", "alice",
140 500, "nanoMUSE", "c" * 64, "memo", ts=2,
141 avax_private_key=avax_key,
142 recipient_avax_address=recipient,
143 )
144 assert claim["recipient_avax_address"] == recipient
145
146 def test_recipient_without_payer_key(self, ed25519_signing: MagicMock) -> None:
147 """``recipient_avax_address`` can be stored without a secp256k1 key."""
148 from muse.core.msign import build_payment_claim
149
150 recipient = "0xDeAD000000000000000042069420694206942069"
151 claim = build_payment_claim(
152 ed25519_signing, "gabriel", "alice",
153 100, "nanoMUSE", "d" * 64, "memo", ts=3,
154 recipient_avax_address=recipient,
155 )
156 assert claim["recipient_avax_address"] == recipient
157 assert "payer_avax_address" not in claim
158 assert "eth_sig" not in claim
159
160
161 # ---------------------------------------------------------------------------
162 # Integration: dual-sig claim round-trip
163 # ---------------------------------------------------------------------------
164
165
166 class TestDualSigRoundTrip:
167 """Ed25519 + secp256k1 dual-signature integration tests."""
168
169 def test_payer_address_matches_derived(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None:
170 """``payer_avax_address`` must equal the derived AVAX C-Chain address."""
171 from muse.core.msign import build_payment_claim
172
173 claim = build_payment_claim(
174 ed25519_signing, "gabriel", "alice",
175 1_000, "nanoMUSE", "e" * 64, "memo", ts=10,
176 avax_private_key=avax_key,
177 )
178 assert claim["payer_avax_address"] == avax_address
179
180 def test_eth_sig_is_65_bytes_hex(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
181 """``eth_sig`` is a 130-char hex string encoding 65 bytes."""
182 from muse.core.msign import build_payment_claim
183
184 claim = build_payment_claim(
185 ed25519_signing, "gabriel", "alice",
186 1_000, "nanoMUSE", "f" * 64, "memo", ts=11,
187 avax_private_key=avax_key,
188 )
189 eth_sig_hex = claim["eth_sig"]
190 assert len(eth_sig_hex) == 130, "65 bytes → 130 hex chars"
191 sig_bytes = bytes.fromhex(eth_sig_hex)
192 assert len(sig_bytes) == 65
193 # v must be 27 or 28 (legacy Ethereum recovery ID)
194 assert sig_bytes[64] in (27, 28)
195
196 def test_ed25519_sig_still_valid(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
197 """Ed25519 ``signature_b64`` must remain valid even with AVAX key."""
198 from muse.core.msign import build_payment_claim
199
200 claim = build_payment_claim(
201 ed25519_signing, "gabriel", "alice",
202 999, "nanoMUSE", "aa" * 32, "memo", ts=12,
203 avax_private_key=avax_key,
204 )
205 # Verify the Ed25519 signature manually
206 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
207 pub = ed25519_signing.private_key.public_key()
208 sig_b64 = claim["signature_b64"]
209 sig_bytes = b64url_decode(sig_b64)
210 canonical_bytes = claim["canonical_message"].encode()
211 # Should not raise
212 pub.verify(sig_bytes, canonical_bytes)
213
214 def test_deterministic_given_fixed_ts(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
215 """Two identical calls with the same ``ts`` must produce identical claims."""
216 from muse.core.msign import build_payment_claim
217
218 kwargs = dict(
219 avax_private_key=avax_key, ts=42,
220 )
221 c1 = build_payment_claim(ed25519_signing, "gabriel", "alice",
222 7, "nanoMUSE", "00" * 32, "m", **kwargs)
223 c2 = build_payment_claim(ed25519_signing, "gabriel", "alice",
224 7, "nanoMUSE", "00" * 32, "m", **kwargs)
225 assert c1["signature_b64"] == c2["signature_b64"]
226 assert c1["eth_sig"] == c2["eth_sig"]
227 assert c1["payer_avax_address"] == c2["payer_avax_address"]
228
229
230 # ---------------------------------------------------------------------------
231 # E2E: verify eth_sig with eip191_verify
232 # ---------------------------------------------------------------------------
233
234
235 class TestEip191Verification:
236 """Verify the dual-sig eth_sig using eip191_verify."""
237
238 def test_eip191_verify_accepts_eth_sig(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None:
239 """``eip191_verify`` must accept the ``eth_sig`` for the canonical message."""
240 from muse.core.msign import build_payment_claim
241 from muse.core.secp256k1_sign import eip191_verify
242
243 claim = build_payment_claim(
244 ed25519_signing, "gabriel", "alice",
245 5_000, "nanoMUSE", "bb" * 32, "verify test", ts=100,
246 avax_private_key=avax_key,
247 )
248 canonical_bytes = claim["canonical_message"].encode()
249 sig_bytes = bytes.fromhex(claim["eth_sig"])
250 assert eip191_verify(sig_bytes, canonical_bytes, avax_address)
251
252 def test_eip191_verify_rejects_wrong_address(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", seed: bytes) -> None:
253 """Verification must fail for a different address."""
254 from muse.core.msign import build_payment_claim
255 from muse.core.secp256k1_sign import avax_c_chain_address, derive_avax_key, eip191_verify
256
257 claim = build_payment_claim(
258 ed25519_signing, "gabriel", "alice",
259 1, "nanoMUSE", "cc" * 32, "memo", ts=200,
260 avax_private_key=avax_key,
261 )
262 # Derive a different key (account=1) to get a different address
263 other_key = derive_avax_key(seed, account=1)
264 other_address = avax_c_chain_address(other_key.public_key)
265
266 canonical_bytes = claim["canonical_message"].encode()
267 sig_bytes = bytes.fromhex(claim["eth_sig"])
268 assert not eip191_verify(sig_bytes, canonical_bytes, other_address)
269
270 def test_eip191_verify_rejects_tampered_message(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None:
271 """Verification must fail when the canonical message is tampered."""
272 from muse.core.msign import build_payment_claim
273 from muse.core.secp256k1_sign import eip191_verify
274
275 claim = build_payment_claim(
276 ed25519_signing, "gabriel", "alice",
277 1, "nanoMUSE", "dd" * 32, "memo", ts=300,
278 avax_private_key=avax_key,
279 )
280 sig_bytes = bytes.fromhex(claim["eth_sig"])
281 tampered = b"MPAY\ngabriel\nalice\n9999999\nnanoMUSE\n" + b"d" * 64 + b"\nmemo\n300"
282 assert not eip191_verify(sig_bytes, tampered, avax_address)
283
284
285 # ---------------------------------------------------------------------------
286 # Stress: 50 consecutive dual-sig claims
287 # ---------------------------------------------------------------------------
288
289
290 class TestStress:
291 """Stress: 50 back-to-back dual-signed claims."""
292
293 def test_50_claims(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None:
294 """Produce 50 dual-signed claims; all must have correct AVAX address."""
295 from muse.core.msign import build_payment_claim
296
297 for i in range(50):
298 nonce = hashlib.sha256(str(i).encode()).hexdigest()
299 claim = build_payment_claim(
300 ed25519_signing, "gabriel", "alice",
301 i * 100, "nanoMUSE", nonce, f"stress-{i}", ts=i,
302 avax_private_key=avax_key,
303 )
304 assert claim["payer_avax_address"] == avax_address
305 assert len(claim["eth_sig"]) == 130
306
307
308 # ---------------------------------------------------------------------------
309 # Data integrity: canonical_message matches signer input
310 # ---------------------------------------------------------------------------
311
312
313 class TestDataIntegrity:
314 """Canonical message structure matches what both signers used."""
315
316 def test_canonical_message_format(self, ed25519_signing: MagicMock) -> None:
317 """canonical_message must follow MPAY\\n…\\nTS format."""
318 from muse.core.msign import build_payment_claim
319
320 claim = build_payment_claim(
321 ed25519_signing, "payer", "payee",
322 42_000, "nanoETH", "0" * 64, "stem:sha256:abc", ts=1_744_000_000,
323 )
324 expected = f"MPAY\npayer\npayee\n42000\nnanoETH\n{'0' * 64}\nstem:sha256:abc\n1744000000"
325 assert claim["canonical_message"] == expected
326
327 def test_dual_sig_uses_same_canonical_message(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
328 """Ed25519 and EIP-191 must sign the identical canonical bytes."""
329 from muse.core.msign import build_payment_claim
330 from muse.core.secp256k1_sign import eip191_verify, avax_c_chain_address
331
332 claim = build_payment_claim(
333 ed25519_signing, "p", "q", 1, "nanoMUSE", "ee" * 32, "x", ts=999,
334 avax_private_key=avax_key,
335 )
336 addr = avax_c_chain_address(avax_key.public_key)
337 sig_bytes = bytes.fromhex(claim["eth_sig"])
338 # EIP-191 verification uses canonical_message bytes — proves same input was signed
339 assert eip191_verify(sig_bytes, claim["canonical_message"].encode(), addr)
340
341
342 # ---------------------------------------------------------------------------
343 # Performance: dual-sig claim under 200 ms
344 # ---------------------------------------------------------------------------
345
346
347 class TestPerformance:
348 """Single dual-sig claim must complete within 200 ms."""
349
350 def test_claim_latency(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
351 """build_payment_claim with dual-sig must complete in < 200 ms."""
352 from muse.core.msign import build_payment_claim
353
354 start = time.perf_counter()
355 build_payment_claim(
356 ed25519_signing, "gabriel", "alice",
357 1_000_000, "nanoMUSE", "ff" * 32, "perf test",
358 avax_private_key=avax_key,
359 )
360 duration_ms = (time.perf_counter() - start) * 1000
361 assert duration_ms < 200, f"Too slow: {duration_ms:.1f} ms"
362
363
364 # ---------------------------------------------------------------------------
365 # Security: AVAX fields absent by default; no key material in claim dict
366 # ---------------------------------------------------------------------------
367
368
369 class TestSecurity:
370 """Security properties of dual-sig claims."""
371
372 def test_no_avax_fields_by_default(self, ed25519_signing: MagicMock) -> None:
373 """AVAX fields must be completely absent unless explicitly requested."""
374 from muse.core.msign import build_payment_claim
375
376 claim = build_payment_claim(
377 ed25519_signing, "a", "b", 1, "nanoMUSE", "00" * 32, "", ts=0,
378 )
379 avax_keys = {"payer_avax_address", "eth_sig", "recipient_avax_address"}
380 assert not avax_keys.intersection(claim.keys())
381
382 def test_no_private_key_in_claim(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
383 """The claim dict must not contain any private key material."""
384 from muse.core.msign import build_payment_claim
385
386 claim = build_payment_claim(
387 ed25519_signing, "a", "b", 1, "nanoMUSE", "11" * 32, "", ts=5,
388 avax_private_key=avax_key,
389 )
390 # Private key bytes are 32 bytes; they must not appear as a value
391 priv_hex = avax_key.to_bytes().hex()
392 for v in claim.values():
393 if isinstance(v, str):
394 assert priv_hex not in v
395
396 def test_ed25519_domain_separation(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None:
397 """Ed25519 sig must differ from EIP-191 sig bytes — no cross-protocol reuse."""
398 from muse.core.msign import build_payment_claim
399
400 claim = build_payment_claim(
401 ed25519_signing, "a", "b", 1, "nanoMUSE", "22" * 32, "", ts=6,
402 avax_private_key=avax_key,
403 )
404 ed_sig_hex = b64url_decode(claim["signature_b64"]).hex()
405 assert ed_sig_hex != claim["eth_sig"]
406
407
408 # ---------------------------------------------------------------------------
409 # Docstrings: public API coverage
410 # ---------------------------------------------------------------------------
411
412
413 class TestDocstrings:
414 """Public API must have docstrings."""
415
416 def test_build_payment_claim_docstring(self) -> None:
417 from muse.core.msign import build_payment_claim
418 assert build_payment_claim.__doc__
419
420 def test_payment_claim_docstring(self) -> None:
421 from muse.core.msign import PaymentClaim
422 assert PaymentClaim.__doc__
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago