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

Make bls.Signer api fallible #3696

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open

Conversation

richardpringle
Copy link
Contributor

@richardpringle richardpringle commented Feb 3, 2025

  • Make ProofOfPossession creation fallible
  • Make bls.Signer api fallible

Why this should be merged

If we want to add any networking in between the keys, we'll need a fallible API.

I've implemented the rpcsigner here as a grpc client with a server implemented in a _test.go file. The idea here is that anyone who implements the interface.

Open Question:

There's one little gotchya here for anyone implementing the interface. It's that we expect the keys and signatures to be compressed when sent over the wire. I'm open to using the more general serialization OR adding the compressed prefix to the proto-message field names.

TODO:

Further, we should probably add a minimal test suite that an external implementor can run, however, I think could probably be implemented in a follow up PR as it is not a priority at the moment.

How this works

How this was tested

Need to be documented in RELEASES.md?

chains/manager.go Outdated Show resolved Hide resolved
network/peer/ip.go Show resolved Hide resolved
@richardpringle richardpringle force-pushed the consolidate-signers branch 2 times, most recently from 2653584 to 9fcc414 Compare February 4, 2025 20:49
Copy link
Contributor Author

Choose a reason for hiding this comment

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

please help with naming!?

Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't this just be server.go? I don't think this is a test file - this is the server implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought I already responded to this (maybe I forgot to submit the comment). My issue here is that I actually don't want people to use this. There's no benefit to running this on the same server vs running a local-signer and there's no auth mechanism implementation for running it on a separate server.

Maybe the mistake is testing the client at all?

Maybe we can just have a set of tests that implementors can run to make sure this works as expected? Curious to hear your thoughts

