diff --git a/pdp/auth.go b/pdp/auth.go index c12c3cd..cdf66d9 100644 --- a/pdp/auth.go +++ b/pdp/auth.go @@ -12,19 +12,32 @@ import ( "github.com/ipfs/go-cid" ) +// SignDigestFunc signs a 32-byte keccak digest and returns a 65-byte +// recoverable secp256k1 signature in [R || S || V] form, where V is the +// recovery ID (0 or 1). AuthHelper normalizes V to 27/28 internally +// before producing the AuthSignature for on-chain consumption. +type SignDigestFunc func(digest []byte) ([]byte, error) + +// AuthHelper signs PDP extraData blobs (CreateDataSet, AddPieces, etc.) +// against the FWSS EIP-712 domain. It does not hold a key directly: +// callers supply a SignDigestFunc, which lets the EVMSigner abstraction +// (or an HSM, remote signer, etc.) own the key material. type AuthHelper struct { - privateKey *ecdsa.PrivateKey + signDigest SignDigestFunc address common.Address warmStorageAddress common.Address chainID *big.Int domain apitypes.TypedDataDomain } -func NewAuthHelper(privateKey *ecdsa.PrivateKey, warmStorageAddr common.Address, chainID *big.Int) *AuthHelper { - address := crypto.PubkeyToAddress(privateKey.PublicKey) - +// NewAuthHelper builds an AuthHelper bound to the given signer, payer +// address, FWSS contract address, and chainID. The address is the +// recovered signer of every signature this helper produces; passing a +// mismatched (signDigest, address) pair results in signatures that +// FWSS will reject at eth_call time. +func NewAuthHelper(signDigest SignDigestFunc, address common.Address, warmStorageAddr common.Address, chainID *big.Int) *AuthHelper { return &AuthHelper{ - privateKey: privateKey, + signDigest: signDigest, address: address, warmStorageAddress: warmStorageAddr, chainID: chainID, @@ -37,6 +50,17 @@ func NewAuthHelper(privateKey *ecdsa.PrivateKey, warmStorageAddr common.Address, } } +// NewAuthHelperFromKey is a convenience for callers that hold a raw +// secp256k1 key (test fixtures, scripts, examples). Production code +// should plumb through an EVMSigner and use NewAuthHelper directly. +func NewAuthHelperFromKey(privateKey *ecdsa.PrivateKey, warmStorageAddr common.Address, chainID *big.Int) *AuthHelper { + address := crypto.PubkeyToAddress(privateKey.PublicKey) + signDigest := func(digest []byte) ([]byte, error) { + return crypto.Sign(digest, privateKey) + } + return NewAuthHelper(signDigest, address, warmStorageAddr, chainID) +} + func (a *AuthHelper) Address() common.Address { return a.address } @@ -185,10 +209,13 @@ func (a *AuthHelper) signTypedData(primaryType string, message apitypes.TypedDat rawData = append(rawData, messageHash...) signedData := crypto.Keccak256Hash(rawData) - signature, err := crypto.Sign(signedData.Bytes(), a.privateKey) + signature, err := a.signDigest(signedData.Bytes()) if err != nil { return nil, fmt.Errorf("failed to sign: %w", err) } + if len(signature) != 65 { + return nil, fmt.Errorf("signer returned %d bytes, expected 65", len(signature)) + } if signature[64] < 27 { signature[64] += 27 diff --git a/pdp/auth_test.go b/pdp/auth_test.go index fbe53ad..f78de77 100644 --- a/pdp/auth_test.go +++ b/pdp/auth_test.go @@ -135,7 +135,7 @@ func setupAuthHelper(t *testing.T) *AuthHelper { contractAddr := common.HexToAddress(fixtures.ContractAddress) chainID := big.NewInt(fixtures.ChainID) - return NewAuthHelper(privateKey, contractAddr, chainID) + return NewAuthHelperFromKey(privateKey, contractAddr, chainID) } func TestAuthHelper_SignCreateDataSet(t *testing.T) { @@ -361,3 +361,63 @@ func TestAuthHelper_Address(t *testing.T) { t.Errorf("Address() returned %s, expected %s", authHelper.Address().Hex(), expectedAddr.Hex()) } } + +// TestAuthHelper_SignDigestFunc verifies that NewAuthHelper accepts a +// SignDigestFunc and produces signatures that match the from-key path +// byte-for-byte (locks in the contract that the function-based +// constructor is just a thin wrapper). +func TestAuthHelper_SignDigestFunc(t *testing.T) { + privateKeyBytes, _ := hex.DecodeString(fixtures.PrivateKey) + privateKey, _ := crypto.ToECDSA(privateKeyBytes) + address := crypto.PubkeyToAddress(privateKey.PublicKey) + contractAddr := common.HexToAddress(fixtures.ContractAddress) + chainID := big.NewInt(fixtures.ChainID) + + signFn := func(digest []byte) ([]byte, error) { + return crypto.Sign(digest, privateKey) + } + + helperFromFn := NewAuthHelper(signFn, address, contractAddr, chainID) + helperFromKey := NewAuthHelperFromKey(privateKey, contractAddr, chainID) + + clientDataSetID := big.NewInt(fixtures.Signatures.CreateDataSet.ClientDataSetID) + payee := common.HexToAddress(fixtures.Signatures.CreateDataSet.Payee) + + sigA, err := helperFromFn.SignCreateDataSet(clientDataSetID, payee, fixtures.Signatures.CreateDataSet.Metadata) + if err != nil { + t.Fatalf("SignCreateDataSet (fn): %v", err) + } + sigB, err := helperFromKey.SignCreateDataSet(clientDataSetID, payee, fixtures.Signatures.CreateDataSet.Metadata) + if err != nil { + t.Fatalf("SignCreateDataSet (key): %v", err) + } + + if hex.EncodeToString(sigA.Signature) != hex.EncodeToString(sigB.Signature) { + t.Errorf("SignDigestFunc and FromKey paths produced different signatures:\n fn: %x\n key: %x", + sigA.Signature, sigB.Signature) + } + if helperFromFn.Address() != address { + t.Errorf("Address mismatch: helper=%s want=%s", helperFromFn.Address().Hex(), address.Hex()) + } +} + +// TestAuthHelper_RejectsBadSignerOutput verifies the length check in +// signTypedData when the SignDigestFunc misbehaves. +func TestAuthHelper_RejectsBadSignerOutput(t *testing.T) { + contractAddr := common.HexToAddress(fixtures.ContractAddress) + chainID := big.NewInt(fixtures.ChainID) + dummyAddr := common.HexToAddress("0x1234567890123456789012345678901234567890") + + badSignFn := func(digest []byte) ([]byte, error) { + return []byte{0x00, 0x01, 0x02}, nil // wrong length + } + + helper := NewAuthHelper(badSignFn, dummyAddr, contractAddr, chainID) + _, err := helper.SignCreateDataSet(big.NewInt(1), dummyAddr, nil) + if err == nil { + t.Error("expected error from short signer output, got nil") + } + if !strings.Contains(err.Error(), "expected 65") { + t.Errorf("error did not mention expected length: %v", err) + } +} diff --git a/pdp/server_test.go b/pdp/server_test.go index cb13617..57f0e5b 100644 --- a/pdp/server_test.go +++ b/pdp/server_test.go @@ -23,7 +23,7 @@ func testAuthHelper(t *testing.T) *AuthHelper { contractAddr := common.HexToAddress("0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f") chainID := big.NewInt(31337) - return NewAuthHelper(privateKey, contractAddr, chainID) + return NewAuthHelperFromKey(privateKey, contractAddr, chainID) } func setupMockServer(t *testing.T, handler http.Handler) (*Server, *httptest.Server) { diff --git a/signer/secp256k1.go b/signer/secp256k1.go index 2db223e..b6084eb 100644 --- a/signer/secp256k1.go +++ b/signer/secp256k1.go @@ -13,15 +13,15 @@ import ( blake2b "github.com/minio/blake2b-simd" - dcrdecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" dcrdsecp "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrdecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" ) // Secp256k1Signer implements EVMSigner backed by a secp256k1 private key. // It can sign both Filecoin messages and Ethereum transactions. type Secp256k1Signer struct { // redundant with ecdsaKey.D but kept for simplicity; unexported, can be changed later - raw []byte // raw 32-byte scalar + raw []byte // raw 32-byte scalar ecdsaKey *ecdsa.PrivateKey filAddr address.Address ethAddr common.Address @@ -118,3 +118,13 @@ func (s *Secp256k1Signer) EVMAddress() common.Address { func (s *Secp256k1Signer) Transactor(chainID *big.Int) (*bind.TransactOpts, error) { return bind.NewKeyedTransactorWithChainID(s.ecdsaKey, chainID) } + +// SignDigest produces a 65-byte recoverable secp256k1 signature over the +// given 32-byte keccak digest. V is the recovery ID (0 or 1); callers +// requiring the historical Ethereum 27/28 form must add 27 themselves. +func (s *Secp256k1Signer) SignDigest(digest []byte) ([]byte, error) { + if len(digest) != 32 { + return nil, fmt.Errorf("digest must be 32 bytes, got %d", len(digest)) + } + return ethcrypto.Sign(digest, s.ecdsaKey) +} diff --git a/signer/signer.go b/signer/signer.go index 6512496..402f6b2 100644 --- a/signer/signer.go +++ b/signer/signer.go @@ -24,9 +24,17 @@ type Signer interface { Sign(msg []byte) (*crypto.Signature, error) } -// EVMSigner signs Ethereum/FEVM transactions. Only secp256k1 keys can do this. +// EVMSigner signs Ethereum/FEVM transactions and EIP-712 typed data. +// Only secp256k1 keys can do this. +// +// SignDigest produces a 65-byte recoverable secp256k1 signature over a +// 32-byte keccak digest, in [R || S || V] form with V = 0 or 1 (the +// go-ethereum crypto.Sign convention). Callers that need on-chain +// ECDSA recovery (e.g. PDP extraData) must normalize V to 27/28 +// themselves; the digest-signer interface keeps the raw recovery ID. type EVMSigner interface { Signer EVMAddress() common.Address Transactor(chainID *big.Int) (*bind.TransactOpts, error) + SignDigest(digest []byte) ([]byte, error) } diff --git a/signer/signer_test.go b/signer/signer_test.go index 6e73893..6408746 100644 --- a/signer/signer_test.go +++ b/signer/signer_test.go @@ -62,6 +62,31 @@ func TestSecp256k1Signer_DualProtocol(t *testing.T) { if opts.From != expectedEth { t.Errorf("Transactor.From = %s, want %s", opts.From, expectedEth) } + + // SignDigest should produce a 65-byte signature recoverable to the same address + digest := ethcrypto.Keccak256([]byte("test digest input")) + sigBytes, err := s.SignDigest(digest) + if err != nil { + t.Fatalf("SignDigest: %v", err) + } + if len(sigBytes) != 65 { + t.Errorf("SignDigest length = %d, want 65", len(sigBytes)) + } + if sigBytes[64] != 0 && sigBytes[64] != 1 { + t.Errorf("SignDigest V = %d, want 0 or 1", sigBytes[64]) + } + recovered, err := ethcrypto.SigToPub(digest, sigBytes) + if err != nil { + t.Fatalf("SigToPub: %v", err) + } + if ethcrypto.PubkeyToAddress(*recovered) != expectedEth { + t.Errorf("recovered address %s != signer %s", ethcrypto.PubkeyToAddress(*recovered), expectedEth) + } + + // SignDigest rejects non-32-byte input + if _, err := s.SignDigest([]byte("short")); err == nil { + t.Error("SignDigest should reject non-32-byte input") + } } func TestSecp256k1Signer_FromLotusExport(t *testing.T) { diff --git a/synapse.go b/synapse.go index 23d368e..c4374c8 100644 --- a/synapse.go +++ b/synapse.go @@ -124,7 +124,7 @@ func (c *Client) Storage() (*storage.Manager, error) { return nil, fmt.Errorf("provider URL is required for storage operations") } - authHelper := pdp.NewAuthHelper(c.privateKey, c.warmStorageAddress, big.NewInt(c.chainID)) + authHelper := pdp.NewAuthHelperFromKey(c.privateKey, c.warmStorageAddress, big.NewInt(c.chainID)) pdpServer := pdp.NewServer(c.providerURL) var opts []storage.ManagerOption @@ -192,7 +192,7 @@ func (c *Client) Close() { } func (c *Client) NewAuthHelper() *pdp.AuthHelper { - return pdp.NewAuthHelper(c.privateKey, c.warmStorageAddress, big.NewInt(c.chainID)) + return pdp.NewAuthHelperFromKey(c.privateKey, c.warmStorageAddress, big.NewInt(c.chainID)) } func (c *Client) NewPDPServer(providerURL string) *pdp.Server {