-
Notifications
You must be signed in to change notification settings - Fork 868
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
Move Solana ledger integration to untrusted iframe #14096
Conversation
866b04f
to
83c637e
Compare
...allet_ui/components/desktop/popup-modals/add-account-modal/hardware-wallet-connect/index.tsx
Outdated
Show resolved
Hide resolved
setShowAccountsList(false) | ||
if (error === 'unauthorized') { | ||
setShowAuthorizeDevice(true) | ||
setShowAccountsList(false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we check for the 'unauthorized' error which is passed from the SolanaLedgerKeyring class when the SolanaLedgerKeyring class receives a reply from the untrusted iframe saying that permissions need to be granted first.
If that's the case, we show the iframe with the 'authorize' button via setShowAuthorizeDevice
.
// TODO handle authorize failure / deny | ||
}) | ||
} | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we set up the event listeners for the 'authorize' button in the iframe. When that's clicked we send a message to the parent iframe (either chrome://wallet or chrome://wallet-panel) saying authorization has been completed.
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-bridge-transport.ts
Outdated
Show resolved
Hide resolved
} | ||
|
||
protected onMessageReceived = (event: MessageEvent) => { | ||
// TODO use a better message type than just string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
++
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-bridge-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-bridge-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/components/shared/authorize-hardware-device/index.tsx
Outdated
Show resolved
Hide resolved
|
||
import { addLedgerCommandHandler } from '../common/hardware/ledgerjs/ledger-command-handler' | ||
|
||
const createUnlockResponse = (command: UnlockCommand, result: boolean, error?: any): UnlockResponsePayload => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
avoid any
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could probably use https://github.com/LedgerHQ/ledger-live/tree/develop/libs/ledgerjs/packages/errors
My impression is that hardware device error handling probably needs another pass back to front. Some outstanding issues I see -
- A lot of errors are not actually being bubbled up to the UI (e.g. all ledger hardware device errors when adding accounts)
- Error handling is not consistent between chrome://wallet and chrome://wallet-panel
- Conflation of various categories of error
Let me know what you think, but I think I'd rather iterate on those changes in subsequent pull requests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that this will require more planning/iterating to get right.
Cool with putting aside for another PR
This specific function could maybe use a generic until correct types are determined.
const createUnlockResponse = <T = any | undefined>(command: UnlockCommand, result: boolean, error?: T)
4f41771
to
3fcfb77
Compare
d22d6aa
to
28657ef
Compare
A Storybook has been deployed to preview UI for the latest push |
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Show resolved
Hide resolved
@@ -532,7 +537,7 @@ handler.on(PanelActions.signMessageHardware.getType(), async (store, messageData | |||
const payload: SignMessageProcessedPayload = | |||
signed.success | |||
? { approved: signed.success, id: messageData.id, signature: signature } | |||
: { approved: signed.success, id: messageData.id, error: signed.error } | |||
: { approved: signed.success, id: messageData.id, error: (signed.error) as string } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe just call .toString() instead of casting?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried, but won't work here just because the signed.error is of type error?
, and toString is not defined on undefined. signed
is a HardwareOperationalResult:
export type HardwareOperationResult = {
success: boolean
error?: string | LedgerError
code?: string | number
}
So this reduces to the same issue I raise here. I am now wondering if I should not try to propagate the full errors (complete with statusCodes) to getErrorMessage in components/brave_wallet_ui/components/desktop/popup-modals/add-account-modal/hardware-wallet-connect/index.tsx, and instead continue just passing strings up. That way we only have one error type (string) and can eliminate the casts. That would be at the expense of being able to case on statusCode in getErrorMessage. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This cast should actually be as string | undefined
to rule out LedgerError which is the goal of this cast. as string
is actually too narrow since it legitimately may be undefined give the above HardwareOperational result type. I updated in 499d2df.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's create a follow-up issue to work-out hardware error types
components/brave_wallet_ui/panel/async/wallet_panel_async_handler.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/panel/async/wallet_panel_async_handler.ts
Outdated
Show resolved
Hide resolved
28657ef
to
e29249d
Compare
A Storybook has been deployed to preview UI for the latest push |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a quick static analysis at this point. Nice job getting the isolation done and figuring out the flow to make this work.
Things look pretty good so far. I'm going to see if I can get a HW wallet to test this in the next few days as well from a dynamic perspective.
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-trusted-transport.ts
Show resolved
Hide resolved
...ts/brave_wallet_ui/components/shared/authorize-hardware-device/authorize-hardware-device.tsx
Show resolved
Hide resolved
@@ -11,6 +11,7 @@ import CreateNetworkIcon from './create-network-icon' | |||
import SelectNetworkButton from './select-network-button' | |||
import LoadingSkeleton from './loading-skeleton' | |||
import CreateSiteOrigin from './create-site-origin' | |||
import AuthorizeHardwareDeviceIFrame from './authorize-hardware-device/authorize-hardware-device' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've not heard the term "barrel file" before. Is that just the generic term for TS index.ts import/export boilerplate code to export code upwards out of a directory?
@@ -21,6 +21,9 @@ window.requestAnimationFrame = function (cb: FrameRequestCallback) { | |||
|
|||
window.loadTimeData = { | |||
getString (key) { | |||
if (key === 'braveWalletLedgerBridgeUrl') { | |||
return 'chrome-untrusted://ledger-bridge' | |||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needed to so that LEDGER_BRIDGE_URL is an actually URL for the tests, not "braveWalletLedgerBridgeUrl"
A Storybook has been deployed to preview UI for the latest push |
A Storybook has been deployed to preview UI for the latest push |
A Storybook has been deployed to preview UI for the latest push |
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Show resolved
Hide resolved
|
||
import { addLedgerCommandHandler } from '../common/hardware/ledgerjs/ledger-command-handler' | ||
|
||
const createUnlockResponse = (command: UnlockCommand, result: boolean, error?: any): UnlockResponsePayload => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that this will require more planning/iterating to get right.
Cool with putting aside for another PR
This specific function could maybe use a generic until correct types are determined.
const createUnlockResponse = <T = any | undefined>(command: UnlockCommand, result: boolean, error?: T)
@@ -532,7 +537,7 @@ handler.on(PanelActions.signMessageHardware.getType(), async (store, messageData | |||
const payload: SignMessageProcessedPayload = | |||
signed.success | |||
? { approved: signed.success, id: messageData.id, signature: signature } | |||
: { approved: signed.success, id: messageData.id, error: signed.error } | |||
: { approved: signed.success, id: messageData.id, error: (signed.error) as string } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's create a follow-up issue to work-out hardware error types
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-untrusted-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messages.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messages.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messages.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.test.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-messaging-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-trusted-transport.ts
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/ledger-untrusted-transport.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/sol_ledger_bridge_keyring.test.ts
Outdated
Show resolved
Hide resolved
components/brave_wallet_ui/common/hardware/ledgerjs/sol_ledger_bridge_keyring.ts
Outdated
Show resolved
Hide resolved
return result | ||
} | ||
|
||
const data = await this.sendCommand<SignTransactionResponsePayload>({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UnlockResponsePayload
components/brave_wallet_ui/common/hardware/ledgerjs/sol_ledger_bridge_keyring.ts
Outdated
Show resolved
Hide resolved
A Storybook has been deployed to preview UI for the latest push |
A Storybook has been deployed to preview UI for the latest push |
f3dd713
to
3d28872
Compare
A Storybook has been deployed to preview UI for the latest push |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one looks good to me, approving for the security review since this is a follow on issue for https://github.com/brave/security/issues/919
Also, that PR description at the top there is awesome and deserves some recognition! |
A Storybook has been deployed to preview UI for the latest push |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Frontend LGTM
The Solana Ledger JS libraries are now accessed by the chrome://wallet and chrome://wallet-panel contexts via postMessage calls to an iframe whose origin is chrome-untrusted://ledger-bridge. If we detect that user needs to grant the Wallet permssion to access a Ledger device, an 'Authorize' button is displayed *within* the untrusted frame so that the permission dialog (which requires a user gesture to be displayed) screen is shown. When permission is granted, the chrome-untrusted://ledger-bridge sends a postMessage to the parent (trusted) context instructing it to continue the flow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
chromium_src
++
a8142c8
to
fcd4653
Compare
A Storybook has been deployed to preview UI for the latest push |
Verification passed on
|
Summary
Resolves brave/brave-browser#23655
This change isolates the js libraries used to interact with Ledger hardware devices (@ledgerhq/hw-app-solana, and @ledgerhq/hw-transport-webhid) such that they are only called in an untrusted iframe, similar to the Trezor integration.
The libraries are now accessed by the chrome://wallet and chrome://wallet-panel contexts via postMessage calls to an iframe whose origin is chrome-untrusted://ledger-bridge.
This is tricky because the navigator.hid.requestDevices API call made by the Ledger lib to prompt the user to give Brave Wallet permission to access their device must be called in the context of a user gesture (e.g. a click). As a result all UI that could trigger a permissions dialog (e.g. the 'connect' button in the import accounts flow, and the 'confirm' button in the send transaction flow) must be inside the iframe. We cannot have the user click a button outside the iframe and prompt the permissions screen in the iframe because the 'click' context is not propagated through the postMessage API.
To handle this, we create a 'Authorize' button UI that's rendered within the untrusted iframe whenever we detect that the user needs to grant permissions. When clicked, the authorize button will prompt the user to grant permissions and the flow will continue.
This is the 'import accounts' flow:
The flow is similar in the sendTransaction cases, but differs slightly in the way the UI is updated to (a) show the 'Authorize' button, (b) show the normal UI when authorization is completed. In the import accounts flow, this happens via React hooks, but in the sendTransaction flow, this is done by dispatching actions. Also, in the sendTransaction cases, the parent context (chrome://wallet-panel) is different from the parent context for the import account case (chrome://wallet). As a result permissions need to be granted for both separately, and the implementation abstracts to handle both cases.
Screen shots
Remaining
Submitter Checklist:
QA/Yes
orQA/No
;release-notes/include
orrelease-notes/exclude
;OS/...
) to the associated issuenpm run test -- brave_browser_tests
,npm run test -- brave_unit_tests
,npm run lint
,npm run gn_check
,npm run tslint
git rebase master
(if needed)Reviewer Checklist:
gn
After-merge Checklist:
changes has landed on
Test plan
(await navigator.hid.getDevices()).forEach((d) => d.forget())
in the js console to forget devices which have already been granted permissions, then refresh the page(await navigator.hid.getDevices()).forEach((d) => d.forget())
to forget any device which already has permissions on the panel (kind of need to be quick since this closes not long after the panel closes..)