Skip to content
21 changes: 21 additions & 0 deletions universalClient/pushcore/pushCore.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,27 @@ func (c *Client) GetCurrentKey(ctx context.Context) (*utsstypes.TssKey, error) {
)
}

// GetKeyByID retrieves a single TSS key from the on-chain key history.
// Returns an error if the key ID is not in the history.
func (c *Client) GetKeyByID(ctx context.Context, keyID string) (*utsstypes.TssKey, error) {
return retryWithRoundRobin(
len(c.utssClients),
&c.rr,
func(idx int) (*utsstypes.TssKey, error) {
resp, err := c.utssClients[idx].KeyById(ctx, &utsstypes.QueryKeyByIdRequest{KeyId: keyID})
if err != nil {
return nil, err
}
if resp == nil || resp.Key == nil {
return nil, fmt.Errorf("pushcore: TSS key %s not found", keyID)
}
return resp.Key, nil
},
"GetKeyByID",
c.logger,
)
}

// GetGasPrice retrieves the median gas price for a specific chain from the on-chain oracle.
func (c *Client) GetGasPrice(ctx context.Context, chainID string) (*big.Int, error) {
if chainID == "" {
Expand Down
75 changes: 70 additions & 5 deletions universalClient/pushcore/pushCore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package pushcore

import (
"context"
"errors"
"math/big"
"testing"

Expand Down Expand Up @@ -965,10 +966,11 @@ func (m *mockUValidatorQueryClient) UniversalValidator(ctx context.Context, req

type mockUTSSQueryClient struct {
utsstypes.QueryClient
currentKeyResp *utsstypes.QueryCurrentKeyResponse
pendingTssEventsResp *utsstypes.QueryAllPendingTssEventsResponse
pendingFundMigrationsResp *utsstypes.QueryPendingFundMigrationsResponse
err error
currentKeyResp *utsstypes.QueryCurrentKeyResponse
keyByIdResp *utsstypes.QueryKeyByIdResponse
pendingTssEventsResp *utsstypes.QueryAllPendingTssEventsResponse
pendingFundMigrationsResp *utsstypes.QueryPendingFundMigrationsResponse
err error
}

func (m *mockUTSSQueryClient) CurrentKey(ctx context.Context, req *utsstypes.QueryCurrentKeyRequest, opts ...grpc.CallOption) (*utsstypes.QueryCurrentKeyResponse, error) {
Expand All @@ -993,7 +995,10 @@ func (m *mockUTSSQueryClient) PendingFundMigrations(ctx context.Context, req *ut
}

func (m *mockUTSSQueryClient) KeyById(ctx context.Context, req *utsstypes.QueryKeyByIdRequest, opts ...grpc.CallOption) (*utsstypes.QueryKeyByIdResponse, error) {
return nil, nil
if m.err != nil {
return nil, m.err
}
return m.keyByIdResp, nil
}

type mockTxServiceClient struct {
Expand Down Expand Up @@ -1084,3 +1089,63 @@ func (m *mockAuthAccountQueryClient) Account(ctx context.Context, req *authtypes
}
return m.accountResp, nil
}

func TestClient_GetKeyByID(t *testing.T) {
logger := zerolog.Nop()

t.Run("no endpoints configured", func(t *testing.T) {
client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{}}

key, err := client.GetKeyByID(context.Background(), "key-123")
require.Error(t, err)
assert.Contains(t, err.Error(), "no endpoints configured")
assert.Nil(t, key)
})

t.Run("successful query returns key", func(t *testing.T) {
mockClient := &mockUTSSQueryClient{
keyByIdResp: &utsstypes.QueryKeyByIdResponse{
Key: &utsstypes.TssKey{KeyId: "key-123", TssPubkey: "0xpub"},
},
}
client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}}

key, err := client.GetKeyByID(context.Background(), "key-123")
require.NoError(t, err)
require.NotNil(t, key)
assert.Equal(t, "key-123", key.KeyId)
assert.Equal(t, "0xpub", key.TssPubkey)
})

t.Run("unknown key id errors", func(t *testing.T) {
mockClient := &mockUTSSQueryClient{
keyByIdResp: &utsstypes.QueryKeyByIdResponse{Key: nil},
}
client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}}

key, err := client.GetKeyByID(context.Background(), "missing")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Nil(t, key)
})

// A nil response with a nil error must not panic.
t.Run("nil response errors", func(t *testing.T) {
mockClient := &mockUTSSQueryClient{keyByIdResp: nil}
client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}}

key, err := client.GetKeyByID(context.Background(), "key-123")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Nil(t, key)
})

