Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Non-Recoverable Signatures #58

Merged
merged 9 commits into from
Jun 5, 2019
Merged

Conversation

jannikluhn
Copy link
Contributor

What was wrong?

#57

How was it fixed?

Signatures without v values are implemented in a new class called NonRecoverableSignature. Some of the shared functionality with the existing Signature class has been moved to BaseSignature. Creating non recoverable signatures is handled by new backend methods. Verifying them uses the existing ones, which have been updated to accept all BaseSignatures (i.e. not recover and compare, but direct verification).

Coincurve uses a DER format to encode/decode these signatures. As it is quite complicated I added a new dependency: asn1tools which looks relatively well maintained.

This should probably be merged after #56, then I can use the improved validation functions from there.

Cute Animal Picture

Cute animal picture

@carver carver mentioned this pull request May 17, 2019
@jannikluhn jannikluhn mentioned this pull request May 20, 2019
@carver
Copy link
Collaborator

carver commented May 22, 2019

Any other asn1 tools that you considered? pyasn1 seems to be much more widely used: https://github.com/etingof/pyasn1/network/dependents
(37k dependent projects)

vs https://github.com/eerimoq/asn1tools/network/dependents
(3 dependent projects?)

Also, pyasn1 doesn't have any sub-dependencies, where asn1tools has a few.

Normally, I'm relatively liberal with dependencies, but since this is an absolutely security-critical library, it would be nice to minimize the dependency risk.

@carver
Copy link
Collaborator

carver commented May 22, 2019

asn1tools has another issue: there are some dependency conflicts with prompt_toolkit that ipython also depends on. It might be possible to manually resolve it by figuring out which ipython that trinity needs to specify, but I'd rather not go down that rabbit hole.

@jannikluhn
Copy link
Contributor Author

Nice, I didn't know of the dependency graph feature. Now that I've seen it, I agree that pyasn1 is the much better choice. Will swap them.

@pipermerriam
Copy link
Member

pipermerriam commented May 23, 2019 via email

@jannikluhn
Copy link
Contributor Author

I didn't dare to try as DER encoding is relatively complicated even if implemented just for the signature object. It's ancient and weird, in some cases even requires checking individual bit values. Here's documentation of the parts that would be relevant to us: Sequence and Integer.

So I think using an external library is the better way to go. But if you feel strongly about this, I can also try to reimplement it here.

@carver
Copy link
Collaborator

carver commented May 23, 2019

Looks pretty doable, since we know a lot about the specific spec and the input range. two_int_sequence_encoder appears to be a working encoder, below:

import pytest
  
import asn1tools
from eth_utils import (
    apply_to_return_value,
    int_to_big_endian,
)
from hypothesis import (
    example,
    settings,
    strategies as st,
    given,
)

ASN1_ECDSA_SPEC_STRING = """\
ECDSASpec DEFINITIONS ::= BEGIN
      ECDSASignature ::= SEQUENCE {
         r   INTEGER,
         s   INTEGER
     }
END
"""
ASN1_SPEC = asn1tools.compile_string(ASN1_ECDSA_SPEC_STRING, "der")

@apply_to_return_value(bytes)
def int_encoder(primitive):
    """
    Only handles 32-byte positive ints
    """
    # Integer tag
    yield 0x02

    encoded = int_to_big_endian(primitive)
    if encoded[0] >= 128:
        # Indicate that integer is positive, if the first bit is 1
        # we need to disambiguate it from a 2's complement negative number
        yield len(encoded) + 1 
        yield 0x00
    else: 
        yield len(encoded) 

    yield from encoded


@apply_to_return_value(bytes)
def two_int_sequence_encoder(int1, int2):
    # Sequence tag
    yield 0x30

    encoded1 = int_encoder(int1)
    encoded2 = int_encoder(int2)

    # Sequence length
    yield len(encoded1) + len(encoded2)

    yield from encoded1
    yield from encoded2


