Skip to content

Commit 3abce17

Browse files
committed
feat(identity): add secp256k1 identity type and EIP-712 endorsement envelope
Adds the foundational secp256k1 identity primitives needed by the Ethereum/EVM driver. Ethereum accounts are identified by a 20-byte address derived from a secp256k1 public key, and off-chain co-signers approve token operations by signing an EIP-712 typed-data digest. Changes: - token/driver/wallet.go: register Secp256k1IdentityType (= 7) and Secp256k1IdentityTypeString ("secp256k1") alongside the existing identity type constants so the rest of the SDK can reference the new type without importing the eth package. - token/services/identity/typed.go: extend TypeToString to return the correct label for Secp256k1IdentityType. - token/services/identity/eth/signer.go: Signer wraps a secp256k1 private key. Sign keccak256-hashes the message then returns a DER-encoded ECDSA signature, matching the Ethereum eth_sign and EIP-712 conventions. - token/services/identity/eth/verifier.go: Verifier wraps a secp256k1 public key. Verify parses the DER signature, keccak256-hashes the message, and checks the result. AddressFromPublicKey derives the standard 20-byte Ethereum address from the public key (keccak256 of X||Y bytes, last 20 bytes). - token/services/identity/eth/eip712.go: Domain and EndorsementRequest typed-data structs plus HashEndorsementRequest, which computes the full EIP-712 digest keccak256(0x1901 || domainSeparator || structHash). Uses the pre-standardisation Keccak-256 variant that Ethereum adopted, via golang.org/x/crypto/sha3.NewLegacyKeccak256 (already a direct dependency). secp256k1 signing is provided by github.com/decred/dcrd/dcrec/secp256k1/v4 (already an indirect dependency), so no new modules are introduced. - token/services/identity/eth/eth_test.go: 14 unit tests covering sign/verify round-trip, wrong-message and wrong-key rejection, nil key error paths, malformed signature rejection, address derivation properties, EIP-712 hash determinism and field sensitivity, and a full end-to-end endorse-and-verify flow. Closes #1667 Signed-off-by: Rama542 <ramasasankgudipati@gmail.com> Signed-off-by: Rama542 <Rama542@users.noreply.github.com>
1 parent d370298 commit 3abce17

6 files changed

Lines changed: 419 additions & 0 deletions

File tree

token/driver/wallet.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ const (
207207
HTLCScriptIdentityType IdentityType = 4
208208
MultiSigIdentityType IdentityType = 5
209209
PolicyIdentityType IdentityType = 6
210+
Secp256k1IdentityType IdentityType = 7
210211
)
211212

212213
// IdentityTypeString identifies the type of identity as a string
@@ -219,6 +220,7 @@ const (
219220
HTLCScriptIdentityTypeString IdentityTypeString = "htlc"
220221
MultiSigIdentityTypeString IdentityTypeString = "multisig"
221222
PolicyIdentityTypeString IdentityTypeString = "policy"
223+
Secp256k1IdentityTypeString IdentityTypeString = "secp256k1"
222224
)
223225

