Skip to content

Commit

Permalink
Merge pull request #212 from bitcoin-sv/feat-400-totp
Browse files Browse the repository at this point in the history
feat(BUX-400): contact confirmation after totp validation
  • Loading branch information
arkadiuszos4chain authored Apr 9, 2024
2 parents c4a680f + 7c3aea0 commit cacb5a4
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 14 deletions.
17 changes: 14 additions & 3 deletions contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package walletclient

import (
"context"
"errors"
"fmt"

"github.com/bitcoin-sv/spv-wallet-go-client/transports"
"github.com/bitcoin-sv/spv-wallet/models"
Expand All @@ -27,9 +29,18 @@ func (b *WalletClient) RejectContact(ctx context.Context, paymail string) transp
return b.transport.RejectContact(ctx, paymail)
}

// ConfirmContact will confirm the contact associated with the paymail
func (b *WalletClient) ConfirmContact(ctx context.Context, paymail string) transports.ResponseError {
return b.transport.ConfirmContact(ctx, paymail)
// ConfirmContact will try to confirm the contact
func (b *WalletClient) ConfirmContact(ctx context.Context, contact *models.Contact, passcode string, period, digits uint) transports.ResponseError {
isTotpValid, err := b.ValidateTotpForContact(contact, passcode, period, digits)
if err != nil {
return transports.WrapError(fmt.Errorf("totp validation failed: %w", err))
}

if !isTotpValid {
return transports.WrapError(errors.New("totp is invalid"))
}

return b.transport.ConfirmContact(ctx, contact.Paymail)
}

// GetContacts will get contacts by conditions
Expand Down
74 changes: 66 additions & 8 deletions contacts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"testing"

"github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
"github.com/bitcoin-sv/spv-wallet/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestRejectContact will test the RejectContact method
// TestContactActionsRouting will test routing
func TestContactActionsRouting(t *testing.T) {
tcs := []struct {
name string
Expand All @@ -28,12 +30,6 @@ func TestContactActionsRouting(t *testing.T) {
responsePayload: "{}",
f: func(c *WalletClient) error { return c.AcceptContact(context.Background(), fixtures.PaymailAddress) },
},
{
name: "ConfirmContact",
route: "/contact/confirmed/",
responsePayload: "{}",
f: func(c *WalletClient) error { return c.ConfirmContact(context.Background(), fixtures.PaymailAddress) },
},
{
name: "GetContacts",
route: "/contact/search/",
Expand Down Expand Up @@ -61,6 +57,17 @@ func TestContactActionsRouting(t *testing.T) {
return err
},
},
{
name: "ConfirmContact",
route: "/contact/confirmed/",
responsePayload: "{}",
f: func(c *WalletClient) error {
contact := models.Contact{PubKey: fixtures.PubKey}

passcode, _ := c.GenerateTotpForContact(&contact, 30, 2)
return c.ConfirmContact(context.Background(), &contact, passcode, 30, 2)
},
},
}

for _, tc := range tcs {
Expand All @@ -74,7 +81,7 @@ func TestContactActionsRouting(t *testing.T) {
Client: WithHTTPClient,
}

client := getTestWalletClient(tmq, true)
client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString))

// when
err := tc.f(client)
Expand All @@ -85,3 +92,54 @@ func TestContactActionsRouting(t *testing.T) {
}

}

func TestConfirmContact(t *testing.T) {
t.Run("TOTP is valid - call Confirm Action", func(t *testing.T) {
// given
tmq := testTransportHandler{
Type: fixtures.RequestType,
Path: "/contact/confirmed/",
Result: "{}",
ClientURL: fixtures.ServerURL,
Client: WithHTTPClient,
}

client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString))

contact := &models.Contact{PubKey: fixtures.PubKey}
totp, err := client.GenerateTotpForContact(contact, 30, 2)
require.NoError(t, err)

// when
result := client.ConfirmContact(context.Background(), contact, totp, 30, 2)

// then
require.Nil(t, result)
})

t.Run("TOTP is invalid - do not call Confirm Action", func(t *testing.T) {
// given
tmq := testTransportHandler{
Type: fixtures.RequestType,
Path: "/unknown/",
Result: "{}",
ClientURL: fixtures.ServerURL,
Client: WithHTTPClient,
}

client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString))

