Skip to content

Commit

Permalink
add support for the BitBox02 hardware wallet (#117)
Browse files Browse the repository at this point in the history
* add support for the BitBox02 hardware wallet

Using the `bitbox-api` NPM package, which loads a WASM module.

Note about CJS:
Currently the `bitbox-api` package is an ESM package with a `module:
...` entrypoint, so it is not compatible with the `cjs` target of
caravan-wallets. The only workaround that I could find where
compilation succeeds and the package works in the browser is to mark
bitbox-api as external in tsup.config.ts.

Note about signing tests:

- The BitBox02 requires the previous transaction of each input to be
present in the PSBT (`PSBT_IN_NON_WITNESS_UTXO`), so it can verify the
input amount and avoid fee attacks. The signing test fixtures are
missing these, so they fail.

- The BitBox02 uses the anti-klepto (anti-exfil) protocol to mitigate
covert nonce exfil attacks. This results in random signatures. The
unit test fixtures hardcode the expected signatures, assuming they are
always the same. As a result, also here the tests fail. To fix this,
the tests should rather verify the ECDSA signatures against the
transaction sighash for each input.

* hide BitBox02 menu item for P2SH

BitBox02 does not support legacy P2SH.

* bitbox: display pairing code

The BitBox, if not paired yet, will show a pairing code for
confirmation. This can happen in any BitBox interaction.

This commit adds a `showPairingCode` param to all BitBox
interactions. If not provided, a default implementation is used which
shows the pairing code in a browser popup.

The current `messages()` system is not a good fit, as the client does
not know when to call `messagesFor()` to display it. Having a separate
UI button to pair the BitBox is not good UX (why should the user be
bothered to click a "pair" button first? What if the user doesn't) and
also fragile (a re-pairing could be needed at any time).

* add regtest support for BitBox02

---------

Co-authored-by: buck <[email protected]>
  • Loading branch information
benma and bucko13 authored Nov 8, 2024
1 parent f4552b7 commit d0e08e8
Show file tree
Hide file tree
Showing 24 changed files with 749 additions and 13 deletions.
6 changes: 6 additions & 0 deletions .changeset/heavy-kids-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@caravan/wallets": minor
"caravan-coordinator": minor
---

Add support for the BitBox02 hardware wallet
3 changes: 2 additions & 1 deletion apps/coordinator/src/actions/keystoreActions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";

export const SET_KEYSTORE = "SET_KEYSTORE";
export const SET_KEYSTORE_NOTE = "SET_KEYSTORE_NOTE";
export const SET_KEYSTORE_STATUS = "SET_KEYSTORE_STATUS";

type KeyStoreType =
| typeof BITBOX
| typeof TREZOR
| typeof LEDGER
| typeof HERMIT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ACTIVE,
ERROR,
ExportPublicKey,
BITBOX,
TREZOR,
LEDGER,
} from "@caravan/wallets";
Expand Down Expand Up @@ -172,7 +173,7 @@ const HardwareWalletPublicKeyImporter = ({

HardwareWalletPublicKeyImporter.propTypes = {
network: PropTypes.string.isRequired,
method: PropTypes.oneOf([LEDGER, TREZOR]).isRequired,
method: PropTypes.oneOf([BITBOX, LEDGER, TREZOR]).isRequired,
defaultBIP32Path: PropTypes.string.isRequired,
validatePublicKey: PropTypes.func.isRequired,
enableChangeMethod: PropTypes.func.isRequired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { validatePublicKey as baseValidatePublicKey } from "@caravan/bitcoin";
import { TREZOR, LEDGER } from "@caravan/wallets";
import { BITBOX, TREZOR, LEDGER } from "@caravan/wallets";

// Components
import {
Expand Down Expand Up @@ -213,6 +213,7 @@ const PublicKeyImporter = ({

const renderImportByMethod = () => {
if (
publicKeyImporter.method === BITBOX ||
publicKeyImporter.method === TREZOR ||
publicKeyImporter.method === LEDGER
) {
Expand Down Expand Up @@ -261,6 +262,7 @@ const PublicKeyImporter = ({
variant="standard"
>
<MenuItem value="">{"< Select method >"}</MenuItem>
<MenuItem value={BITBOX}>BitBox</MenuItem>
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={XPUB}>Derive from extended public key</MenuItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { useState } from "react";
import { Button } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { BITBOX, RegisterWalletPolicy } from "@caravan/wallets";
import { getWalletConfig } from "../../selectors/wallet";
import { setErrorNotification } from "../../actions/errorNotificationActions";

const RegisterBitBoxButton = ({ ...otherProps }) => {
const [isActive, setIsActive] = useState(false);
const walletConfig = useSelector(getWalletConfig);
const dispatch = useDispatch();

const registerWallet = async () => {
setIsActive(true);
try {
const interaction = new RegisterWalletPolicy({
keystore: BITBOX,
...walletConfig,
});
await interaction.run();
} catch (e) {
dispatch(setErrorNotification(e.message));
} finally {
setIsActive(false);
}
};
return (
<Button
variant="outlined"
onClick={registerWallet}
disabled={isActive}
{...otherProps}
>
Register w/ BitBox
</Button>
);
};

export default RegisterBitBoxButton;
2 changes: 2 additions & 0 deletions apps/coordinator/src/components/RegisterWallet/index.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import DownloadColdardConfigButton from "./DownloadColdcardConfig";
import PolicyRegistrationTable from "./PolicyRegistrationsTable";
import RegisterBitBoxButton from "./RegisterBitBoxButton";
import RegisterLedgerButton from "./RegisterLedgerButton";

export {
DownloadColdardConfigButton,
PolicyRegistrationTable,
RegisterBitBoxButton,
RegisterLedgerButton,
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
multisigBIP32Root,
validateBIP32Path,
getMaskedDerivation,
P2SH,
} from "@caravan/bitcoin";
import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import {
Card,
CardHeader,
Expand Down Expand Up @@ -93,7 +94,7 @@ class SignatureImporter extends React.Component {
};

renderImport = () => {
const { signatureImporter, number, isWallet } = this.props;
const { signatureImporter, number, isWallet, addressType } = this.props;
const currentNumber = this.getCurrent();
const notMyTurn = number > currentNumber;
const { disableChangeMethod } = this.state;
Expand All @@ -119,6 +120,7 @@ class SignatureImporter extends React.Component {
onChange={this.handleMethodChange}
>
<MenuItem value={UNKNOWN}>{"< Select method >"}</MenuItem>
{addressType != P2SH && <MenuItem value={BITBOX}>BitBox</MenuItem>}
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={COLDCARD} disabled={!isWallet}>
Expand Down Expand Up @@ -155,7 +157,7 @@ class SignatureImporter extends React.Component {
} = this.props;
const { method } = signatureImporter;

if (method === TREZOR || method === LEDGER) {
if (method === BITBOX || method === TREZOR || method === LEDGER) {
return (
<DirectSignatureImporter
network={network}
Expand Down
6 changes: 6 additions & 0 deletions apps/coordinator/src/components/Slices/ConfirmAddress.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import {
multisigAddressType,
multisigRequiredSigners,
multisigTotalSigners,
P2SH,
} from "@caravan/bitcoin";
import {
BITBOX,
TREZOR,
LEDGER,
COLDCARD,
Expand Down Expand Up @@ -138,6 +140,7 @@ const ConfirmAddress = ({ slice, network }) => {
}
// FIXME - hardcoded to just show up for trezor
if (
extendedPublicKeyImporter.method === BITBOX ||
extendedPublicKeyImporter.method === TREZOR ||
extendedPublicKeyImporter.method === LEDGER
) {
Expand Down Expand Up @@ -230,6 +233,9 @@ const ConfirmAddress = ({ slice, network }) => {
variant="standard"
>
<MenuItem value="">{"< Select method >"}</MenuItem>
{addressType != P2SH && (
<MenuItem value={BITBOX}>BitBox</MenuItem>
)}
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={COLDCARD} disabled>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import { connect } from "react-redux";

import {
BITBOX,
TREZOR,
LEDGER,
HERMIT,
Expand Down Expand Up @@ -89,6 +90,7 @@ const KeystorePickerBase = ({
variant="standard"
>
<MenuItem value="">{"< Select type >"}</MenuItem>
<MenuItem value={BITBOX}>BitBox</MenuItem>
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={COLDCARD}>Coldcard</MenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import moment from "moment";
import { useSelector, useDispatch } from "react-redux";
import Bowser from "bowser";
import {
BITBOX,
TREZOR,
LEDGER,
HERMIT,
Expand Down Expand Up @@ -178,6 +179,8 @@ const TestSuiteRunSummaryBase = () => {

const keystoreName = (type: string) => {
switch (type) {
case BITBOX:
return "BitBox";
case TREZOR:
return "Trezor";
case LEDGER:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
convertExtendedPublicKey,
validateExtendedPublicKey,
Network,
P2SH,
} from "@caravan/bitcoin";
import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import {
Card,
CardHeader,
Expand Down Expand Up @@ -68,7 +69,7 @@ class ExtendedPublicKeyImporter extends React.Component {
};

renderImport = () => {
const { extendedPublicKeyImporter, number } = this.props;
const { extendedPublicKeyImporter, number, addressType } = this.props;
const { disableChangeMethod } = this.state;
return (
<div>
Expand All @@ -82,6 +83,7 @@ class ExtendedPublicKeyImporter extends React.Component {
variant="standard"
onChange={this.handleMethodChange}
>
{addressType != P2SH && <MenuItem value={BITBOX}>BitBox</MenuItem>}
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={COLDCARD}>Coldcard</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
Expand All @@ -105,7 +107,7 @@ class ExtendedPublicKeyImporter extends React.Component {
} = this.props;
const { method } = extendedPublicKeyImporter;

if (method === TREZOR || method === LEDGER) {
if (method === BITBOX || method === TREZOR || method === LEDGER) {
return (
<DirectExtendedPublicKeyImporter
extendedPublicKeyImporter={extendedPublicKeyImporter}
Expand Down
4 changes: 4 additions & 0 deletions apps/coordinator/src/components/Wallet/RegisterWallet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getWalletConfig } from "../../selectors/wallet";
import PolicyRegistrationTable from "../RegisterWallet/PolicyRegistrationsTable";
import {
DownloadColdardConfigButton,
RegisterBitBoxButton,
RegisterLedgerButton,
} from "../RegisterWallet";

Expand Down Expand Up @@ -89,6 +90,9 @@ const WalletRegistrations = () => {
<AccordionDetails>
<Box>
<Grid container spacing={2}>
<Grid item>
<RegisterBitBoxButton />
</Grid>
<Grid item>
<RegisterLedgerButton />
</Grid>
Expand Down
1 change: 1 addition & 0 deletions apps/coordinator/src/components/Wallet/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class CreateWallet extends React.Component {
method: (method, index) => {
if (
[
"bitbox",
"trezor",
"coldcard",
"ledger",
Expand Down
13 changes: 13 additions & 0 deletions apps/coordinator/src/tests/bitbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { BITBOX } from "@caravan/wallets";

import publicKeyTests from "./publicKeys";
import extendedPublicKeyTests from "./extendedPublicKeys";
import { signingTests } from "./signing";
import addressTests from "./addresses";
import registrationTests from "./registration";

export default publicKeyTests(BITBOX)
.concat(extendedPublicKeyTests(BITBOX))
.concat(signingTests(BITBOX))
.concat(addressTests(BITBOX))
.concat(registrationTests(BITBOX));
4 changes: 3 additions & 1 deletion apps/coordinator/src/tests/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets";
import { TEST_FIXTURES } from "@caravan/bitcoin";

import bitboxTests from "./bitbox";
import trezorTests from "./trezor";
import ledgerTests from "./ledger";
import hermitTests from "./hermit";
import coldcardTests from "./coldcard";

const SUITE = {};

SUITE[BITBOX] = bitboxTests;
SUITE[TREZOR] = trezorTests;
SUITE[LEDGER] = ledgerTests;
SUITE[HERMIT] = hermitTests;
Expand Down
6 changes: 5 additions & 1 deletion apps/coordinator/src/tests/registration.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";

import { TEST_FIXTURES } from "@caravan/bitcoin";
import { RegisterWalletPolicy } from "@caravan/wallets";
import { BITBOX, RegisterWalletPolicy } from "@caravan/wallets";
import { Box, Table, TableBody, TableRow, TableCell } from "@mui/material";

import Test from "./Test";
Expand All @@ -13,6 +13,10 @@ class RegisterWalletPolicyTest extends Test {
}

expected() {
if (this.params.keystore === BITBOX) {
// BitBox does not use HMACs to register policies.
return undefined;
}
return this.params.policyHmac;
}

Expand Down
12 changes: 12 additions & 0 deletions apps/coordinator/src/tests/signing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TEST_FIXTURES,
} from "@caravan/bitcoin";
import {
BITBOX,
COLDCARD,
HERMIT,
LEDGER,
Expand Down Expand Up @@ -193,6 +194,17 @@ export function signingTests(keystore) {
}
});
return transactions;
case BITBOX:
return TEST_FIXTURES.transactions
.filter((fixture) => fixture.braidDetails)
.map(
(fixture) =>
new SignMultisigTransactionTest({
...fixture,
...{ keystore },
returnSignatureArray: true,
}),
);
case LEDGER:
return TEST_FIXTURES.transactions
.filter((fixture) => fixture.policyHmac && fixture.braidDetails)
Expand Down
1 change: 1 addition & 0 deletions apps/coordinator/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default defineConfig({
}),
],
build: {
target: "esnext", // browsers can handle the latest ES features
outDir: "build",
rollupOptions: {
onwarn(warning, warn) {
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/caravan-wallets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@typescript-eslint/parser": "^5.51.0",
"babel-jest": "^29.7.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"bitbox-api": "^0.7.0",
"esbuild-plugin-polyfill-node": "^0.3.0",
"eslint": "^8.34.0",
"eslint-plugin-import": "^2.27.5",
Expand Down
Loading

0 comments on commit d0e08e8

Please sign in to comment.