t.Run("query error propagates", func(t *testing.T) {
mockClient := &mockUTSSQueryClient{err: errors.New("rpc down")}
client := &Client{logger: logger, utssClients: []utsstypes.QueryClient{mockClient}}

key, err := client.GetKeyByID(context.Background(), "key-123")
require.Error(t, err)
assert.Nil(t, key)
})
}
53 changes: 53 additions & 0 deletions universalClient/tss/keyshare/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,59 @@ func (m *Manager) Exists(id string) (bool, error) {
return true, nil
}

// List returns the IDs of all stored keyshares.
func (m *Manager) List() ([]string, error) {
entries, err := os.ReadDir(m.keysharesDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read keyshares directory: %w", err)
}

ids := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
ids = append(ids, e.Name())
}
}
return ids, nil
}

// Delete removes a stored keyshare. It overwrites the file with random bytes
// before unlinking; on SSD/COW filesystems that is best-effort, so the real
// protection remains the at-rest encryption. Deleting a missing ID is a no-op.
func (m *Manager) Delete(id string) error {
if id == "" {
return ErrInvalidID
}

if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
return fmt.Errorf("%w: id contains invalid characters", ErrInvalidID)
}

filePath := filepath.Join(m.keysharesDir, id)
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat keyshare file: %w", err)
}

if info.Mode().IsRegular() && info.Size() > 0 {
scratch := make([]byte, info.Size())
if _, rerr := rand.Read(scratch); rerr == nil {
_ = os.WriteFile(filePath, scratch, filePerms)
}
}

if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove keyshare file: %w", err)
}
return nil
}

// encrypt encrypts keyshare data using AES-256-GCM with a password-derived key.
// Returns encrypted data in format: [salt(32) || nonce(12) || ciphertext || tag(16)]
func (m *Manager) encrypt(keyshareData []byte) ([]byte, error) {
Expand Down
130 changes: 130 additions & 0 deletions universalClient/tss/keyshare/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,3 +516,133 @@ func TestManager_EncryptDecrypt(t *testing.T) {
}
})
}

func TestList(t *testing.T) {
t.Run("empty directory", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
ids, err := mgr.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(ids) != 0 {
t.Errorf("List() = %v, want empty", ids)
}
})

t.Run("returns stored ids", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
for _, id := range []string{"key-a", "key-b"} {
if err := mgr.Store([]byte("share-"+id), id); err != nil {
t.Fatalf("Store(%s) error = %v", id, err)
}
}
ids, err := mgr.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(ids) != 2 {
t.Fatalf("List() returned %d ids, want 2", len(ids))
}
found := map[string]bool{}
for _, id := range ids {
found[id] = true
}
if !found["key-a"] || !found["key-b"] {
t.Errorf("List() = %v, want key-a and key-b", ids)
}
})

t.Run("ignores subdirectories", func(t *testing.T) {
tmpDir := t.TempDir()
mgr, err := NewManager(tmpDir, "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
if err := os.MkdirAll(filepath.Join(mgr.keysharesDir, "nested"), dirPerms); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
ids, err := mgr.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(ids) != 0 {
t.Errorf("List() = %v, want empty (dirs ignored)", ids)
}
})
}

func TestDelete(t *testing.T) {
t.Run("removes stored keyshare", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
if err := mgr.Store([]byte("secret-share"), "key-1"); err != nil {
t.Fatalf("Store() error = %v", err)
}
if err := mgr.Delete("key-1"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if _, err := mgr.Get("key-1"); !errors.Is(err, ErrKeyshareNotFound) {
t.Errorf("Get() after Delete error = %v, want ErrKeyshareNotFound", err)
}
exists, err := mgr.Exists("key-1")
if err != nil {
t.Fatalf("Exists() error = %v", err)
}
if exists {
t.Error("Exists() = true after Delete, want false")
}
})

t.Run("missing id is a no-op", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
if err := mgr.Delete("never-stored"); err != nil {
t.Errorf("Delete() on missing id error = %v, want nil", err)
}
})

t.Run("rejects invalid ids", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
for _, id := range []string{"", "../escape", "sub/dir", "back\\slash"} {
if err := mgr.Delete(id); !errors.Is(err, ErrInvalidID) {
t.Errorf("Delete(%q) error = %v, want ErrInvalidID", id, err)
}
}
})

t.Run("leaves other keyshares intact", func(t *testing.T) {
mgr, err := NewManager(t.TempDir(), "pw")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
if err := mgr.Store([]byte("share-a"), "key-a"); err != nil {
t.Fatalf("Store() error = %v", err)
}
if err := mgr.Store([]byte("share-b"), "key-b"); err != nil {
t.Fatalf("Store() error = %v", err)
}
if err := mgr.Delete("key-a"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
got, err := mgr.Get("key-b")
if err != nil {
t.Fatalf("Get(key-b) error = %v", err)
}
if string(got) != "share-b" {
t.Errorf("Get(key-b) = %q, want %q", got, "share-b")
}
})
}
Loading
Loading