aboutsummaryrefslogtreecommitdiffstats
path: root/accounts/usbwallet/hub.go
blob: 640320bc91434814ff79acf47b735ef277fc1bc1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.

package usbwallet

import (
    "errors"
    "runtime"
    "sync"
    "time"

    "github.com/ethereum/go-ethereum/accounts"
    "github.com/ethereum/go-ethereum/event"
    "github.com/ethereum/go-ethereum/log"
    "github.com/karalabe/hid"
)

// LedgerScheme is the protocol scheme prefixing account and wallet URLs.
const LedgerScheme = "ledger"

// TrezorScheme is the protocol scheme prefixing account and wallet URLs.
const TrezorScheme = "trezor"

// refreshCycle is the maximum time between wallet refreshes (if USB hotplug
// notifications don't work).
const refreshCycle = time.Second

// refreshThrottling is the minimum time between wallet refreshes to avoid USB
// trashing.
const refreshThrottling = 500 * time.Millisecond

// Hub is a accounts.Backend that can find and handle generic USB hardware wallets.
type Hub struct {
    scheme     string                  // Protocol scheme prefixing account and wallet URLs.
    vendorID   uint16                  // USB vendor identifier used for device discovery
    productIDs []uint16                // USB product identifiers used for device discovery
    usageID    uint16                  // USB usage page identifier used for macOS device discovery
    endpointID int                     // USB endpoint identifier used for non-macOS device discovery
    makeDriver func(log.Logger) driver // Factory method to construct a vendor specific driver

    refreshed   time.Time               // Time instance when the list of wallets was last refreshed
    wallets     []accounts.Wallet       // List of USB wallet devices currently tracking
    updateFeed  event.Feed              // Event feed to notify wallet additions/removals
    updateScope event.SubscriptionScope // Subscription scope tracking current live listeners
    updating    bool                    // Whether the event notification loop is running

    quit chan chan error

    stateLock sync.RWMutex // Protects the internals of the hub from racey access

    // TODO(karalabe): remove if hotplug lands on Windows
    commsPend int        // Number of operations blocking enumeration
    commsLock sync.Mutex // Lock protecting the pending counter and enumeration
}

// NewLedgerHub creates a new hardware wallet manager for Ledger devices.
func NewLedgerHub() (*Hub, error) {
    return newHub(LedgerScheme, 0x2c97, []uint16{0x0000 /* Ledger Blue */, 0x0001 /* Ledger Nano S */}, 0xffa0, 0, newLedgerDriver)
}

// NewTrezorHub creates a new hardware wallet manager for Trezor devices.
func NewTrezorHub() (*Hub, error) {
    return newHub(TrezorScheme, 0x534c, []uint16{0x0001 /* Trezor 1 */}, 0xff00, 0, newTrezorDriver)
}

// newHub creates a new hardware wallet manager for generic USB devices.
func newHub(scheme string, vendorID uint16, productIDs []uint16, usageID uint16, endpointID int, makeDriver func(log.Logger) driver) (*Hub, error) {
    if !hid.Supported() {
        return nil, errors.New("unsupported platform")
    }
    hub := &Hub{
        scheme:     scheme,
        vendorID:   vendorID,
        productIDs: productIDs,
        usageID:    usageID,
        endpointID: endpointID,
        makeDriver: makeDriver,
        quit:       make(chan chan error),
    }
    hub.refreshWallets()
    return hub, nil
}

// Wallets implements accounts.Backend, returning all the currently tracked USB
// devices that appear to be hardware wallets.
func (hub *Hub) Wallets() []accounts.Wallet {
    // Make sure the list of wallets is up to date
    hub.refreshWallets()

    hub.stateLock.RLock()
    defer hub.stateLock.RUnlock()

    cpy := make([]accounts.Wallet, len(hub.wallets))
    copy(cpy, hub.wallets)
    return cpy
}

