Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SPV-679): directed secret #222

Merged
merged 2 commits into from
Apr 25, 2024
Merged
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
4 changes: 2 additions & 2 deletions contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ func (b *WalletClient) RejectContact(ctx context.Context, paymail string) transp
}

// 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)
func (b *WalletClient) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) transports.ResponseError {
isTotpValid, err := b.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits)
if err != nil {
return transports.WrapError(fmt.Errorf("totp validation failed: %w", err))
}
Expand Down
56 changes: 35 additions & 21 deletions contacts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package walletclient

import (
"context"
"strings"
"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"
)
Expand Down Expand Up @@ -57,17 +57,6 @@ 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 Down Expand Up @@ -104,14 +93,18 @@ func TestConfirmContact(t *testing.T) {
Client: WithHTTPClient,
}

client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString))
clientMaker := func(opts ...ClientOps) (*WalletClient, error) {
return getTestWalletClientWithOpts(tmq, opts...), nil
}

alice := makeMockUser("alice", clientMaker)
bob := makeMockUser("bob", clientMaker)

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

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

// then
require.Nil(t, result)
Expand All @@ -127,19 +120,40 @@ func TestConfirmContact(t *testing.T) {
Client: WithHTTPClient,
}

client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString))
clientMaker := func(opts ...ClientOps) (*WalletClient, error) {
return getTestWalletClientWithOpts(tmq, opts...), nil
}

alice := makeMockUser("alice", clientMaker)
bob := makeMockUser("bob", clientMaker)

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

bob := &models.Contact{PubKey: "02dde493752f7bc89822ed8a13e0e4aa04550c6c4430800e4be1e5e5c2556cf65b"}
//make sure the wrongTotp is not the same as the generated one
wrongTotp := incrementDigits(totp) //the length should remain the same

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

// then
require.NotNil(t, result)
require.Equal(t, result.Error(), "totp is invalid")
})
}

// incrementDigits takes a string of digits and increments each digit by 1.
// Digits wrap around such that '9' becomes '0'.
func incrementDigits(input string) string {
var result strings.Builder

for _, c := range input {
if c == '9' {
result.WriteRune('0')
} else {
result.WriteRune(c + 1)
}
}

return result.String()
}
34 changes: 24 additions & 10 deletions totp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,51 @@ import (
var ErrClientInitNoXpriv = errors.New("init client with xPriv first")

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

/*
Basic flow:
Alice generates passcodeForBob with (sharedSecret+(contact.Paymail as bobPaymail))
Alice sends passcodeForBob to Bob (e.g. via email)
Bob validates passcodeForBob with (sharedSecret+(requesterPaymail as bobPaymail))
The (sharedSecret+paymail) is a "directedSecret". This ensures that passcodeForBob-from-Alice != passcodeForAlice-from-Bob.
The flow looks the same for Bob generating passcodeForAlice.
*/

// 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)
sharedSecret, err := makeSharedSecret(b, contact)
if err != nil {
return "", err
}

opts := getTotpOpts(period, digits)
return totp.GenerateCodeCustom(string(secret), time.Now(), *opts)
return totp.GenerateCodeCustom(directedSecret(sharedSecret, contact.Paymail), 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)
func (b *WalletClient) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) (bool, error) {
sharedSecret, err := makeSharedSecret(b, contact)
if err != nil {
return false, err
}

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

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

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

func getTotpOpts(period, digits uint) *totp.ValidateOpts {
Expand Down Expand Up @@ -115,3 +124,8 @@ func convertPubKey(pubKey string) (*bec.PublicKey, error) {

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

// directedSecret appends a paymail to the secret and encodes it into base32 string
func directedSecret(sharedSecret []byte, paymail string) string {
return base32.StdEncoding.EncodeToString(append(sharedSecret, []byte(paymail)...))
}
60 changes: 52 additions & 8 deletions totp_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package walletclient

import (
"encoding/hex"
"testing"

"github.com/bitcoin-sv/spv-wallet-go-client/fixtures"
"github.com/bitcoin-sv/spv-wallet-go-client/xpriv"
"github.com/bitcoin-sv/spv-wallet/models"
"github.com/libsv/go-bk/bip32"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -55,15 +58,18 @@ func TestGenerateTotpForContact(t *testing.T) {
func TestValidateTotpForContact(t *testing.T) {
t.Run("success", func(t *testing.T) {
// given
sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001"))
require.NoError(t, err)

contact := models.Contact{PubKey: fixtures.PubKey}
pass, err := sut.GenerateTotpForContact(&contact, 30, 2)
clientMaker := func(opts ...ClientOps) (*WalletClient, error) {
allOptions := append(opts, WithHTTP("localhost:3001"))
return New(allOptions...)
}
alice := makeMockUser("alice", clientMaker)
bob := makeMockUser("bob", clientMaker)

pass, err := alice.client.GenerateTotpForContact(bob.contact, 3600, 2)
require.NoError(t, err)

// when
result, err := sut.ValidateTotpForContact(&contact, pass, 30, 2)
result, err := bob.client.ValidateTotpForContact(alice.contact, pass, bob.paymail, 3600, 2)

// then
require.NoError(t, err)
Expand All @@ -76,7 +82,7 @@ func TestValidateTotpForContact(t *testing.T) {
require.NoError(t, err)

// when
_, err = sut.ValidateTotpForContact(nil, "", 30, 2)
_, err = sut.ValidateTotpForContact(nil, "", fixtures.PaymailAddress, 30, 2)

// then
require.ErrorIs(t, err, ErrClientInitNoXpriv)
Expand All @@ -90,10 +96,48 @@ func TestValidateTotpForContact(t *testing.T) {
contact := models.Contact{PubKey: "invalid-pk-format"}

// when
_, err = sut.ValidateTotpForContact(&contact, "", 30, 2)
_, err = sut.ValidateTotpForContact(&contact, "", fixtures.PaymailAddress, 30, 2)

// then
require.ErrorContains(t, err, "contact's PubKey is invalid:")

})
}

type mockUser struct {
contact *models.Contact
client *WalletClient
paymail string
}

func makeMockUser(name string, clientMaker func(opts ...ClientOps) (*WalletClient, error)) mockUser {
keys, _ := xpriv.Generate()
paymail := name + "@example.com"
client, _ := clientMaker(WithXPriv(keys.XPriv()))
pki := makeMockPKI(keys.XPub().String())
contact := models.Contact{PubKey: pki, Paymail: paymail}
return mockUser{
contact: &contact,
client: client,
paymail: paymail,
}
}

func makeMockPKI(xpub string) string {
xPub, _ := bip32.NewKeyFromString(xpub)
magicNumberOfInheritance := 3 //2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI
var err error
for i := 0; i < magicNumberOfInheritance; i++ {
xPub, err = xPub.Child(0)
if err != nil {
panic(err)
}
}

pubKey, err := xPub.ECPubKey()
if err != nil {
panic(err)
}

return hex.EncodeToString(pubKey.SerialiseCompressed())
}
Loading