alice := &models.Contact{PubKey: "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"}
a_totp, err := client.GenerateTotpForContact(alice, 30, 2)
require.NoError(t, err)

bob := &models.Contact{PubKey: "02dde493752f7bc89822ed8a13e0e4aa04550c6c4430800e4be1e5e5c2556cf65b"}

// when
result := client.ConfirmContact(context.Background(), bob, a_totp, 30, 2)

// then
require.NotNil(t, result)
require.Equal(t, result.Error(), "totp is invalid")
})
}
1 change: 1 addition & 0 deletions fixtures/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
XPrivString = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ"
AccessKeyString = "7779d24ca6f8821f225042bf55e8f80aa41b08b879b72827f51e41e6523b9cd0"
PaymailAddress = "[email protected]"
PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"
)

func MarshallForTestHandler(object any) string {
Expand Down
4 changes: 3 additions & 1 deletion go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions totp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package walletclient

import (
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
"time"

"github.com/bitcoin-sv/spv-wallet-go-client/utils"
"github.com/bitcoin-sv/spv-wallet/models"
"github.com/bitcoinschema/go-bitcoin/v2"
"github.com/libsv/go-bk/bec"
"github.com/libsv/go-bk/bip32"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)

var ErrClientInitNoXpriv = errors.New("init client with xPriv first")

const (
// Default number of seconds a TOTP is valid for.
TotpDefaultPeriod uint = 30
// Default TOTP length
TotpDefaultDigits uint = 2
)

// GenerateTotpForContact creates one time-based one-time password based on secret shared between the user and the contact
func (b *WalletClient) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) {
secret, err := sharedSecret(b, contact)
if err != nil {
return "", err
}

opts := getTotpOpts(period, digits)
return totp.GenerateCodeCustom(string(secret), time.Now(), *opts)
}

// ValidateTotpForContact validates one time-based one-time password based on secret shared between the user and the contact
func (b *WalletClient) ValidateTotpForContact(contact *models.Contact, passcode string, period, digits uint) (bool, error) {
secret, err := sharedSecret(b, contact)
if err != nil {
return false, err
}

opts := getTotpOpts(period, digits)
return totp.ValidateCustom(passcode, string(secret), time.Now(), *opts)
}

func sharedSecret(b *WalletClient, c *models.Contact) (string, error) {
privKey, pubKey, err := getSharedSecretFactors(b, c)
if err != nil {
return "", err
}

x, _ := bec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes())
return base32.StdEncoding.EncodeToString(x.Bytes()), nil
}

func getTotpOpts(period, digits uint) *totp.ValidateOpts {
if period == 0 {
period = TotpDefaultPeriod
}

if digits == 0 {
digits = TotpDefaultDigits
}

return &totp.ValidateOpts{
Period: period,
Digits: otp.Digits(digits),
}
}

func getSharedSecretFactors(b *WalletClient, c *models.Contact) (*bec.PrivateKey, *bec.PublicKey, error) {
if b.xPriv == nil {
return nil, nil, ErrClientInitNoXpriv
}

xpriv, err := deriveXprivForPki(b.xPriv)
if err != nil {
return nil, nil, err
}

privKey, err := xpriv.ECPrivKey()
if err != nil {
return nil, nil, err
}

pubKey, err := convertPubKey(c.PubKey)
if err != nil {
return nil, nil, fmt.Errorf("contact's PubKey is invalid: %w", err)
}

return privKey, pubKey, nil
}

func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) {
// PKI derivation path: m/0/0/0
// NOTICE: we currently do not support PKI rotation; however, adjustments will be made if and when we decide to implement it

pkiXpriv, err := bitcoin.GetHDKeyByPath(xpriv, utils.ChainExternal, 0)
if err != nil {
return nil, err
}

return pkiXpriv.Child(0)
}

func convertPubKey(pubKey string) (*bec.PublicKey, error) {
hex, err := hex.DecodeString(pubKey)
if err != nil {
return nil, err
}

return bec.ParsePubKey(hex, bec.S256())
}
Loading

0 comments on commit cacb5a4

Please sign in to comment.