Skip to content

Commit

Permalink
Adding more L1 wallet validation in go-sdk (#138)
Browse files Browse the repository at this point in the history
In order to complete withdrawals in the L1 wallet, we must validate all derive_and_sign webhooks by deriving the Tx script once again from the master seed. In general, the path is as follows:

Master seed + Hardened Derivation Path = Base XPub
Base XPub + Non-Hardened Derivation Path = Child Pubkey
Child Pubkey + Hashing => Script

Related PR for Sparkcore:
https://app.graphite.dev/github/pr/lightsparkdev/webdev/13519/Modifying-l1_wallet-code-for-added-validation-in-sign-transactions
  • Loading branch information
JasonCWang authored Nov 26, 2024
1 parent 80c3198 commit b756433
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 30 deletions.
25 changes: 25 additions & 0 deletions keyscripts/gen_xpub.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,28 @@ func GenHardenedXPub(masterSeedHex string, derivationPath []uint32, bitcoinNetwo
}
return xpub.String(), nil
}

func DeriveChildPubKeyFromExistingXPub(xpubStr string, remainingPath []uint32) ([]byte, error) {
extKey, err := hdkeychain.NewKeyFromString(xpubStr)
if err != nil {
return nil, fmt.Errorf("failed to parse xpub: %v", err)
}

key := extKey
for _, index := range remainingPath {
if index >= 0x80000000 {
return nil, fmt.Errorf("cannot do hardened derivation from xpub")
}
key, err = key.Derive(index)
if err != nil {
return nil, err
}
}

ecPubKey, err := key.ECPubKey()
if err != nil {
return nil, fmt.Errorf("failed to get EC pubkey: %v", err)
}

return ecPubKey.SerializeCompressed(), nil
}
31 changes: 29 additions & 2 deletions remotesigning/remote_signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"

"github.com/lightsparkdev/go-sdk/crypto"
utils "github.com/lightsparkdev/go-sdk/keyscripts"

lightspark_crypto "github.com/lightsparkdev/lightspark-crypto-uniffi/lightspark-crypto-go"

