Skip to content

Commit

Permalink
Add from_shamir_mnemonic_phrases and `generate_shamir_mnemonic_phra…
Browse files Browse the repository at this point in the history
…ses` to `Keypair` (#1012)

* Add from_shamir_mnemonic_phrases and generate_shamir_mnemonic_phrases

* Add index support for from_shamir_mnemonic_phrases

* shamir_mnemonic as optional deps

* Add test for from_shamir_mnemonic_phrases

* Add more tests and simplify implementation

* Add docs and changelog

* Adjust CI configuration.

* fix type hinting (Python 3.8)

---------

Co-authored-by: Jun Luo <[email protected]>
  • Loading branch information
tupui and overcat authored Dec 26, 2024
1 parent ad1d0e3 commit ab0e0e8
Show file tree
Hide file tree
Showing 7 changed files with 448 additions and 212 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/continuous-integration-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:

- name: Install dependencies
run: |
poetry install --extras 'aiohttp'
poetry install --with dev --all-extras
- name: Echo installed packages
run: |
Expand Down Expand Up @@ -108,7 +108,7 @@ jobs:

- name: Install dependencies
run: |
poetry install --extras 'aiohttp'
poetry install --with dev --all-extras
- name: Echo installed packages
run: |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ Release History

### Pending

#### Update
- feat: Add optional support for Shamir Secret Sharing with `Keypair.from_shamir_mnemonic_phrases` and `Keypair.generate_shamir_mnemonic_phrases`. ([#1010](https://github.com/StellarCN/py-stellar-base/pull/1010))

### Version 12.0.0

Released on November 28, 2024
Expand Down
32 changes: 30 additions & 2 deletions docs/en/generate_keypair.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ You can create a keypair from public key, but its function is limited:
keypair = Keypair.from_public_key(public_key)
can_sign = keypair.can_sign() # False
You can also create a randomly generated keypair:
You can create a randomly generated keypair:

.. code-block:: python
:linenos:
Expand All @@ -47,4 +47,32 @@ You can also create a randomly generated keypair:
keypair = Keypair.random()
print("Public Key: " + keypair.public_key)
print("Secret Seed: " + keypair.secret)
print("Secret Seed: " + keypair.secret)
Vou can also generate a mnemonic phrase and later use it to generate a keypair:

.. code-block:: python
:linenos:
from stellar_sdk import Keypair
mnemonic_phrase = Keypair.generate_mnemonic_phrase()
print(f"Mnemonic phrase: {mnemonic_phrase}")
keypair = Keypair.from_mnemonic_phrase(mnemonic_phrase)
print(f"Public Key: {keypair.public_key}")
print(f"Secret Seed: {keypair.secret}")
Lastly, you can also use the Shamir secret shamir method to split a mnemonic
phrase into multiple phrases. In the following example, we need exactly 2
phrases in order to reconstruct the secret:

.. code-block:: python
:linenos:
from stellar_sdk import Keypair
mnemonic_phrases = Keypair.generate_shamir_mnemonic_phrases(member_threshold=2, member_count=3)
print(f"Mnemonic phrases: {mnemonic_phrases}")
keypair = Keypair.from_shamir_mnemonic_phrases(mnemonic_phrases[:2]) # any combinations
print(f"Public Key: {keypair.public_key}")
print(f"Secret Seed: {keypair.secret}")
461 changes: 254 additions & 207 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ requests = "^2.32.3"
aiohttp = { version = "^3.9.1", optional = true}
aiohttp-sse-client = { version = "^0.2.1", optional = true}
mnemonic = "^0.20"
shamir-mnemonic = { version = "^0.3.0", optional = true }
toml = "^0.10.2"
pydantic = "^2.5.2"
xdrlib3 = "^0.1.1"
requests-sse = "^0.3.0"

[tool.poetry.extras]
aiohttp = ["aiohttp", "aiohttp-sse-client"]
shamir = ["shamir-mnemonic"]

[tool.poetry.dev-dependencies]
pytest = "^8.3.4"
Expand Down
69 changes: 68 additions & 1 deletion stellar_sdk/keypair.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import Optional, Union
from typing import Iterable, List, Optional, Union

import nacl.signing as ed25519
from nacl.exceptions import BadSignatureError as NaclBadSignatureError
Expand All @@ -24,6 +24,7 @@ class Keypair:
* :meth:`Keypair.from_secret`
* :meth:`Keypair.from_public_key`
* :meth:`Keypair.from_mnemonic_phrase`
* :meth:`Keypair.from_shamir_mnemonic_phrases`
Learn how to create a key through our documentation:
`Generate Keypair <https://stellar-sdk.readthedocs.io/en/latest/generate_keypair.html>`__.
Expand Down Expand Up @@ -256,6 +257,72 @@ def from_mnemonic_phrase(
)
return cls.from_raw_ed25519_seed(raw_ed25519_seed)

@staticmethod
def generate_shamir_mnemonic_phrases(
member_threshold: int, member_count: int, passphrase: str = ""
) -> List[str]:
"""Generate mnemonic phrases using Shamir secret sharing method.
A randomly generated secret key is generated and split into `member_count`
mnemonic phrases. The secret key can be later reconstructed using any
subset of `member_threshold` phrases.
:param member_threshold: Number of members required to reconstruct the secret key.
:param member_count: Number of shares the secret is split into.
:param passphrase: An optional passphrase used to decrypt the secret key.
:return: A list of mnemonic phrases.
"""
try:
import shamir_mnemonic # noqa: F401
except ModuleNotFoundError as exc:
message = "shamir_mnemonic must be installed to use method `generate_shamir_mnemonic_phrases`."
raise ModuleNotFoundError(message) from exc

secrets = Keypair.random().secret.encode()
try:
phrases = shamir_mnemonic.generate_mnemonics(
group_threshold=1,
groups=[(member_threshold, member_count)],
master_secret=secrets,
passphrase=passphrase.encode(),
)[0]
except shamir_mnemonic.utils.MnemonicError as exc:
raise ValueError(exc) from exc

return phrases

@classmethod
def from_shamir_mnemonic_phrases(
cls,
mnemonic_phrases: Iterable[str],
passphrase: str = "",
index: int = 0,
) -> "Keypair":
"""Generate a :class:`Keypair` object via a list of mnemonic phrases.
:param mnemonic_phrases: A list of unique strings used to deterministically generate a keypair.
:param passphrase: An optional passphrase used to decrypt the secret key.
:param index: The index of the keypair generated by the mnemonic.
This allows for multiple Keypairs to be derived from the same
mnemonic.
:return: A new :class:`Keypair` object derived from the mnemonic phrases.
"""
try:
import shamir_mnemonic # noqa: F401
except ModuleNotFoundError as exc:
message = "shamir_mnemonic must be installed to use method `from_shamir_mnemonic_phrases`."
raise ModuleNotFoundError(message) from exc

try:
main_seed = shamir_mnemonic.combine_mnemonics(
mnemonics=mnemonic_phrases, passphrase=passphrase.encode()
)
except shamir_mnemonic.utils.MnemonicError as exc:
raise ValueError(exc) from exc

derived_seed = StellarMnemonic.derive(seed=main_seed, index=index)
return cls.from_raw_ed25519_seed(derived_seed)

def sign_decorated(self, data: bytes) -> DecoratedSignature:
"""Sign the provided data with the keypair's private key and returns DecoratedSignature.
Expand Down
89 changes: 89 additions & 0 deletions tests/test_keypair.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import itertools

import pytest

from stellar_sdk import Keypair, StrKey
Expand Down Expand Up @@ -519,6 +521,93 @@ def test_invalid_mnemonic_raise(self, mnemonic, language):
mnemonic_phrase=mnemonic, language=language
)

@pytest.mark.parametrize(
"public_key, index",
[
(
"GDZ4GYLVRLM2E6CGCOVYXAYMXJJAV3IHDXU6RUHX5AJVYS4AE6R6CHPJ",
0,
), # m/44h/148h/0h
("GCMZKXAAPQ3TDY5P7QDUVJBW66R2DGT7AM6MA3MCQENIF37E25U2PEK3", 1),
("GCF7DAVTXXVQPOSB5TCA2CIFT7DZIPK23NCOV3RJ6FTYTZM6S6RPPACM", 100),
],
)
def test_from_shamir_mnemonic_phrases(self, public_key, index):
# generated from Trezor Safe 3
shares = [
"glimpse buyer academic acid branch sled disaster sunlight material junction float emperor intend priority scene trash remember radar prospect dryer",
"glimpse buyer academic agency burden payroll alpha oven large amount smear forward pharmacy symbolic junk axle exercise segment frequent axle",
"glimpse buyer academic always careful become dance teaspoon daisy orange careful steady boundary exceed robin remind software grin space advocate",
]
passphrase = "9012"

for perms in itertools.permutations(shares, 2):
kp = Keypair.from_shamir_mnemonic_phrases(
perms, index=index, passphrase=passphrase
)
assert kp.public_key == public_key

def test_raise_from_shamir_mnemonic_phrases(self):
shares = [
"glimpse buyer academic acid branch sled disaster sunlight material junction float emperor intend priority scene trash remember radar prospect dryer",
"glimpse buyer academic agency burden payroll alpha oven large amount smear forward pharmacy symbolic junk axle exercise segment frequent axle",
"glimpse buyer academic always careful become dance teaspoon daisy orange careful steady boundary exceed robin remind software grin space advocate",
]
_ = Keypair.from_shamir_mnemonic_phrases(shares[:-1]) # validate good run

with pytest.raises(ValueError, match="Wrong number of mnemonics"):
Keypair.from_shamir_mnemonic_phrases(shares)

with pytest.raises(ValueError, match="Wrong number of mnemonics"):
Keypair.from_shamir_mnemonic_phrases([shares[0]])

with pytest.raises(ValueError, match="mnemonic word"):
Keypair.from_shamir_mnemonic_phrases([shares[0], shares[1] + "a"])

# remove first word
shares_1 = "buyer academic agency burden payroll alpha oven large amount smear forward pharmacy symbolic junk axle exercise segment frequent axle"
with pytest.raises(ValueError, match="mnemonic length"):
Keypair.from_shamir_mnemonic_phrases([shares[0], shares_1])

# another first word
shares_1 = "acid buyer academic agency burden payroll alpha oven large amount smear forward pharmacy symbolic junk axle exercise segment frequent axle"
with pytest.raises(ValueError, match="mnemonic checksum"):
Keypair.from_shamir_mnemonic_phrases([shares[0], shares_1])

@pytest.mark.parametrize(
"member_threshold, member_count, passphrase",
[
(1, 1, ""),
(1, 1, "abcde"),
(2, 3, "0"),
],
)
def test_generate_shamir_mnemonic_phrases(
self, member_threshold, member_count, passphrase
):
Keypair.generate_shamir_mnemonic_phrases(
member_threshold=member_threshold,
member_count=member_count,
passphrase=passphrase,
)

@pytest.mark.parametrize(
"member_threshold, member_count, err_msg",
[
(0, 1, "threshold must be a positive"),
(1, 2, "multiple member shares with member threshold 1"),
(2, 1, "threshold must not exceed the number of shares"),
(3, 1000, "shares must not exceed 16"),
],
)
def test_raise_generate_shamir_mnemonic_phrases(
self, member_threshold, member_count, err_msg
):
with pytest.raises(ValueError, match=err_msg):
Keypair.generate_shamir_mnemonic_phrases(
member_threshold=member_threshold, member_count=member_count
)

def test_xdr_public_key(self):
public_key = "GBRF6PKZYP4J4WI2A3NF4CGF23SL34GRKA5LTQZCQFEUT2YJDZO2COXH"
kp = Keypair.from_public_key(public_key)
Expand Down

0 comments on commit ab0e0e8

Please sign in to comment.