Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions token/driver/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ const (
HTLCScriptIdentityType IdentityType = 4
MultiSigIdentityType IdentityType = 5
PolicyIdentityType IdentityType = 6
Secp256k1IdentityType IdentityType = 7
)

// IdentityTypeString identifies the type of identity as a string
Expand All @@ -219,6 +220,7 @@ const (
HTLCScriptIdentityTypeString IdentityTypeString = "htlc"
MultiSigIdentityTypeString IdentityTypeString = "multisig"
PolicyIdentityTypeString IdentityTypeString = "policy"
Secp256k1IdentityTypeString IdentityTypeString = "secp256k1"
)

// Authorization checks the relationship between a token and different wallet types (owner, issuer, auditor).
Expand Down
121 changes: 121 additions & 0 deletions token/services/identity/eth/eip712.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package eth

import (
"encoding/binary"

"golang.org/x/crypto/sha3"
)

// Domain holds the EIP-712 domain separator fields that identify a specific
// token SDK deployment. Every deployment should choose a unique Name +
// Version + ChainID combination so that a signature produced for one network
// cannot be replayed on another.
type Domain struct {
// Name is a human-readable label for the signing domain, e.g. "FabricTokenSDK".
Name string
// Version is the domain version string, e.g. "1".
Version string
// ChainID is the EIP-155 chain identifier of the target EVM network.
ChainID uint64
}

// EndorsementRequest is the typed data that co-signers sign off-chain to
// approve a pending token operation before it is submitted to the ledger.
//
// The three fields map directly to the EIP-712 type string:
//
// EndorsementRequest(string tmsID,string txID,uint64 deadline)
//
// tmsID identifies the Token Management System (network:channel:namespace).
// txID is the transaction identifier of the pending token request.
// deadline is a Unix timestamp after which the approval is considered void
// (use 0 to express no expiry).
type EndorsementRequest struct {
TMSID string
TxID string
Deadline uint64
}

// endorsementTypeString is the canonical EIP-712 type string for EndorsementRequest.
const endorsementTypeString = "EndorsementRequest(string tmsID,string txID,uint64 deadline)"

// domainTypeString is the canonical EIP-712 type string for the domain separator.
const domainTypeString = "EIP712Domain(string name,string version,uint64 chainID)"

// HashEndorsementRequest returns the 32-byte EIP-712 digest for req under the
// given domain. Pass this digest directly to Signer.Sign — the signer will
// keccak256 it once more, producing the final value that is actually signed
// (matching the Ethereum convention of always signing a hash).
//
// The computation follows EIP-712 exactly:
//
// digest = keccak256("\x19\x01" || domainSeparator(domain) || structHash(req))
func HashEndorsementRequest(domain Domain, req EndorsementRequest) []byte {
domainSep := hashDomain(domain)
structHash := hashEndorsementStruct(req)

// EIP-712 envelope: 0x19 0x01 || domainSeparator || structHash
buf := make([]byte, 2+32+32)
buf[0] = 0x19
buf[1] = 0x01
copy(buf[2:34], domainSep)
copy(buf[34:], structHash)

return keccak256(buf)
}

// hashDomain computes the EIP-712 domain separator for d.
func hashDomain(d Domain) []byte {
typeHash := keccak256([]byte(domainTypeString))
nameHash := keccak256([]byte(d.Name))
versionHash := keccak256([]byte(d.Version))
chainIDPadded := uint64ToBytes32(d.ChainID)

buf := make([]byte, 4*32)
copy(buf[0:32], typeHash)
copy(buf[32:64], nameHash)
copy(buf[64:96], versionHash)
copy(buf[96:128], chainIDPadded)

return keccak256(buf)
}

// hashEndorsementStruct computes the EIP-712 struct hash for req.
func hashEndorsementStruct(req EndorsementRequest) []byte {
typeHash := keccak256([]byte(endorsementTypeString))
tmsIDHash := keccak256([]byte(req.TMSID))
txIDHash := keccak256([]byte(req.TxID))
deadlinePadded := uint64ToBytes32(req.Deadline)

buf := make([]byte, 4*32)
copy(buf[0:32], typeHash)
copy(buf[32:64], tmsIDHash)
copy(buf[64:96], txIDHash)
copy(buf[96:128], deadlinePadded)

return keccak256(buf)
}