Expand Down Expand Up @@ -56,10 +58,35 @@ func GraphQLResponseForRemoteSigningWebhook(
webhook webhooks.WebhookEvent,
seedBytes []byte,
) (SigningResponse, error) {
if !validator.ShouldSign(webhook) {
return nil, errors.New("declined to sign messages")
// Calculate the xpub for each L1 signing job
signingJobs, hasSigningJobs := (*webhook.Data)["signing_jobs"].([]interface{})
if !hasSigningJobs {
return nil, fmt.Errorf("signing_jobs not found or invalid type")
}

var xpubs []string
for _, job := range signingJobs {
jobMap, isValidJobMap := job.(map[string]interface{})
if !isValidJobMap {
return nil, fmt.Errorf("invalid signing job format")
}
derivationPath := jobMap["derivation_path"].(string)

hardenedPath, _, err := SplitDerivationPath(derivationPath)
if err != nil {
return nil, err
}

masterSeedHex := hex.EncodeToString(seedBytes)
xpub, err := utils.GenHardenedXPub(masterSeedHex, hardenedPath, "mainnet")
if err != nil {
return nil, err
}
xpubs = append(xpubs, xpub)
}
if !validator.ShouldSign(webhook, xpubs) {
return nil, errors.New("declined to sign messages")
}
if webhook.EventType != objects.WebhookEventTypeRemoteSigning {
return nil, errors.New("webhook event is not for remote signing")
}
Expand Down
17 changes: 9 additions & 8 deletions remotesigning/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,14 +480,15 @@ func (r *ReleaseCounterpartyPerCommitmentSecretRequest) Type() objects.RemoteSig
// DerivationPath is the bip32 derivation path to get the key from the master key `k`.
// Then apply MulTweak * k + AddTweak to get the final signing key.
type SigningJob struct {
Id string `json:"id"`
DerivationPath string `json:"derivation_path"`
Message string `json:"message"`
AddTweak *string `json:"add_tweak"`
MulTweak *string `json:"mul_tweak"`
Script *string `json:"script"`
Transaction *string `json:"transaction"`
Amount *int64 `json:"amount"`
Id string `json:"id"`
DerivationPath string `json:"derivation_path"`
Message string `json:"message"`
AddTweak *string `json:"add_tweak"`
MulTweak *string `json:"mul_tweak"`
Script *string `json:"script"`
Transaction *string `json:"transaction"`
Amount *int64 `json:"amount"`
DestinationDerivationPath string `json:"destination_derivation_path"`
}

func (j *SigningJob) MulTweakBytes() ([]byte, error) {
Expand Down
148 changes: 145 additions & 3 deletions remotesigning/test/validation_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package remotesigning_test

import (
"github.com/lightsparkdev/go-sdk/objects"
"github.com/lightsparkdev/go-sdk/webhooks"
"github.com/stretchr/testify/assert"
"testing"
"time"

utils "github.com/lightsparkdev/go-sdk/keyscripts"
"github.com/lightsparkdev/go-sdk/objects"
"github.com/lightsparkdev/go-sdk/remotesigning"
"github.com/lightsparkdev/go-sdk/webhooks"
"github.com/stretchr/testify/assert"
)

func TestGetPaymentHashFromScript(t *testing.T) {
Expand Down Expand Up @@ -42,3 +43,144 @@ func TestParseReleasePaymentPreimage(t *testing.T) {
assert.True(t, parsedRequest.IsUma)
assert.False(t, parsedRequest.IsLnurl)
}
func TestSplitDerivationPath(t *testing.T) {
tests := []struct {
name string
path string
wantHardened []uint32
wantRemaining []uint32
expectedErrMsg string
}{
{
name: "valid path with hardened and non-hardened components",
path: "m/84'/0'/0'/0/1",
wantHardened: []uint32{84 + 0x80000000, 0x80000000, 0x80000000},
wantRemaining: []uint32{0, 1},
},
{
name: "path with empty component 1",
path: "m/",
expectedErrMsg: "invalid derivation path: empty component",
},
{
name: "path with empty component 2",
path: "m//1/2",
expectedErrMsg: "invalid derivation path: empty component",
},
{
name: "invalid number",
path: "m/84'/abc/0",
expectedErrMsg: "invalid path: abc",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hardened, remaining, err := remotesigning.SplitDerivationPath(tt.path)

if tt.expectedErrMsg != "" {
assert.EqualError(t, err, tt.expectedErrMsg)
return
}

assert.NoError(t, err)
assert.Equal(t, tt.wantHardened, hardened)
assert.Equal(t, tt.wantRemaining, remaining)
})
}
}

func TestDeriveChildPubKeyFromExistingXPub(t *testing.T) {
testXPub := "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj"

tests := []struct {
name string
path []uint32
expectedLen int
}{
{
name: "valid derivation",
path: []uint32{0, 0},
expectedLen: 33,
},
{
name: "empty path",
path: []uint32{},
expectedLen: 33,
},
{
name: "hardened index should fail",
path: []uint32{0x80000000},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pubkey, err := utils.DeriveChildPubKeyFromExistingXPub(testXPub, tt.path)

if tt.expectedLen > 0 {
assert.NoError(t, err)
assert.Equal(t, tt.expectedLen, len(pubkey))
} else {
assert.Error(t, err)
}
})
}
}
func TestValidateScript(t *testing.T) {
// THIS IS A TEST XPUB - DO NOT USE IN PRODUCTION!
testXPub := "xpub6CrnwQT4n7fEqLPG6A4KZcNXRctojRQGtvUztN5aKUqEMDU3ai5N9SvPnA56y5kwATN6CCHzmA7ccTwXbKtU7kZALRCVs1YY88987Ghv4jy"

tests := []struct {
name string
signingJob *remotesigning.SigningJob
xpub string
expectValid bool
}{
{
name: "valid transaction and script",
signingJob: &remotesigning.SigningJob{
DerivationPath: "m/84'/1'/0'/0/0",
DestinationDerivationPath: "m/1/77",
Transaction: ptr("020000000001017ab44ffadf03b57ce0eb63074c541b3aea0b57497764a6790611332c441b989d0100000000ffffffff02f40100000000000016001427d703f9a06364bd45d122da1baea1e517a9ff1810500e0000000000160014b8a75a216b0a957b259d1b27049fdbaba42f950c020021021c902f59731d64721914f4826bc1868d2b3e5df40edbb3a8d7c4b21b95affb0400000000"),
},
xpub: testXPub,
expectValid: true,
}, {
name: "invalid transaction",
signingJob: &remotesigning.SigningJob{
DerivationPath: "m/84'/1'/0'/0/0",
DestinationDerivationPath: "m/1/77",
Transaction: ptr("abcd"),
},
xpub: testXPub,
expectValid: false,
}, {
name: "invalid derivation path",
signingJob: &remotesigning.SigningJob{
DerivationPath: "m/84'/0/0",
DestinationDerivationPath: "m/1/299",
Transaction: ptr("020000000001017ab44ffadf03b57ce0eb63074c541b3aea0b57497764a6790611332c441b989d0100000000ffffffff02f40100000000000016001427d703f9a06364bd45d122da1baea1e517a9ff1810500e0000000000160014b8a75a216b0a957b259d1b27049fdbaba42f950c020021021c902f59731d64721914f4826bc1868d2b3e5df40edbb3a8d7c4b21b95affb0400000000"),
},
xpub: testXPub,
expectValid: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid, err := remotesigning.ValidateScript(tt.signingJob, tt.xpub)
if tt.expectValid {
assert.NoError(t, err)
assert.True(t, isValid)
} else {
assert.False(t, isValid)
}
})
}
}

// Helper function to create string pointer
func ptr(s string) *string {
return &s
}
95 changes: 95 additions & 0 deletions remotesigning/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strconv"
"strings"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
utils "github.com/lightsparkdev/go-sdk/keyscripts"
)