// refreshWallets scans the USB devices attached to the machine and updates the
// list of wallets based on the found devices.
func (hub *Hub) refreshWallets() {
    // Don't scan the USB like crazy it the user fetches wallets in a loop
    hub.stateLock.RLock()
    elapsed := time.Since(hub.refreshed)
    hub.stateLock.RUnlock()

    if elapsed < refreshThrottling {
        return
    }
    // Retrieve the current list of USB wallet devices
    var devices []hid.DeviceInfo

    if runtime.GOOS == "linux" {
        // hidapi on Linux opens the device during enumeration to retrieve some infos,
        // breaking the Ledger protocol if that is waiting for user confirmation. This
        // is a bug acknowledged at Ledger, but it won't be fixed on old devices so we
        // need to prevent concurrent comms ourselves. The more elegant solution would
        // be to ditch enumeration in favor of hotplug events, but that don't work yet
        // on Windows so if we need to hack it anyway, this is more elegant for now.
        hub.commsLock.Lock()
        if hub.commsPend > 0 { // A confirmation is pending, don't refresh
            hub.commsLock.Unlock()
            return
        }
    }
    for _, info := range hid.Enumerate(hub.vendorID, 0) {
        for _, id := range hub.productIDs {
            if info.ProductID == id && (info.UsagePage == hub.usageID || info.Interface == hub.endpointID) {
                devices = append(devices, info)
                break
            }
        }
    }
    if runtime.GOOS == "linux" {
        // See rationale before the enumeration why this is needed and only on Linux.
        hub.commsLock.Unlock()
    }
    // Transform the current list of wallets into the new one
    hub.stateLock.Lock()

    wallets := make([]accounts.Wallet, 0, len(devices))
    events := []accounts.WalletEvent{}

    for _, device := range devices {
        url := accounts.URL{Scheme: hub.scheme, Path: device.Path}

        // Drop wallets in front of the next device or those that failed for some reason
        for len(hub.wallets) > 0 {
            // Abort if we're past the current device and found an operational one
            _, failure := hub.wallets[0].Status()
            if hub.wallets[0].URL().Cmp(url) >= 0 || failure == nil {
                break
            }
            // Drop the stale and failed devices
            events = append(events, accounts.WalletEvent{Wallet: hub.wallets[0], Kind: accounts.WalletDropped})
            hub.wallets = hub.wallets[1:]
        }
        // If there are no more wallets or the device is before the next, wrap new wallet
        if len(hub.wallets) == 0 || hub.wallets[0].URL().Cmp(url) > 0 {
            logger := log.New("url", url)
            wallet := &wallet{hub: hub, driver: hub.makeDriver(logger), url: &url, info: device, log: logger}

            events = append(events, accounts.WalletEvent{Wallet: wallet, Kind: accounts.WalletArrived})
            wallets = append(wallets, wallet)
            continue
        }
        // If the device is the same as the first wallet, keep it
        if hub.wallets[0].URL().Cmp(url) == 0 {
            wallets = append(wallets, hub.wallets[0])
            hub.wallets = hub.wallets[1:]
            continue
        }
    }
    // Drop any leftover wallets and set the new batch
    for _, wallet := range hub.wallets {
        events = append(events, accounts.WalletEvent{Wallet: wallet, Kind: accounts.WalletDropped})
    }
    hub.refreshed = time.Now()
    hub.wallets = wallets
    hub.stateLock.Unlock()

    // Fire all wallet events and return
    for _, event := range events {
        hub.updateFeed.Send(event)
    }
}

// Subscribe implements accounts.Backend, creating an async subscription to
// receive notifications on the addition or removal of USB wallets.
func (hub *Hub) Subscribe(sink chan<- accounts.WalletEvent) event.Subscription {
    // We need the mutex to reliably start/stop the update loop
    hub.stateLock.Lock()
    defer hub.stateLock.Unlock()

    // Subscribe the caller and track the subscriber count
    sub := hub.updateScope.Track(hub.updateFeed.Subscribe(sink))

    // Subscribers require an active notification loop, start it
    if !hub.updating {
        hub.updating = true
        go hub.updater()
    }
    return sub
}

// updater is responsible for maintaining an up-to-date list of wallets managed
// by the USB hub, and for firing wallet addition/removal events.
func (hub *Hub) updater() {
    for {
        // TODO: Wait for a USB hotplug event (not supported yet) or a refresh timeout
        // <-hub.changes
        time.Sleep(refreshCycle)

        // Run the wallet refresher
        hub.refreshWallets()

        // If all our subscribers left, stop the updater
        hub.stateLock.Lock()
        if hub.updateScope.Count() == 0 {
            hub.updating = false
            hub.stateLock.Unlock()
            return
        }
        hub.stateLock.Unlock()
    }
}