Skip to content

Commit 3b9885a

Browse files
authored
Add sign_raw() (#50)
`BaseAccount.sign_raw()` accepts raw bytes and outputs a signature as a string in the preferred encoding. This allows signing of arbitrary data besides Aleph messages, making it a useful tool in dApp development.
1 parent aea2d4d commit 3b9885a

18 files changed

+437
-63
lines changed

setup.cfg

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ testing =
7979
black
8080
isort
8181
flake8
82+
substrate-interface
83+
py-sr25519-bindings
8284
mqtt =
8385
aiomqtt<=0.1.3
8486
certifi
@@ -90,7 +92,8 @@ ethereum =
9092
# Required to fix a dependency issue with parsimonious and Python3.11
9193
eth_abi==4.0.0b2; python_version>="3.11"
9294
polkadot =
93-
substrate-interface==1.3.4
95+
substrate-interface
96+
py-sr25519-bindings
9497
cosmos =
9598
cosmospy
9699
solana =

src/aleph/sdk/chains/common.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ def _setup_sender(self, message: Dict) -> Dict:
6262
else:
6363
raise ValueError("Message sender does not match the account's public key.")
6464

65-
@abstractmethod
6665
async def sign_message(self, message: Dict) -> Dict:
6766
"""
6867
Returns a signed message from an Aleph message.
@@ -71,6 +70,20 @@ async def sign_message(self, message: Dict) -> Dict:
7170
Returns:
7271
Dict: Signed message
7372
"""
73+
message = self._setup_sender(message)
74+
signature = await self.sign_raw(get_verification_buffer(message))
75+
message["signature"] = signature.hex()
76+
return message
77+
78+
@abstractmethod
79+
async def sign_raw(self, buffer: bytes) -> bytes:
80+
"""
81+
Returns a signed message from a raw buffer.
82+
Args:
83+
buffer: Buffer to sign
84+
Returns:
85+
bytes: Signature in preferred format
86+
"""
7487
raise NotImplementedError
7588

7689
@abstractmethod
@@ -143,3 +156,10 @@ def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
143156
if not default_key_path.exists():
144157
default_key_path.symlink_to(path)
145158
return private_key
159+
160+
161+
def bytes_from_hex(hex_string: str) -> bytes:
162+
if hex_string.startswith("0x"):
163+
hex_string = hex_string[2:]
164+
hex_string = bytes.fromhex(hex_string)
165+
return hex_string

src/aleph/sdk/chains/cosmos.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,30 @@ def __init__(self, private_key=None, hrp=DEFAULT_HRP):
5151

5252
async def sign_message(self, message):
5353
message = self._setup_sender(message)
54-
5554
verif = get_verification_string(message)
56-
57-
privkey = ecdsa.SigningKey.from_string(self.private_key, curve=ecdsa.SECP256k1)
58-
signature_compact = privkey.sign_deterministic(
59-
verif.encode("utf-8"),
60-
hashfunc=hashlib.sha256,
61-
sigencode=ecdsa.util.sigencode_string_canonize,
62-
)
63-
signature_base64_str = base64.b64encode(signature_compact).decode("utf-8")
6455
base64_pubkey = base64.b64encode(self.get_public_key().encode()).decode("utf-8")
56+
signature = await self.sign_raw(verif.encode("utf-8"))
6557

6658
sig = {
67-
"signature": signature_base64_str,
59+
"signature": signature.decode("utf-8"),
6860
"pub_key": {"type": "tendermint/PubKeySecp256k1", "value": base64_pubkey},
6961
"account_number": str(0),
7062
"sequence": str(0),
7163
}
7264
message["signature"] = json.dumps(sig)
7365
return message
7466

67+
async def sign_raw(self, buffer: bytes) -> bytes:
68+
privkey = ecdsa.SigningKey.from_string(self.private_key, curve=ecdsa.SECP256k1)
69+
signature_compact = privkey.sign_deterministic(
70+
buffer,
71+
hashfunc=hashlib.sha256,
72+
sigencode=ecdsa.util.sigencode_string_canonize,
73+
)
74+
return base64.b64encode(signature_compact)
75+
7576
def get_address(self) -> str:
77+
# WARNING: Fails with OpenSSL >= 3.2.0 due to deprecation of ripemd160
7678
return privkey_to_address(self.private_key)
7779

7880
def get_public_key(self) -> str:

src/aleph/sdk/chains/ethereum.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from typing import Dict, Optional, Union
2+
from typing import Optional, Union
33

44
from eth_account import Account
55
from eth_account.messages import encode_defunct
@@ -9,9 +9,9 @@
99
from ..exceptions import BadSignatureError
1010
from .common import (
1111
BaseAccount,
12+
bytes_from_hex,
1213
get_fallback_private_key,
1314
get_public_key,
14-
get_verification_buffer,
1515
)
1616

1717

@@ -24,15 +24,11 @@ def __init__(self, private_key: bytes):
2424
self.private_key = private_key
2525
self._account = Account.from_key(self.private_key)
2626

27-
async def sign_message(self, message: Dict) -> Dict:
28-
"""Sign a message inplace."""
29-
message = self._setup_sender(message)
30-
31-
msghash = encode_defunct(text=get_verification_buffer(message).decode("utf-8"))
27+
async def sign_raw(self, buffer: bytes) -> bytes:
28+
"""Sign a raw buffer."""
29+
msghash = encode_defunct(text=buffer.decode("utf-8"))
3230
sig = self._account.sign_message(msghash)
33-
34-
message["signature"] = sig["signature"].hex()
35-
return message
31+
return sig["signature"]
3632

3733
def get_address(self) -> str:
3834
return self._account.address
@@ -60,19 +56,14 @@ def verify_signature(
6056
BadSignatureError: If the signature is invalid.
6157
"""
6258
if isinstance(signature, str):
63-
if signature.startswith("0x"):
64-
signature = signature[2:]
65-
signature = bytes.fromhex(signature)
66-
else:
67-
if signature.startswith(b"0x"):
68-
signature = signature[2:]
69-
signature = bytes.fromhex(signature.decode("utf-8"))
59+
signature = bytes_from_hex(signature)
7060
if isinstance(public_key, bytes):
7161
public_key = "0x" + public_key.hex()
7262
if isinstance(message, bytes):
73-
message = message.decode("utf-8")
63+
message_hash = encode_defunct(primitive=message)
64+
else:
65+
message_hash = encode_defunct(text=message)
7466

75-
message_hash = encode_defunct(text=message)
7667
try:
7768
address = Account.recover_message(message_hash, signature=signature)
7869
if address.casefold() != public_key.casefold():

src/aleph/sdk/chains/nuls1.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ async def sign_message(self, message):
315315
message["signature"] = sig.serialize().hex()
316316
return message
317317

318+
async def sign_raw(self, buffer: bytes) -> bytes:
319+
sig = NulsSignature.sign_data(self.private_key, buffer)
320+
return sig.serialize()
321+
318322
def get_address(self):
319323
return address_from_hash(
320324
public_key_to_hash(self.get_public_key(), chain_id=self.chain_id)

src/aleph/sdk/chains/nuls2.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import base64
2-
from typing import Union
2+
from typing import Dict, Union
33

44
from nuls2.model.data import (
55
NETWORKS,
@@ -37,17 +37,23 @@ def __init__(self, private_key=None, chain_id=1, prefix=None):
3737
else:
3838
self.prefix = prefix
3939

40-
async def sign_message(self, message):
41-
# sig = NulsSignature.sign_message(self.private_key,
42-
# get_verification_buffer(message))
40+
async def sign_message(self, message: Dict) -> Dict:
41+
"""
42+
Returns a signed message from an Aleph message.
43+
Args:
44+
message: Message to sign
45+
Returns:
46+
Dict: Signed message
47+
"""
4348
message = self._setup_sender(message)
44-
45-
sig = sign_recoverable_message(
46-
self.private_key, get_verification_buffer(message)
47-
)
48-
message["signature"] = base64.b64encode(sig).decode()
49+
signature = await self.sign_raw(get_verification_buffer(message))
50+
message["signature"] = signature.decode()
4951
return message
5052

53+
async def sign_raw(self, buffer: bytes) -> bytes:
54+
sig = sign_recoverable_message(self.private_key, buffer)
55+
return base64.b64encode(sig)
56+
5157
def get_address(self):
5258
return address_from_hash(
5359
public_key_to_hash(self.get_public_key(), chain_id=self.chain_id),

src/aleph/sdk/chains/remote.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ async def sign_message(self, message: Dict) -> Dict:
7777
response.raise_for_status()
7878
return await response.json()
7979

80+
async def sign_raw(self, buffer: bytes) -> bytes:
81+
raise NotImplementedError()
82+
8083
def get_address(self) -> str:
8184
return self._address
8285

src/aleph/sdk/chains/sol.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ async def sign_message(self, message: Dict) -> Dict:
3030
"""Sign a message inplace."""
3131
message = self._setup_sender(message)
3232
verif = get_verification_buffer(message)
33+
signature = await self.sign_raw(verif)
3334
sig = {
3435
"publicKey": self.get_address(),
35-
"signature": encode(self._signing_key.sign(verif).signature),
36+
"signature": encode(signature),
3637
}
3738
message["signature"] = json.dumps(sig)
3839
return message
3940

41+
async def sign_raw(self, buffer: bytes) -> bytes:
42+
"""Sign a raw buffer."""
43+
sig = self._signing_key.sign(buffer)
44+
return sig.signature
45+
4046
def get_address(self) -> str:
4147
return encode(self._signing_key.verify_key)
4248

src/aleph/sdk/chains/substrate.py

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,73 @@
11
import json
2-
from typing import Union
2+
import logging
3+
from pathlib import Path
4+
from typing import Optional, Union
35

6+
from sr25519 import verify
47
from substrateinterface import Keypair
8+
from substrateinterface.utils.ss58 import ss58_decode
59

610
from ..conf import settings
7-
from .common import BaseAccount, get_verification_buffer
11+
from ..exceptions import BadSignatureError
12+
from .common import BaseAccount, bytes_from_hex, get_verification_buffer
13+
14+
logger = logging.getLogger(__name__)
815

916

1017
class DOTAccount(BaseAccount):
1118
CHAIN = "DOT"
1219
CURVE = "sr25519"
1320

14-
def __init__(self, mnemonics=None, address_type=42):
21+
def __init__(self, mnemonics: str, address_type=42):
1522
self.mnemonics = mnemonics
1623
self.address_type = address_type
1724
self._account = Keypair.create_from_mnemonic(
18-
self.mnemonics, address_type=address_type
25+
self.mnemonics, ss58_format=address_type
1926
)
2027

2128
async def sign_message(self, message):
2229
message = self._setup_sender(message)
2330
verif = get_verification_buffer(message).decode("utf-8")
24-
sig = {"curve": self.CURVE, "data": self._account.sign(verif)}
31+
signature = await self.sign_raw(verif.encode("utf-8"))
32+
sig = {"curve": self.CURVE, "data": signature.hex()}
2533
message["signature"] = json.dumps(sig)
2634
return message
2735

28-
def get_address(self):
36+
async def sign_raw(self, buffer: bytes) -> bytes:
37+
return self._account.sign(buffer)
38+
39+
def get_address(self) -> str:
2940
return self._account.ss58_address
3041

31-
def get_public_key(self):
32-
return self._account.public_key
42+
def get_public_key(self) -> str:
43+
return "0x" + self._account.public_key.hex()
3344

3445

35-
def get_fallback_account():
36-
return DOTAccount(mnemonics=get_fallback_mnemonics())
46+
def get_fallback_account(path: Optional[Path] = None) -> DOTAccount:
47+
return DOTAccount(mnemonics=get_fallback_mnemonics(path))
3748

3849

39-
def get_fallback_mnemonics():
40-
try:
41-
mnemonic = settings.PRIVATE_KEY_FILE.read_text()
42-
except OSError:
50+
def get_fallback_mnemonics(path: Optional[Path] = None) -> str:
51+
path = path or settings.PRIVATE_MNEMONIC_FILE
52+
if path.exists() and path.stat().st_size > 0:
53+
mnemonic = path.read_text()
54+
else:
4355
mnemonic = Keypair.generate_mnemonic()
44-
settings.PRIVATE_KEY_FILE.write_text(mnemonic)
56+
path.parent.mkdir(exist_ok=True, parents=True)
57+
path.write_text(mnemonic)
58+
default_mnemonic_path = path.parent / "default.mnemonic"
59+
60+
# If the symlink exists but does not point to a file, delete it.
61+
if (
62+
default_mnemonic_path.is_symlink()
63+
and not default_mnemonic_path.resolve().exists()
64+
):
65+
default_mnemonic_path.unlink()
66+
logger.warning("The symlink to the mnemonic is broken")
67+
68+
# Create a symlink to use this mnemonic by default
69+
if not default_mnemonic_path.exists():
70+
default_mnemonic_path.symlink_to(path)
4571

4672
return mnemonic
4773

@@ -50,6 +76,26 @@ def verify_signature(
5076
signature: Union[bytes, str],
5177
public_key: Union[bytes, str],
5278
message: Union[bytes, str],
53-
) -> bool:
54-
"""TODO: Implement this"""
55-
raise NotImplementedError("Not implemented yet")
79+
) -> None:
80+
if isinstance(signature, str):
81+
signature = bytes_from_hex(signature)
82+
if isinstance(public_key, str):
83+
public_key = bytes_from_hex(public_key)
84+
if isinstance(message, str):
85+
message = message.encode()
86+
87+
try:
88+
# Another attempt with the data wrapped, as discussed in https://github.com/polkadot-js/extension/pull/743
89+
if not verify(signature, message, public_key) or verify(
90+
signature, b"<Bytes>" + message + b"</Bytes>", public_key
91+
):
92+
raise BadSignatureError
93+
except Exception as e:
94+
raise BadSignatureError from e
95+
96+
97+
def verify_signature_with_ss58_address(
98+
signature: Union[bytes, str], address: str, message: Union[bytes, str]
99+
) -> None:
100+
address_bytes = ss58_decode(address)
101+
return verify_signature(signature, address_bytes, message)

src/aleph/sdk/chains/tezos.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,18 @@ async def sign_message(self, message: Dict) -> Dict:
2525
message = self._setup_sender(message)
2626

2727
verif = get_verification_buffer(message)
28+
signature = await self.sign_raw(verif)
2829
sig = {
2930
"publicKey": self.get_public_key(),
30-
"signature": self._account.sign(verif),
31+
"signature": signature.decode(),
3132
}
3233

3334
message["signature"] = json.dumps(sig)
3435
return message
3536

37+
async def sign_raw(self, buffer: bytes) -> bytes:
38+
return self._account.sign(buffer).encode()
39+
3640
def get_address(self) -> str:
3741
return self._account.public_key_hash()
3842

0 commit comments

Comments
 (0)