Skip to content

Commit bbb7013

Browse files
committed
Add property-based tests for Feldman VSS using Hypothesis
Signed-off-by: DavidOsipov <[email protected]>
1 parent 3da6b2f commit bbb7013

File tree

1 file changed

+298
-0
lines changed

1 file changed

+298
-0
lines changed
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# tests/test_feldman_vss_properties.py
2+
# Property-based tests for Feldman VSS using Hypothesis.
3+
4+
import copy
5+
import random
6+
import secrets
7+
import warnings
8+
9+
import pytest
10+
from gmpy2 import mpz
11+
12+
# Skip all tests in this module if Hypothesis is not installed
13+
hypothesis = pytest.importorskip("hypothesis")
14+
from hypothesis import HealthCheck, Phase, Verbosity, assume, find, given, settings
15+
from hypothesis import strategies as st
16+
17+
# Import necessary components from the main module and conftest
18+
from feldman_vss import (
19+
CommitmentList,
20+
FeldmanVSS,
21+
ParameterError,
22+
ProofDict,
23+
SecurityError,
24+
SerializationError,
25+
ShareDict,
26+
VerificationError,
27+
)
28+
29+
from .conftest import (
30+
TEST_PRIME_BITS_FAST,
31+
MockField,
32+
MockShamirSecretSharing,
33+
generate_poly_and_shares,
34+
test_logger,
35+
)
36+
37+
# --- Hypothesis Configuration ---
38+
39+
# Register profiles for different testing levels
40+
settings.register_profile(
41+
"ci",
42+
max_examples=200,
43+
deadline=None, # No deadline for CI
44+
verbosity=Verbosity.normal,
45+
phases=[Phase.explicit, Phase.reuse, Phase.generate, Phase.target, Phase.shrink]
46+
)
47+
settings.register_profile(
48+
"dev",
49+
max_examples=50,
50+
deadline=2000, # 2 seconds deadline for dev
51+
verbosity=Verbosity.verbose
52+
)
53+
settings.register_profile(
54+
"deep",
55+
max_examples=1000,
56+
deadline=None,
57+
verbosity=Verbosity.normal,
58+
phases=[Phase.explicit, Phase.reuse, Phase.generate, Phase.target, Phase.shrink]
59+
)
60+
61+
# Load the desired profile (e.g., 'dev' for local runs, 'ci' for CI)
62+
# Can be overridden by environment variable HYPOTHESIS_PROFILE
63+
settings.load_profile("dev")
64+
65+
# --- Test Class ---
66+
67+
@pytest.mark.properties # Custom marker for property-based tests
68+
class TestPropertyBased:
69+
"""Property-based tests using Hypothesis for robustness."""
70+
71+
# Define strategies within the class to potentially access class-level attributes if needed
72+
# Using a fixed small prime for faster hypothesis runs
73+
# Note: Strategies themselves can't directly use fixtures. We pass prime in the test method.
74+
75+
# Strategy for threshold t (ensure t >= 2)
76+
threshold_strategy = st.integers(min_value=2, max_value=10) # Keep max small for speed
77+
78+
@staticmethod
79+
@st.composite
80+
def coeffs_and_shares_strategy(draw, field: MockField):
81+
"""Composite strategy to generate consistent coefficients and shares."""
82+
t = draw(TestPropertyBased.threshold_strategy)
83+
# Ensure n >= t
84+
n = draw(st.integers(min_value=t, max_value=15)) # Keep max small
85+
secret = draw(st.integers(min_value=0, max_value=int(field.prime) - 1))
86+
# Generate coefficients using the field's random element method
87+
coeffs = [mpz(secret)] + [field.random_element() for _ in range(t-1)]
88+
shares: ShareDict = {}
89+
for i in range(1, n + 1):
90+
x = mpz(i)
91+
y = field.eval_poly(coeffs, x)
92+
shares[i] = (x, y)
93+
return coeffs, shares, t, n
94+
95+
# --- Tests ---
96+
97+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large])
98+
@given(st.data()) # Use st.data() to allow drawing based on fixtures
99+
def test_prop_verify_valid_shares(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data):
100+
"""Property: Correctly generated shares should always verify."""
101+
coeffs, shares, t, n = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
102+
103+
assume(coeffs) # Skip if coeffs list is empty (shouldn't happen with t>=2)
104+
assume(shares) # Skip if shares dict is empty
105+
106+
try:
107+
commitments = default_vss.create_commitments(coeffs)
108+
for share_id in shares:
109+
x, y = shares[share_id]
110+
assert default_vss.verify_share(x, y, commitments) is True, f"Valid share ({x},{y}) failed verification"
111+
except (ParameterError, ValueError, SecurityError, MemoryError) as e:
112+
test_logger.debug(f"Hypothesis verify valid share caught expected error: {e}")
113+
# Allow expected errors during generation/verification with edge cases
114+
except Exception as e:
115+
pytest.fail(f"Unexpected exception during valid share verification: {e}")
116+
117+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large])
118+
@given(st.data(),
119+
tamper_amount=st.integers(min_value=1)) # Tamper by at least 1
120+
def test_prop_verify_invalid_shares(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data, tamper_amount):
121+
"""Property: Tampered shares should always fail verification."""
122+
coeffs, shares, t, n = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
123+
124+
assume(coeffs)
125+
assume(shares)
126+
127+
try:
128+
commitments = default_vss.create_commitments(coeffs)
129+
share_id_to_tamper = random.choice(list(shares.keys()))
130+
x, y = shares[share_id_to_tamper]
131+
132+
# Tamper the y value, ensuring it's different
133+
invalid_y = (y + tamper_amount) % mock_field_fast.prime
134+
assume(invalid_y != y) # Ensure tampering actually changed the value
135+
136+
assert default_vss.verify_share(x, invalid_y, commitments) is False, f"Invalid share ({x},{invalid_y}) passed verification"
137+
138+
# Also test tampering x value (less common but should fail)
139+
invalid_x = x + 1 # Simple tamper
140+
assert default_vss.verify_share(invalid_x, y, commitments) is False, f"Share with invalid x ({invalid_x},{y}) passed verification"
141+
142+
except (ParameterError, ValueError, SecurityError, MemoryError) as e:
143+
test_logger.debug(f"Hypothesis verify invalid share caught expected error: {e}")
144+
except Exception as e:
145+
pytest.fail(f"Unexpected exception during invalid share verification: {e}")
146+
147+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large])
148+
@given(st.data())
149+
def test_prop_zkp_roundtrip(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data):
150+
"""Property: ZKP creation and verification should succeed for valid inputs."""
151+
coeffs, _, _, _ = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
152+
assume(coeffs)
153+
154+
try:
155+
commitments = default_vss.create_commitments(coeffs)
156+
proof = default_vss.create_polynomial_proof(coeffs, commitments)
157+
158+
# Verify the proof itself
159+
assert default_vss.verify_polynomial_proof(proof, commitments) is True, "ZKP verification failed for valid proof"
160+
161+
# Also verify using the combined method
162+
assert default_vss.verify_commitments_with_proof(commitments, proof) is True, "Combined ZKP verification failed"
163+
164+
# Explicitly test challenge consistency check as well
165+
assert default_vss._verify_challenge_consistency(proof, commitments) is True, "Challenge consistency check failed"
166+
167+
except (ParameterError, ValueError, SecurityError, MemoryError) as e:
168+
test_logger.debug(f"Hypothesis ZKP roundtrip caught expected error: {e}")
169+
except Exception as e:
170+
pytest.fail(f"Unexpected exception during ZKP roundtrip: {e}")
171+
172+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large])
173+
@given(st.data())
174+
def test_prop_zkp_tampered_proof_fails(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data):
175+
"""Property: Tampered ZKP should fail verification."""
176+
coeffs, _, _, _ = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
177+
assume(coeffs)
178+
179+
try:
180+
commitments = default_vss.create_commitments(coeffs)
181+
proof = default_vss.create_polynomial_proof(coeffs, commitments)
182+
183+
# Tamper with challenge
184+
tampered_proof_c = copy.deepcopy(proof)
185+
tampered_proof_c['challenge'] = (proof['challenge'] + 1) % mock_field_fast.prime
186+
assert default_vss.verify_polynomial_proof(tampered_proof_c, commitments) is False, "Verification passed for tampered challenge"
187+
188+
# Tamper with a response
189+
tampered_proof_r = copy.deepcopy(proof)
190+
if tampered_proof_r['responses']:
191+
idx_to_tamper = random.randrange(len(tampered_proof_r['responses']))
192+
tampered_proof_r['responses'][idx_to_tamper] = (proof['responses'][idx_to_tamper] + 1) % mock_field_fast.prime
193+
assert default_vss.verify_polynomial_proof(tampered_proof_r, commitments) is False, "Verification passed for tampered response"
194+
195+
# Tamper with a blinding commitment
196+
tampered_proof_bc = copy.deepcopy(proof)
197+
if tampered_proof_bc['blinding_commitments']:
198+
idx_bc = random.randrange(len(tampered_proof_bc['blinding_commitments']))
199+
orig_bc, orig_br = tampered_proof_bc['blinding_commitments'][idx_bc]
200+
tampered_proof_bc['blinding_commitments'][idx_bc] = ((orig_bc + 1) % mock_field_fast.prime, orig_br)
201+
assert default_vss.verify_polynomial_proof(tampered_proof_bc, commitments) is False, "Verification passed for tampered blinding commitment"
202+
203+
except (ParameterError, ValueError, SecurityError, MemoryError) as e:
204+
test_logger.debug(f"Hypothesis ZKP tampering test caught expected error: {e}")
205+
except Exception as e:
206+
pytest.fail(f"Unexpected exception during ZKP tampering test: {e}")
207+
208+
209+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large])
210+
@given(st.data())
211+
def test_prop_serialization_roundtrip(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data):
212+
"""Property: Serialization and deserialization should be lossless."""
213+
coeffs, _, _, _ = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
214+
assume(coeffs)
215+
216+
try:
217+
commitments = default_vss.create_commitments(coeffs)
218+
serialized = default_vss.serialize_commitments(commitments)
219+
deserialized, gen, prime, ts, is_hash = default_vss.deserialize_commitments(serialized)
220+
221+
assert gen == default_vss.generator
222+
assert prime == default_vss.group.prime
223+
assert is_hash is True # Should always be hash based
224+
assert len(deserialized) == len(commitments)
225+
# Compare components of each commitment tuple
226+
for i in range(len(commitments)):
227+
assert deserialized[i][0] == commitments[i][0] # Hash value
228+
assert deserialized[i][1] == commitments[i][1] # Randomizer
229+
assert deserialized[i][2] == commitments[i][2] # Entropy (bytes or None)
230+
231+
# Also test serialization with proof
232+
proof = default_vss.create_polynomial_proof(coeffs, commitments)
233+
serialized_with_proof = default_vss.serialize_commitments_with_proof(commitments, proof)
234+
deser_comm, deser_proof, _, _, _ = default_vss.deserialize_commitments_with_proof(serialized_with_proof)
235+
236+
assert len(deser_comm) == len(commitments)
237+
assert isinstance(deser_proof, dict)
238+
assert deser_proof['challenge'] == proof['challenge']
239+
assert len(deser_proof['responses']) == len(proof['responses'])
240+
# Could add more detailed proof comparison if needed
241+
242+
except (ParameterError, ValueError, SerializationError, SecurityError, MemoryError) as e:
243+
test_logger.debug(f"Hypothesis serialization roundtrip caught expected error: {e}")
244+
except Exception as e:
245+
pytest.fail(f"Unexpected exception during serialization roundtrip: {e}")
246+
247+
# Note: Refresh shares can be computationally intensive for property tests
248+
@settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large, HealthCheck.too_slow], max_examples=20) # Reduce examples for refresh
249+
@given(st.data())
250+
def test_prop_refresh_preserves_secret(self, default_vss: FeldmanVSS, mock_field_fast: MockField, data):
251+
"""Property: Share refreshing should preserve the original secret."""
252+
coeffs, shares, t, n = data.draw(self.coeffs_and_shares_strategy(mock_field_fast))
253+
254+
assume(coeffs)
255+
assume(len(shares) >= t) # Need enough shares to potentially refresh
256+
257+
try:
258+
original_commitments = default_vss.create_commitments(coeffs)
259+
participant_ids = list(shares.keys())
260+
261+
# Use a copy to avoid modifying original shares dict if refresh fails midway
262+
shares_copy = copy.deepcopy(shares)
263+
264+
# Perform the refresh
265+
with warnings.catch_warnings():
266+
# Ignore potential security warnings about insufficient shares during refresh in edge cases
267+
warnings.simplefilter("ignore", SecurityWarning)
268+
new_shares, new_commitments, verification_data = default_vss.refresh_shares(
269+
shares_copy, t, n, original_commitments, participant_ids
270+
)
271+
272+
# Verify reconstruction from new shares using MockShamir
273+
shamir_mock = MockShamirSecretSharing(mock_field_fast)
274+
275+
# We need at least t new shares to reconstruct
276+
assume(len(new_shares) >= t)
277+
278+
# Select a random subset of t shares for reconstruction
279+
subset_ids = random.sample(list(new_shares.keys()), t)
280+
subset_shares_dict = {pid: new_shares[pid] for pid in subset_ids}
281+
282+
reconstructed_secret = shamir_mock.reconstruct_secret(subset_shares_dict)
283+
original_secret = coeffs[0]
284+
285+
assert reconstructed_secret == original_secret, "Secret not preserved after refreshing"
286+
287+
# Optional: Verify new shares against new commitments
288+
for share_id in new_shares:
289+
x, y = new_shares[share_id]
290+
assert default_vss.verify_share(x, y, new_commitments), "Refreshed share failed verification against new commitments"
291+
292+
except (ParameterError, ValueError, SecurityError, MemoryError) as e:
293+
# Allow expected errors, especially SecurityError if refresh fails due to byzantine simulation
294+
test_logger.debug(f"Hypothesis refresh secret preservation caught expected error: {e}")
295+
except Exception as e:
296+
# Catch unexpected errors during complex refresh
297+
test_logger.error(f"Unexpected error during Hypothesis refresh test: {e}", exc_info=True)
298+
pytest.fail(f"Unexpected exception in refresh test: {e}")

0 commit comments

Comments
 (0)