diff --git a/README.md b/README.md index 246f79e..13892ee 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Welcome to the BSV Blockchain Libraries Project, the comprehensive Python SDK de 2. [Getting Started](#getting-started) 3. [Features & Deliverables](#features--deliverables) 4. [Documentation](#documentation) +5. [Tutorial](#Tutorial) 5. [Contribution Guidelines](#contribution-guidelines) 6. [Support & Contacts](#support--contacts) @@ -111,6 +112,11 @@ Detailed documentation of the SDK with code examples can be found at [BSV Skills You can also refer to the [User Test Report](./docs/Py-SDK%20User%20Test%20Report.pdf) for insights and feedback provided by [Yenpoint](https://yenpoint.jp/). +## Beginner Tutorial +#### [Step-by-Step BSV Tutorial: Sending BSV and NFTs](./docs/beginner_tutorial.md) + +This beginner-friendly guide will walk you through sending BSV (Bitcoin SV) and creating NFTs using the BSV Python SDK. We'll take it step-by-step so you can learn at your own pace. + ## Contribution Guidelines We're always looking for contributors to help us improve the project. Whether it's bug reports, feature requests, or pull requests - all diff --git a/bsv/__init__.py b/bsv/__init__.py index 78109e1..c82d7eb 100644 --- a/bsv/__init__.py +++ b/bsv/__init__.py @@ -20,4 +20,4 @@ from .signed_message import * -__version__ = '1.0.3' +__version__ = '1.0.5' \ No newline at end of file diff --git a/bsv/keys.py b/bsv/keys.py index 982237d..085d1ec 100644 --- a/bsv/keys.py +++ b/bsv/keys.py @@ -1,4 +1,3 @@ -import tempfile import hashlib import hmac from base64 import b64encode, b64decode @@ -15,6 +14,7 @@ from .hash import hash160, hash256, hmac_sha256 from .utils import decode_wif, text_digest, stringify_ecdsa_recoverable, unstringify_ecdsa_recoverable from .utils import deserialize_ecdsa_recoverable, serialize_ecdsa_der +from .polynomial import Polynomial, PointInFiniteField, KeyShares class PublicKey: @@ -373,6 +373,132 @@ def from_pem(cls, octets: Union[str, bytes]) -> 'PrivateKey': # pragma: no cove b: bytes = octets if isinstance(octets, bytes) else bytes.fromhex(octets) return PrivateKey(CcPrivateKey.from_pem(b)) + def to_key_shares(self, threshold: int, total_shares: int) -> 'KeyShares': + """ + Split the private key into shares using Shamir's Secret Sharing Scheme. + + Args: + threshold: The minimum number of shares required to reconstruct the private key + total_shares: The total number of shares to generate + + Returns: + A KeyShares object containing the generated shares + + Raises: + ValueError: If threshold or total_shares are invalid + """ + + # Input validation + if not isinstance(threshold, int) or not isinstance(total_shares, int): + raise ValueError("threshold and totalShares must be numbers") + if threshold < 2: + raise ValueError("threshold must be at least 2") + if total_shares < 2: + raise ValueError("totalShares must be at least 2") + if threshold > total_shares: + raise ValueError("threshold should be less than or equal to totalShares") + + # Create polynomial from private key + poly = Polynomial.from_private_key(self.int(), threshold) + + # Generate shares + points = [] + for i in range(total_shares): + # Generate random x coordinate using a new private key + # Using private_key.key.to_int() based on the structure in keys.py + random_private_key = PrivateKey() + x = random_private_key.int() + + # Evaluate polynomial at x to get y coordinate + y = poly.value_at(x) + + # Create a point and add to points' list + points.append(PointInFiniteField(x, y)) + + # Calculate integrity hash from the public key + # In the JS implementation: (this.toPublicKey().toHash('hex') as string).slice(0, 8) + integrity = self.public_key().hash160().hex()[:8] + + return KeyShares(points, threshold, integrity) + + def to_backup_shares(self, threshold: int, total_shares: int) -> list: + """ + Creates a backup of the private key by splitting it into shares. + + Args: + threshold: The number of shares which will be required to reconstruct the private key + total_shares: The number of shares to generate for distribution + + Returns: + List of share strings in backup format + """ + key_shares = self.to_key_shares(threshold, total_shares) + return key_shares.to_backup_format() + + @staticmethod + def from_backup_shares(shares: list) -> 'PrivateKey': + """ + Reconstructs a private key from backup shares. + + Args: + shares: List of share strings in backup format + + Returns: + The reconstructed PrivateKey object + + Raises: + ValueError: If shares are invalid or inconsistent + """ + return PrivateKey.from_key_shares(KeyShares.from_backup_format(shares)) + + @staticmethod + def from_key_shares(key_shares: 'KeyShares') -> 'PrivateKey': + """ + Combines shares to reconstruct the private key. + + Args: + key_shares: A KeyShares object containing the shares + + Returns: + The reconstructed PrivateKey object + + Raises: + ValueError: If not enough shares are provided or shares are invalid + """ + + + points = key_shares.points + threshold = key_shares.threshold + integrity = key_shares.integrity + + # Validate inputs + if threshold < 2: + raise ValueError("threshold must be at least 2") + if len(points) < threshold: + raise ValueError(f"At least {threshold} shares are required to reconstruct the private key") + + # Check for duplicate x values + for i in range(threshold): + for j in range(i + 1, threshold): + if points[i].x == points[j].x: + raise ValueError("Duplicate share detected, each must be unique.") + + # Create polynomial from points + poly = Polynomial(points[:threshold], threshold) + + # Evaluate polynomial at x=0 to get the private key + secret_value = poly.value_at(0) + + # Create private key from secret value + # Instead of from_int (which doesn't exist), use the proper constructor + private_key = PrivateKey(secret_value) + + # Verify integrity by comparing hash of public key + reconstructed_integrity = private_key.public_key().hash160().hex()[:8] + if reconstructed_integrity != integrity: + raise ValueError("Integrity hash mismatch") + + return private_key def verify_signed_text( text: str, diff --git a/bsv/polynomial.py b/bsv/polynomial.py new file mode 100644 index 0000000..578ccf8 --- /dev/null +++ b/bsv/polynomial.py @@ -0,0 +1,264 @@ +import secrets +from typing import List, Optional, Union + +from .base58 import b58_encode, b58_decode +from .curve import curve + + +# Define PointInFiniteField class for points in finite field +class PointInFiniteField: + """ + A class representing a point in a finite field. + + Used for implementing Shamir's Secret Sharing scheme. + """ + + def __init__(self, x: Union[int, bytes], y: Union[int, bytes]): + """ + Initialize a point in a finite field. + + Args: + x: The x-coordinate (can be int or bytes) + y: The y-coordinate (can be int or bytes) + """ + # Convert inputs to integers if they are bytes + if isinstance(x, bytes): + x = int.from_bytes(x, 'big') + if isinstance(y, bytes): + y = int.from_bytes(y, 'big') + + # Take modulo of coordinates with prime field size + self.x = x % curve.p + self.y = y % curve.p + + def __str__(self) -> str: + """ + String representation of the point using base58 encoding. + + Returns: + String in format x.y where x and y are base58 encoded + """ + # Convert integers to bytes and then base58 encode + x_bytes = self.x.to_bytes((self.x.bit_length() + 7) // 8, 'big') + y_bytes = self.y.to_bytes((self.y.bit_length() + 7) // 8, 'big') + + return f"{b58_encode(x_bytes)}.{b58_encode(y_bytes)}" + + @staticmethod + def from_string(s: str) -> 'PointInFiniteField': + """ + Create a point from its string representation. + + Args: + s: String in format x.y where x and y are base58 encoded + + Returns: + A PointInFiniteField object + + Raises: + ValueError: If the string is not in the correct format + """ + parts = s.split('.') + if len(parts) < 2: + raise ValueError(f"Invalid point format: {s}") + + x, y = parts[0], parts[1] + + # Decode base58 strings to bytes, then to integers + x_int = int.from_bytes(b58_decode(x), 'big') + y_int = int.from_bytes(b58_decode(y), 'big') + + return PointInFiniteField(x_int, y_int) + + +class Polynomial: + """ + A class representing a polynomial for Shamir's Secret Sharing. + + This is used to split a private key into shares such that a certain + threshold of shares is required to reconstruct the key. + """ + + def __init__(self, points: List[PointInFiniteField], threshold: Optional[int] = None): + """ + Initialize a polynomial with the given points. + + Args: + points: List of points defining the polynomial + threshold: Number of points required to reconstruct the polynomial. + If None, uses the length of points. + """ + self.points = points + # Default threshold is the number of points if not specified + self.threshold = threshold if threshold is not None else len(points) + + @staticmethod + def from_private_key(private_key_int: int, threshold: int) -> 'Polynomial': + """ + Create a polynomial from a private key. + + The private key becomes the y-intercept (at x=0) of the polynomial. + Additional random points are generated to define the polynomial. + + Args: + private_key_int: The private key (integer)) + threshold: Number of shares required to reconstruct the key + + Returns: + A Polynomial object + """ + + # Create first point at x=0, y=private_key + points = [PointInFiniteField(0, private_key_int)] + + # Generate additional random coefficients (as points) + for i in range(1, threshold): + # Generate cryptographically secure random values for x and y + # The random function from secrets should be more secure than random + random_x_bytes = secrets.token_bytes(32) + random_y_bytes = secrets.token_bytes(32) + + # Convert to integers and take modulo p + random_x = int.from_bytes(random_x_bytes, 'big') % curve.p + random_y = int.from_bytes(random_y_bytes, 'big') % curve.p + + points.append(PointInFiniteField(random_x, random_y)) + + return Polynomial(points) + + def value_at(self, x: Union[int, bytes]) -> int: + """ + Evaluate the polynomial at the given x coordinate using Lagrange interpolation. + + Args: + x: The x coordinate at which to evaluate the polynomial + + Returns: + The y coordinate (as integer) corresponding to the given x + """ + # Convert x to integer if it's bytes + if isinstance(x, bytes): + x = int.from_bytes(x, 'big') + + # Ensure x is within the field + x = x % curve.p + + # Initialize result + y = 0 + + # Lagrange interpolation + for i in range(self.threshold): + term = self.points[i].y + for j in range(self.threshold): + if i != j: + xi = self.points[i].x + xj = self.points[j].x + + # Calculate numerator: (x - xj) mod p + numerator = (x - xj) % curve.p + + # Calculate denominator: (xi - xj) mod p + denominator = (xi - xj) % curve.p + + # Calculate modular inverse of denominator + # pow(base, exponent, modulus) computes (base^exponent) % modulus + # When exponent is -1, it computes the modular multiplicative inverse + denominator_inverse = pow(denominator, -1, curve.p) + + # Calculate the fraction: numerator * denominator_inverse mod p + fraction = (numerator * denominator_inverse) % curve.p + + # Multiply the current term by the fraction + term = (term * fraction) % curve.p + + # Add this term to the result + y = (y + term) % curve.p + + return y + + +class KeyShares: + """ + A class representing key shares for Shamir's Secret Sharing. + + This is used to store the shares of a split private key along with + metadata like threshold and integrity hash. + """ + + def __init__(self, points: List[PointInFiniteField], threshold: int, integrity: str): + """ + Initialize key shares. + + Args: + points: List of points representing the shares + threshold: Number of shares required to reconstruct the key + integrity: Integrity check hash derived from the public key + """ + self.points = points + self.threshold = threshold + self.integrity = integrity + + @staticmethod + def from_backup_format(shares: List[str]) -> 'KeyShares': + """ + Create KeyShares from backup format strings. + + Args: + shares: List of share strings in format "x.y.t.i" + + Returns: + A KeyShares object + + Raises: + ValueError: If shares have invalid format or inconsistent threshold/integrity + """ + threshold = 0 + integrity = '' + points = [] + + for idx, share in enumerate(shares): + # Split the share string into parts + share_parts = share.split('.') + if len(share_parts) != 4: + raise ValueError( + f'Invalid share format in share {idx}. ' + f'Expected format: "x.y.t.i" - received {share}' + ) + + # Parse the parts + x, y, t, i = share_parts + + if not t: + raise ValueError(f'Threshold not found in share {idx}') + if not i: + raise ValueError(f'Integrity not found in share {idx}') + + # Parse threshold as integer + t_int = int(t) + + # Check consistency across shares + if idx > 0 and threshold != t_int: + raise ValueError(f'Threshold mismatch in share {idx}') + if idx > 0 and integrity != i: + raise ValueError(f'Integrity mismatch in share {idx}') + + threshold = t_int + integrity = i + + # Create point from x and y components + point = PointInFiniteField.from_string(f"{x}.{y}") + points.append(point) + + return KeyShares(points, threshold, integrity) + + def to_backup_format(self) -> List[str]: + """ + Convert shares to backup format strings. + + Returns: + List of share strings in format "x.y.t.i" + """ + return [ + f"{point}.{self.threshold}.{self.integrity}" + for point in map(str, self.points) + ] \ No newline at end of file diff --git a/examples/threshold_signature.py b/examples/threshold_signature.py new file mode 100644 index 0000000..5c45c7c --- /dev/null +++ b/examples/threshold_signature.py @@ -0,0 +1,67 @@ +from bsv.keys import PrivateKey + +# Create a private key (or you can use an existing one) + +private_key = PrivateKey() +#if you want to use a sepecific private key. +#private_key = PrivateKey("L1X1sP2C------------ur5LQHeJWDoW") + +public_key = private_key.public_key() +address = public_key.address() + +print(f"Original Private Key (WIF): {private_key.wif()}") +print(f"Original Address: {address}") + + # Create 5 shares, with a threshold of 3 required for recovery + +this_threshold = 3 +this_total_shares = 5 +backup_shares = private_key.to_backup_shares(this_threshold, this_total_shares) +# Print the shares + +print("\nGenerated Shares:") +for i, share in enumerate(backup_shares): + print(f"Share {i + 1}: {share}") + +# Select 3 of the 5 shares to recover the private key + +selected_shares = [backup_shares[1], backup_shares[2], backup_shares[4]] +print(f"\nRecovering with shares: {selected_shares}") + +# Recover the private key +recovered_key = PrivateKey.from_backup_shares(selected_shares) + +print(f"Recovered Private Key (WIF): {recovered_key.wif()}") +print(f"Recovered Address: {recovered_key.address()}") + +# Verify the keys match +is_keys_match = private_key.wif() == recovered_key.wif() +print(f"\nOriginal and recovered keys match: {is_keys_match}") + + +#Check compatibility with Ts-sdk library + + +ts_privatekey = "Kyig4TeeVahiY838EjiC72kzWkZXtjSj7m5axdQwPXRed57MiYUS" +# Original Address: 179UiTpk4mqjQv6CJ4NoZqxwURym4uwV4v +#these shares are created from the ts-sdk library. +ts_shares = { + "Share1": "7bG9x34Mae9oJCFgdb4NqgDDf687hfUxpa2xhx66nQrB.4qtoDQ4vTL25YbmGKWU9qN8g4gYV9f1HLajXKtp5RXU1.3.436b37b4", + "Share2": "AsMwU9H3LpTM1T11nU2i3dJHtXR1DyYXoJyfE8GKiMYN.9Euk7J9RuPYwtNvJSkK7Q5uNyq3v8HnSVBgTkxAL3zyx.3.436b37b4", + "Share3": "4Zne9ueQgdE7xRt3WAB5AVwDEyxZ5DdvepAC41edPrhh.AxaokRsrRgsdLp37SdBgWF4RKX7hJmUDpJK7WdbFaK6s.3.436b37b4", + "Share4": "2cjyXyvQrzrFdzXCBaiTpPuCwNDPUDg4XyXADrxgoJkX.63v3CxqFhy9SUNPusijUjcyzqhwBvNDvy2nJdQmWWm4y.3.436b37b4", + "Share5": "HvZamWCw4bDe2iNaWJW9Sk4ESoBEufMxDMy4LLHUEKQj.HC5VmyQC3vtioq3t1rXtQgVupe47m8GeGFL8q2kBg48o.3.436b37b4" +} + +selected_shares2 = [ts_shares["Share1"], ts_shares["Share2"], ts_shares["Share4"]] +print(f"\nRecovering with shares: {selected_shares2}") + +# Recover the private key +recovered_key2 = PrivateKey.from_backup_shares(selected_shares2) + +print(f"Recovered Private Key (WIF): {recovered_key2.wif()}") +print(f"Recovered Address: {recovered_key2.address()}") + +is_keys_match = ts_privatekey == recovered_key2.wif() +print(f"\nOriginal and recovered keys match: {is_keys_match}") + diff --git a/tests/test_key_shares.py b/tests/test_key_shares.py new file mode 100644 index 0000000..d52bd95 --- /dev/null +++ b/tests/test_key_shares.py @@ -0,0 +1,202 @@ +import unittest + + +from bsv.keys import PrivateKey +from bsv.polynomial import KeyShares, PointInFiniteField + + +class TestPrivateKeySharing(unittest.TestCase): + # 既知のバックアップシェアデータ + sample_backup = [ + '45s4vLL2hFvqmxrarvbRT2vZoQYGZGocsmaEksZ64o5M.A7nZrGux15nEsQGNZ1mbfnMKugNnS6SYYEQwfhfbDZG8.3.2f804d43', + '7aPzkiGZgvU4Jira5PN9Qf9o7FEg6uwy1zcxd17NBhh3.CCt7NH1sPFgceb6phTRkfviim2WvmUycJCQd2BxauxP9.3.2f804d43', + '9GaS2Tw5sXqqbuigdjwGPwPsQuEFqzqUXo5MAQhdK3es.8MLh2wyE3huyq6hiBXjSkJRucgyKh4jVY6ESq5jNtXRE.3.2f804d43', + 'GBmoNRbsMVsLmEK5A6G28fktUNonZkn9mDrJJ58FXgsf.HDBRkzVUCtZ38ApEu36fvZtDoDSQTv3TWmbnxwwR7kto.3.2f804d43', + '2gHebXBgPd7daZbsj6w9TPDta3vQzqvbkLtJG596rdN1.E7ZaHyyHNDCwR6qxZvKkPPWWXzFCiKQFentJtvSSH5Bi.3.2f804d43' + ] + + def test_split_private_key_into_shares_correctly(self): + """Test that a private key can be split into shares correctly.""" + private_key = PrivateKey() # Generate random private key + threshold = 2 + total_shares = 5 + + # Split the private key + shares = private_key.to_key_shares(threshold, total_shares) + backup = shares.to_backup_format() + + # Check the number of shares + self.assertEqual(len(backup), total_shares) + + # Check that each share is a PointInFiniteField + for share in shares.points: + self.assertIsInstance(share, PointInFiniteField) + + # Check the threshold + self.assertEqual(shares.threshold, threshold) + + def test_recombine_shares_into_private_key_correctly(self): + """Test that shares can be recombined to recover the original key.""" + for _ in range(3): + key = PrivateKey() + all_shares = key.to_key_shares(3, 5) + backup = all_shares.to_backup_format() + + # Use only the first 3 shares (the threshold) + some_shares = KeyShares.from_backup_format(backup[:3]) + rebuilt_key = PrivateKey.from_key_shares(some_shares) + + # Check if the recovered key matches the original + self.assertEqual(rebuilt_key.wif(), key.wif()) + + def test_invalid_threshold_or_total_shares_type(self): + """Test that invalid threshold or totalShares types raise errors.""" + k = PrivateKey() + + # Test with invalid threshold type + with self.assertRaises(ValueError) as cm: + k.to_key_shares("invalid", 14) # type: ignore + self.assertIn("threshold and totalShares must be numbers", str(cm.exception)) + + # Test with invalid totalShares type + with self.assertRaises(ValueError) as cm: + k.to_key_shares(4, None) # type: ignore + self.assertIn("threshold and totalShares must be numbers", str(cm.exception)) + + def test_invalid_threshold_value(self): + """Test that invalid threshold values raise errors.""" + k = PrivateKey() + + # Test with threshold less than 2 + with self.assertRaises(ValueError) as cm: + k.to_key_shares(1, 2) + self.assertIn("threshold must be at least 2", str(cm.exception)) + + def test_invalid_total_shares_value(self): + """Test that invalid totalShares values raise errors.""" + k = PrivateKey() + + # Test with negative totalShares + with self.assertRaises(ValueError) as cm: + k.to_key_shares(2, -4) + self.assertIn("totalShares must be at least 2", str(cm.exception)) + + def test_threshold_greater_than_total_shares(self): + """Test that threshold greater than totalShares raises an error.""" + k = PrivateKey() + + # Test with threshold > totalShares + with self.assertRaises(ValueError) as cm: + k.to_key_shares(3, 2) + self.assertIn("threshold should be less than or equal to totalShares", str(cm.exception)) + + def test_duplicate_share_in_recovery_with_sample_data(self): + """Test that using duplicate shares from sample data during recovery raises an error.""" + # 既知のバックアップデータから重複するシェアを含むリストを作成 + duplicate_shares = [ + self.sample_backup[0], + self.sample_backup[1], + self.sample_backup[1] # 重複するシェア + ] + + # KeySharesオブジェクトを作成 + recovery = KeyShares.from_backup_format(duplicate_shares) + + # 重複するシェアがあるため、キーの復元時にエラーが発生することを確認 + with self.assertRaises(ValueError) as cm: + PrivateKey.from_key_shares(recovery) + self.assertIn("Duplicate share detected, each must be unique", str(cm.exception)) + + def test_parse_and_verify_sample_shares(self): + """Test parsing and verification of sample backup shares.""" + # サンプルバックアップデータからKeySharesオブジェクトを作成 + shares = KeyShares.from_backup_format(self.sample_backup[:3]) + + # 基本的な検証 + self.assertEqual(shares.threshold, 3) + self.assertEqual(shares.integrity, "2f804d43") + self.assertEqual(len(shares.points), 3) + + # 各ポイントがPointInFiniteFieldインスタンスであることを確認 + for point in shares.points: + self.assertIsInstance(point, PointInFiniteField) + + # バックアップ形式に戻せることを確認 + backup_format = shares.to_backup_format() + self.assertEqual(len(backup_format), 3) + + # 元のバックアップと同じフォーマットであることを確認 + for i in range(3): + parts_original = self.sample_backup[i].split('.') + parts_new = backup_format[i].split('.') + + # 最後の2つの部分(しきい値と整合性ハッシュ)が同じか確認 + self.assertEqual(parts_original[-2:], parts_new[-2:]) + + def test_recombination_with_sample_shares(self): + """Test recombination of private key using different combinations of sample shares.""" + # サンプルシェアの様々な組み合わせでキーを復元 + combinations = [ + [0, 1, 2], # 最初の3つのシェア + [0, 2, 4], # 異なる3つのシェア + [1, 3, 4] # 別の組み合わせ + ] + + # 各組み合わせでキーを復元 + for combo in combinations: + selected_shares = [self.sample_backup[i] for i in combo] + key_shares = KeyShares.from_backup_format(selected_shares) + + # キーを復元(例外が投げられなければテストは成功) + recovered_key = PrivateKey.from_key_shares(key_shares) + + # 復元されたキーがPrivateKeyインスタンスであることを確認 + self.assertIsInstance(recovered_key, PrivateKey) + + # WIFを生成できることを確認 + wif = recovered_key.wif() + self.assertIsInstance(wif, str) + self.assertTrue(len(wif) > 0) + + def test_create_backup_and_recover(self): + """Test creating backup shares and recovering the key from them.""" + key = PrivateKey() + backup = key.to_backup_shares(3, 5) + + # Recover using only the first 3 shares + recovered_key = PrivateKey.from_backup_shares(backup[:3]) + + # Verify the recovered key matches the original + self.assertEqual(recovered_key.wif(), key.wif()) + + def test_insufficient_shares_for_recovery(self): + """Test that attempting to recover with insufficient shares raises an error.""" + key = PrivateKey() + all_shares = key.to_key_shares(3, 5) + backup = all_shares.to_backup_format() + + # しきい値未満のシェアでKeySharesオブジェクトを作成 + insufficient_shares = KeyShares.from_backup_format(backup[:2]) + + # シェアが不足しているため、キーの復元時にエラーが発生することを確認 + with self.assertRaises(ValueError) as cm: + PrivateKey.from_key_shares(insufficient_shares) + self.assertIn("At least 3 shares are required", str(cm.exception)) + + def test_share_format_validation(self): + """Test validation of share format.""" + # 不正なフォーマットのシェア + invalid_shares = [ + '45s4vLL2hFvqmxrarvbRT2vZoQYGZGocsmaEksZ64o5M.A7nZrGux15nEsQGNZ1mbfnMKugNnS6SYYEQwfhfbDZG8.3', # 完全ではない + 'invalid-format', # 完全に無効 + '45s4vLL2hFvqmxrarvbRT2vZoQYGZGocsmaEksZ64o5M' # ドットがない + ] + + # 各無効なシェアに対して、エラーが発生することを確認 + for invalid_share in invalid_shares: + with self.assertRaises(ValueError): + KeyShares.from_backup_format([invalid_share]) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file