224226
// Authorization checks the relationship between a token and different wallet types (owner, issuer, auditor).
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package eth
8+
9+
import (
10+
"encoding/binary"
11+
12+
"golang.org/x/crypto/sha3"
13+
)
14+
15+
// Domain holds the EIP-712 domain separator fields that identify a specific
16+
// token SDK deployment. Every deployment should choose a unique Name +
17+
// Version + ChainID combination so that a signature produced for one network
18+
// cannot be replayed on another.
19+
type Domain struct {
20+
// Name is a human-readable label for the signing domain, e.g. "FabricTokenSDK".
21+
Name string
22+
// Version is the domain version string, e.g. "1".
23+
Version string
24+
// ChainID is the EIP-155 chain identifier of the target EVM network.
25+
ChainID uint64
26+
}
27+
28+
// EndorsementRequest is the typed data that co-signers sign off-chain to
29+
// approve a pending token operation before it is submitted to the ledger.
30+
//
31+
// The three fields map directly to the EIP-712 type string:
32+
//
33+
// EndorsementRequest(string tmsID,string txID,uint64 deadline)
34+
//
35+
// tmsID identifies the Token Management System (network:channel:namespace).
36+
// txID is the transaction identifier of the pending token request.
37+
// deadline is a Unix timestamp after which the approval is considered void
38+
// (use 0 to express no expiry).
39+
type EndorsementRequest struct {
40+
TMSID string
41+
TxID string
42+
Deadline uint64
43+
}
44+
45+
// endorsementTypeString is the canonical EIP-712 type string for EndorsementRequest.
46+
const endorsementTypeString = "EndorsementRequest(string tmsID,string txID,uint64 deadline)"
47+
48+
// domainTypeString is the canonical EIP-712 type string for the domain separator.
49+
const domainTypeString = "EIP712Domain(string name,string version,uint64 chainID)"
50+
51+
// HashEndorsementRequest returns the 32-byte EIP-712 digest for req under the
52+
// given domain. Pass this digest directly to Signer.Sign — the signer will
53+
// keccak256 it once more, producing the final value that is actually signed
54+
// (matching the Ethereum convention of always signing a hash).
55+
//
56+
// The computation follows EIP-712 exactly:
57+
//
58+
// digest = keccak256("\x19\x01" || domainSeparator(domain) || structHash(req))
59+
func HashEndorsementRequest(domain Domain, req EndorsementRequest) []byte {
60+
domainSep := hashDomain(domain)
61+
structHash := hashEndorsementStruct(req)
62+
63+
// EIP-712 envelope: 0x19 0x01 || domainSeparator || structHash
64+
buf := make([]byte, 2+32+32)
65+
buf[0] = 0x19
66+
buf[1] = 0x01
67+
copy(buf[2:34], domainSep)
68+
copy(buf[34:], structHash)
69+
70+
return keccak256(buf)
71+
}
72+
73+
// hashDomain computes the EIP-712 domain separator for d.
74+
func hashDomain(d Domain) []byte {
75+
typeHash := keccak256([]byte(domainTypeString))
76+
nameHash := keccak256([]byte(d.Name))
77+
versionHash := keccak256([]byte(d.Version))
78+
chainIDPadded := uint64ToBytes32(d.ChainID)
79+
80+
buf := make([]byte, 4*32)
81+
copy(buf[0:32], typeHash)
82+
copy(buf[32:64], nameHash)
83+
copy(buf[64:96], versionHash)
84+
copy(buf[96:128], chainIDPadded)
85+
86+
return keccak256(buf)
87+
}
88+
89+
// hashEndorsementStruct computes the EIP-712 struct hash for req.
90+
func hashEndorsementStruct(req EndorsementRequest) []byte {
91+
typeHash := keccak256([]byte(endorsementTypeString))
92+
tmsIDHash := keccak256([]byte(req.TMSID))
93+
txIDHash := keccak256([]byte(req.TxID))
94+
deadlinePadded := uint64ToBytes32(req.Deadline)
95+
96+
buf := make([]byte, 4*32)
97+
copy(buf[0:32], typeHash)
98+
copy(buf[32:64], tmsIDHash)
99+
copy(buf[64:96], txIDHash)
100+
copy(buf[96:128], deadlinePadded)
101+
102+
return keccak256(buf)
103+
}
104+
105+
// keccak256 computes the Ethereum-compatible Keccak-256 hash of data.
106+
// It uses the pre-standardisation variant (legacy Keccak) that Ethereum
107+
// adopted, which differs from the NIST SHA3-256 standard.
108+
func keccak256(data []byte) []byte {
109+
h := sha3.NewLegacyKeccak256()
110+
h.Write(data)
111+
return h.Sum(nil)
112+
}
113+
114+
// uint64ToBytes32 encodes v as a 32-byte big-endian value (ABI uint256 encoding).
115+
func uint64ToBytes32(v uint64) []byte {
116+
b := make([]byte, 32)
117+
binary.BigEndian.PutUint64(b[24:], v)
118+
return b
119+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package eth_test
8+
9+
import (
10+
"testing"
11+
12+
"github.com/decred/dcrd/dcrec/secp256k1/v4"
13+
"github.com/hyperledger-labs/fabric-token-sdk/token/services/identity/eth"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// generateKey is a test helper that creates a fresh secp256k1 key pair.
19+
func generateKey(t *testing.T) (*secp256k1.PrivateKey, *secp256k1.PublicKey) {
20+
t.Helper()
21+
priv, err := secp256k1.GeneratePrivateKey()
22+
require.NoError(t, err)
23+
return priv, priv.PubKey()
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Signer / Verifier round-trip tests
28+
// ---------------------------------------------------------------------------
29+
30+
func TestSignVerify_RoundTrip(t *testing.T) {
31+
priv, pub := generateKey(t)
32+
signer := eth.NewSigner(priv)
33+
verifier := eth.NewVerifier(pub)
34+
35+
message := []byte("approve token transfer tx-001")
36+
sig, err := signer.Sign(message)
37+
require.NoError(t, err)
38+
require.NotEmpty(t, sig)
39+
40+
require.NoError(t, verifier.Verify(message, sig))
41+
}
42+
43+
func TestVerify_WrongMessage(t *testing.T) {
44+
priv, pub := generateKey(t)
45+
signer := eth.NewSigner(priv)
46+
verifier := eth.NewVerifier(pub)
47+
48+
sig, err := signer.Sign([]byte("original message"))
49+
require.NoError(t, err)
50+
51+
err = verifier.Verify([]byte("tampered message"), sig)
52+
require.Error(t, err)
53+
}
54+
55+
func TestVerify_WrongKey(t *testing.T) {
56+
priv, _ := generateKey(t)
57+
_, differentPub := generateKey(t)
58+
59+
signer := eth.NewSigner(priv)
60+
verifier := eth.NewVerifier(differentPub)
61+
62+
sig, err := signer.Sign([]byte("hello"))
63+
require.NoError(t, err)
64+
65+
err = verifier.Verify([]byte("hello"), sig)
66+
require.Error(t, err)
67+
}
68+
69+
func TestSign_NilKey_ReturnsError(t *testing.T) {
70+
signer := eth.NewSigner(nil)
71+
_, err := signer.Sign([]byte("msg"))
72+
require.Error(t, err)
73+
}
74+
75+
func TestVerify_NilKey_ReturnsError(t *testing.T) {
76+
verifier := eth.NewVerifier(nil)
77+
err := verifier.Verify([]byte("msg"), []byte("sig"))
78+
require.Error(t, err)
79+
}
80+
81+
func TestVerify_MalformedSignature(t *testing.T) {
82+
_, pub := generateKey(t)
83+
verifier := eth.NewVerifier(pub)
84+
err := verifier.Verify([]byte("msg"), []byte("not-a-der-signature"))
85+
require.Error(t, err)
86+
}
87+
88+
// ---------------------------------------------------------------------------
89+
// AddressFromPublicKey tests
90+
// ---------------------------------------------------------------------------
91+
92+
func TestAddressFromPublicKey_Deterministic(t *testing.T) {
93+
_, pub := generateKey(t)
94+
addr1 := eth.AddressFromPublicKey(pub)
95+
addr2 := eth.AddressFromPublicKey(pub)
96+
assert.Equal(t, addr1, addr2)
97+
}
98+
99+
func TestAddressFromPublicKey_DifferentKeys_DifferentAddresses(t *testing.T) {
100+
_, pub1 := generateKey(t)
101+
_, pub2 := generateKey(t)
102+
addr1 := eth.AddressFromPublicKey(pub1)
103+
addr2 := eth.AddressFromPublicKey(pub2)
104+
assert.NotEqual(t, addr1, addr2)
105+
}
106+
107+
func TestAddressFromPublicKey_Length(t *testing.T) {
108+
_, pub := generateKey(t)
109+
addr := eth.AddressFromPublicKey(pub)
110+
assert.Len(t, addr, 20)
111+
}
112+
113+
// ---------------------------------------------------------------------------
114+
// EIP-712 HashEndorsementRequest tests
115+
// ---------------------------------------------------------------------------
116+
117+
var testDomain = eth.Domain{
118+
Name: "FabricTokenSDK",
119+
Version: "1",
120+
ChainID: 1,
121+
}
122+
123+
func TestHashEndorsementRequest_Deterministic(t *testing.T) {
124+
req := eth.EndorsementRequest{
125+
TMSID: "testnet:ch1:ns1",
126+
TxID: "tx-abc-123",
127+
Deadline: 9999999999,
128+
}
129+
130+
h1 := eth.HashEndorsementRequest(testDomain, req)
131+
h2 := eth.HashEndorsementRequest(testDomain, req)
132+
assert.Equal(t, h1, h2)
133+
assert.Len(t, h1, 32)
134+
}
135+
136+
func TestHashEndorsementRequest_DifferentTxIDs_DifferentHashes(t *testing.T) {
137+
req1 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}
138+
req2 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-2", Deadline: 0}
139+
140+
h1 := eth.HashEndorsementRequest(testDomain, req1)
141+
h2 := eth.HashEndorsementRequest(testDomain, req2)
142+
assert.NotEqual(t, h1, h2)
143+
}
144+
145+
func TestHashEndorsementRequest_DifferentDomains_DifferentHashes(t *testing.T) {
146+
req := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}
147+
148+
domainA := eth.Domain{Name: "SDKv1", Version: "1", ChainID: 1}
149+
domainB := eth.Domain{Name: "SDKv1", Version: "1", ChainID: 137} // Polygon
150+
151+
h1 := eth.HashEndorsementRequest(domainA, req)
152+
h2 := eth.HashEndorsementRequest(domainB, req)
153+
assert.NotEqual(t, h1, h2)
154+
}
155+
156+
func TestHashEndorsementRequest_DifferentDeadlines_DifferentHashes(t *testing.T) {
157+
req1 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 0}
158+
req2 := eth.EndorsementRequest{TMSID: "net:ch:ns", TxID: "tx-1", Deadline: 1700000000}
159+
160+
h1 := eth.HashEndorsementRequest(testDomain, req1)
161+
h2 := eth.HashEndorsementRequest(testDomain, req2)
162+
assert.NotEqual(t, h1, h2)
163+
}
164+
165+
// ---------------------------------------------------------------------------
166+
// End-to-end: sign an EIP-712 endorsement and verify it
167+
// ---------------------------------------------------------------------------
168+
169+
func TestEndorseAndVerify_EIP712(t *testing.T) {
170+
priv, pub := generateKey(t)
171+
signer := eth.NewSigner(priv)
172+
verifier := eth.NewVerifier(pub)
173+
174+
req := eth.EndorsementRequest{
175+
TMSID: "testnet:mychannel:token-ns",
176+
TxID: "transfer-tx-xyz",
177+
Deadline: 1800000000,
178+
}
179+
180+
// The endorser hashes the request with EIP-712 and signs.
181+
digest := eth.HashEndorsementRequest(testDomain, req)
182+
sig, err := signer.Sign(digest)
183+
require.NoError(t, err)
184+
185+
// The verifier independently re-derives the digest and confirms the signature.
186+
require.NoError(t, verifier.Verify(digest, sig))
187+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// Package eth provides secp256k1 identity primitives for the Ethereum/EVM driver.
8+
//
9+
// Identities are Ethereum accounts: a 20-byte address derived from a secp256k1
10+
// public key. Signatures are ECDSA over secp256k1 using keccak256 as the
11+
// pre-hash, which matches the Ethereum eth_sign and EIP-712 conventions.
12+
//
13+
// Endorsement approvals for off-chain co-signers use the EIP-712 typed-data
14+
// envelope defined in eip712.go. Callers build an EndorsementRequest, obtain
15+
// its canonical digest via HashEndorsementRequest, then hand that digest to
16+
// Signer.Sign.
17+
package eth
18+
19+
import (
20+
"github.com/decred/dcrd/dcrec/secp256k1/v4"
21+
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
22+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
23+
)
24+
25+
// Signer produces secp256k1 ECDSA signatures compatible with Ethereum's
26+
// signing conventions. It implements driver.Signer.
27+
//
28+
// Sign hashes the supplied message with keccak256 and signs the resulting
29+
// 32-byte digest with the private key. The returned signature is DER-encoded.
30+
// Callers that want EIP-712 semantics should pass the output of
31+
// HashEndorsementRequest as the message so that the final keccak256 inside
32+
// Sign produces the correct EIP-712 digest.
33+
type Signer struct {
34+
privKey *secp256k1.PrivateKey
35+
}
36+
37+
// NewSigner returns a Signer backed by the given secp256k1 private key.
38+
func NewSigner(privKey *secp256k1.PrivateKey) *Signer {
39+
return &Signer{privKey: privKey}
40+
}
41+
42+
// Sign hashes message with keccak256 and returns a DER-encoded ECDSA signature.
43+
func (s *Signer) Sign(message []byte) ([]byte, error) {
44+
if s.privKey == nil {
45+
return nil, errors.New("secp256k1 signer: nil private key")
46+
}
47+
48+
digest := keccak256(message)
49+
sig := ecdsa.Sign(s.privKey, digest)
50+
51+
return sig.Serialize(), nil
52+
}

0 commit comments

Comments
 (0)