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