Comment on lines 560 to 564
BLSSignature: (func() *bls.Signature {
sig, err := blsKey.SignProofOfPossession([]byte("wrong message"))
require.NoError(t, err)
return sig
})(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
BLSSignature: (func() *bls.Signature {
sig, err := blsKey.SignProofOfPossession([]byte("wrong message"))
require.NoError(t, err)
return sig
})(),
BLSSignature: func() *bls.Signature {
sig, err := blsKey.SignProofOfPossession([]byte("wrong message"))
require.NoError(t, err)
return sig
}(),

Copy link
Contributor

Choose a reason for hiding this comment

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

We wrap this with () in a few spots in this PR, but I don't think it's necessary?

node/node.go Outdated
Comment on lines 1382 to 1384
if err != nil {
return err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't have a strong preference for wrapping this error or not, but I think we should be consistent w/ whatever we end up doing for this and the above error earlier in this diff

service Signer {
rpc PublicKey(PublicKeyRequest) returns (PublicKeyResponse) {}
rpc Sign(SignatureRequest) returns (SignatureResponse) {}
rpc ProofOfPossession(ProofOfPossessionSignatureRequest) returns (ProofOfPossessionSignatureResponse) {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe prefix this + the corresponding request/response types with Sign* to align this more closely with the existing Sign rpc, as they are similar apis with differing ciphertexts? i.e
rpc SignProofOfPossession(SignProofOfPossessionRequest) returns (SignProofOfPossessionResponse) {}?

Comment on lines 33 to 51
func collectN[T any](n int, fn func() T) []T {
result := make([]T, n)
for i := 0; i < n; i++ {
result[i] = fn()
}
return result
}

func mapWithError[T any, U any](arr []T, fn func(T) (U, error)) ([]U, error) {
result := make([]U, len(arr))
for i, val := range arr {
newVal, err := fn(val)
if err != nil {
return nil, err
}
result[i] = newVal
}
return result, nil
}
Copy link
Contributor

@joshua-kim joshua-kim Feb 5, 2025

Choose a reason for hiding this comment

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

I would probably not use these functions... the way we're using collectN I think is over-engineering/not improving the readability of the tests. mapWithError is more useful than collectN so if you want to keep it in I'm fine with it, but I don't think writing out:

sig1, err := sk1.Sign(msg)
require.NoError(err)
sig2, err := sk2.Sign(msg)
require.NoError(err)
sig3, err := sk3.Sign(msg)
require.NoError(err)

sigs := []*bls.SIgnature{sig1, sig2, sig3}

is actually that bad IMO.

wrongSig, err := signers[0].Sign(differentMsg)
require.NoError(err)

signatures[0] = wrongSig
Copy link
Contributor

@joshua-kim joshua-kim Feb 5, 2025

Choose a reason for hiding this comment

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

This line is an example where I feel like the mapWithError util looks worse, instead of just writing out:

wrongSig, err := sk1.Sign(differentMsg)
require.NoError(err)
sig2, err := sk2.Sign(msg)
require.NoError(err)
sig3, err := sk3.Sign(msg)
require.NoError(err)

sigs := []*bls.SIgnature{wrongSig, sig2, sig3}

Comment on lines 117 to 118
isValid := bls.Verify(signer.PublicKey(), sig, msg)
require.True(isValid)
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. This can be done in a one-liner
  2. Generally Go uses the ok var name for bool return values
Suggested change
isValid := bls.Verify(signer.PublicKey(), sig, msg)
require.True(isValid)
require.True(bls.Verify(signer.PublicKey(), sig, msg))

Comment on lines 126 to 127
msg := []byte("TestVerifyWrongMessageSignature local signer")
wrongMsg := []byte("TestVerifyWrongMessageSignature local signer with wrong message")
Copy link
Contributor

@joshua-kim joshua-kim Feb 5, 2025

Choose a reason for hiding this comment

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

Nit: The payloads of these messages in this test file don't need to be related to the test name + why make them longer when the contents don't matter? Ex. my IDE will pick up on this + we have to keep the test names/strings consistent

Suggested change
msg := []byte("TestVerifyWrongMessageSignature local signer")
wrongMsg := []byte("TestVerifyWrongMessageSignature local signer with wrong message")
msg := []byte("foo")
wrongMsg := []byte("bar")

require.True(isValid)
}

func TestVerifyWrongMessageSignature(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be refactored w/ TestVerifyValidSignature into a test-table if we the expected message a parameter

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is it really worth combining two tests into a table test? The logic is different for each test. In the first test, we have a message stored at variable msg. We sign the message and pass that same message and signature for verification. In the second test we have two separate inputs. We sign msg again, but we pass wrongMsg in for signature verification. Therefore the logic for each of the two tests are subtly different.

I could make a table test (with only two tests) that has a msg field as well as a verificationMsg field, but now I have to either copy/paste the same thing into one of the test-structs or define a variable outside. I'm kind of shoehorning things to save a few lines of code.

Comment on lines 136 to 174
func TestValidAggregation(t *testing.T) {
require := require.New(t)
keyPairs := collectN(2, func() bls.Signer {
return NewLocalSigner(require)
})

keyPairs = append(keyPairs, NewLocalSigner(require))

msg := []byte("TestValidAggregation local signer")

sigs, err := mapWithError(keyPairs, func(s bls.Signer) (*bls.Signature, error) {
return s.Sign(msg)
})
require.NoError(err)

pks, _ := mapWithError(keyPairs, func(s bls.Signer) (*bls.PublicKey, error) {
return s.PublicKey(), nil
})

isValid, err := AggregateAndVerify(pks, sigs, msg)
require.NoError(err)
require.True(isValid)
}

func TestSingleKeyAggregation(t *testing.T) {
require := require.New(t)
signer := NewRpcSigner(require)

pks := []*bls.PublicKey{signer.PublicKey()}

msg := []byte("TestSingleKeyAggregation local signer")

sig, err := signer.Sign(msg)
require.NoError(err)

isValid, err := AggregateAndVerify(pks, []*bls.Signature{sig}, msg)
require.NoError(err)
require.True(isValid)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need tests on aggregation working? I feel like we already know aggregation will work because we are just implementing a type that produces bls.Signature and bls.Publickey, and those types + aggregation are already tested in the upstream package bls. I feel like coverage for rpcsigner only needs to cover that the Sign* functions produce signatures that are verifiable off of the PublicKey.

utils/crypto/bls/signer/rpcsigner/server_test.go Outdated Show resolved Hide resolved
require.True(isValid)
}

func TestVerifyValidProofOfPossession(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar comment regarding test tables w/ the following test

@richardpringle richardpringle force-pushed the consolidate-signers branch 2 times, most recently from d57fb5b to 69ae4aa Compare February 6, 2025 21:59
Comment on lines 17 to 20
message SignatureRequest {
bytes message = 1;
}
message SignatureResponse {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: SignRequest/SignResponse

Comment on lines 23 to 26
message ProofOfPossessionSignatureRequest {
bytes message = 1;
}
message ProofOfPossessionSignatureResponse {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: SignProofOfPossessionSignatureRequest/Response

return getSignatureFromResponse(resp)
}

func (c *Client) PublicKey() *bls.PublicKey {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: put this before Sign to be consistent w/ ordering on the interface

}

func (c *Client) Sign(message []byte) (*bls.Signature, error) {
resp, err := c.client.Sign(context.Background(), &pb.SignatureRequest{Message: message})
Copy link
Contributor

Choose a reason for hiding this comment

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

Use TODO instead of Background


pubkeyResponse, err := client.PublicKey(context.TODO(), &pb.PublicKeyRequest{})
if err != nil {
conn.Close()
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel weird about this code owning the lifecycle of the connection since it's provided by the caller. Some of these errors don't look persistent + you can still re-use the connection since you can retry rpcs on different streams on the same http/2 connection even if this errored out.

You could probably make a strong case for some of these errors being persistent (i.e the empty public key case, the invalid pk case), but I would still think that you could handle all of these errors at the call-site through a connection close and have all the connection lifecycle managed there instead of having mixed ownership

Comment on lines 58 to 60
func (c *Client) Close() error {
return c.conn.Close()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Related to my last comment but I think this can be removed as well

Comment on lines +62 to +73
type signatureResponse interface {
GetSignature() []byte
}

func getSignatureFromResponse[T signatureResponse](resp T) (*bls.Signature, error) {
signature := resp.GetSignature()
if len(signature) == 0 {
return nil, ErrorEmptySignature
}

return bls.SignatureFromBytes(signature)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't tell if this is hacky or clever but it works


var tests = []test{
{
name: "Uncompressed",
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: lowercase all of the test names (not sure if this is a go standard or not, but it is consistent w/ the rest of our tests)

func TestValidSignature(t *testing.T) {
sig, err := client.Sign(validSignatureMsg)
require.NoError(t, err)
bls.Verify(client.PublicKey(), sig, validSignatureMsg)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you probably meant to wrap a require.True(...) around this

t.Run(test.name, func(t *testing.T) {
sig, err := client.Sign(test.msg)
require.Nil(t, sig)
require.EqualError(t, err, test.err.Error())
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment regarding ErrorIs

func TestValidPOPSignature(t *testing.T) {
sig, err := client.SignProofOfPossession(validSignatureMsg)
require.NoError(t, err)
bls.VerifyProofOfPossession(client.PublicKey(), sig, validSignatureMsg)
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment regarding the assertion

t.Run(test.name, func(t *testing.T) {
sig, err := client.SignProofOfPossession(test.msg)
require.Nil(t, sig)
require.EqualError(t, err, test.err.Error())
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't these tests use require.ErrorIs which also handles error wrapping?

Comment on lines 22 to 28
localSigner, _ = localsigner.New()

client = &Client{
client: &testClient{},
pk: localSigner.PublicKey(),
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we re-use this instance across tests? Even if there isn't mutable state I generally try to avoid sharing types across tests to avoid introducing it accidentally

}

func (*testClient) PublicKey(_ context.Context, _ *signer.PublicKeyRequest, _ ...grpc.CallOption) (*signer.PublicKeyResponse, error) {
return nil, nil
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is supposed to return the pk instead of nil - my guess is that this isn't caught because of the missing assertions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't tested, I'll add a comment

Copy link
Contributor Author

@richardpringle richardpringle Feb 10, 2025

Choose a reason for hiding this comment

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

Comment added

bytes := sig.Serialize()

return &signer.SignResponse{
Signature: bytes[:bls.SignatureLen],
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we reslice here? Is this this sig otherwise not 96 bytes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a failure case, I'll add a comment

Copy link
Contributor Author

Choose a reason for hiding this comment

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

comments added

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.

2 participants