// keccak256 computes the Ethereum-compatible Keccak-256 hash of data.
// It uses the pre-standardisation variant (legacy Keccak) that Ethereum
// adopted, which differs from the NIST SHA3-256 standard.
func keccak256(data []byte) []byte {
h := sha3.NewLegacyKeccak256()
h.Write(data)

return h.Sum(nil)
}

// uint64ToBytes32 encodes v as a 32-byte big-endian value (ABI uint256 encoding).
func uint64ToBytes32(v uint64) []byte {
b := make([]byte, 32)
binary.BigEndian.PutUint64(b[24:], v)

return b
}
188 changes: 188 additions & 0 deletions token/services/identity/eth/eth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package eth_test

import (
"testing"

"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/hyperledger-labs/fabric-token-sdk/token/services/identity/eth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// generateKey is a test helper that creates a fresh secp256k1 key pair.
func generateKey(t *testing.T) (*secp256k1.PrivateKey, *secp256k1.PublicKey) {
t.Helper()
priv, err := secp256k1.GeneratePrivateKey()
require.NoError(t, err)

return priv, priv.PubKey()
}

// ---------------------------------------------------------------------------
// Signer / Verifier round-trip tests
// ---------------------------------------------------------------------------

func TestSignVerify_RoundTrip(t *testing.T) {
priv, pub := generateKey(t)
signer := eth.NewSigner(priv)
verifier := eth.NewVerifier(pub)

message := []byte("approve token transfer tx-001")
sig, err := signer.Sign(message)
require.NoError(t, err)
require.NotEmpty(t, sig)

require.NoError(t, verifier.Verify(message, sig))
}

func TestVerify_WrongMessage(t *testing.T) {
priv, pub := generateKey(t)
signer := eth.NewSigner(priv)
verifier := eth.NewVerifier(pub)

sig, err := signer.Sign([]byte("original message"))
require.NoError(t, err)

err = verifier.Verify([]byte("tampered message"), sig)
require.Error(t, err)
}

func TestVerify_WrongKey(t *testing.T) {
priv, _ := generateKey(t)
_, differentPub := generateKey(t)

signer := eth.NewSigner(priv)
verifier := eth.NewVerifier(differentPub)

sig, err := signer.Sign([]byte("hello"))
require.NoError(t, err)

err = verifier.Verify([]byte("hello"), sig)
require.Error(t, err)
}

func TestSign_NilKey_ReturnsError(t *testing.T) {
signer := eth.NewSigner(nil)
_, err := signer.Sign([]byte("msg"))
require.Error(t, err)
}

func TestVerify_NilKey_ReturnsError(t *testing.T) {
verifier := eth.NewVerifier(nil)
err := verifier.Verify([]byte("msg"), []byte("sig"))
require.Error(t, err)
}

func TestVerify_MalformedSignature(t *testing.T) {
_, pub := generateKey(t)
verifier := eth.NewVerifier(pub)
err := verifier.Verify([]byte("msg"), []byte("not-a-der-signature"))
require.Error(t, err)
}

// ---------------------------------------------------------------------------
// AddressFromPublicKey tests
// ---------------------------------------------------------------------------

func TestAddressFromPublicKey_Deterministic(t *testing.T) {
_, pub := generateKey(t)
addr1 := eth.AddressFromPublicKey(pub)
addr2 := eth.AddressFromPublicKey(pub)
assert.Equal(t, addr1, addr2)
}

func TestAddressFromPublicKey_DifferentKeys_DifferentAddresses(t *testing.T) {
_, pub1 := generateKey(t)
_, pub2 := generateKey(t)
addr1 := eth.AddressFromPublicKey(pub1)
addr2 := eth.AddressFromPublicKey(pub2)
assert.NotEqual(t, addr1, addr2)
}

func TestAddressFromPublicKey_Length(t *testing.T) {
_, pub := generateKey(t)
addr := eth.AddressFromPublicKey(pub)
assert.Len(t, addr, 20)
}

// ---------------------------------------------------------------------------
// EIP-712 HashEndorsementRequest tests
// ---------------------------------------------------------------------------

var testDomain = eth.Domain{
Name: "FabricTokenSDK",
Version: "1",
ChainID: 1,
}

func TestHashEndorsementRequest_Deterministic(t *testing.T) {
req := eth.EndorsementRequest{
TMSID: "testnet:ch1:ns1",
TxID: "tx-abc-123",
Deadline: 9999999999,
}

h1 := eth.HashEndorsementRequest(testDomain, req)
h2 := eth.HashEndorsementRequest(testDomain, req)
assert.Equal(t, h1, h2)
assert.Len(t, h1, 32)
}

func TestHashEndorsementRequest_DifferentTxIDs_DifferentHashes(t *testing.T) {
req1 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}
req2 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-2", Deadline: 0}

h1 := eth.HashEndorsementRequest(testDomain, req1)
h2 := eth.HashEndorsementRequest(testDomain, req2)
assert.NotEqual(t, h1, h2)
}

