From 385c9aa1d9a4f3ac1cc94700afd1d237e2143284 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 16 Jan 2025 19:15:36 -0500 Subject: [PATCH] feat: participation key integrity hash --- internal/algod/participation/integrity.go | 77 +++++++++ .../algod/participation/integrity_test.go | 58 +++++++ internal/test/mock/fixtures.go | 39 +++++ ui/modal/modal_test.go | 10 +- .../testdata/Test_Snapshot/InfoModal.golden | 160 +++++++++--------- ui/modals/info/info.go | 29 +++- ui/modals/info/info_test.go | 6 +- .../testdata/Test_Snapshot/Visible.golden | 25 +-- ui/style/style.go | 4 +- 9 files changed, 308 insertions(+), 100 deletions(-) create mode 100644 internal/algod/participation/integrity.go create mode 100644 internal/algod/participation/integrity_test.go diff --git a/internal/algod/participation/integrity.go b/internal/algod/participation/integrity.go new file mode 100644 index 00000000..68333a64 --- /dev/null +++ b/internal/algod/participation/integrity.go @@ -0,0 +1,77 @@ +package participation + +import ( + "crypto/sha512" + "encoding/base32" + "encoding/binary" + "errors" + "github.com/algorand/go-algorand-sdk/v2/types" + "github.com/algorandfoundation/nodekit/api" + "strings" +) + +// concat merges multiple byte slices into a single byte slice by appending all input slices into a single output slice. +func concat(slices [][]byte) []byte { + var all []byte + for _, s := range slices { + all = append(all, s...) + } + return all +} + +// hash calculates a shortened base32-encoded SHA-512/256 hash of the input byte slice and returns it as a string. +func hash(rawBytes []byte) string { + hashBytes := sha512.Sum512_256(rawBytes) + return strings.Replace(base32.StdEncoding.EncodeToString(hashBytes[:8]), "===", "", 1) +} + +// IntegrityHash computes a unique, deterministic hash based on a ParticipationKey, ensuring data integrity for validation. +// Returns a base32-encoded string and an error if the input data is invalid or hashing fails. +func IntegrityHash(partkey api.ParticipationKey) (string, error) { + address, err := types.DecodeAddress(partkey.Address) + if err != nil { + return "", err + } + + encodedFV := make([]byte, 8) + binary.BigEndian.PutUint64(encodedFV, uint64(partkey.Key.VoteFirstValid)) + + encodedLV := make([]byte, 8) + binary.BigEndian.PutUint64(encodedLV, uint64(partkey.Key.VoteLastValid)) + + encodedVoteKeyDilution := make([]byte, 8) + binary.BigEndian.PutUint64(encodedVoteKeyDilution, uint64(partkey.Key.VoteKeyDilution)) + + // raw bytes + rawData := concat([][]byte{ + address[:], + partkey.Key.SelectionParticipationKey[:], + partkey.Key.VoteParticipationKey[:], + *partkey.Key.StateProofKey, + encodedFV[:], + encodedLV[:], + encodedVoteKeyDilution[:], + }) + + if len(rawData) != 184 { + return "", errors.New("invalid raw data length") + } + + // Enchode + hashBytes := sha512.Sum512_256(rawData) + return strings.Replace(base32.StdEncoding.EncodeToString(hashBytes[:8]), "===", "", 1), nil +} + +func OfflineHash(address string, network string) (string, error) { + addr, err := types.DecodeAddress(address) + if err != nil { + return "", err + } + + rawBytes := concat([][]byte{ + addr[:], + []byte(network), + }) + + return hash(rawBytes), nil +} diff --git a/internal/algod/participation/integrity_test.go b/internal/algod/participation/integrity_test.go new file mode 100644 index 00000000..55331ab2 --- /dev/null +++ b/internal/algod/participation/integrity_test.go @@ -0,0 +1,58 @@ +package participation + +import ( + "encoding/base64" + "github.com/algorandfoundation/nodekit/api" + "testing" +) + +func Test_IntegrityHash(t *testing.T) { + account := "TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU" + selectionKey, err := base64.StdEncoding.DecodeString("DM9cyZ0oLuVHDtVzhkhLIW06uE0J9faf6aL/FeLFj3o=") + if err != nil { + t.Fatal(err) + } + stateProofKey, err := base64.StdEncoding.DecodeString("+DAZBTXOletJxFUhEaYQaWaNs3Q4DLEwOlJ68gI8IGq9Ss/1szOimQiAt+f6lqk4FxEe/XvaAXkMbv2/9OiE1g==") + if err != nil { + t.Fatal(err) + } + + voteKey, err := base64.StdEncoding.DecodeString("dHynahCuNWpeR9BcE+B8VE1GM/KdUj759k9ja8zNY30=") + if err != nil { + t.Fatal(err) + } + + var fv = 47733256 + var lv = 47861731 + var kd = 359 + + var expectedHash = "4OAJOXKPLUQM2" + + res, err := IntegrityHash(api.ParticipationKey{ + Address: account, + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "", + Key: api.AccountParticipation{ + SelectionParticipationKey: selectionKey, + StateProofKey: &stateProofKey, + VoteFirstValid: fv, + VoteKeyDilution: kd, + VoteLastValid: lv, + VoteParticipationKey: voteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }) + if res != expectedHash { + t.Error("expected", expectedHash, "got", res) + } + + var expectedOfflineHash = "FFDBPNDG63S7C" + res, err = OfflineHash(account, "testnet") + if res != expectedOfflineHash { + t.Error("expected", expectedOfflineHash, "got", res) + } + +} diff --git a/internal/test/mock/fixtures.go b/internal/test/mock/fixtures.go index 59293229..cb54ec2f 100644 --- a/internal/test/mock/fixtures.go +++ b/internal/test/mock/fixtures.go @@ -1,9 +1,48 @@ package mock import ( + "encoding/base64" "github.com/algorandfoundation/nodekit/api" ) +func GetPartKey() (*api.ParticipationKey, error) { + account := "TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU" + selectionKey, err := base64.StdEncoding.DecodeString("DM9cyZ0oLuVHDtVzhkhLIW06uE0J9faf6aL/FeLFj3o=") + if err != nil { + return nil, err + } + stateProofKey, err := base64.StdEncoding.DecodeString("+DAZBTXOletJxFUhEaYQaWaNs3Q4DLEwOlJ68gI8IGq9Ss/1szOimQiAt+f6lqk4FxEe/XvaAXkMbv2/9OiE1g==") + if err != nil { + return nil, err + } + + voteKey, err := base64.StdEncoding.DecodeString("dHynahCuNWpeR9BcE+B8VE1GM/KdUj759k9ja8zNY30=") + if err != nil { + return nil, err + } + + var fv = 47733256 + var lv = 47861731 + var kd = 359 + return &api.ParticipationKey{ + Address: account, + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "DRINJEQ6PN4GDQYE6ECJFRTVSCXZA4BUWNZMPFL6Q2MFTEFBPXGA", + Key: api.AccountParticipation{ + SelectionParticipationKey: selectionKey, + StateProofKey: &stateProofKey, + VoteFirstValid: fv, + VoteKeyDilution: kd, + VoteLastValid: lv, + VoteParticipationKey: voteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, nil +} + var VoteKey = []byte("TESTKEY") var SelectionKey = []byte("TESTKEY") var StateProofKey = []byte("TESTKEY") diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go index 7c43ba2c..df7763a5 100644 --- a/ui/modal/modal_test.go +++ b/ui/modal/modal_test.go @@ -25,10 +25,14 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("InfoModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&mock.Keys[0]) + model := New(lipgloss.NewStyle().Width(200).Height(80).Render(""), true, test.GetState(nil)) + key, err := mock.GetPartKey() + if err != nil { + t.Fatal(err) + } + model.SetKey(key) model.SetType(app.InfoModal) - model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 201, Height: 80}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) diff --git a/ui/modal/testdata/Test_Snapshot/InfoModal.golden b/ui/modal/testdata/Test_Snapshot/InfoModal.golden index 3e28416a..01b5a749 100644 --- a/ui/modal/testdata/Test_Snapshot/InfoModal.golden +++ b/ui/modal/testdata/Test_Snapshot/InfoModal.golden @@ -1,80 +1,80 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ╭──Key Information────────────╮ - │ │ - │ Account: ABC │ - │ Participation ID: 123 │ - │ │ - │ Vote Key: VEVTVEtFWQ │ - │ Selection Key: VEVTVEtFWQ │ - │ State Proof Key: VEVTVEtFWQ │ - │ │ - │ Vote First Valid: 0 │ - │ Vote Last Valid: 30000 │ - │ Vote Key Dilution: 100 │ - │ │ - ╰──( (d)elete | (o)nline )────╯ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Key Information────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ Account: TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU │ + │ Participation ID: DRINJEQ6PN4GDQYE6ECJFRTVSCXZA4BUWNZMPFL6Q2MFTEFBPXGA │ + │ Integrity: 4OAJOXKPLUQM2 │ + │ │ + │ Vote Key: dHynahCuNWpeR9BcE-B8VE1GM_KdUj759k9ja8zNY30 │ + │ Selection Key: DM9cyZ0oLuVHDtVzhkhLIW06uE0J9faf6aL_FeLFj3o │ + │ State Proof Key: -DAZBTXOletJxFUhEaYQaWaNs3Q4DLEwOlJ68gI8IGq9Ss_1szOimQiAt-f6lqk4FxEe_XvaAXkMbv2_9OiE1g │ + │ │ + │ Vote First Valid: 47733256 │ + │ Vote Last Valid: 47861731 │ + │ Vote Key Dilution: 359 │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────( (d)elete | (o)nline )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index fa9b2073..376f07a5 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -3,12 +3,14 @@ package info import ( "github.com/algorandfoundation/nodekit/api" "github.com/algorandfoundation/nodekit/internal/algod" + "github.com/algorandfoundation/nodekit/internal/algod/participation" "github.com/algorandfoundation/nodekit/ui/app" "github.com/algorandfoundation/nodekit/ui/style" "github.com/algorandfoundation/nodekit/ui/utils" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "strings" ) type ViewModel struct { @@ -90,7 +92,7 @@ func (m ViewModel) View() string { if m.Participation == nil { return "No key selected" } - account := style.Cyan.Render("Account: ") + m.Participation.Address + account := lipgloss.JoinHorizontal(lipgloss.Left, style.Cyan.Render("Account: "), m.Participation.Address) id := style.Cyan.Render("Participation ID: ") + m.Participation.Id selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.SelectionParticipationKey[:]) vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.VoteParticipationKey[:]) @@ -98,6 +100,29 @@ func (m ViewModel) View() string { voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.Participation.Key.VoteFirstValid) voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.Participation.Key.VoteLastValid) voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.Participation.Key.VoteKeyDilution) + hashBlock := "" + + var hashResult string + var err error + if !m.Active { + hashResult, err = participation.IntegrityHash(*m.Participation) + if err == nil { + hashBlock = style.Cyan.Render("Integrity: ") + hashResult + "\n" + } else { + hashBlock = style.Cyan.Render("Integrity: ") + "Error" + err.Error() + "\n" + } + } else { + var loraNetwork = strings.Replace(strings.Replace(m.State.Status.Network, "-v1.0", "", 1), "-v1", "", 1) + if loraNetwork == "dockernet" || loraNetwork == "tuinet" { + loraNetwork = "localnet" + } + hashResult, err = participation.OfflineHash(m.Participation.Address, loraNetwork) + if err == nil { + hashBlock = style.Cyan.Render("Integrity: ") + hashResult + "\n" + } else { + hashBlock = style.Cyan.Render("Integrity: ") + "Error\n" + } + } prefix := "" if m.Prefix != "" { @@ -108,7 +133,7 @@ func (m ViewModel) View() string { prefix, account, id, - "", + hashBlock, vote, selection, stateProof, diff --git a/ui/modals/info/info_test.go b/ui/modals/info/info_test.go index f3211e9f..43e61799 100644 --- a/ui/modals/info/info_test.go +++ b/ui/modals/info/info_test.go @@ -33,7 +33,11 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &mock.Keys[0] + var err error + model.Participation, err = mock.GetPartKey() + if err != nil { + t.Fatal(err) + } got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) diff --git a/ui/modals/info/testdata/Test_Snapshot/Visible.golden b/ui/modals/info/testdata/Test_Snapshot/Visible.golden index e8bc0e8d..b43255a0 100644 --- a/ui/modals/info/testdata/Test_Snapshot/Visible.golden +++ b/ui/modals/info/testdata/Test_Snapshot/Visible.golden @@ -1,12 +1,13 @@ - -Account: ABC -Participation ID: 123 - -Vote Key: VEVTVEtFWQ -Selection Key: VEVTVEtFWQ -State Proof Key: VEVTVEtFWQ - -Vote First Valid: 0 -Vote Last Valid: 30000 -Vote Key Dilution: 100 - \ No newline at end of file + +Account: TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU +Participation ID: DRINJEQ6PN4GDQYE6ECJFRTVSCXZA4BUWNZMPFL6Q2MFTEFBPXGA +Integrity: 4OAJOXKPLUQM2 + +Vote Key: dHynahCuNWpeR9BcE-B8VE1GM_KdUj759k9ja8zNY30 +Selection Key: DM9cyZ0oLuVHDtVzhkhLIW06uE0J9faf6aL_FeLFj3o +State Proof Key: -DAZBTXOletJxFUhEaYQaWaNs3Q4DLEwOlJ68gI8IGq9Ss_1szOimQiAt-f6lqk4FxEe_XvaAXkMbv2_9OiE1g + +Vote First Valid: 47733256 +Vote Last Valid: 47861731 +Vote Key Dilution: 359 + \ No newline at end of file diff --git a/ui/style/style.go b/ui/style/style.go index a609666b..a762eb6c 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -22,9 +22,9 @@ var ( MarketingBlue = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d2df1")).Render MarketingTeal = lipgloss.NewStyle().Foreground(lipgloss.Color("#17CAC6")).Render Blue = func() lipgloss.Style { - return lipgloss.NewStyle().Foreground(lipgloss.Color("#12")) + return lipgloss.NewStyle().Foreground(lipgloss.Color("12")) }() - Cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("#14")) + Cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) Yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) Green = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) Red = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))