Skip to content

Commit 23461fd

Browse files
authored
Feature: Signature verification for Solana and Ethereum messages (#19)
* Problem: Users cannot verify signatures Solution: Implement verify_signature() functions for Ethereum and Solana signatures, more are following * reformat tests with black * add tezos signature verification function * remove unused local variable * raise new BadSignatureError exception instead of boolean check for verify_signature()
1 parent 75d1ce7 commit 23461fd

File tree

12 files changed

+289
-15
lines changed

12 files changed

+289
-15
lines changed

src/aleph/sdk/chains/common.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@
99
from aleph.sdk.conf import settings
1010

1111

12-
def get_verification_buffer(message):
13-
"""Returns a serialized string to verify the message integrity
14-
(this is was it signed)
12+
def get_verification_buffer(message: Dict) -> bytes:
13+
"""
14+
Returns the verification buffer that Aleph nodes use to verify the signature of a message.
15+
Note:
16+
The verification buffer is a string of the following format:
17+
b"{chain}\\n{sender}\\n{type}\\n{item_hash}"
18+
Args:
19+
message: Message to get the verification buffer for
20+
Returns:
21+
bytes: Verification buffer
1522
"""
1623
return "{chain}\n{sender}\n{type}\n{item_hash}".format(**message).encode("utf-8")
1724

@@ -27,8 +34,13 @@ class BaseAccount(ABC):
2734
private_key: bytes
2835

2936
def _setup_sender(self, message: Dict) -> Dict:
30-
"""Set the sender of the message as the account's public key.
37+
"""
38+
Set the sender of the message as the account's public key.
3139
If a sender is already specified, check that it matches the account's public key.
40+
Args:
41+
message: Message to add the sender to
42+
Returns:
43+
Dict: Message with the sender set
3244
"""
3345
if not message.get("sender"):
3446
message["sender"] = self.get_address()
@@ -40,24 +52,51 @@ def _setup_sender(self, message: Dict) -> Dict:
4052

4153
@abstractmethod
4254
async def sign_message(self, message: Dict) -> Dict:
55+
"""
56+
Returns a signed message from an Aleph message.
57+
Args:
58+
message: Message to sign
59+
Returns:
60+
Dict: Signed message
61+
"""
4362
raise NotImplementedError
4463

4564
@abstractmethod
4665
def get_address(self) -> str:
66+
"""
67+
Returns the account's displayed address.
68+
"""
4769
raise NotImplementedError
4870

4971
@abstractmethod
5072
def get_public_key(self) -> str:
73+
"""
74+
Returns the account's public key.
75+
"""
5176
raise NotImplementedError
5277

53-
async def encrypt(self, content) -> bytes:
78+
async def encrypt(self, content: bytes) -> bytes:
79+
"""
80+
Encrypts a message using the account's public key.
81+
Args:
82+
content: Content bytes to encrypt
83+
Returns:
84+
bytes: Encrypted content as bytes
85+
"""
5486
if self.CURVE == "secp256k1":
5587
value: bytes = encrypt(self.get_public_key(), content)
5688
return value
5789
else:
5890
raise NotImplementedError
5991

60-
async def decrypt(self, content) -> bytes:
92+
async def decrypt(self, content: bytes) -> bytes:
93+
"""
94+
Decrypts a message using the account's private key.
95+
Args:
96+
content: Content bytes to decrypt
97+
Returns:
98+
bytes: Decrypted content as bytes
99+
"""
61100
if self.CURVE == "secp256k1":
62101
value: bytes = decrypt(self.private_key, content)
63102
return value

src/aleph/sdk/chains/cosmos.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import hashlib
33
import json
4+
from typing import Union
45

56
import ecdsa
67
from cosmospy._wallet import privkey_to_address, privkey_to_pubkey
@@ -80,3 +81,12 @@ def get_public_key(self) -> str:
8081

8182
def get_fallback_account(hrp=DEFAULT_HRP):
8283
return CSDKAccount(private_key=get_fallback_private_key(), hrp=hrp)
84+
85+
86+
def verify_signature(
87+
signature: Union[bytes, str],
88+
public_key: Union[bytes, str],
89+
message: Union[bytes, str],
90+
) -> bool:
91+
"""TODO: Implement this"""
92+
raise NotImplementedError("Not implemented yet")

src/aleph/sdk/chains/ethereum.py

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

44
from eth_account import Account
55
from eth_account.messages import encode_defunct
66
from eth_account.signers.local import LocalAccount
7+
from eth_keys.exceptions import BadSignature as EthBadSignatureError
78

9+
from ..exceptions import BadSignatureError
810
from .common import (
911
BaseAccount,
1012
get_fallback_private_key,
@@ -41,3 +43,39 @@ def get_public_key(self) -> str:
4143

4244
def get_fallback_account(path: Optional[Path] = None) -> ETHAccount:
4345
return ETHAccount(private_key=get_fallback_private_key(path=path))
46+
47+
48+
def verify_signature(
49+
signature: Union[bytes, str],
50+
public_key: Union[bytes, str],
51+
message: Union[bytes, str],
52+
):
53+
"""
54+
Verifies a signature.
55+
Args:
56+
signature: The signature to verify. Can be a hex encoded string or bytes.
57+
public_key: The sender's public key to use for verification. Can be a checksummed, hex encoded string or bytes.
58+
message: The message to verify. Can be an utf-8 string or bytes.
59+
Raises:
60+
BadSignatureError: If the signature is invalid.
61+
"""
62+
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"))
70+
if isinstance(public_key, bytes):
71+
public_key = "0x" + public_key.hex()
72+
if isinstance(message, bytes):
73+
message = message.decode("utf-8")
74+
75+
message_hash = encode_defunct(text=message)
76+
try:
77+
address = Account.recover_message(message_hash, signature=signature)
78+
if address != public_key:
79+
raise BadSignatureError
80+
except (EthBadSignatureError, BadSignatureError) as e:
81+
raise BadSignatureError from e

src/aleph/sdk/chains/nuls1.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
import struct
66
from binascii import hexlify, unhexlify
7-
from typing import Optional
7+
from typing import Optional, Union
88

99
from coincurve.keys import PrivateKey, PublicKey
1010

@@ -324,3 +324,12 @@ def get_public_key(self):
324324

325325
def get_fallback_account(chain_id=8964):
326326
return NULSAccount(private_key=get_fallback_private_key(), chain_id=chain_id)
327+
328+
329+
def verify_signature(
330+
signature: Union[bytes, str],
331+
public_key: Union[bytes, str],
332+
message: Union[bytes, str],
333+
) -> bool:
334+
"""TODO: Implement this"""
335+
raise NotImplementedError("Not implemented yet")

src/aleph/sdk/chains/nuls2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
from typing import Union
23

34
from nuls2.model.data import (
45
NETWORKS,
@@ -60,3 +61,12 @@ def get_public_key(self):
6061
def get_fallback_account(chain_id=1):
6162
acc = NULSAccount(private_key=get_fallback_private_key(), chain_id=chain_id)
6263
return acc
64+
65+
66+
def verify_signature(
67+
signature: Union[bytes, str],
68+
public_key: Union[bytes, str],
69+
message: Union[bytes, str],
70+
) -> bool:
71+
"""TODO: Implement this"""
72+
raise NotImplementedError("Not implemented yet")

src/aleph/sdk/chains/sol.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import json
22
import os
33
from pathlib import Path
4-
from typing import Dict, Optional
4+
from typing import Dict, Optional, Union
55

66
import base58
7+
from nacl.exceptions import BadSignatureError as NaclBadSignatureError
78
from nacl.public import PrivateKey, SealedBox
8-
from nacl.signing import SigningKey
9+
from nacl.signing import SigningKey, VerifyKey
910

1011
from ..conf import settings
12+
from ..exceptions import BadSignatureError
1113
from .common import BaseAccount, get_verification_buffer
1214

1315

@@ -81,3 +83,29 @@ def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
8183
# Create a symlink to use this key by default
8284
os.symlink(path, default_key_path)
8385
return private_key
86+
87+
88+
def verify_signature(
89+
signature: Union[bytes, str],
90+
public_key: Union[bytes, str],
91+
message: Union[bytes, str],
92+
):
93+
"""
94+
Verifies a signature.
95+
Args:
96+
signature: The signature to verify. Can be a base58 encoded string or bytes.
97+
public_key: The public key to use for verification. Can be a base58 encoded string or bytes.
98+
message: The message to verify. Can be an utf-8 string or bytes.
99+
Raises:
100+
BadSignatureError: If the signature is invalid.
101+
"""
102+
if isinstance(signature, str):
103+
signature = base58.b58decode(signature)
104+
if isinstance(message, str):
105+
message = message.encode("utf-8")
106+
if isinstance(public_key, str):
107+
public_key = base58.b58decode(public_key)
108+
try:
109+
VerifyKey(public_key).verify(message, signature)
110+
except NaclBadSignatureError as e:
111+
raise BadSignatureError from e

src/aleph/sdk/chains/substrate.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from typing import Union
23

34
from substrateinterface import Keypair
45

@@ -45,3 +46,12 @@ def get_fallback_mnemonics():
4546
prvfile.write(mnemonic)
4647

4748
return mnemonic
49+
50+
51+
def verify_signature(
52+
signature: Union[bytes, str],
53+
public_key: Union[bytes, str],
54+
message: Union[bytes, str],
55+
) -> bool:
56+
"""TODO: Implement this"""
57+
raise NotImplementedError("Not implemented yet")

src/aleph/sdk/chains/tezos.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from pathlib import Path
3-
from typing import Dict, Optional
3+
from typing import Dict, Optional, Union
44

55
from aleph_pytezos.crypto.key import Key
66
from nacl.public import SealedBox
@@ -48,3 +48,24 @@ async def decrypt(self, content) -> bytes:
4848

4949
def get_fallback_account(path: Optional[Path] = None) -> TezosAccount:
5050
return TezosAccount(private_key=get_fallback_private_key(path=path))
51+
52+
53+
def verify_signature(
54+
signature: Union[bytes, str],
55+
public_key: Union[bytes, str],
56+
message: Union[bytes, str],
57+
) -> bool:
58+
"""
59+
Verify a signature using the public key (hash) of a tezos account.
60+
61+
Note: It requires the public key hash (sp, p2, ed-prefix), not the address (tz1, tz2 prefix)!
62+
Args:
63+
signature: The signature to verify. Can be a base58 encoded string or bytes.
64+
public_key: The public key (hash) of the account. Can be a base58 encoded string or bytes.
65+
message: The message that was signed. Is a sequence of bytes in raw format or hexadecimal notation.
66+
"""
67+
key = Key.from_encoded_key(public_key)
68+
try:
69+
return key.verify(signature, message)
70+
except ValueError:
71+
return False

src/aleph/sdk/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,11 @@ class InvalidMessageError(BroadcastError):
3434
"""
3535

3636
pass
37+
38+
39+
class BadSignatureError(Exception):
40+
"""
41+
The signature of a message is invalid.
42+
"""
43+
44+
pass

tests/unit/test_chain_ethereum.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import pytest
66

7-
from aleph.sdk.chains.ethereum import get_fallback_account
7+
from aleph.sdk.chains.common import get_verification_buffer
8+
from aleph.sdk.chains.ethereum import get_fallback_account, verify_signature
9+
from aleph.sdk.exceptions import BadSignatureError
810

911

1012
@dataclass
@@ -42,6 +44,48 @@ async def test_ETHAccount(ethereum_account):
4244
assert len(pubkey) == 68
4345

4446

47+
@pytest.mark.asyncio
48+
async def test_verify_signature(ethereum_account):
49+
account = ethereum_account
50+
51+
message = asdict(
52+
Message(
53+
"ETH",
54+
account.get_address(),
55+
"POST",
56+
"SomeHash",
57+
)
58+
)
59+
await account.sign_message(message)
60+
assert message["signature"]
61+
62+
verify_signature(
63+
message["signature"], message["sender"], get_verification_buffer(message)
64+
)
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_verify_signature_with_forged_signature(ethereum_account):
69+
account = ethereum_account
70+
71+
message = asdict(
72+
Message(
73+
"ETH",
74+
account.get_address(),
75+
"POST",
76+
"SomeHash",
77+
)
78+
)
79+
await account.sign_message(message)
80+
assert message["signature"]
81+
82+
forged_signature = "0x" + "0" * 130
83+
with pytest.raises(BadSignatureError):
84+
verify_signature(
85+
forged_signature, message["sender"], get_verification_buffer(message)
86+
)
87+
88+
4589
@pytest.mark.asyncio
4690
async def test_decrypt_secp256k1(ethereum_account):
4791
account = ethereum_account

0 commit comments

Comments
 (0)