From ad3ec9d3e033ecd4e3a0c58067d2b8319cec345d Mon Sep 17 00:00:00 2001 From: Wojciech Regulski <48433067+wregulski@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:57:08 +0100 Subject: [PATCH] feat(SPV-1419): manage contacts flow example with proper TOTP generation & validation (#320) Co-authored-by: chris-4chain <152964795+chris-4chain@users.noreply.github.com> --- examples/Taskfile.yml | 16 +- examples/generate_totp/generate_totp.go | 43 ----- examples/manage_contacts/helpers.go | 73 +++++++++ examples/manage_contacts/manage_contacts.go | 168 ++++++++++++++++++++ internal/api/v1/user/totp/totp.go | 6 +- user_api.go | 4 +- 6 files changed, 254 insertions(+), 56 deletions(-) delete mode 100644 examples/generate_totp/generate_totp.go create mode 100644 examples/manage_contacts/helpers.go create mode 100644 examples/manage_contacts/manage_contacts.go diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml index 86071581..ac476ec0 100644 --- a/examples/Taskfile.yml +++ b/examples/Taskfile.yml @@ -46,14 +46,6 @@ tasks: - go run ../walletkeys/cmd/main.go - echo "==================================================================" - generate_totp: - desc: "Generate totp." - silent: true - cmds: - - echo "==================================================================" - - go run ./generate_totp/generate_totp.go - - echo "==================================================================" - get_balance: desc: "Get balance as User." silent: true @@ -86,6 +78,14 @@ tasks: - go run ./list_transactions/list_transactions.go - echo "==================================================================" + manage_contacts: + desc: "Show possible contact scenario with TOTP generation&validation" + silent: true + cmds: + - echo "==================================================================" + - go run ./manage_contacts/manage_contacts.go + - echo "==================================================================" + send_op_return: desc: "Create draft transaction, finalize transaction and record transaction as User." silent: true diff --git a/examples/generate_totp/generate_totp.go b/examples/generate_totp/generate_totp.go deleted file mode 100644 index 1a240b8e..00000000 --- a/examples/generate_totp/generate_totp.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "log" - - wallet "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" - "github.com/bitcoin-sv/spv-wallet/models" -) - -func main() { - const aliceXPriv = examples.UserXPriv - - // pubKey - PKI can be obtained from the contact's paymail capability - const bobPKI = "03a48e13dc598dce5fda9b14ea13f32d5dbc4e8d8a34447dda84f9f4c457d57fe7" - const digits = 4 - const period = 1200 - - alice, err := wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), aliceXPriv) - if err != nil { - log.Fatalf("Failed to initialize user API with XPriv: %v", err) - } - - bob := &models.Contact{ - PubKey: bobPKI, - Paymail: "test@paymail.com", - } - code, err := alice.GenerateTotpForContact(bob, period, digits) - if err != nil { - log.Fatalf("Failed to generate totp for contact: %v", err) - } - - fmt.Println("TOTP code from Alice to Bob: ", code) - - err = alice.ValidateTotpForContact(bob, code, bob.Paymail, period, digits) - if err != nil { - log.Fatalf("Failed to validate totp for contact: %v", err) - } - - fmt.Println("TOTP code from Alice to Bob is valid") -} diff --git a/examples/manage_contacts/helpers.go b/examples/manage_contacts/helpers.go new file mode 100644 index 00000000..2cbf542b --- /dev/null +++ b/examples/manage_contacts/helpers.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +type user struct { + xPriv string + xPub string + paymail string +} + +type verificationResults struct { + bobValidatedAlicesTotp bool + aliceValidatedBobsTotp bool +} + +func examplePaymailCorrectlyEdited(paymail string) string { + if paymail == "" || paymail == "example.com" { + log.Fatal("Invalid configuration - please replace the paymail domain with your own domain") + } + return paymail +} + +func assertNoError[T any](val T, err error) T { + if err != nil { + log.Fatalf("unexpected error: %v", err) + } + return val +} + +func logSecureMessage(from, to, totp string) { + fmt.Printf("\n!!! SECURE COMMUNICATION REQUIRED !!!\n%s's TOTP code for %s:\n", from, to) + fmt.Printf("TOTP code: %s\n", totp) + fmt.Print("Share using: encrypted message, secure email, phone call or in-person meeting.\n") +} + +func mapToContactModel(resp *response.Contact) *models.Contact { + return &models.Contact{ + ID: resp.ID, + FullName: resp.FullName, + Paymail: resp.Paymail, + PubKey: resp.PubKey, + Status: resp.Status, + } +} + +func setupUsers() { + fmt.Println("0. Setting up users (optional)") + + // Create account for Alice + assertNoError(clients.admin.CreateXPub(ctx, &commands.CreateUserXpub{ + XPub: config.alice.xPub, + })) + assertNoError(clients.admin.CreatePaymail(ctx, &commands.CreatePaymail{ + Key: config.alice.xPub, + Address: config.alice.paymail, + })) + + // Create account for Bob + assertNoError(clients.admin.CreateXPub(ctx, &commands.CreateUserXpub{ + XPub: config.bob.xPub, + })) + assertNoError(clients.admin.CreatePaymail(ctx, &commands.CreatePaymail{ + Key: config.bob.xPub, + Address: config.bob.paymail, + })) +} diff --git a/examples/manage_contacts/manage_contacts.go b/examples/manage_contacts/manage_contacts.go new file mode 100644 index 00000000..76d4fcb1 --- /dev/null +++ b/examples/manage_contacts/manage_contacts.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +// !!! Adjust the paymail domain to the domain supported by the spv-wallet server +const yourPaymailDomain = "example.com" + +// Example configuration – adjust as needed. +// It holds the values required to present the example. +var config = struct { + setupUsers bool + totpDigits uint + totpPeriods uint + server string + paymailDomain string + alice user + bob user +}{ + // We assume that the users: Alice and Bob are already registered. + // If they're not, please set this to true to make the example create them. + setupUsers: false, + + totpDigits: 2, + totpPeriods: 1200, + server: "http://localhost:3003", + paymailDomain: examplePaymailCorrectlyEdited(yourPaymailDomain), + alice: user{ + xPriv: "xprv9s21ZrQH143K2jMwweKF33hFDDvwxEooDtXbZ7mGTJQfmSs8aD77ThuYDsfNrgBAbHr9Yx8FrPaukMLHpxFUyyvBuzAJBMpd4a2xFxr6qts", + xPub: "xpub661MyMwAqRbcFDSR3frFQBdymFmSMhXeb7TCMWAt1dweeFCH7kRN1WE257E65MufrqngaLK46ERg5LHHouHiS8DvHKovmo5VhjLs5vgwqdp", + paymail: "alice" + "@" + yourPaymailDomain, + }, + bob: user{ + xPriv: "xprv9s21ZrQH143K3DkTDsWwvUb3pwgKoYGp9hxYe2coqZz3pvE1kQfe1dQLdcN82XSeLmw1nGpMZLnXZktf9hFJTu9NRLBpQnGHwYpo4SmszZY", + xPub: "xpub661MyMwAqRbcFhpvKu3xHcXnNyWpCzzfWvt9SR2RPuX2hiZAHwytZRipUtM4qG2PPPF5pZttP3grZM9N9MR5jSek7RRgyggsLJAWFJJUAko", + paymail: "bob" + "@" + yourPaymailDomain, + }, +} + +var clients = struct { + alice *wallet.UserAPI + bob *wallet.UserAPI + admin *wallet.AdminAPI +}{ + alice: assertNoError(wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), config.alice.xPriv)), + bob: assertNoError(wallet.NewUserAPIWithXPriv(exampleutil.NewDefaultConfig(), config.bob.xPriv)), + admin: assertNoError(wallet.NewAdminAPIWithXPriv(exampleutil.NewDefaultConfig(), examples.AdminXPriv)), +} + +var ctx = context.Background() + +func verificationFlow() (*verificationResults, error) { + fmt.Println("\n1. Creating initial contacts") + + alicePaymail := config.alice.paymail + bobPaymail := config.bob.paymail + + _, err := clients.alice.UpsertContact(ctx, commands.UpsertContact{ + ContactPaymail: bobPaymail, + FullName: "Bob Smith", + RequesterPaymail: alicePaymail, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Bob's contact for Alice: %w", err) + } + + _, err = clients.bob.UpsertContact(ctx, commands.UpsertContact{ + ContactPaymail: alicePaymail, + FullName: "Alice Smith", + RequesterPaymail: bobPaymail, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Alice's contact for Bob: %w", err) + } + + respBob, err := clients.alice.ContactWithPaymail(ctx, bobPaymail) + if err != nil { + return nil, fmt.Errorf("failed to get Bob's contact: %w", err) + } + bobContact := mapToContactModel(respBob) + + respAlice, err := clients.bob.ContactWithPaymail(ctx, alicePaymail) + if err != nil { + return nil, fmt.Errorf("failed to get Alice's contact: %w", err) + } + aliceContact := mapToContactModel(respAlice) + + fmt.Println("\n2. Alice initiates verification") + aliceTotpForBob, err := clients.alice.GenerateTotpForContact(bobContact, config.totpPeriods, config.totpDigits) + if err != nil { + return nil, fmt.Errorf("failed to generate Alice's TOTP for Bob: %w", err) + } + logSecureMessage("Alice", "Bob", aliceTotpForBob) + + fmt.Println("\n3. Bob validates Alice's TOTP") + bobValidationErr := clients.bob.ValidateTotpForContact(aliceContact, aliceTotpForBob, respBob.Paymail, config.totpPeriods, config.totpDigits) + bobValidatedAlicesTotp := bobValidationErr == nil + fmt.Printf("Validation status: %v\n", bobValidatedAlicesTotp) + + fmt.Println("\n4. Bob initiates verification") + bobTotpForAlice, err := clients.bob.GenerateTotpForContact(aliceContact, config.totpPeriods, config.totpDigits) + if err != nil { + return nil, fmt.Errorf("failed to generate Bob's TOTP for Alice: %w", err) + } + logSecureMessage("Bob", "Alice", bobTotpForAlice) + + fmt.Println("\n5. Alice validates Bob's TOTP") + aliceValidationErr := clients.alice.ValidateTotpForContact(bobContact, bobTotpForAlice, respAlice.Paymail, config.totpPeriods, config.totpDigits) + aliceValidatedBobsTotp := aliceValidationErr == nil + fmt.Printf("Validation status: %v\n", aliceValidatedBobsTotp) + + return &verificationResults{ + bobValidatedAlicesTotp: bobValidatedAlicesTotp, + aliceValidatedBobsTotp: aliceValidatedBobsTotp, + }, nil +} + +func finalizeAndCleanup(results *verificationResults) error { + isFullyVerified := results.bobValidatedAlicesTotp && results.aliceValidatedBobsTotp + fmt.Printf("\nBidirectional verification complete: %v\n", isFullyVerified) + + if isFullyVerified { + fmt.Println("\n6. Admin confirms verified contacts") + if err := clients.admin.ConfirmContacts(ctx, &commands.ConfirmContacts{ + PaymailA: config.alice.paymail, + PaymailB: config.bob.paymail, + }); err != nil { + _ = fmt.Errorf("failed to confirm contacts: %w", err) + } + } + + fmt.Println("\n7. Cleaning up contacts") + if err := clients.alice.RemoveContact(ctx, config.bob.paymail); err != nil { + return fmt.Errorf("failed to remove Bob's contact: %w", err) + } + + if err := clients.bob.RemoveContact(ctx, config.alice.paymail); err != nil { + return fmt.Errorf("failed to remove Alice's contact: %w", err) + } + + return nil +} + +func main() { + if config.setupUsers { + setupUsers() + } else { + fmt.Println("We assume that the users: Alice and Bob are already registered.") + fmt.Println("If they're not, please set config.setupUsers to true to make the example create them.") + } + + results, err := verificationFlow() + if err != nil { + log.Fatalf("Error during verification flow: %v", err) + } + + if err := finalizeAndCleanup(results); err != nil { + log.Fatalf("Error during cleanup: %v", err) + } +} diff --git a/internal/api/v1/user/totp/totp.go b/internal/api/v1/user/totp/totp.go index cb064e54..2eeca375 100644 --- a/internal/api/v1/user/totp/totp.go +++ b/internal/api/v1/user/totp/totp.go @@ -52,14 +52,14 @@ func (b *API) GenerateTotpForContact(contact *models.Contact, period, digits uin } // ValidateTotpForContact validates a TOTP for a contact. -func (b *API) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { - sharedSecret, err := b.makeSharedSecret(contact) +func (b *API) ValidateTotpForContact(generatorContact *models.Contact, passcode, validatorContact string, period, digits uint) error { + sharedSecret, err := b.makeSharedSecret(generatorContact) if err != nil { return fmt.Errorf("ValidateTotpForContact: error when making shared secret: %w", err) } opts := getTotpOpts(period, digits) - valid, err := totp.ValidateCustom(passcode, directedSecret(sharedSecret, requesterPaymail), time.Now(), *opts) + valid, err := totp.ValidateCustom(passcode, directedSecret(sharedSecret, validatorContact), time.Now(), *opts) if err != nil { return fmt.Errorf("ValidateTotpForContact: error when validating TOTP: %w", err) } diff --git a/user_api.go b/user_api.go index 9f972f16..48d4b996 100644 --- a/user_api.go +++ b/user_api.go @@ -398,12 +398,12 @@ func (u *UserAPI) GenerateTotpForContact(contact *models.Contact, period, digits } // ValidateTotpForContact validates a TOTP code for the specified contact. -func (u *UserAPI) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { +func (u *UserAPI) ValidateTotpForContact(generatorContact *models.Contact, passcode, validatorPaymail string, period, digits uint) error { if u.totpAPI == nil { return errors.New("totp client not initialized - xPriv authentication required") } - if err := u.totpAPI.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + if err := u.totpAPI.ValidateTotpForContact(generatorContact, passcode, validatorPaymail, period, digits); err != nil { return fmt.Errorf("failed to validate TOTP for contact: %w", err) }