MAX_32_BYTE_INT = 256 ** 32 - 1 
@given(
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
)
@example(0, 0)
@example(MAX_32_BYTE_INT, MAX_32_BYTE_INT)
@example(MAX_32_BYTE_INT // 2, MAX_32_BYTE_INT // 2)
@example(MAX_32_BYTE_INT // 2 + 1, MAX_32_BYTE_INT // 2 + 1)
@settings(max_examples=1000)
def test_hand_coded_asn1(r, s):
    encoded = two_int_sequence_encoder(r, s)
    expected = ASN1_SPEC.encode("ECDSASignature", {"r": r, "s": s})
    assert encoded == bytes(expected) 

(though we should re-write the tests to work against pyasn1)

@carver
Copy link
Collaborator

carver commented May 24, 2019

If you like, I can open a separate PR to add the internal asn1 util, since it looks like I'm at least 40% of the way there (need to add the decoder, and to switch to the other library)

@pipermerriam
Copy link
Member

pipermerriam commented May 24, 2019 via email

@carver
Copy link
Collaborator

carver commented May 24, 2019

Okay, the decoder is also straightforward:

import pytest

import asn1tools
from eth_utils import (
    apply_to_return_value,
    big_endian_to_int, 
    int_to_big_endian,
)
from hypothesis import (
    example,
    settings,
    strategies as st,
    given,
)

ASN1_ECDSA_SPEC_STRING = """\
ECDSASpec DEFINITIONS ::= BEGIN
      ECDSASignature ::= SEQUENCE {
         r   INTEGER,
         s   INTEGER
     }
END
"""
ASN1_SPEC = asn1tools.compile_string(ASN1_ECDSA_SPEC_STRING, "der")

@apply_to_return_value(bytes)
def int_encoder(primitive):
    """
    Only handles 32-byte (yes *byte*) positive ints
    """
    # Integer tag
    yield 0x02

    encoded = int_to_big_endian(primitive)
    if encoded[0] >= 128:
        # Indicate that integer is positive (it always is, but doesn't always need the flag)
        yield len(encoded) + 1
        yield 0x00
    else:
        yield len(encoded)

    yield from encoded


@apply_to_return_value(bytes)
def two_int_sequence_encoder(int1, int2):
    # Sequence tag
    yield 0x30

    encoded1 = int_encoder(int1)
    encoded2 = int_encoder(int2)

    # Sequence length
    yield len(encoded1) + len(encoded2)

    yield from encoded1
    yield from encoded2


def decode_int(encoded):
    assert encoded[0] == 0x02

    length = encoded[1] 
    # to_int can handle leading zeros
    decoded_int = big_endian_to_int(encoded[2:2 + length])

    return decoded_int, encoded[2 + length:]
     
def two_int_sequence_decoder(encoded):
    assert encoded[0] == 0x30
     
    # skip sequence length 
    int1, rest = decode_int(encoded[2:]) 
    int2, empty = decode_int(rest)

    assert len(empty) == 0 
    return int1, int2 


MAX_32_BYTE_INT = 256 ** 32 - 1
@given(
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
)
@example(0, 0)
@example(MAX_32_BYTE_INT, MAX_32_BYTE_INT)
@example(MAX_32_BYTE_INT // 2, MAX_32_BYTE_INT // 2)
@example(MAX_32_BYTE_INT // 2 + 1, MAX_32_BYTE_INT // 2 + 1)
@settings(max_examples=1000)
def test_hand_coded_asn1(r, s):
    encoded = two_int_sequence_encoder(r, s)
    expected = ASN1_SPEC.encode("ECDSASignature", {"r": r, "s": s})
    assert encoded == bytes(expected)


MAX_32_BYTE_INT = 256 ** 32 - 1
@given(
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
    st.integers(min_value=0, max_value=MAX_32_BYTE_INT),
)
@example(0, 0)
@example(MAX_32_BYTE_INT, MAX_32_BYTE_INT)
@example(MAX_32_BYTE_INT // 2, MAX_32_BYTE_INT // 2)
@example(MAX_32_BYTE_INT // 2 + 1, MAX_32_BYTE_INT // 2 + 1)
@settings(max_examples=1000)
def test_asn1tools_encode_handrolled_decode(r, s):
    encoded = ASN1_SPEC.encode("ECDSASignature", {"r": r, "s": s})
    end_r, end_s = two_int_sequence_decoder(encoded) 
    assert (end_r, end_s) == (r, s)

If I don't hear back, I'll pick this up on Tuesday into its own PR.

@jannikluhn
Copy link
Contributor Author

Nice, thank you! That's indeed more straightforward than I expected. I've started to integrate your code, but now that you've written the decoder as well I think it would fit better in its own PR.

@jannikluhn
Copy link
Contributor Author

Removed the asn1tools dependency, in anticipation of @carver's PR. I just guessed an interface from the snippet above for now, but don't mind updating it if it turns out to be different.

@carver
Copy link
Collaborator

carver commented Jun 3, 2019

Ok, can rebase on #60 now

@jannikluhn
Copy link
Contributor Author

I've updated this PR to use new DER codec, cleaned up the commit history a little, and removed some direction duplication in the tests that carver already pointed out but slipped through for some reason. So it's ready from my side, could I get a final review so that we can merge this please?

Copy link
Collaborator

@carver carver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, the validation duplication is the only thing holding me back from a 👍 .

@@ -60,7 +72,7 @@ def test_recover_from_public_key_class(key_api, private_key):


def test_verify_from_public_key_obj(key_api, private_key):
signature = key_api.ecdsa_sign(MSGHASH, private_key)
signature = key_api.ecdsa_sign_non_recoverable(MSGHASH, private_key)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this one and test_recover_from_public_key_class should both be parametrized with [key_api.ecdsa_sign, key_api.ecdsa_sign_non_recoverable] se we can test against both kinds of signatures?

def s(self, value: int) -> None:
validate_integer(value)
validate_gte(value, 0)
validate_lt_secpk1n(value)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This move out of BaseSignature confused me on the first read-through. I thought the validations were missing from the non-recoverable. Now I see that the validations are duplicated, once in the BaseSignature init and once here. (I suppose this is because we want NonRecoverableSignature to be immutable, which seems like a nice direction).

The problem with the duplication that it would be very easy to update validation in one place but not the other. Maybe we can add a validate_? utility method (not sure what to call the union of r and s). Could maybe be an unbound utility method, or a staticmethod of BaseSignature I suppose. Then the setter here could just call validate_rs(value). BaseSignature init would use it also.

@@ -381,6 +340,15 @@ def __getitem__(self, index: int) -> int: # type: ignore
def __repr__(self) -> str:
return "'{0}'".format(self.to_hex())

def __index__(self) -> int:
return self.__int__()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, interesting, when would we ever be using a signature as an index for a slice?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@carver see: https://docs.python.org/3/library/operator.html#operator.__index__

It's part the python API for interpreting values as integers.

Copy link
Collaborator

@carver carver Jun 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was basing the comment on this note: https://docs.python.org/2.5/whatsnew/pep-357.html

So AFAICT, obj.__index__() is only called when you do something like mylist[obj:], and I couldn't imagine that being a common thing to do with a signature. :) I don't really have a problem with it, was just surprised that it was included.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was just a blind inclusion by myself a long time ago. @jannikluhn can you try dropping this method and see if anything in the tests breaks?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh, I missed that it was there before.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing it breaks this test: https://github.com/ethereum/eth-keys/blob/master/tests/core/test_key_and_signature_datastructures.py#L115

Looks like __index__ is used for hex, instead of __hex__. To me this was surprising. but apparently __hex__ got removed with python 3 (I can't find it in the docs anymore at least).

In my opinion, hex support should probably be removed altogether as it doesn't guarantee a length, so hex(signature) is not always the same as signature.to_hex(). But maybe in a separate PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But maybe in a separate PR?

👍

Can you open an issue for this.

Copy link
Member

@pipermerriam pipermerriam left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seconding @carver comments about duplication of validation logic.

msg_hash: bytes,
private_key: PrivateKey) -> NonRecoverableSignature:
signature_vrs = ecdsa_raw_sign(msg_hash, private_key.to_bytes())
signature_rs = signature_vrs[1:]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like this to be precise, us knowing that signature_rs is exactly two values, which this currently allows for it to be anything from empty to many values.

  • signature_vrs[1], signature_vrs[2]
  • signature_v, signature_r = signature_vrs[1:]

@@ -381,6 +340,15 @@ def __getitem__(self, index: int) -> int: # type: ignore
def __repr__(self) -> str:
return "'{0}'".format(self.to_hex())

def __index__(self) -> int:
return self.__int__()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@carver see: https://docs.python.org/3/library/operator.html#operator.__index__

It's part the python API for interpreting values as integers.

@pipermerriam
Copy link
Member

@jannikluhn let me know when you're read for us to click merge (or you can if you have permission but I don't think you do)

@jannikluhn
Copy link
Contributor Author

Thanks, you're right, I don't have permission. It's ready from my side, as long as you're happy with the name validate_signature_r_or_s (couldn't come up with something better, but at least it's expressive).

Also, would be great if you could make a new release so that we can use it in Trinity.

@pipermerriam
Copy link
Member

gonna wait for @carver to second this, then I'll merge and cut you a release.

Copy link
Collaborator

@carver carver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@carver carver merged commit 2abd193 into ethereum:master Jun 5, 2019
@pipermerriam
Copy link
Member

@jannikluhn available as v0.3.0

pacrob pushed a commit to pacrob/eth-keys that referenced this pull request Dec 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants