Skip to content

Commit

Permalink
Delegate Bitcoin Core's private key management to Eclair (#2613)
Browse files Browse the repository at this point in the history
* Make Eclair manage bitcoin core's wallet private keys

We create an empty watch-only wallet and import public descriptors generated by Eclair.
Bitcoin Core can fund transaction and manage utxos, but can no longer sign transactions.

* Check that spent amounts and utxos are consistent before we sign a PSBT

PSBT utxo fields include the amount that are being spent by the PSBT inputs, but there is a "fee attack"
where using amounts that are lower than what is actually spent may make us sign a tx that spends much more
in fees than we think.

* Check that non-segwit uxto has been provided and inputs are signed with SIGHASH_ALL

* Verify that Bitcoin Core's fee match what we specified

When we call Bitcoin Core's `fundrawtransaction` RPC method, we check that the fee that we pay match the fee rate that we requested.
The fee is computed using the utxo information that Bitcoin Core adds to our PSBT before we sign it.

We can safely used this information because if Bitcoin Core lies about the value of the inputs that we're spending then the signature we produce will also not be valid (it commits to the value being spent).

When we're adding wallet inputs to "bump" the fees of a parent transaction we need to take the whole package into account when we verify the actual fee rate, which is why some internal methods were modified to return the package weight that was used as reference when `fundrawtransaction` was called.

* Check that fundrawtransaction does not add more than 1 change output

* Validate addresses and keys generated by bitcoin core

When eclair manages private keys, make sure that we can re-compute addresses and keys
generated by bitcoin core.

* Add a separate configuration file for Eclair's onchain signer

Eclair's onchain signer now has its own `eclair-signer.conf` configuration file in HOCON format.
It includes BIP39 mnemonic codes and passphrase, a wallet name and a timestamp.

When an `eclair-signer.conf` file is found, Eclair's API will return descriptors that can be imported into an
empty watch-only Bitcoin Wallet.
When wallet name in `eclair-signer.conf` matches the name of the Bitcoin Core wallet defined in `eclair.conf`
(`eclair.bitcoind.wallet`), Eclair will bypass Bitcoin Core and sign onchain transactions directly.

* Skip validation of local non-change outputs:

Local non-change outputs send to an external address, for splicing out funds for example.
We assume that these inputs are trusted i.e have been created by a trusted API call and our local
onchain key manager will not validate them during the signing process.

* Document why we use a separate, specific file for the onchain key manager

Using a new signer section is eclair.conf would be simpler but "leaks" because it becomes available everywhere
in the code through the actor system's settings instead of being contained to where it is actually needed
and could potentially be exposed through a bug that "exports" the configuration (through logs, ....)
though this is highly unlikely.