func TestHashEndorsementRequest_DifferentDomains_DifferentHashes(t *testing.T) {
req := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}

domainA := eth.Domain{Name: "SDKv1", Version: "1", ChainID: 1}
domainB := eth.Domain{Name: "SDKv1", Version: "1", ChainID: 137} // Polygon

h1 := eth.HashEndorsementRequest(domainA, req)
h2 := eth.HashEndorsementRequest(domainB, req)
assert.NotEqual(t, h1, h2)
}

func TestHashEndorsementRequest_DifferentDeadlines_DifferentHashes(t *testing.T) {
req1 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}
req2 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 1700000000}

h1 := eth.HashEndorsementRequest(testDomain, req1)
h2 := eth.HashEndorsementRequest(testDomain, req2)
assert.NotEqual(t, h1, h2)
}

// ---------------------------------------------------------------------------
// End-to-end: sign an EIP-712 endorsement and verify it
// ---------------------------------------------------------------------------

func TestEndorseAndVerify_EIP712(t *testing.T) {
priv, pub := generateKey(t)
signer := eth.NewSigner(priv)
verifier := eth.NewVerifier(pub)

req := eth.EndorsementRequest{
TMSID: "testnet:mychannel:token-ns",
TxID: "transfer-tx-xyz",
Deadline: 1800000000,
}

// The endorser hashes the request with EIP-712 and signs.
digest := eth.HashEndorsementRequest(testDomain, req)
sig, err := signer.Sign(digest)
require.NoError(t, err)

// The verifier independently re-derives the digest and confirms the signature.
require.NoError(t, verifier.Verify(digest, sig))
}
52 changes: 52 additions & 0 deletions token/services/identity/eth/signer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

// Package eth provides secp256k1 identity primitives for the Ethereum/EVM driver.
//
// Identities are Ethereum accounts: a 20-byte address derived from a secp256k1
// public key. Signatures are ECDSA over secp256k1 using keccak256 as the
// pre-hash, which matches the Ethereum eth_sign and EIP-712 conventions.
//
// Endorsement approvals for off-chain co-signers use the EIP-712 typed-data
// envelope defined in eip712.go. Callers build an EndorsementRequest, obtain
// its canonical digest via HashEndorsementRequest, then hand that digest to
// Signer.Sign.
package eth

import (
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
)

// Signer produces secp256k1 ECDSA signatures compatible with Ethereum's
// signing conventions. It implements driver.Signer.
//
// Sign hashes the supplied message with keccak256 and signs the resulting
// 32-byte digest with the private key. The returned signature is DER-encoded.
// Callers that want EIP-712 semantics should pass the output of
// HashEndorsementRequest as the message so that the final keccak256 inside
// Sign produces the correct EIP-712 digest.
type Signer struct {
privKey *secp256k1.PrivateKey
}

// NewSigner returns a Signer backed by the given secp256k1 private key.
func NewSigner(privKey *secp256k1.PrivateKey) *Signer {
return &Signer{privKey: privKey}
}

// Sign hashes message with keccak256 and returns a DER-encoded ECDSA signature.
func (s *Signer) Sign(message []byte) ([]byte, error) {
if s.privKey == nil {
return nil, errors.New("secp256k1 signer: nil private key")
}

digest := keccak256(message)
sig := ecdsa.Sign(s.privKey, digest)

return sig.Serialize(), nil
}
Loading
Loading