Store our closing_complete in the simple close session (#3289)
What changed, and why it matters
This commit changes how Eclair (a Bitcoin Lightning node) remembers its own closing signatures during a mutual channel close. Previously, the code re-generated signing nonces and re-signed closing transactions when the peer's signature arrived. Now it stores the original closing message and reuses its signatures. It also adds an early check that the peer's partial signature is valid before trying to build a final signed transaction. The change is a defensive correctness improvement rather than a clear fix for an active exploit.
Review the new checkRemotePartialSignature implementation and ensure it covers all closing transaction variants. Verify that storing ClosingComplete in volatile memory (simple close session is never persisted, per commit message) does not introduce a crash-recovery issue where a stale or missing closing_complete could lead to force-close. Consider whether the early InvalidCloseSignature failure path correctly updates nonce state to allow negotiation to continue.
Security signals we found
MuSig2 nonce handling changed: local partial signatures are now stored and reused instead of regenerated
Early signature validation added for peer's partial signature before final transaction aggregation
Removed CloserNonces helper that generated three random nonces per closing attempt
Potential nonce-reuse class eliminated by storing closing_complete message
Evidence from the diff
In the simple-taproot mutual-close flow, makeSimpleClosingTx no longer returns CloserNonces; instead the caller stores the ClosingComplete message containing the partial signatures. receiveSimpleClosingSig now uses those stored partial signatures instead of re-deriving nonces and re-signing. A new early validity check on the peer’s partial signature (checkRemotePartialSignature) was added before aggregating signatures. The CloserNonces class and its generator were removed. These changes reduce nonce reuse risk and prevent building an invalid closing transaction only to discover it later.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scalaeclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scalaInspect captured patch +24 / −38
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
index e09f422..230f2dd 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
@@ -770,7 +770,7 @@ object Helpers {
}
/** We are the closer: we sign closing transactions for which we pay the fees. */
- def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, (ClosingTxs, ClosingComplete, CloserNonces)] = {
+ def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, (ClosingTxs, ClosingComplete)] = {
// We must convert the feerate to a fee: we must build dummy transactions to compute their weight.
val commitInput = commitment.commitInput(channelKeys)
val closingFee = {
@@ -797,7 +797,6 @@ object Helpers {
case _ => return Left(CannotGenerateClosingTx(commitment.channelId))
}
val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
- val localNonces = CloserNonces.generate(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId)
val tlvs: TlvStream[ClosingCompleteTlv] = commitment.commitmentFormat match {
case _: SimpleTaprootChannelCommitmentFormat =>
remoteNonce_opt match {
@@ -806,14 +805,15 @@ object Helpers {
// If we cannot create our partial signature for one of our closing txs, we just skip it.
// It will only happen if our peer sent an invalid nonce, in which case we cannot do anything anyway
// apart from eventually force-closing.
- def localSig(tx: ClosingTx, localNonce: LocalNonce): Option[PartialSignatureWithNonce] = {
+ def localSig(tx: ClosingTx): Option[PartialSignatureWithNonce] = {
+ val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId)
tx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)).toOption
}
TlvStream(Set(
- closingTxs.localAndRemote_opt.flatMap(tx => localSig(tx, localNonces.localAndRemote)).map(ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(_)),
- closingTxs.localOnly_opt.flatMap(tx => localSig(tx, localNonces.localOnly)).map(ClosingCompleteTlv.CloserOutputOnlyPartialSignature(_)),
- closingTxs.remoteOnly_opt.flatMap(tx => localSig(tx, localNonces.remoteOnly)).map(ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(_)),
+ closingTxs.localAndRemote_opt.flatMap(tx => localSig(tx)).map(ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(_)),
+ closingTxs.localOnly_opt.flatMap(tx => localSig(tx)).map(ClosingCompleteTlv.CloserOutputOnlyPartialSignature(_)),
+ closingTxs.remoteOnly_opt.flatMap(tx => localSig(tx)).map(ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(_)),
).flatten[ClosingCompleteTlv])
}
case _: AnchorOutputsCommitmentFormat => TlvStream(Set(
@@ -823,7 +823,7 @@ object Helpers {
).flatten[ClosingCompleteTlv])
}
val closingComplete = ClosingComplete(commitment.channelId, localScriptPubkey, remoteScriptPubkey, closingFee.fee, currentBlockHeight.toLong, tlvs)
- Right(closingTxs, closingComplete, localNonces)
+ Right(closingTxs, closingComplete)
}
/**
@@ -853,9 +853,11 @@ object Helpers {
closingComplete.closeeOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloseeOutputOnlyPartialSignature(localSig)))),
closingComplete.closerOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserOutputOnlyPartialSignature(localSig)))),
).flatten
+ val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
closingTxsWithSigs.headOption match {
+ case Some((closingTx, remoteSig, _)) if !closingTx.checkRemotePartialSignature(localFundingKey.publicKey, commitment.remoteFundingPubKey, remoteSig, localNonce.publicNonce) =>
+ Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid))
case Some((closingTx, remoteSig, sigToTlv)) =>
- val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
val signedClosingTx_opt = for {
localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteSig.nonce)).toOption
signedTx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig).toOption
@@ -906,7 +908,7 @@ object Helpers {
* sent another closing_complete before receiving their closing_sig, which is now obsolete: we ignore it and wait
* for their next closing_sig that will match our latest closing_complete.
*/
- def receiveSimpleClosingSig(channelKeys: ChannelKeys, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig, localNonces_opt: Option[CloserNonces], remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, ClosingTx] = {
+ def receiveSimpleClosingSig(channelKeys: ChannelKeys, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig, localClosingComplete_opt: Option[ClosingComplete], remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, ClosingTx] = {
val closingTxsWithSig = Seq(
closingSig.closerAndCloseeOutputsSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, IndividualSignature(sig)))),
closingSig.closerAndCloseeOutputsPartialSig_opt.flatMap(sig => remoteNonce_opt.flatMap(nonce => closingTxs.localAndRemote_opt.map(tx => (tx, PartialSignatureWithNonce(sig, nonce))))),
@@ -924,14 +926,14 @@ object Helpers {
val signedTx = closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
Some(closingTx.copy(tx = signedTx))
case remoteSig: PartialSignatureWithNonce =>
- val localNonce = localNonces_opt match {
- case Some(localNonces) if closingTx.tx.txOut.size == 2 => localNonces.localAndRemote
- case Some(localNonces) if closingTx.toLocalOutput_opt.nonEmpty => localNonces.localOnly
- case Some(localNonces) => localNonces.remoteOnly
+ val localSig_opt = localClosingComplete_opt match {
+ case Some(closingComplete) if closingTx.tx.txOut.size == 2 => closingComplete.closerAndCloseeOutputsPartialSig_opt
+ case Some(closingComplete) if closingTx.toLocalOutput_opt.nonEmpty => closingComplete.closerOutputOnlyPartialSig_opt
+ case Some(closingComplete) => closingComplete.closeeOutputOnlyPartialSig_opt
case None => return Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid))
}
for {
- localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteSig.nonce)).toOption
+ localSig <- localSig_opt
signedTx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig).toOption
} yield closingTx.copy(tx = signedTx)
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index 7b25860..172f5bb 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -227,8 +227,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// Closee nonces are first exchanged in shutdown messages, and replaced by a new nonce after each closing_sig.
var localCloseeNonce_opt: Option[LocalNonce] = None
var remoteCloseeNonce_opt: Option[IndividualNonce] = None
- // Closer nonces are randomly generated when sending our closing_complete.
- var localCloserNonces_opt: Option[CloserNonces] = None
+ // our closing_complete message, that includes partial musig2 signatures generated with random nonces.
+ var localClosingComplete_opt: Option[ClosingComplete] = None
// we pass these to helpers classes so that they have the logging context
implicit def implicitLog: akka.event.DiagnosticLoggingAdapter = diagLog
@@ -1923,9 +1923,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
} else {
MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, d.commitments.latest, localScript, d.remoteScriptPubKey, closingFeerate, remoteCloseeNonce_opt) match {
case Left(f) => handleCommandError(f, c)
- case Right((closingTxs, closingComplete, closerNonces)) =>
+ case Right((closingTxs, closingComplete)) =>
log.debug("signing local mutual close transactions: {}", closingTxs)
- localCloserNonces_opt = Some(closerNonces)
+ localClosingComplete_opt = Some(closingComplete)
handleCommandSuccess(c, d.copy(lastClosingFeerate = closingFeerate, localScriptPubKey = localScript, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs)) storing() sending closingComplete
}
}
@@ -1954,7 +1954,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// Note that if we sent two closing_complete in a row, without waiting for their closing_sig for the first one,
// this will fail because we only care about our latest closing_complete. This is fine, we should receive their
// closing_sig for the last closing_complete afterwards.
- MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, localCloserNonces_opt, remoteCloseeNonce_opt) match {
+ MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, localClosingComplete_opt, remoteCloseeNonce_opt) match {
case Left(f) =>
log.warning("invalid closing_sig: {}", f.getMessage)
remoteCloseeNonce_opt = closingSig.nextCloseeNonce_opt
@@ -3208,7 +3208,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
remoteNextCommitNonces = Map.empty
localCloseeNonce_opt = None
remoteCloseeNonce_opt = None
- localCloserNonces_opt = None
+ localClosingComplete_opt = None
}
/*
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala
index 451150d..16df2dd 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala
@@ -156,9 +156,9 @@ trait CommonHandlers {
log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage)
val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, Nil, Nil)
(d, None)
- case Right((closingTxs, closingComplete, closerNonces)) =>
+ case Right((closingTxs, closingComplete)) =>
log.debug("signing local mutual close transactions: {}", closingTxs)
- localCloserNonces_opt = Some(closerNonces)
+ localClosingComplete_opt = Some(closingComplete)
val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, closingTxs :: Nil, Nil)
(d, Some(closingComplete))
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala
index fe23aa6..a9b9c98 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala
@@ -28,7 +28,6 @@ import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.ChannelSpendSignature
import fr.acinq.eclair.channel.ChannelSpendSignature._
-import fr.acinq.eclair.crypto.NonceGenerator
import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, LocalCommitmentKeys, RemoteCommitmentKeys}
import fr.acinq.eclair.transactions.CommitmentOutput._
import fr.acinq.eclair.transactions.Scripts.Taproot.NUMS_POINT
@@ -1465,21 +1464,6 @@ object Transactions {
}
// @formatter:on
- /**
- * When sending [[fr.acinq.eclair.wire.protocol.ClosingComplete]], we use a different nonce for each closing transaction we create.
- * We generate nonces for all variants of the closing transaction for simplicity, even though we never use them all.
- */
- case class CloserNonces(localAndRemote: LocalNonce, localOnly: LocalNonce, remoteOnly: LocalNonce)
-
- object CloserNonces {
- /** Generate a set of random signing nonces for our closing transactions. */
- def generate(localFundingKey: PublicKey, remoteFundingKey: PublicKey, fundingTxId: TxId): CloserNonces = CloserNonces(
- NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId),
- NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId),
- NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId),
- )
- }
-
/** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */
case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) {
/** Preferred closing transaction for this closing attempt. */
Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.