func GetPaymentHashFromScript(scriptHex string) (*string, error) {
Expand Down Expand Up @@ -89,3 +94,93 @@ func CalculateWitnessHashPSBT(transaction string) (*string, error) {
result := hex.EncodeToString(hash)
return &result, nil
}

func ValidateScript(signing *SigningJob, xpub string) (bool, error) {
// Step 1: Derive Tx Script from extended public key
_, nonHardenedPath, err := SplitDerivationPath(signing.DestinationDerivationPath)
if err != nil {
return false, err
}
childPubkey, err := utils.DeriveChildPubKeyFromExistingXPub(xpub, nonHardenedPath)
if err != nil {
return false, err
}
generated_script, err := GenerateP2WPKHFromPubkey(childPubkey)
if err != nil {
return false, err
}

// Step 2: Obtain Tx Script from Change Address (Directly from Transaction)
txHex := *signing.Transaction
rawTxBytes, err := hex.DecodeString(txHex)
if err != nil {
return false, fmt.Errorf("failed to decode transaction hex: %v", err)
}

var tx wire.MsgTx
if err := tx.Deserialize(bytes.NewReader(rawTxBytes)); err != nil {
return false, fmt.Errorf("failed to deserialize transaction: %v", err)
}

if len(tx.TxOut) < 2 {
// TODO: May need to modify this to validate non-withdrawal L1 transactions.
return false, fmt.Errorf("no change output found")
}
expected_script := tx.TxOut[1].PkScript

// Step 3: Compare the two scripts
if !bytes.Equal(generated_script, expected_script) {
return false, fmt.Errorf("scripts do not match")
}

return true, nil
}

func SplitDerivationPath(path string) (hardenedPath []uint32, remainingPath []uint32, err error) {
if !strings.HasPrefix(path, "m/") {
return nil, nil, fmt.Errorf("invalid derivation path: derivation path must start with 'm/'")
}

components := strings.Split(path[2:], "/")
if len(components) == 0 {
return nil, nil, fmt.Errorf("invalid derivation path: empty component")
}

// Validate no empty components
for _, component := range components {
if component == "" {
return nil, nil, fmt.Errorf("invalid derivation path: empty component")
}
}
hardenedPath = make([]uint32, 0)
remainingPath = make([]uint32, 0)

for _, component := range components {
isHardened := strings.HasSuffix(component, "'")
if isHardened {
component = component[:len(component)-1]
}

num, err := strconv.ParseUint(component, 10, 32)
if err != nil {
return nil, nil, fmt.Errorf("invalid path: %s", component)
}

if isHardened {
hardenedPath = append(hardenedPath, uint32(num)+0x80000000)
} else {
remainingPath = append(remainingPath, uint32(num))
}
}

return hardenedPath, remainingPath, nil
}

func GenerateP2WPKHFromPubkey(child_pubkey []byte) ([]byte, error) {
pkHash := btcutil.Hash160(child_pubkey)
// Create P2WPKH script: OP_0 <20-byte-key-hash>
return txscript.NewScriptBuilder().
AddOp(txscript.OP_0).
AddData(pkHash).
Script()
}
Loading

0 comments on commit b756433

Please sign in to comment.