* Additional changes to delegate bitcoin core keys to eclair (#2726)

Refactor the `BitcoinCoreClient` and `LocalOnChainKeyManager` to:

- rely less on exceptions
- use more idiomatic scala (reduce dependency on kotlin types)
- provide more detailed logs

We also simplify the `useEclairSigner` field in `BitcoinCoreClient`.
The complexity of handling the case where there was an on-chain key
manager but for a different wallet than the one configured isn't
something that should be used, so it wasn't worth supporting.

Some checks were inconsistent and are now unified:

- checking the exact `scriptPubKey` in our outputs in TODO and TODO
- we allow using `fundTransaction` with a tx that already includes a
  change output (which may happen when RBF-ing a transaction)
- `getP2wpkhPubkeyHashForChange` didn't verify the returned key

We completely separate the two cases in `signPsbt`, because otherwise
in the non eclair-backed case, we were calling bitcoind's `processpsbt`
twice for no good reason, which is bad for performance.

We also decouple the `OnChainKeyManager` from the `BitcoinCoreClient`.
This lets users keep running their eclair node with a bitcoin client that
owns the private key while configuring the on-chain key manager for a
future bitcoin client that will leverage this on-chain key manager.

Users can use the eclair APIs to get the master xpub and descriptors to
properly configure their next bitcoin core node, and switch to it once it
has synchronized the descriptors.

* Simplify replaceable tx funding

We were previously signing twice (with makes a call to `bitcoind`),
just to get the final weights and adjust the change outputs. This was
unnecessary, as we can adjust the weights before adding inputs.

We were also duplicating the checks where we verify that `bitcoind` is
malicious. We only need to check that once, during the final signing step.

---------

Co-authored-by: Bastien Teinturier <[email protected]>
  • Loading branch information
sstone and t-bast authored Sep 21, 2023
1 parent 148fc67 commit 6f87137
Show file tree
Hide file tree
Showing 32 changed files with 1,434 additions and 369 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ limitdescendantcount=20

Setting these parameters lets you unblock long chains of unconfirmed channel funding transactions by using child-pays-for-parent (CPFP) to make them confirm.

With the default `bitcoind` parameters, if your node created a chain of 25 unconfirmed funding transactions with a low-feerate, you wouldn't be able to use CPFP to raise their fees because your CPFP transaction would likely be rejected by the rest of the network.
With the default `bitcoind` parameters, if your node created a chain of 25 unconfirmed funding transactions with a low-feerate, you wouldn't be able to use CPFP to raise their fees because your CPFP transaction would likely be rejected by
the rest of the network.

You can also configure Eclair to manage Bitcoin Core's private keys, see our [guides](./docs/Guides.md) for more details.

### Java Environment Variables

Expand Down
1 change: 1 addition & 0 deletions docs/Guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This section contains how-to guides for more advanced scenarios:

* [Customize Logging](./Logging.md)
* [Customize Features](./Features.md)
* [Manage Bitcoin Core's private keys](./ManagingBitcoinCoreKeys.md)
* [Use Tor with Eclair](./Tor.md)
* [Multipart Payments](./MultipartPayments.md)
* [Trampoline Payments](./TrampolinePayments.md)
Expand Down
80 changes: 80 additions & 0 deletions docs/ManagingBitcoinCoreKeys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Using Eclair to manage your Bitcoin Core wallet's private keys

You can configure Eclair to control (and never expose) the private keys of your Bitcoin Core wallet. This feature was designed to take advantage of deployment where your Eclair node runs in a
"trusted" runtime environment, but is also very useful if your Bitcoin and Eclair nodes run on different machines for example, with a setup for the Bitcoin host that
is less secure than for Eclair (because it is shared among several services for example).

## Configuring Eclair and Bitcoin Core to use a new Eclair-backed bitcoin wallet

Follow these steps to delegate on-chain key management to eclair:

### 1. Generate a BIP39 mnemonic code and passphrase

You can use any BIP39-compatible tool, including most hardware wallets.

### 2. Create an `eclair-signer.conf` configuration file add it to eclair's data directory

A signer configuration file uses the HOCON format that we already use for `eclair.conf` and must include the following options:

key | description
--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
eclair.signer.wallet | wallet name
eclair.signer.mnemonics | BIP39 mnemonic words
eclair.signer.passphrase | passphrase
eclair.signer.timestamp | wallet creation UNIX timestamp. Bitcoin core will rescan the blockchain from this UNIX timestamp. Set it to the wallet creation timestamp for simplicity, or a later date if you only have recent UTXOs and you know what you are doing.

This is an example of `eclair-signer.conf` configuration file:

```hocon
{
eclair {
signer {
wallet = "eclair"
mnemonics = "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
passphrase = ""
timestamp = 1686055705
}
}
}
```

### 3. Use Eclair to generate descriptors and import them into a new bitcoin wallet

Restart eclair, without changing `eclair.bitcoind.wallet` (so it uses the default wallet or the previously used bitcoin wallet for existing nodes).

Create a new empty, decriptor-enabled wallet on your new Bitcoin Core node.

:warning: The name must match the one that you set in `eclair-signer.conf` (here we use "eclair")

```shell
$ bitcoin-cli -named createwallet wallet_name=eclair disable_private_keys=true blank=true descriptors=true load_on_startup=true
```

Generate the descriptors with your Eclair node and import them into a Bitcoin node with the following commands:

```shell
$ eclair-cli getdescriptors | jq --raw-output -c > descriptors.json
$ cat descriptors.json | xargs -0 bitcoin-cli -rpcwallet=eclair importdescriptors
```

Bitcoin core will import descriptors and rescan the blockchain from the time set in `eclair-signer.conf`.
This can take a long time (if you're moving an old existing node to a new setup for example) and your Bitcoin Core node will not be usable until it's done.

### 4. Configure Eclair to use the wallet you created and restart Eclair

In your `eclair.conf`, set `eclair.bitcoind.wallet` to the name of the wallet in `eclair-signer.conf`, and restart Eclair.

You now have a Bitcoin Core watch-only wallet for which only your Eclair node can sign transactions. This Bitcoin Core wallet can
safely be copied to another Bitcoin Core node to monitor your on-chain funds.

:warning: this means that your Bitcoin Core wallet cannot send funds on its own (since it cannot access private keys to sign transactions).
To send funds on-chain you must use `eclair-cli sendonchain`.

:warning: to backup the private keys of this wallet you must either backup your mnemonic code and passphrase, or backup the `eclair-signer.conf` file in your eclair
directory (default is `~/.eclair`) along with your channels and node seed files.

:warning: You can also initialize a backup on-chain wallet with the same mnemonic code and passphrase (on a hardware wallet for example), but be warned that using them may interfere with your node's operations (for example you may end up
double-spending funding transactions generated by your node).

You can also use `eclair-cli getmasterxpub` to get a BIP32 extended public key that you can import into any compatible Bitcoin wallet
to create a watch-only wallet (Electrum for example) that you can use to monitor your Bitcoin Core balance.
6 changes: 6 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ eclair.on-chain-fees.confirmation-priority {

This configuration section replaces the previous `eclair.on-chain-fees.target-blocks` section.

### Managing Bitcoin Core wallet keys

You can now use Eclair to manage the private keys for on-chain funds monitored by a Bitcoin Core watch-only wallet.

See `docs/BitcoinCoreKeys.md` for more details.

### API changes

- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)
Expand Down
52 changes: 39 additions & 13 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.WalletTx
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
Expand Down Expand Up @@ -130,7 +131,7 @@ trait Eclair {

def sentInfo(id: PaymentIdentifier)(implicit timeout: Timeout): Future[Seq[OutgoingPayment]]

def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32]
def sendOnChain(address: String, amount: Satoshi, confirmationTargetOrFeerate: Either[Long, FeeratePerByte]): Future[ByteVector32]

def cpfpBumpFees(targetFeeratePerByte: FeeratePerByte, outpoints: Set[OutPoint]): Future[ByteVector32]

Expand Down Expand Up @@ -180,6 +181,10 @@ trait Eclair {

def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent]

def getOnChainMasterPubKey(account: Long): String

def getDescriptors(account: Long): Descriptors

def stop(): Future[Unit]
}

Expand Down Expand Up @@ -352,9 +357,20 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

override def sendOnChain(address: String, amount: Satoshi, confirmationTarget: Long): Future[ByteVector32] = {
override def sendOnChain(address: String, amount: Satoshi, confirmationTargetOrFeerate: Either[Long, FeeratePerByte]): Future[ByteVector32] = {
val feeRate = confirmationTargetOrFeerate match {
case Left(blocks) =>
if (blocks < 3) appKit.nodeParams.currentFeerates.fast
else if (blocks > 6) appKit.nodeParams.currentFeerates.slow
else appKit.nodeParams.currentFeerates.medium
case Right(feeratePerByte) => FeeratePerKw(feeratePerByte)
}
appKit.wallet match {
case w: BitcoinCoreClient => w.sendToAddress(address, amount, confirmationTarget)
case w: BitcoinCoreClient =>
addressToPublicKeyScript(appKit.nodeParams.chainHash, address) match {
case Right(pubkeyScript) => w.sendToPubkeyScript(pubkeyScript, amount, feeRate)
case Left(failure) => Future.failed(new IllegalArgumentException(s"invalid address ($failure)"))
}
case _ => Future.failed(new IllegalArgumentException("this call is only available with a bitcoin core backend"))
}
}
Expand Down Expand Up @@ -665,16 +681,16 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

def payOfferInternal(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean,
blocking: Boolean)(implicit timeout: Timeout): Future[Any] = {
private def payOfferInternal(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean,
blocking: Boolean)(implicit timeout: Timeout): Future[Any] = {
if (externalId_opt.exists(_.length > externalIdMaxLength)) {
return Future.failed(new IllegalArgumentException(s"externalId is too long: cannot exceed $externalIdMaxLength characters"))
}
Expand Down Expand Up @@ -717,6 +733,16 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
payOfferInternal(offer, amount, quantity, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent]
}

override def getDescriptors(account: Long): Descriptors = appKit.nodeParams.onChainKeyManager_opt match {
case Some(keyManager) => keyManager.descriptors(account)
case _ => throw new RuntimeException("on-chain seed is not configured")
}

override def getOnChainMasterPubKey(account: Long): String = appKit.nodeParams.onChainKeyManager_opt match {
case Some(keyManager) => keyManager.masterPubKey(account)
case _ => throw new RuntimeException("on-chain seed is not configured")
}

override def stop(): Future[Unit] = {
// README: do not make this smarter or more complex !
// eclair can simply and cleanly be stopped by killing its process without fear of losing data, payments, ... and it should remain this way.
Expand Down
11 changes: 7 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ import fr.acinq.eclair.channel.ChannelFlags
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.fsm.Channel.{ChannelConf, UnhandledExceptionStrategy}
import fr.acinq.eclair.crypto.Noise.KeyPair
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager}
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager, OnChainKeyManager}
import fr.acinq.eclair.db._
import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy}
import fr.acinq.eclair.io.PeerConnection
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
import fr.acinq.eclair.router.Announcements.AddressException
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.router.{Graph, PathFindingExperimentConf}
import fr.acinq.eclair.router.Router.{MessageRouteParams, MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.tor.Socks5ProxyParams
import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
Expand All @@ -54,6 +54,7 @@ import scala.jdk.CollectionConverters._
*/
case class NodeParams(nodeKeyManager: NodeKeyManager,
channelKeyManager: ChannelKeyManager,
onChainKeyManager_opt: Option[OnChainKeyManager],
instanceId: UUID, // a unique instance ID regenerated after each restart
private val blockHeight: AtomicLong,
private val feerates: AtomicReference[FeeratesPerKw],
Expand Down Expand Up @@ -101,7 +102,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
def currentFeerates: FeeratesPerKw = feerates.get()

/** Only to be used in tests. */
def setFeerates(value: FeeratesPerKw) = feerates.set(value)
def setFeerates(value: FeeratesPerKw): Unit = feerates.set(value)

/** Returns the features that should be used in our init message with the given peer. */
def initFeaturesFor(nodeId: PublicKey): Features[InitFeature] = overrideInitFeatures.getOrElse(nodeId, features).initFeatures()
Expand Down Expand Up @@ -211,7 +212,8 @@ object NodeParams extends Logging {
}
}

def makeNodeParams(config: Config, instanceId: UUID, nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager,
def makeNodeParams(config: Config, instanceId: UUID,
nodeKeyManager: NodeKeyManager, channelKeyManager: ChannelKeyManager, onChainKeyManager_opt: Option[OnChainKeyManager],
torAddress_opt: Option[NodeAddress], database: Databases, blockHeight: AtomicLong, feerates: AtomicReference[FeeratesPerKw],
pluginParams: Seq[PluginParams] = Nil): NodeParams = {
// check configuration for keys that have been renamed
Expand Down Expand Up @@ -475,6 +477,7 @@ object NodeParams extends Logging {
NodeParams(
nodeKeyManager = nodeKeyManager,
channelKeyManager = channelKeyManager,
onChainKeyManager_opt = onChainKeyManager_opt,
instanceId = instanceId,
blockHeight = blockHeight,
feerates = feerates,
Expand Down
Loading

0 comments on commit 6f87137

Please sign in to comment.