Merge pull request #10994 from spesmilo/ln_config_symmetry
What changed, and why it matters
This is a large internal refactoring of how Electrum stores Lightning channel secrets. It removes private keys from the channel configuration objects saved in the wallet file, keeping only public data there, and puts the actual secrets (channel seed and multisig private key) directly in the channel storage. It also makes the local and remote channel configurations symmetric. The change includes a wallet database upgrade that converts old wallets to the new format and verifies that derived public keys match the stored ones. There is no direct evidence in the commit of an exploitable vulnerability; it looks like a hardening/cleanup change, but it touches sensitive key-handling code and the upgrade routine could in theory lock users out if key derivation does not match stored data.
Treat as a significant refactoring in security-critical code. Reviewers should verify that the wallet upgrade never rejects valid wallets, that ChannelKeys.check_against_config cannot fail for legitimate users, that the new storage layout does not accidentally serialize private keys into ChannelConfig, and that backup/restore and watchtower sweeping still work correctly after the config symmetry changes. No immediate user action is indicated by the commit itself.
Security signals we found
Refactoring of secret storage for Lightning channel keys
Wallet database upgrade that validates derived keys against stored keys
Removal of private keys from persisted ChannelConfig objects
Introduction of ChannelKeys rebuilt from seed and checked against config
Changes to backup handling for anchor channels (payment_basepoint privkey in backup)
Changes to force-close sweeping code paths (lnsweep.py)
Evidence from the diff
The commit merges PR #10994 (‘ln_config_symmetry’). It replaces the separate LocalConfig/RemoteConfig classes with a single ChannelConfig class that holds only public parameters and state (basepoint pubkeys, signatures, per-commitment points). A new ChannelKeys class holds the derived private keys and is rebuilt from the channel seed at load time. The channel seed and multisig private key are moved out of the config and stored at the channel level. wallet_db.py adds _convert_version_74 to migrate existing wallets: it strips private key fields from local_config, derives them from the channel seed, checks they match, and populates new symmetric fields. The change affects channel opening, commitment signing, revocation, sweeping, and backups. It also adds tests verifying key derivation matches stored pubkeys and that per-commitment points track the local commitment number.
Changed components
electrum/lnutil.pyelectrum/lnchannel.pyelectrum/lnpeer.pyelectrum/lnsweep.pyelectrum/lnworker.pyelectrum/wallet_db.pytests/lnhelpers.pytests/test_lnchannel.pytests/test_lnutil.pyInspect captured patch +431 / −234
### electrum/lnchannel.py
@@ -42,13 +42,13 @@
from .lntransport import LNPeerAddr
from .lnonion import OnionRoutingFailure
from . import lnutil
-from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, ChannelConstraints,
- get_per_commitment_secret_from_seed, secret_to_pubkey, derive_privkey, make_closing_tx,
+from .lnutil import (Outpoint, ChannelConfig, ChannelKeys, OnlyPubkeyKeypair, ChannelConstraints,
+ secret_to_pubkey, derive_privkey, make_closing_tx,
sign_and_get_sig_string, RevocationStore, derive_blinded_pubkey, Direction, derive_pubkey,
make_htlc_tx_with_open_channel, make_commitment, UpdateAddHtlc,
funding_output_script, SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, make_commitment_outputs,
ScriptHtlc, PaymentFailure, calc_fees_for_commitment_tx, RemoteMisbehaving, make_htlc_output_witness_script,
- ShortChannelID, map_htlcs_to_ctx_output_idxs,
+ ShortChannelID, map_htlcs_to_ctx_output_idxs, derive_payment_basepoint,
fee_for_htlc_output, offered_htlc_trim_threshold_sat,
received_htlc_trim_threshold_sat, make_commitment_output_to_remote_address, FIXED_ANCHOR_SAT,
ChannelType, LNProtocolWarning, ZEROCONF_TIMEOUT)
@@ -178,7 +178,8 @@ class HTLCWithStatus(NamedTuple):
class AbstractChannel(Logger, ABC):
storage: Union['StoredDict', dict]
- config: Dict[HTLCOwner, Union[LocalConfig, RemoteConfig]]
+ config: Dict[HTLCOwner, ChannelConfig]
+ keys: Optional[ChannelKeys] = None # None for on-chain channel backups
lnworker: 'LNWallet'
channel_id: bytes
short_channel_id: Optional[ShortChannelID] = None
@@ -617,35 +618,35 @@ def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
self.unconfirmed_closing_txid = None # not a state, only for GUI
def init_config(self, cb: ImportedChannelBackupStorage):
- local_payment_basepoint = cb.local_payment_basepoint
- multisig_funding_keypair = None
- if multisig_funding_secret := cb.multisig_funding_privkey:
- multisig_funding_keypair = Keypair(
- privkey=multisig_funding_secret,
- pubkey=ecc.ECPrivkey(multisig_funding_secret).get_public_key_bytes(),
- )
- self.config[LOCAL] = LocalConfig.from_seed(
- channel_seed=cb.channel_seed,
+ payment_basepoint = cb.local_payment_basepoint
+ payment_basepoint_privkey = None
+ if payment_basepoint and len(payment_basepoint) == 32:
+ # v3+ backups of anchor channels carry the privkey, so that non-deterministic
+ # LNWallets can recover their to_remote outputs
+ assert cb.channel_type & ChannelType.OPTION_ANCHORS
+ payment_basepoint, payment_basepoint_privkey = None, payment_basepoint
+ self.keys = ChannelKeys.from_seed(
+ cb.channel_seed,
+ multisig_privkey=cb.multisig_funding_privkey,
+ payment_basepoint_privkey=payment_basepoint_privkey,
+ )
+ self.config[LOCAL] = ChannelConfig.for_us(
+ keys=self.keys,
to_self_delay=cb.local_delay,
channel_type=cb.channel_type,
- payment_basepoint=local_payment_basepoint,
- multisig_key=multisig_funding_keypair,
+ payment_basepoint=payment_basepoint,
# dummy values
- static_payment_key=None,
dust_limit_sat=None,
max_htlc_value_in_flight_msat=None,
max_accepted_htlcs=None,
initial_msat=None,
reserve_sat=None,
- funding_locked_received=False,
- current_commitment_signature=None,
- current_htlc_signatures=b'',
htlc_minimum_msat=1,
upfront_shutdown_script='',
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
)
- self.config[REMOTE] = RemoteConfig(
+ self.config[REMOTE] = ChannelConfig(
# payment_basepoint needed to deobfuscate ctn in our_ctx
payment_basepoint=OnlyPubkeyKeypair(cb.remote_payment_pubkey),
# revocation_basepoint is used to claim to_local in our ctx
@@ -661,8 +662,10 @@ def init_config(self, cb: ImportedChannelBackupStorage):
initial_msat = None,
reserve_sat = None,
htlc_minimum_msat=None,
- next_per_commitment_point=None,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=None,
upfront_shutdown_script='',
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -824,6 +827,9 @@ def __init__(
self.config = {}
self.config[LOCAL] = state["local_config"]
self.config[REMOTE] = state["remote_config"]
+ self.keys = ChannelKeys.from_seed(
+ bfh(state["channel_seed"]), multisig_privkey=bfh(state["multisig_privkey"]))
+ self.keys.check_against_config(self.config[LOCAL])
self.constraints = state["constraints"] # type: ChannelConstraints
self.funding_outpoint = state["funding_outpoint"]
self.node_id = bfh(state["node_id"])
@@ -842,6 +848,29 @@ def __init__(
self.sent_channel_ready = False # no need to persist this, because channel_ready is re-sent in channel_reestablish
self.sent_announcement_signatures = False
+ @property
+ def funding_locked_received(self) -> bool:
+ """Whether the peer has told us that the funding tx is locked."""
+ return self.storage['funding_locked_received']
+
+ @funding_locked_received.setter
+ def funding_locked_received(self, b: bool) -> None:
+ self.storage['funding_locked_received'] = b
+
+ def get_payment_basepoint_privkey(self) -> bytes:
+ """The privkey of our payment_basepoint. (only for anchor channels)
+
+ It is not stored: it is derived from a static wallet secret and from the funding
+ pubkey, which is a public nonce. (see derive_payment_basepoint)
+ """
+ assert self.has_anchors()
+ keypair = derive_payment_basepoint(
+ static_payment_secret=self.lnworker.static_payment_key.privkey,
+ funding_pubkey=self.config[LOCAL].multisig_key.pubkey,
+ )
+ assert keypair.pubkey == self.config[LOCAL].payment_basepoint.pubkey
+ return keypair.privkey
+
def get_local_scid_alias(self, *, create_new_if_needed: bool = False) -> Optional[bytes]:
"""Get scid_alias to be used for *outgoing* HTLCs.
(called local as we choose the value)
@@ -1311,11 +1340,11 @@ def sign_next_commitment(self) -> Tuple[bytes, Sequence[bytes]]:
assert not self.is_closed(), self.get_state()
pending_remote_commitment = self.get_next_commitment(REMOTE)
- sig_64 = sign_and_get_sig_string(pending_remote_commitment, self.config[LOCAL], self.config[REMOTE])
+ sig_64 = sign_and_get_sig_string(pending_remote_commitment, self.keys.multisig_key)
self.logger.debug(f"sign_next_commitment. {pending_remote_commitment.serialize()=}. {sig_64.hex()=}")
their_remote_htlc_privkey_number = derive_privkey(
- int.from_bytes(self.config[LOCAL].htlc_basepoint.privkey, 'big'),
+ int.from_bytes(self.keys.htlc_basepoint.privkey, 'big'),
self.config[REMOTE].next_per_commitment_point)
their_remote_htlc_privkey = their_remote_htlc_privkey_number.to_bytes(32, 'big')
@@ -1345,6 +1374,8 @@ def sign_next_commitment(self) -> Tuple[bytes, Sequence[bytes]]:
htlcsigs = [x[1] for x in htlcsigs]
with self.db_lock:
self.hm.send_ctx()
+ self.config[REMOTE].current_commitment_signature = sig_64
+ self.config[REMOTE].current_htlc_signatures = b''.join(htlcsigs)
return sig_64, htlcsigs
def receive_new_commitment(self, sig: bytes, htlc_sigs: Sequence[bytes]) -> None:
@@ -1441,10 +1472,14 @@ def revoke_current_commitment(self):
if not self.signature_fits(new_ctx):
# this should never fail; as receive_new_commitment already did this test
raise Exception("refusing to revoke as remote sig does not fit")
- with self.db_lock:
- self.hm.send_rev()
last_secret, last_point = self.get_secret_and_point(LOCAL, new_ctn - 1)
+ _cur_secret, cur_point = self.get_secret_and_point(LOCAL, new_ctn)
next_secret, next_point = self.get_secret_and_point(LOCAL, new_ctn + 1)
+ with self.db_lock:
+ self.hm.send_rev()
+ # new_ctn is now our oldest unrevoked ctn
+ self.config[LOCAL].current_per_commitment_point = cur_point
+ self.config[LOCAL].next_per_commitment_point = next_point
return RevokeAndAck(last_secret, next_point)
def receive_revocation(self, revocation: RevokeAndAck):
@@ -1719,7 +1754,7 @@ def get_secret_and_point(self, subject: HTLCOwner, ctn: int) -> Tuple[Optional[b
secret = self.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn)
point = secret_to_pubkey(int.from_bytes(secret, 'big'))
else:
- secret = get_per_commitment_secret_from_seed(self.config[LOCAL].per_commitment_secret_seed, RevocationStore.START_INDEX - ctn)
+ secret = self.keys.per_commitment_secret(ctn)
point = secret_to_pubkey(int.from_bytes(secret, 'big'))
return secret, point
@@ -1958,7 +1993,7 @@ def make_closing_tx(self, local_script: bytes, remote_script: bytes,
funding_sat=self.constraints.capacity,
outputs=outputs)
- der_sig = closing_tx.sign_txin(0, self.config[LOCAL].multisig_key.privkey)
+ der_sig = closing_tx.sign_txin(0, self.keys.multisig_key.privkey)
sig = ecc.ecdsa_sig64_from_der_sig(der_sig[:-1])
return sig, closing_tx
@@ -1973,7 +2008,7 @@ def signature_fits(self, tx: PartialTransaction) -> bool:
def force_close_tx(self) -> PartialTransaction:
tx = self.get_latest_commitment(LOCAL)
assert self.signature_fits(tx)
- tx.sign({self.config[LOCAL].multisig_key.pubkey: self.config[LOCAL].multisig_key.privkey})
+ tx.sign({self.keys.multisig_key.pubkey: self.keys.multisig_key.privkey})
remote_sig = self.config[LOCAL].current_commitment_signature
remote_sig = ecc.ecdsa_der_sig_from_ecdsa_sig64(remote_sig) + Sighash.to_sigbytes(Sighash.ALL)
tx.add_signature_to_txin(txin_idx=0,
### electrum/lnpeer.py
@@ -38,10 +38,10 @@
OnionParsingError)
from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL
from . import lnutil
-from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts,
- RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore,
- funding_output_script, get_per_commitment_secret_from_seed,
- secret_to_pubkey, PaymentFailure, LnFeatures,
+from .lnutil import (Outpoint, RECEIVED, UpdateAddHtlc,
+ ChannelConfig, ChannelKeys, LnFeatureContexts,
+ OnlyPubkeyKeypair, ChannelConstraints, RevocationStore,
+ funding_output_script, PaymentFailure, LnFeatures,
LOCAL, REMOTE, HTLCOwner,
ln_compare_features, MIN_FINAL_CLTV_DELTA_ACCEPTED,
RemoteMisbehaving, ShortChannelID,
@@ -1054,7 +1054,7 @@ async def channel_establishment_flow(
)
else:
multisig_funding_keypair = None
- local_config = self.lnworker.make_local_config_for_new_channel(
+ local_config, local_keys = self.lnworker.make_local_config_for_new_channel(
funding_sat=funding_sat,
push_msat=push_msat,
initiator=LOCAL,
@@ -1071,14 +1071,6 @@ async def channel_establishment_flow(
open_channel_tlvs['channel_opening_fee'] = {
'channel_opening_fee': opening_fee
}
- # for the first commitment transaction
- per_commitment_secret_first = get_per_commitment_secret_from_seed(
- local_config.per_commitment_secret_seed,
- RevocationStore.START_INDEX
- )
- per_commitment_point_first = secret_to_pubkey(
- int.from_bytes(per_commitment_secret_first, 'big'))
-
# store the temp id now, so that it is recognized for e.g. 'error' messages
self.temp_id_to_id[temp_channel_id] = None
self._cleanup_temp_channelids()
@@ -1096,7 +1088,7 @@ async def channel_establishment_flow(
htlc_basepoint=local_config.htlc_basepoint.pubkey,
payment_basepoint=local_config.payment_basepoint.pubkey,
delayed_payment_basepoint=local_config.delayed_basepoint.pubkey,
- first_per_commitment_point=per_commitment_point_first,
+ first_per_commitment_point=local_config.current_per_commitment_point,
to_self_delay=local_config.to_self_delay,
max_htlc_value_in_flight_msat=local_config.max_htlc_value_in_flight_msat,
channel_flags=channel_flags,
@@ -1130,7 +1122,7 @@ async def channel_establishment_flow(
if their_channel_type != our_channel_type:
raise Exception(f"channel_type is not the one that we sent. {our_channel_type=}. {their_channel_type=}.")
- remote_config = RemoteConfig(
+ remote_config = ChannelConfig(
payment_basepoint=OnlyPubkeyKeypair(payload['payment_basepoint']),
multisig_key=OnlyPubkeyKeypair(payload["funding_pubkey"]),
htlc_basepoint=OnlyPubkeyKeypair(payload['htlc_basepoint']),
@@ -1143,8 +1135,10 @@ async def channel_establishment_flow(
initial_msat=push_msat,
reserve_sat=payload["channel_reserve_satoshis"],
htlc_minimum_msat=payload['htlc_minimum_msat'],
- next_per_commitment_point=remote_per_commitment_point,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=remote_per_commitment_point,
upfront_shutdown_script=upfront_shutdown_script,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -1199,6 +1193,7 @@ async def channel_establishment_flow(
channel_id=channel_id,
outpoint=outpoint,
local_config=local_config,
+ local_keys=local_keys,
remote_config=remote_config,
constraints=constraints,
channel_type=our_channel_type,
@@ -1245,8 +1240,9 @@ def create_channel_storage(
self, *,
channel_id: bytes,
outpoint: Outpoint,
- local_config: LocalConfig,
- remote_config: RemoteConfig,
+ local_config: ChannelConfig,
+ local_keys: ChannelKeys,
+ remote_config: ChannelConfig,
constraints: ChannelConstraints,
channel_type: ChannelType,
) -> dict:
@@ -1257,6 +1253,11 @@ def create_channel_storage(
"funding_outpoint": outpoint,
"remote_config": remote_config,
"local_config": local_config,
+ # our channel secrets. everything else we need is derived from the seed.
+ # (see ChannelKeys)
+ "channel_seed": local_keys.channel_seed.hex(),
+ "multisig_privkey": local_keys.multisig_key.privkey.hex(),
+ "funding_locked_received": False,
"constraints": constraints,
"remote_update": None,
"state": ChannelState.PREOPENING.name,
@@ -1343,7 +1344,7 @@ async def on_open_channel(self, payload):
)
else:
multisig_funding_keypair = None
- local_config = self.lnworker.make_local_config_for_new_channel(
+ local_config, local_keys = self.lnworker.make_local_config_for_new_channel(
funding_sat=funding_sat,
push_msat=push_msat,
initiator=REMOTE,
@@ -1355,7 +1356,7 @@ async def on_open_channel(self, payload):
upfront_shutdown_script = self.upfront_shutdown_script_from_payload(
payload, 'open')
- remote_config = RemoteConfig(
+ remote_config = ChannelConfig(
payment_basepoint=OnlyPubkeyKeypair(payload['payment_basepoint']),
multisig_key=OnlyPubkeyKeypair(payload['funding_pubkey']),
htlc_basepoint=OnlyPubkeyKeypair(payload['htlc_basepoint']),
@@ -1368,8 +1369,10 @@ async def on_open_channel(self, payload):
initial_msat=funding_sat * 1000 - push_msat,
reserve_sat=payload['channel_reserve_satoshis'],
htlc_minimum_msat=payload['htlc_minimum_msat'],
- next_per_commitment_point=payload['first_per_commitment_point'],
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=payload['first_per_commitment_point'],
upfront_shutdown_script=upfront_shutdown_script,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
@@ -1388,14 +1391,6 @@ async def on_open_channel(self, payload):
channel_flags = ord(payload['channel_flags'])
# -> accept channel
- # for the first commitment transaction
- per_commitment_secret_first = get_per_commitment_secret_from_seed(
- local_config.per_commitment_secret_seed,
- RevocationStore.START_INDEX
- )
- per_commitment_point_first = secret_to_pubkey(
- int.from_bytes(per_commitment_secret_first, 'big'))
-
min_depth = 0 if is_zeroconf else 3
accept_channel_tlvs = {
@@ -1422,7 +1417,7 @@ async def on_open_channel(self, payload):
payment_basepoint=local_config.payment_basepoint.pubkey,
delayed_payment_basepoint=local_config.delayed_basepoint.pubkey,
htlc_basepoint=local_config.htlc_basepoint.pubkey,
- first_per_commitment_point=per_commitment_point_first,
+ first_per_commitment_point=local_config.current_per_commitment_point,
accept_channel_tlvs=accept_channel_tlvs,
)
@@ -1448,6 +1443,7 @@ async def on_open_channel(self, payload):
channel_id=channel_id,
outpoint=outpoint,
local_config=local_config,
+ local_keys=local_keys,
remote_config=remote_config,
constraints=constraints,
channel_type=channel_type,
@@ -1814,9 +1810,7 @@ def send_channel_ready(self, chan: Channel):
if chan.sent_channel_ready:
return
channel_id = chan.channel_id
- per_commitment_secret_index = RevocationStore.START_INDEX - 1
- second_per_commitment_point = secret_to_pubkey(int.from_bytes(
- get_per_commitment_secret_from_seed(chan.config[LOCAL].per_commitment_secret_seed, per_commitment_secret_index), 'big'))
+ second_per_commitment_point = chan.keys.per_commitment_point(1)
channel_ready_tlvs = {}
if self.features.supports(LnFeatures.OPTION_SCID_ALIAS_OPT):
# LND requires that we send an alias if the option has been negotiated in INIT.
@@ -1845,10 +1839,10 @@ def on_channel_ready(self, chan: Channel, payload):
scid_alias = payload.get('channel_ready_tlvs', {}).get('short_channel_id', {}).get('alias')
if scid_alias:
chan.save_remote_scid_alias(scid_alias)
- if not chan.config[LOCAL].funding_locked_received:
+ if not chan.funding_locked_received:
their_next_point = payload["second_per_commitment_point"]
chan.config[REMOTE].next_per_commitment_point = their_next_point
- chan.config[LOCAL].funding_locked_received = True
+ chan.funding_locked_received = True
self.lnworker.save_channel(chan)
self.maybe_mark_open(chan)
@@ -1916,7 +1910,7 @@ def send_channel_update(self, chan: Channel):
def maybe_mark_open(self, chan: Channel):
if not chan.sent_channel_ready:
return
- if not chan.config[LOCAL].funding_locked_received:
+ if not chan.funding_locked_received:
return
self.mark_open(chan)
@@ -1929,7 +1923,7 @@ def mark_open(self, chan: Channel):
if old_state != ChannelState.FUNDED:
self.logger.info(f"cannot mark open ({chan.get_id_for_log()}), current state: {repr(old_state)}")
return
- assert chan.config[LOCAL].funding_locked_received
+ assert chan.funding_locked_received
chan.set_state(ChannelState.OPEN)
util.trigger_callback('channel', self.lnworker.wallet, chan)
# peer may have sent us a channel update for the incoming direction previously
@@ -1951,7 +1945,7 @@ def maybe_send_announcement_signatures(self, chan: Channel, is_reply=False):
if not is_reply and chan.config[REMOTE].announcement_node_sig:
return
h = chan.get_channel_announcement_hash()
- bitcoin_signature = ecc.ECPrivkey(chan.config[LOCAL].multisig_key.privkey).ecdsa_sign(h, sigencode=ecdsa_sig64_from_r_and_s)
+ bitcoin_signature = ecc.ECPrivkey(chan.keys.multisig_key.privkey).ecdsa_sign(h, sigencode=ecdsa_sig64_from_r_and_s)
node_signature = ecc.ECPrivkey(self.privkey).ecdsa_sign(h, sigencode=ecdsa_sig64_from_r_and_s)
self.send_message(
"announcement_signatures",
### electrum/lnsweep.py
@@ -16,11 +16,11 @@
derive_privkey, derive_pubkey, derive_blinded_pubkey, derive_blinded_privkey,
make_htlc_tx_witness, make_htlc_tx_with_open_channel, UpdateAddHtlc,
LOCAL, REMOTE, make_htlc_output_witness_script,
- get_ordered_channel_configs, get_per_commitment_secret_from_seed,
+ get_ordered_channel_configs,
RevocationStore, extract_ctn_from_tx_and_chan, UnableToDeriveSecret, SENT, RECEIVED,
map_htlcs_to_ctx_output_idxs, Direction, make_commitment_output_to_remote_witness_script,
derive_payment_basepoint, ctx_has_anchors, SCRIPT_TEMPLATE_FUNDING, Keypair,
- derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened, LocalConfig)
+ derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened)
from .transaction import (Transaction, TxInput, PartialTxInput,
PartialTxOutput, TxOutpoint, script_GetOp, match_script_against_template)
from .logging import get_logger, Logger
@@ -85,7 +85,7 @@ def sweep_their_ctx_watchtower(
pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True)
breacher_conf, watcher_conf = get_ordered_channel_configs(chan=chan, for_us=False)
watcher_revocation_privkey = derive_blinded_privkey(
- watcher_conf.revocation_basepoint.privkey,
+ chan.keys.revocation_basepoint.privkey,
per_commitment_secret
)
to_self_delay = watcher_conf.to_self_delay
@@ -201,7 +201,7 @@ def sweep_their_ctx_justice(
# prep
pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True)
this_conf, other_conf = get_ordered_channel_configs(chan=chan, for_us=False)
- other_revocation_privkey = derive_blinded_privkey(other_conf.revocation_basepoint.privkey,
+ other_revocation_privkey = derive_blinded_privkey(chan.keys.revocation_basepoint.privkey,
per_commitment_secret)
to_self_delay = other_conf.to_self_delay
this_delayed_pubkey = derive_pubkey(this_conf.delayed_basepoint.pubkey, pcp)
@@ -245,7 +245,7 @@ def sweep_their_htlctx_justice(
pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True)
this_conf, other_conf = get_ordered_channel_configs(chan=chan, for_us=False)
other_revocation_privkey = derive_blinded_privkey(
- other_conf.revocation_basepoint.privkey,
+ chan.keys.revocation_basepoint.privkey,
per_commitment_secret)
to_self_delay = other_conf.to_self_delay
this_delayed_pubkey = derive_pubkey(this_conf.delayed_basepoint.pubkey, pcp)
@@ -316,15 +316,14 @@ def sweep_our_ctx(
"""
ctn = extract_ctn_from_tx_and_chan(ctx, chan)
our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True)
- our_per_commitment_secret = get_per_commitment_secret_from_seed(
- our_conf.per_commitment_secret_seed, RevocationStore.START_INDEX - ctn)
+ our_per_commitment_secret = chan.keys.per_commitment_secret(ctn)
our_pcp = ecc.ECPrivkey(our_per_commitment_secret).get_public_key_bytes(compressed=True)
- our_delayed_bp_privkey = ecc.ECPrivkey(our_conf.delayed_basepoint.privkey)
+ our_delayed_bp_privkey = ecc.ECPrivkey(chan.keys.delayed_basepoint.privkey)
our_localdelayed_privkey = derive_privkey(our_delayed_bp_privkey.secret_scalar, our_pcp)
our_localdelayed_privkey = ecc.ECPrivkey.from_secret_scalar(our_localdelayed_privkey)
their_revocation_pubkey = derive_blinded_pubkey(their_conf.revocation_basepoint.pubkey, our_pcp)
to_self_delay = their_conf.to_self_delay
- our_htlc_privkey = derive_privkey(secret=int.from_bytes(our_conf.htlc_basepoint.privkey, 'big'),
+ our_htlc_privkey = derive_privkey(secret=int.from_bytes(chan.keys.htlc_basepoint.privkey, 'big'),
per_commitment_point=our_pcp).to_bytes(32, 'big')
our_localdelayed_pubkey = our_localdelayed_privkey.get_public_key_bytes(compressed=True)
to_local_witness_script = make_commitment_output_to_local_witness_script(
@@ -350,7 +349,7 @@ def sweep_our_ctx(
# local anchor
if actual_htlc_tx is None and chan.has_anchors():
- if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=our_conf.multisig_key):
+ if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=chan.keys.multisig_key):
txs[txin.prevout.to_str()] = SweepInfo(
name='local_anchor',
our_cltv_abs=None,
@@ -576,16 +575,16 @@ def sweep_their_ctx_to_remote_backup(
and anchors are enabled, we need to sweep to_remote."""
txs = {} # type: Dict[str, SweepInfo]
- local_config = chan.config.get(LOCAL) # type: Optional[LocalConfig]
+ keys = chan.keys # None for on-chain backups
fp_idx = None # type: Optional[int]
if ctx_has_anchors(ctx):
funding_pubkeys = extract_funding_pubkeys_from_ctx(ctx.inputs()[0])
# for anchors we need the payment_basepoint to spend the to_remote
- if local_config and isinstance(local_config.payment_basepoint, Keypair):
+ if keys and keys.payment_basepoint:
_logger.debug("using payment_basepoint key from channel backup")
# if we have a channel backup v3+ the imported payment_basepoint is a private key for anchor channels
# so non-deterministic LNWallets can recover their to_remote outputs
- our_payment_keypair = local_config.payment_basepoint
+ our_payment_keypair = keys.payment_basepoint
to_remote_address = make_commitment_output_to_remote_address(our_payment_keypair.pubkey, has_anchors=True)
if not ctx.get_output_idxs_from_address(to_remote_address):
_logger.debug(f"no to_remote output found for {to_remote_address=} from backup")
@@ -610,12 +609,12 @@ def sweep_their_ctx_to_remote_backup(
return {}
# get remote anchor funding_privkey ("multisig_key")
- # note: for imported backups, we already have this as 'local_config.multisig_key'
+ # note: for imported backups, we already have this as 'chan.keys.multisig_key'
# but for on-chain backups, we need to derive it.
our_ms_funding_keypair = None
- if local_config and local_config.multisig_key.pubkey in funding_pubkeys:
+ if keys and keys.multisig_key.pubkey in funding_pubkeys:
_logger.debug("using multisig_key from channel backup to spend remote anchor")
- our_ms_funding_keypair = local_config.multisig_key
+ our_ms_funding_keypair = keys.multisig_key
elif fp_idx is not None:
_logger.debug("found no multisig_key for remote anchor in channel backup, deriving key")
our_funding_pubkey = funding_pubkeys[fp_idx]
@@ -716,7 +715,7 @@ def sweep_their_ctx(
# remote anchor
if chan.has_anchors():
- if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=our_conf.multisig_key):
+ if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=chan.keys.multisig_key):
txs[txin.prevout.to_str()] = SweepInfo(
name='remote_anchor',
our_cltv_abs=None,
@@ -728,7 +727,7 @@ def sweep_their_ctx(
# to_local is handled by lnwatcher
if is_revocation:
- our_revocation_privkey = derive_blinded_privkey(our_conf.revocation_basepoint.privkey, per_commitment_secret)
+ our_revocation_privkey = derive_blinded_privkey(chan.keys.revocation_basepoint.privkey, per_commitment_secret)
if txin := sweep_their_ctx_justice(chan, ctx, per_commitment_secret):
txs[txin.prevout.to_str()] = SweepInfo(
name='to_local_for_revoked_ctx',
@@ -742,7 +741,7 @@ def sweep_their_ctx(
# to_remote
if chan.has_anchors():
sweep_to_remote = True
- our_payment_privkey = ecc.ECPrivkey(our_conf.payment_basepoint.privkey)
+ our_payment_privkey = ecc.ECPrivkey(chan.get_payment_basepoint_privkey())
else:
assert chan.is_static_remotekey_enabled()
sweep_to_remote = False
@@ -771,7 +770,7 @@ def sweep_their_ctx(
)
# HTLCs
- our_htlc_privkey = derive_privkey(secret=int.from_bytes(our_conf.htlc_basepoint.privkey, 'big'), per_commitment_point=their_pcp)
+ our_htlc_privkey = derive_privkey(secret=int.from_bytes(chan.keys.htlc_basepoint.privkey, 'big'), per_commitment_point=their_pcp)
our_htlc_privkey = ecc.ECPrivkey.from_secret_scalar(our_htlc_privkey)
their_htlc_pubkey = derive_pubkey(their_conf.htlc_basepoint.pubkey, their_pcp)
def tx_htlc(
### electrum/lnutil.py
@@ -89,8 +89,14 @@ def int_to_bytes_minimal(n: int, byteorder: Literal['big', 'little'] = 'big') ->
return int.to_bytes(n, length=int_min_byte_len(n), byteorder=byteorder)
-def json_to_keypair(arg: Union['OnlyPubkeyKeypair', dict]) -> Union['OnlyPubkeyKeypair', 'Keypair']:
- return arg if isinstance(arg, OnlyPubkeyKeypair) else Keypair(**arg) if len(arg) == 2 else OnlyPubkeyKeypair(**arg)
+def json_to_pubkey(arg: Union['OnlyPubkeyKeypair', dict]) -> 'OnlyPubkeyKeypair':
+ """Channel configs only ever hold pubkeys; our privkeys live in ChannelKeys."""
+ pubkey = arg.pubkey if isinstance(arg, OnlyPubkeyKeypair) else arg['pubkey']
+ return OnlyPubkeyKeypair(pubkey)
+
+
+def keypair_from_privkey(privkey: bytes) -> 'Keypair':
+ return Keypair(pubkey=ecc.ECPrivkey(privkey).get_public_key_bytes(), privkey=privkey)
def serialize_htlc_key(scid: bytes, htlc_id: int) -> str:
@@ -122,9 +128,16 @@ def __post_init__(self):
self.privkey = hex_to_bytes(self.privkey)
+@stored_at('/channels/*/local_config')
+@stored_at('/channels/*/remote_config')
@dataclasses.dataclass(repr=False)
class ChannelConfig(StoredObject):
- # shared channel config fields
+ """The channel parameters and state of one side of a channel.
+
+ Both sides are described by the same fields, and neither holds any secret: our
+ privkeys are all derived from the channel seed, which lives next to the two configs
+ in the channel storage. (see ChannelKeys)
+ """
payment_basepoint: OnlyPubkeyKeypair
multisig_key: OnlyPubkeyKeypair
htlc_basepoint: OnlyPubkeyKeypair
@@ -140,23 +153,89 @@ class ChannelConfig(StoredObject):
upfront_shutdown_script: bytes
announcement_node_sig: bytes
announcement_bitcoin_sig: bytes
+ # signatures for this side's latest ctx and its htlc txs: for LOCAL, the ones the
+ # remote sent us; for REMOTE, the ones we sent them.
+ current_commitment_signature: bytes
+ current_htlc_signatures: bytes
+ # per-commitment points of this side's oldest unrevoked ctx, and of the next one.
+ # ours are derivable from the channel seed; we keep them so that both configs can be
+ # described, and sent to a peer that backs up our channel, in the same way.
+ current_per_commitment_point: bytes
+ next_per_commitment_point: bytes
def __post_init__(self):
# keypairs are stored as dicts, and bytes as hex
- self.payment_basepoint = json_to_keypair(self.payment_basepoint)
- self.multisig_key = json_to_keypair(self.multisig_key)
- self.htlc_basepoint = json_to_keypair(self.htlc_basepoint)
- self.delayed_basepoint = json_to_keypair(self.delayed_basepoint)
- self.revocation_basepoint = json_to_keypair(self.revocation_basepoint)
+ self.payment_basepoint = json_to_pubkey(self.payment_basepoint)
+ self.multisig_key = json_to_pubkey(self.multisig_key)
+ self.htlc_basepoint = json_to_pubkey(self.htlc_basepoint)
+ self.delayed_basepoint = json_to_pubkey(self.delayed_basepoint)
+ self.revocation_basepoint = json_to_pubkey(self.revocation_basepoint)
self.upfront_shutdown_script = hex_to_bytes(self.upfront_shutdown_script)
self.announcement_node_sig = hex_to_bytes(self.announcement_node_sig)
self.announcement_bitcoin_sig = hex_to_bytes(self.announcement_bitcoin_sig)
+ self.current_commitment_signature = hex_to_bytes(self.current_commitment_signature)
+ self.current_htlc_signatures = hex_to_bytes(self.current_htlc_signatures)
+ self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
+ self.next_per_commitment_point = hex_to_bytes(self.next_per_commitment_point)
def __repr__(self):
return repr_dataclass(self, {bytes: bytes_to_hex})
- def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
- conf_name = type(self).__name__
+ @classmethod
+ def for_us(
+ cls,
+ *,
+ keys: 'ChannelKeys',
+ channel_type: 'ChannelType',
+ static_payment_key: Optional['Keypair'] = None,
+ payment_basepoint: Optional[bytes] = None,
+ **kwargs,
+ ) -> 'ChannelConfig':
+ """Our side of the config, holding the pubkeys of the keys we just derived.
+
+ The payment_basepoint is the one key that does not come from the channel seed,
+ so it is resolved here: see the cases below.
+ """
+ assert bool(static_payment_key) + bool(payment_basepoint) <= 1
+ if static_payment_key:
+ assert channel_type & ChannelType.OPTION_ANCHORS
+ # We derive the payment_basepoint from a static secret (derived from
+ # the wallet seed) and a public nonce that is revealed
+ # when the funding transaction is spent. This way we can restore the
+ # payment_basepoint, needed for sweeping in the event of a force close.
+ # note: only the pubkey is kept, the privkey is derived when it is needed.
+ # (see Channel.get_payment_basepoint_privkey)
+ payment_basepoint = derive_payment_basepoint(
+ static_payment_secret=static_payment_key.privkey,
+ funding_pubkey=keys.multisig_key.pubkey,
+ ).pubkey
+ elif keys.payment_basepoint: # channel backup that carries the privkey (anchors)
+ payment_basepoint = keys.payment_basepoint.pubkey
+ elif payment_basepoint: # channel backup (or new SRK chan in unit tests)
+ assert len(payment_basepoint) == 33 # pubkey
+ else:
+ # v0 channel backup for srk channel: the real basepoint is a wallet pubkey that is
+ # not part of the backup and cannot be derived, see: https://github.com/spesmilo/electrum/pull/8536
+ assert channel_type == ChannelType.OPTION_STATIC_REMOTEKEY
+ payment_basepoint = None
+ assert ecc.ECPubkey.is_pubkey_bytes(payment_basepoint)
+ return cls(
+ payment_basepoint=OnlyPubkeyKeypair(payment_basepoint),
+ multisig_key=OnlyPubkeyKeypair(keys.multisig_key.pubkey),
+ htlc_basepoint=OnlyPubkeyKeypair(keys.htlc_basepoint.pubkey),
+ delayed_basepoint=OnlyPubkeyKeypair(keys.delayed_basepoint.pubkey),
+ revocation_basepoint=OnlyPubkeyKeypair(keys.revocation_basepoint.pubkey),
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
+ current_per_commitment_point=keys.per_commitment_point(0),
+ next_per_commitment_point=keys.per_commitment_point(1),
+ **kwargs,
+ )
+
+ def validate_params(
+ self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures',
+ is_local: bool = False) -> None:
+ conf_name = 'LOCAL' if is_local else 'REMOTE'
for key in (
self.payment_basepoint,
self.multisig_key,
@@ -198,13 +277,19 @@ def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_feat
raise Exception(f"{conf_name}. to_self_delay too high: {self.to_self_delay} > {MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED}")
if self.max_htlc_value_in_flight_msat < min(1000 * funding_sat, 90_000_000):
raise Exception(f"{conf_name}. max_htlc_value_in_flight_msat is too small: {self.max_htlc_value_in_flight_msat}")
+ if is_local:
+ # stricter checks on our own config (make sure we ourselves do the sane thing,
+ # even if we are lenient with the remote for compatibility reasons)
+ HTLC_MINIMUM_MSAT_MIN = 1
+ if self.htlc_minimum_msat < HTLC_MINIMUM_MSAT_MIN:
+ raise Exception(f"{conf_name}. htlc_minimum_msat too low: {self.htlc_minimum_msat} msat < {HTLC_MINIMUM_MSAT_MIN}")
@classmethod
def cross_validate_params(
cls,
*,
- local_config: 'LocalConfig',
- remote_config: 'RemoteConfig',
+ local_config: 'ChannelConfig',
+ remote_config: 'ChannelConfig',
funding_sat: int,
is_local_initiator: bool, # whether we are the funder
initial_feerate_per_kw: int,
@@ -214,7 +299,7 @@ def cross_validate_params(
) -> None:
has_anchors = bool(channel_type & ChannelType.OPTION_ANCHORS)
# first we validate the configs separately
- local_config.validate_params(funding_sat=funding_sat, config=config, peer_features=peer_features)
+ local_config.validate_params(funding_sat=funding_sat, config=config, peer_features=peer_features, is_local=True)
remote_config.validate_params(funding_sat=funding_sat, config=config, peer_features=peer_features)
# now do tests that need access to both configs
if is_local_initiator:
@@ -255,88 +340,72 @@ def cross_validate_params(
raise Exception(f"feerate lower than min relay fee. {initial_feerate_per_kw} sat/kw.")
-@stored_at('/channels/*/local_config')
@dataclasses.dataclass(repr=False)
-class LocalConfig(ChannelConfig):
- channel_seed: Optional[bytes]
- funding_locked_received: bool
- current_commitment_signature: bytes
- current_htlc_signatures: bytes
+class ChannelKeys:
+ """Our secret keys for one channel.
+
+ Everything here is derived from the channel seed, except the multisig privkey: for
+ anchor channels it comes from the funding root key, and re-deriving it would require
+ the funding transaction (we only store its prevouts). So channel_seed and the
+ multisig privkey are the only local channel secrets in the wallet file, and they are
+ kept in the channel storage; the channel configs hold pubkeys only.
+
+ Note that this is not a StoredObject: it is rebuilt from the seed when the channel is
+ loaded, and checked against the pubkeys in our config. (see check_against_config)
+ """
+ channel_seed: bytes
per_commitment_secret_seed: bytes
+ multisig_key: Keypair
+ htlc_basepoint: Keypair
+ delayed_basepoint: Keypair
+ revocation_basepoint: Keypair
+ # only imported channel backups carry the payment_basepoint privkey; for our own
+ # channels we derive it when we need it. (see Channel.get_payment_basepoint_privkey)
+ payment_basepoint: Optional[Keypair] = None
- def __post_init__(self):
- super().__post_init__()
- self.channel_seed = hex_to_bytes(self.channel_seed)
- self.current_commitment_signature = hex_to_bytes(self.current_commitment_signature)
- self.current_htlc_signatures = hex_to_bytes(self.current_htlc_signatures)
- self.per_commitment_secret_seed = hex_to_bytes(self.per_commitment_secret_seed)
+ def __repr__(self):
+ # never show secrets, in case this gets logged
+ return repr_dataclass(self, {bytes: lambda _: '<hidden>', Keypair: lambda kp: bytes_to_hex(kp.pubkey)})
@classmethod
- def from_seed(cls, **kwargs):
- channel_seed = kwargs['channel_seed']
+ def from_seed(
+ cls,
+ channel_seed: bytes,
+ *,
+ multisig_privkey: Optional[bytes] = None,
+ payment_basepoint_privkey: Optional[bytes] = None,
+ ) -> 'ChannelKeys':
node = BIP32Node.from_rootseed(channel_seed, xtype='standard')
-
- def keypair_generator(family: 'LnKeyFamily') -> 'Keypair':
- return generate_keypair(node, family)
-
- kwargs['per_commitment_secret_seed'] = keypair_generator(LnKeyFamily.REVOCATION_ROOT).privkey
- if kwargs['multisig_key'] is None:
- kwargs['multisig_key'] = keypair_generator(LnKeyFamily.MULTISIG)
- kwargs['htlc_basepoint'] = keypair_generator(LnKeyFamily.HTLC_BASE)
- kwargs['delayed_basepoint'] = keypair_generator(LnKeyFamily.DELAY_BASE)
- kwargs['revocation_basepoint'] = keypair_generator(LnKeyFamily.REVOCATION_BASE)
- static_payment_key = kwargs.pop('static_payment_key')
- channel_type = kwargs.pop('channel_type')
- payment_basepoint = kwargs.pop('payment_basepoint', None) # type: bytes | None
- assert bool(static_payment_key) + bool(payment_basepoint) <= 1
- if static_payment_key:
- assert channel_type & ChannelType.OPTION_ANCHORS
- # We derive the payment_basepoint from a static secret (derived from
- # the wallet seed) and a public nonce that is revealed
- # when the funding transaction is spent. This way we can restore the
- # payment_basepoint, needed for sweeping in the event of a force close.
- kwargs['payment_basepoint'] = derive_payment_basepoint(
- static_payment_secret=static_payment_key.privkey,
- funding_pubkey=kwargs['multisig_key'].pubkey
- )
- elif payment_basepoint: # channel backup (or new SRK chan in unit tests)
- if len(payment_basepoint) == 32: # privkey
- assert channel_type & ChannelType.OPTION_ANCHORS
- privkey = ecc.ECPrivkey(payment_basepoint)
- kwargs['payment_basepoint'] = Keypair(privkey=privkey.get_secret_bytes(), pubkey=privkey.get_public_key_bytes())
- else:
- assert len(payment_basepoint) == 33 # pubkey
- kwargs['payment_basepoint'] = OnlyPubkeyKeypair(payment_basepoint)
+ if multisig_privkey is None:
+ # static_remotekey channels derive it like the rest; for anchor channels it
+ # comes from the funding root key, and the caller passes it.
+ multisig_key = generate_keypair(node, LnKeyFamily.MULTISIG)
else:
- # v0 channel backup for srk channel: the real basepoint is a wallet pubkey that is
- # not part of the backup and cannot be derived, see: https://github.com/spesmilo/electrum/pull/8536
- assert channel_type == ChannelType.OPTION_STATIC_REMOTEKEY
- kwargs['payment_basepoint'] = OnlyPubkeyKeypair(None)
-
- assert ecc.ECPubkey.is_pubkey_bytes(kwargs['payment_basepoint'].pubkey)
- return LocalConfig(**kwargs)
-
- def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
- conf_name = type(self).__name__
- # run base checks regardless whether LOCAL/REMOTE config
- super().validate_params(funding_sat=funding_sat, config=config, peer_features=peer_features)
- # run some stricter checks on LOCAL config (make sure we ourselves do the sane thing,
- # even if we are lenient with REMOTE for compatibility reasons)
- HTLC_MINIMUM_MSAT_MIN = 1
- if self.htlc_minimum_msat < HTLC_MINIMUM_MSAT_MIN:
- raise Exception(f"{conf_name}. htlc_minimum_msat too low: {self.htlc_minimum_msat} msat < {HTLC_MINIMUM_MSAT_MIN}")
+ multisig_key = keypair_from_privkey(multisig_privkey)
+ payment_basepoint = (keypair_from_privkey(payment_basepoint_privkey)
+ if payment_basepoint_privkey else None)
+ return ChannelKeys(
+ channel_seed=channel_seed,
+ per_commitment_secret_seed=generate_keypair(node, LnKeyFamily.REVOCATION_ROOT).privkey,
+ multisig_key=multisig_key,
+ payment_basepoint=payment_basepoint,
+ **{name: generate_keypair(node, key_family)
+ for name, key_family in DERIVED_LOCAL_BASEPOINTS.items()},
+ )
+ def check_against_config(self, config: ChannelConfig) -> None:
+ """The pubkeys are stored, so we can tell that we derived the right keys."""
+ for name in (*DERIVED_LOCAL_BASEPOINTS, 'multisig_key'):
+ derived = getattr(self, name).pubkey
+ if derived != getattr(config, name).pubkey:
+ raise Exception(f"unexpected {name} derived from channel_seed")
-@stored_at('/channels/*/remote_config')
-@dataclasses.dataclass(repr=False)
-class RemoteConfig(ChannelConfig):
- next_per_commitment_point: bytes
- current_per_commitment_point: Optional[bytes] = None
+ def per_commitment_secret(self, ctn: int) -> bytes:
+ return get_per_commitment_secret_from_seed(
+ self.per_commitment_secret_seed, RevocationStore.START_INDEX - ctn)
- def __post_init__(self):
- super().__post_init__()
- self.next_per_commitment_point = hex_to_bytes(self.next_per_commitment_point)
- self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
+ def per_commitment_point(self, ctn: int) -> bytes:
+ return get_per_commitment_point_from_seed(self.per_commitment_secret_seed, ctn)
@stored_at('/channels/*/log/*/fee_updates/*')
@@ -766,6 +835,11 @@ def get_per_commitment_secret_from_seed(seed: bytes, i: int, bits: int = Revocat
return bajts
+def get_per_commitment_point_from_seed(seed: bytes, ctn: int) -> bytes:
+ secret = get_per_commitment_secret_from_seed(seed, RevocationStore.START_INDEX - ctn)
+ return secret_to_pubkey(int.from_bytes(secret, 'big'))
+
+
def secret_to_pubkey(secret: int) -> bytes:
assert type(secret) is int
return ecc.ECPrivkey.from_secret_scalar(secret).get_public_key_bytes(compressed=True)
@@ -1096,7 +1170,7 @@ def make_htlc_output_witness_script(
def get_ordered_channel_configs(
chan: 'AbstractChannel',
for_us: bool
-) -> Tuple[Union[LocalConfig, RemoteConfig], Union[LocalConfig, RemoteConfig]]:
+) -> Tuple[ChannelConfig, ChannelConfig]:
conf = chan.config[LOCAL] if for_us else chan.config[REMOTE]
other_conf = chan.config[LOCAL] if not for_us else chan.config[REMOTE]
return conf, other_conf
@@ -1513,14 +1587,14 @@ def make_commitment_output_to_anchor_address(funding_pubkey: bytes) -> str:
return bitcoin.redeem_script_to_address('p2wsh', script)
-def sign_and_get_sig_string(tx: PartialTransaction, local_config, remote_config):
- tx.sign({local_config.multisig_key.pubkey: local_config.multisig_key.privkey})
- sig = tx.inputs()[0].sigs_ecdsa[local_config.multisig_key.pubkey]
+def sign_and_get_sig_string(tx: PartialTransaction, multisig_key: 'Keypair') -> bytes:
+ tx.sign({multisig_key.pubkey: multisig_key.privkey})
+ sig = tx.inputs()[0].sigs_ecdsa[multisig_key.pubkey]
sig_64 = ecdsa_sig64_from_der_sig(sig[:-1])
return sig_64
-def funding_output_script(local_config: 'LocalConfig', remote_config: 'RemoteConfig') -> bytes:
+def funding_output_script(local_config: 'ChannelConfig', remote_config: 'ChannelConfig') -> bytes:
return funding_output_script_from_keys(local_config.multisig_key.pubkey, remote_config.multisig_key.pubkey)
@@ -2051,6 +2125,15 @@ class LnKeyFamily(IntEnum):
FUNDING_ROOT_KEY = 10 | BIP32_PRIME
+# Basepoints of our local channel config that we derive from the channel seed, instead
+# of storing their privkeys. (see ChannelKeys)
+DERIVED_LOCAL_BASEPOINTS = {
+ 'htlc_basepoint': LnKeyFamily.HTLC_BASE,
+ 'delayed_basepoint': LnKeyFamily.DELAY_BASE,
+ 'revocation_basepoint': LnKeyFamily.REVOCATION_BASE,
+}
+
+
def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair:
node2 = node.subkey_at_private_derivation([key_family, 0, 0])
k = node2.eckey.get_secret_bytes()
### electrum/lnworker.py
@@ -74,7 +74,7 @@
OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget,
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT,
MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, RecvMPPResolution, ReceivedMPPStatus, ReceivedMPPHtlc,
- PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT,
+ PaymentSuccess, ChannelType, ChannelConfig, ChannelKeys, Keypair, ZEROCONF_TIMEOUT,
)
from .lnonion import (
decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket,
@@ -1767,7 +1767,7 @@ def make_local_config_for_new_channel(
multisig_funding_keypair: Optional[Keypair], # if None, will get derived from channel_seed
peer_features: LnFeatures,
channel_seed: bytes | None = None,
- ) -> LocalConfig:
+ ) -> Tuple[ChannelConfig, ChannelKeys]:
if channel_seed is None:
channel_seed = crandom.get_rand_bytes(32)
initial_msat = funding_sat * 1000 - push_msat if initiator == LOCAL else push_msat
@@ -1805,28 +1805,29 @@ def make_local_config_for_new_channel(
# https://github.com/ElementsProject/lightning/blob/0056dd75572a8857cff36fcbdb1a2295a1ac9253/lightningd/options.c#L657
# https://github.com/lightningnetwork/lnd/blob/56b61078c5b2be007d318673a5f3b40c6346883a/config.go#L81
max_htlc_value_in_flight_msat = self.network.config.LIGHTNING_MAX_HTLC_VALUE_IN_FLIGHT_MSAT or funding_sat * 1000
- local_config = LocalConfig.from_seed(
- channel_seed=channel_seed,
+ keys = ChannelKeys.from_seed(
+ channel_seed,
+ multisig_privkey=multisig_funding_keypair.privkey if multisig_funding_keypair else None)
+ local_config = ChannelConfig.for_us(
+ keys=keys,
channel_type=channel_type,
payment_basepoint=payment_basepoint,
static_payment_key=static_payment_key,
- multisig_key=multisig_funding_keypair,
upfront_shutdown_script=upfront_shutdown_script,
to_self_delay=self.network.config.LIGHTNING_TO_SELF_DELAY_CSV,
dust_limit_sat=dust_limit_sat,
max_htlc_value_in_flight_msat=max_htlc_value_in_flight_msat,
max_accepted_htlcs=30,
initial_msat=initial_msat,
reserve_sat=reserve_sat,
- funding_locked_received=False,
- current_commitment_signature=None,
- current_htlc_signatures=b'',
htlc_minimum_msat=1,
announcement_node_sig=b'',
announcement_bitcoin_sig=b'',
)
- local_config.validate_params(funding_sat=funding_sat, config=self.network.config, peer_features=peer_features)
- return local_config
+ local_config.validate_params(
+ funding_sat=funding_sat, config=self.network.config,
+ peer_features=peer_features, is_local=True)
+ return local_config, keys
def cb_data(self, node_id: bytes) -> bytes:
return CB_MAGIC_BYTES + node_id[0:NODE_ID_PREFIX_LEN]
@@ -3821,7 +3822,7 @@ def create_channel_backup(self, channel_id: bytes):
peer_addresses = list(chan.get_peer_addresses())
peer_addr = peer_addresses[0] if peer_addresses else None
if chan.has_anchors():
- local_payment_basepoint = chan.config[LOCAL].payment_basepoint.privkey
+ local_payment_basepoint = chan.get_payment_basepoint_privkey()
else:
local_payment_basepoint = chan.config[LOCAL].payment_basepoint.pubkey
return ImportedChannelBackupStorage(
@@ -3833,14 +3834,14 @@ def create_channel_backup(self, channel_id: bytes):
host=peer_addr.host if peer_addr else '',
port=peer_addr.port if peer_addr else 0,
is_initiator=chan.constraints.is_initiator,
- channel_seed=chan.config[LOCAL].channel_seed,
+ channel_seed=chan.keys.channel_seed,
channel_type=int(chan.storage['channel_type']),
local_delay=chan.config[LOCAL].to_self_delay,
remote_delay=chan.config[REMOTE].to_self_delay,
remote_revocation_pubkey=chan.config[REMOTE].revocation_basepoint.pubkey,
remote_payment_pubkey=chan.config[REMOTE].payment_basepoint.pubkey,
local_payment_basepoint=local_payment_basepoint,
- multisig_funding_privkey=chan.config[LOCAL].multisig_key.privkey,
+ multisig_funding_privkey=chan.keys.multisig_key.privkey,
)
def export_channel_backup(self, channel_id):
### electrum/wallet_db.py
@@ -72,7 +72,7 @@ def __init__(self, wallet_db: 'WalletDB'):
# seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
-FINAL_SEED_VERSION = 73 # electrum >= 2.7 will set this to prevent
+FINAL_SEED_VERSION = 74 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format
@@ -263,6 +263,7 @@ def upgrade(self):
self._convert_version_71()
self._convert_version_72()
self._convert_version_73()
+ self._convert_version_74()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure
def _convert_wallet_type(self):
@@ -1512,6 +1513,64 @@ def _convert_version_73(self):
del invoices[key]
self.data['seed_version'] = 73
+ def _convert_version_74(self):
+ """Stop storing the local channel keys that are derived from the channel seed, and
+ describe both sides of a channel with the same fields.
+
+ The channel seed and the multisig privkey (the one key we cannot re-derive) move
+ out of the config and into the channel itself, together with
+ funding_locked_received. What is left is a config that holds public data only,
+ and that has the same fields for LOCAL and for REMOTE.
+ """
+ from .bip32 import BIP32Node
+ from .lnutil import (DERIVED_LOCAL_BASEPOINTS, LnKeyFamily, generate_keypair,
+ derive_payment_basepoint, get_per_commitment_point_from_seed)
+ if not self._is_upgrade_method_needed(73, 73):
+ return
+
+ def check(channel_id: str, name: str, derived: bytes, stored: str) -> None:
+ # we must never drop a key that we would not be able to derive again
+ if derived.hex() == stored:
+ return
+ raise WalletFileException(
+ f"Cannot upgrade this wallet: the {name} of channel {channel_id} does not "
+ f"match the key derived from its channel_seed. This should not happen, "
+ f"please report it. To recover the channel, open this wallet with an "
+ f"older version of Electrum, and close the channel.")
+
+ for channel_id, c in self.data.get('channels', {}).items():
+ local_config = c['local_config']
+ remote_config = c['remote_config']
+ channel_seed = local_config.pop('channel_seed')
+ node = BIP32Node.from_rootseed(bfh(channel_seed), xtype='standard')
+ for name, key_family in DERIVED_LOCAL_BASEPOINTS.items():
+ keypair = generate_keypair(node, key_family)
+ check(channel_id, name, keypair.privkey, local_config[name].pop('privkey'))
+ secret_seed = generate_keypair(node, LnKeyFamily.REVOCATION_ROOT).privkey
+ check(channel_id, 'per_commitment_secret_seed', secret_seed,
+ local_config.pop('per_commitment_secret_seed'))
+ if privkey := local_config['payment_basepoint'].pop('privkey', None):
+ # anchor channels: derived from a static wallet secret and the funding pubkey
+ ln_xprv = self.data.get('lightning_xprv') or self.data.get('lightning_privkey2')
+ static_payment_key = generate_keypair(BIP32Node.from_xkey(ln_xprv), LnKeyFamily.PAYMENT_BASE)
+ keypair = derive_payment_basepoint(
+ static_payment_secret=static_payment_key.privkey,
+ funding_pubkey=bfh(local_config['multisig_key']['pubkey']))
+ check(channel_id, 'payment_basepoint', keypair.privkey, privkey)
+ # our secrets move to the channel; multisig_key now only stores a pubkey,
+ # like the basepoints above
+ c['channel_seed'] = channel_seed
+ c['multisig_privkey'] = local_config['multisig_key'].pop('privkey')
+ c['funding_locked_received'] = local_config.pop('funding_locked_received')
+ # the remaining fields exist for both sides now. The signatures we sent for
+ # their ctx were not kept, so the remote config starts without them.
+ remote_config['current_commitment_signature'] = None
+ remote_config['current_htlc_signatures'] = ''
+ ctn = max(c['log']['1']['ctn'], 0) # our oldest unrevoked ctn
+ local_config['current_per_commitment_point'] = get_per_commitment_point_from_seed(secret_seed, ctn).hex()
+ local_config['next_per_commitment_point'] = get_per_commitment_point_from_seed(secret_seed, ctn + 1).hex()
+ self.data['seed_version'] = 74
+
def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
### tests/lnhelpers.py
@@ -1,5 +1,6 @@
import asyncio
import copy
+import dataclasses
import os
import socket
from decimal import Decimal
@@ -18,8 +19,8 @@
from electrum.invoices import PR_UNPAID, Invoice, LN_EXPIRY_NEVER
from electrum.lnpeer import Peer
from electrum.lnutil import (
- LnFeatures, PaymentFeeBudget, LOCAL, REMOTE, ChannelType, LocalConfig, RemoteConfig,
- OnlyPubkeyKeypair, secret_to_pubkey, RECEIVED,
+ LnFeatures, PaymentFeeBudget, LOCAL, REMOTE, ChannelType, ChannelConfig, ChannelKeys,
+ RECEIVED,
)
from electrum.lnchannel import ChannelState, Channel
from electrum.lnrouter import LNPathFinder
@@ -512,33 +513,16 @@ def prepare_chans_and_peers_in_graph(
return graph
-def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig) -> RemoteConfig:
- """converts Alice's local config to Bob's remote config (neutering private keys, etc)"""
- ctn = 0
- pcp_secret = lnutil.get_per_commitment_secret_from_seed(
- lconfig.per_commitment_secret_seed,
- lnutil.RevocationStore.START_INDEX - ctn)
- pcp_point = secret_to_pubkey(int.from_bytes(pcp_secret, 'big'))
- rconfig = RemoteConfig(
- payment_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.payment_basepoint.pubkey),
- multisig_key=OnlyPubkeyKeypair(pubkey=lconfig.multisig_key.pubkey),
- htlc_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.htlc_basepoint.pubkey),
- delayed_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.delayed_basepoint.pubkey),
- revocation_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.revocation_basepoint.pubkey),
- to_self_delay=lconfig.to_self_delay,
- dust_limit_sat=lconfig.dust_limit_sat,
- max_htlc_value_in_flight_msat=lconfig.max_htlc_value_in_flight_msat,
- max_accepted_htlcs=lconfig.max_accepted_htlcs,
- initial_msat=lconfig.initial_msat,
- reserve_sat=lconfig.reserve_sat,
- htlc_minimum_msat=lconfig.htlc_minimum_msat,
- upfront_shutdown_script=lconfig.upfront_shutdown_script,
- announcement_node_sig=lconfig.announcement_node_sig,
- announcement_bitcoin_sig=lconfig.announcement_bitcoin_sig,
- next_per_commitment_point=pcp_point,
+def _convert_to_rconfig_from_lconfig(lconfig: ChannelConfig) -> ChannelConfig:
+ """Alice's local config, as it appears in Bob's channel: the same public parameters,
+ with only the first per-commitment point known, and no signatures yet."""
+ return dataclasses.replace(
+ lconfig,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
current_per_commitment_point=None,
+ next_per_commitment_point=lconfig.current_per_commitment_point,
)
- return rconfig
def _create_channel_state(
@@ -549,8 +533,9 @@ def _create_channel_state(
is_initiator: bool,
other_node_id: bytes,
channel_type: ChannelType,
- local_config: LocalConfig,
- remote_config: RemoteConfig,
+ local_config: ChannelConfig,
+ local_keys: ChannelKeys,
+ remote_config: ChannelConfig,
):
channel_id, _ = lnpeer.channel_id_from_funding_tx(funding_txid, funding_index)
state = {
@@ -559,6 +544,9 @@ def _create_channel_state(
"funding_outpoint":lnpeer.Outpoint(funding_txid, funding_index),
"remote_config": remote_config,
"local_config": local_config,
+ "channel_seed": local_keys.channel_seed.hex(),
+ "multisig_privkey": local_keys.multisig_key.privkey.hex(),
+ "funding_locked_received": True, # this helper only creates open channels
"constraints":lnpeer.ChannelConstraints(
flags=lnchannel.CF_ANNOUNCE_CHANNEL,
capacity=funding_sat,
@@ -614,7 +602,7 @@ def create_test_channels(
else:
channel_type = ChannelType.OPTION_STATIC_REMOTEKEY | ChannelType.OPTION_ANCHORS
# create alice's local config
- alice_lconfig = alice_lnwallet.make_local_config_for_new_channel(
+ alice_lconfig, alice_lkeys = alice_lnwallet.make_local_config_for_new_channel(
funding_sat=funding_sat,
push_msat=remote_msat,
initiator=LOCAL,
@@ -623,14 +611,13 @@ def create_test_channels(
peer_features=peer_features,
channel_seed=random_gen.get_bytes(32),
)
- alice_lconfig.funding_locked_received = True
alice_lconfig.dust_limit_sat = 200
alice_lconfig.to_self_delay = 5
alice_lconfig.reserve_sat = 0
alice_lconfig.max_accepted_htlcs = max_accepted_htlcs
alice_lconfig.max_htlc_value_in_flight_msat = local_max_inflight
# create bob's local config
- bob_lconfig = bob_lnwallet.make_local_config_for_new_channel(
+ bob_lconfig, bob_lkeys = bob_lnwallet.make_local_config_for_new_channel(
funding_sat=funding_sat,
push_msat=remote_msat,
initiator=REMOTE,
@@ -639,7 +626,6 @@ def create_test_channels(
peer_features=peer_features,
channel_seed=random_gen.get_bytes(32),
)
- bob_lconfig.funding_locked_received = True
bob_lconfig.dust_limit_sat = 1300
bob_lconfig.to_self_delay = 4
bob_lconfig.reserve_sat = 0
@@ -656,6 +642,7 @@ def create_test_channels(
other_node_id=bob_pubkey,
channel_type=channel_type,
local_config=alice_lconfig,
+ local_keys=alice_lkeys,
remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig),
),
name=f"{alice_name}->{bob_name}",
@@ -671,6 +658,7 @@ def create_test_channels(
other_node_id=alice_pubkey,
channel_type=channel_type,
local_config=bob_lconfig,
+ local_keys=bob_lkeys,
remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig),
),
name=f"{bob_name}->{alice_name}",
@@ -698,10 +686,8 @@ def create_test_channels(
alice.open_with_first_pcp(alice.config[REMOTE].next_per_commitment_point, sig_from_bob)
bob.open_with_first_pcp(bob.config[REMOTE].next_per_commitment_point, sig_from_alice)
- alice_second = lnutil.secret_to_pubkey(int.from_bytes(
- lnutil.get_per_commitment_secret_from_seed(alice.config[LOCAL].per_commitment_secret_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
- bob_second = lnutil.secret_to_pubkey(int.from_bytes(
- lnutil.get_per_commitment_secret_from_seed(bob.config[LOCAL].per_commitment_secret_seed, lnutil.RevocationStore.START_INDEX - 1), "big"))
+ alice_second = alice.keys.per_commitment_point(1)
+ bob_second = bob.keys.per_commitment_point(1)
# from funding_locked:
alice.config[REMOTE].next_per_commitment_point = bob_second
### tests/test_lnchannel.py
@@ -38,7 +38,7 @@
from electrum.lnutil import (
SENT, LOCAL, REMOTE, RECEIVED, UpdateAddHtlc, ChannelType,
effective_htlc_tx_weight, ZEROCONF_TIMEOUT,
- CHANNEL_OPENING_TIMEOUT_SEC,
+ CHANNEL_OPENING_TIMEOUT_SEC, ChannelKeys,
)
from electrum.logging import console_stderr_handler
from electrum.lnchannel import ChannelState, Channel
@@ -916,6 +916,45 @@ def test_get_payments(self):
self.assertIn(phash, self.bob_channel.get_payments(status='inflight', direction=RECEIVED))
self.assertEqual({}, self.bob_channel.get_payments(status='inflight', direction=SENT))
+ def test_check_against_config(self):
+ """Keys re-derived from our secrets must match the pubkeys in our config."""
+ chan = self.alice_channel
+ keys = ChannelKeys.from_seed(
+ chan.keys.channel_seed, multisig_privkey=chan.keys.multisig_key.privkey)
+ self.assertEqual(chan.keys, keys)
+ keys.check_against_config(chan.config[LOCAL])
+ # wrong multisig privkey
+ keys = ChannelKeys.from_seed(chan.keys.channel_seed, multisig_privkey=os.urandom(32))
+ with self.assertRaisesRegex(Exception, 'multisig_key'):
+ keys.check_against_config(chan.config[LOCAL])
+ # wrong channel seed
+ keys = ChannelKeys.from_seed(os.urandom(32), multisig_privkey=chan.keys.multisig_key.privkey)
+ with self.assertRaisesRegex(Exception, 'basepoint'):
+ keys.check_against_config(chan.config[LOCAL])
+ # payment_basepoint is not checked above; for anchor channels we derive its privkey
+ if chan.has_anchors():
+ privkey = chan.get_payment_basepoint_privkey()
+ self.assertEqual(chan.config[LOCAL].payment_basepoint.pubkey,
+ ecc.ECPrivkey(privkey).get_public_key_bytes())
+
+ async def test_local_per_commitment_points_follow_our_ctn(self):
+ """We keep our own per-commitment points in the config, like the remote's, so
+ that both sides are described in the same way. They must follow our ctn.
+ """
+ def check_both_channels():
+ for chan in (self.alice_channel, self.bob_channel):
+ ctn = chan.get_oldest_unrevoked_ctn(LOCAL)
+ config = chan.config[LOCAL]
+ self.assertEqual(chan.keys.per_commitment_point(ctn),
+ config.current_per_commitment_point)
+ self.assertEqual(chan.keys.per_commitment_point(ctn + 1),
+ config.next_per_commitment_point)
+
+ check_both_channels()
+ for _ in range(3):
+ force_state_transition(self.alice_channel, self.bob_channel)
+ check_both_channels()
+
class TestChannelNoAnchors(TestChannel):
assert TestChannel.TEST_ANCHOR_CHANNELS is True
### tests/test_lnutil.py
@@ -1209,13 +1209,14 @@ async def test_decode_imported_channel_backup_v3(self):
cb_with_alias = dataclasses.replace(reference, channel_type=reference.channel_type | ChannelType.OPTION_SCID_ALIAS)
self.assertEqual(reference.channel_type, cb_with_alias.channel_type)
chan_backup = ChannelBackup(decoded_cb, lnworker=None)
- self.assertEqual(
- Keypair(
- privkey=bfh('f406b0899040d762a326035631c2ce38b22c2db18102ab9e3c666c87d9e0ab65'),
- pubkey=bfh('0393145d5cb5d8a73cdffa3caaf3204ae0b2cf0c7a1a4b62c85d2182fefaaeee5e'),
- ),
- chan_backup.config[LOCAL].payment_basepoint,
+ payment_basepoint = Keypair(
+ privkey=bfh('f406b0899040d762a326035631c2ce38b22c2db18102ab9e3c666c87d9e0ab65'),
+ pubkey=bfh('0393145d5cb5d8a73cdffa3caaf3204ae0b2cf0c7a1a4b62c85d2182fefaaeee5e'),
)
+ # this backup carries the payment_basepoint privkey; the config holds pubkeys only
+ self.assertEqual(payment_basepoint, chan_backup.keys.payment_basepoint)
+ self.assertEqual(OnlyPubkeyKeypair(payment_basepoint.pubkey),
+ chan_backup.config[LOCAL].payment_basepoint)
def test_onchain_channel_backup_json_roundtrip(self):
cb = OnchainChannelBackupStorage(Why this scored 27/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.