lightning: derive local channel keys instead of storing them (see #4909)
What changed, and why it matters
This commit refactors how Electrum stores Lightning channel keys. Instead of keeping private keys inside the channel configuration object that gets saved to disk, it now keeps only public keys there and re-derives the private keys from a channel seed when the wallet starts up. The goal is to reduce the amount of secret material sitting in persisted channel data. It also adds a wallet database upgrade that strips old private keys from existing wallets and moves the remaining secrets into the channel storage. This is a defensive hardening change, not a fix for an active exploit.
Review the upgrade conversion path for edge cases where stored keys do not match derived keys, ensure the new ChannelKeys derivation is deterministic and tested across channel backup restoration, and verify that no private key fields remain serialized in LocalConfig. Users should upgrade to a version containing this change once it is released.
Security signals we found
Reduction of persisted secret material in channel config objects
New key-derivation path with pubkey verification on channel load
Wallet database migration that removes old private key fields from existing storage
Explicit check that derived keys match stored pubkeys during upgrade and at runtime
Payment basepoint private key for anchor channels derived from static wallet secret plus funding pubkey nonce
Evidence from the diff
The patch removes private key material (htlc_basepoint, delayed_basepoint, revocation_basepoint, per_commitment_secret_seed, payment_basepoint privkey) from LocalConfig/ChannelConfig and introduces a new ChannelKeys class derived from the channel seed at load time. ChannelKeys.check_against_config verifies derived pubkeys match stored pubkeys. The wallet DB conversion 73->74 strips these private fields from existing local_config entries, derives them from the stored channel_seed to validate correctness, and moves channel_seed, multisig_privkey, and funding_locked_received into channel-level storage. The multisig private key is retained because it cannot be re-derived without the funding transaction. This is a storage-hardening refactor referenced by issue #4909.
Changed components
electrum/lnchannel.pyelectrum/lnpeer.pyelectrum/lnsweep.pyelectrum/lnutil.pyelectrum/lnworker.pyelectrum/wallet_db.pyInspect captured patch +350 / −166
### 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, LocalConfig, RemoteConfig, 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)
@@ -179,6 +179,7 @@ class HTLCWithStatus(NamedTuple):
class AbstractChannel(Logger, ABC):
storage: Union['StoredDict', dict]
config: Dict[HTLCOwner, Union[LocalConfig, RemoteConfig]]
+ keys: Optional[ChannelKeys] = None # None for on-chain channel backups
lnworker: 'LNWallet'
channel_id: bytes
short_channel_id: Optional[ShortChannelID] = None
@@ -617,27 +618,29 @@ 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] = LocalConfig.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,
@@ -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')
@@ -1719,7 +1748,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 +1987,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 +2002,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, LocalConfig, RemoteConfig, 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,
@@ -1072,12 +1072,7 @@ async def channel_establishment_flow(
'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'))
+ per_commitment_point_first = local_keys.per_commitment_point(0)
# store the temp id now, so that it is recognized for e.g. 'error' messages
self.temp_id_to_id[temp_channel_id] = None
@@ -1199,6 +1194,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,
@@ -1246,6 +1242,7 @@ def create_channel_storage(
channel_id: bytes,
outpoint: Outpoint,
local_config: LocalConfig,
+ local_keys: ChannelKeys,
remote_config: RemoteConfig,
constraints: ChannelConstraints,
channel_type: ChannelType,
@@ -1257,6 +1254,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 +1345,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,
@@ -1389,12 +1391,7 @@ async def on_open_channel(self, payload):
# -> 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'))
+ per_commitment_point_first = local_keys.per_commitment_point(0)
min_depth = 0 if is_zeroconf else 3
@@ -1448,6 +1445,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 +1812,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 +1841,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 +1912,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 +1925,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 +1947,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:
@@ -124,7 +130,11 @@ def __post_init__(self):
@dataclasses.dataclass(repr=False)
class ChannelConfig(StoredObject):
- # shared channel config fields
+ """The channel parameters of one side of a channel.
+
+ The config never holds a 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
@@ -143,18 +153,65 @@ class ChannelConfig(StoredObject):
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)
def __repr__(self):
return repr_dataclass(self, {bytes: bytes_to_hex})
+ @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),
+ **kwargs,
+ )
+
def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
conf_name = type(self).__name__
for key in (
@@ -258,63 +315,13 @@ def cross_validate_params(
@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
- per_commitment_secret_seed: bytes
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)
-
- @classmethod
- def from_seed(cls, **kwargs):
- channel_seed = kwargs['channel_seed']
- 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)
- 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__
@@ -339,6 +346,74 @@ def __post_init__(self):
self.current_per_commitment_point = hex_to_bytes(self.current_per_commitment_point)
+@dataclasses.dataclass(repr=False)
+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 __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,
+ channel_seed: bytes,
+ *,
+ multisig_privkey: Optional[bytes] = None,
+ payment_basepoint_privkey: Optional[bytes] = None,
+ ) -> 'ChannelKeys':
+ node = BIP32Node.from_rootseed(channel_seed, xtype='standard')
+ 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:
+ 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")
+
+ 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 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/*')
@dataclasses.dataclass
class FeeUpdate(StoredObject):
@@ -766,6 +841,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)
@@ -1513,9 +1593,9 @@ 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
@@ -2051,6 +2131,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, LocalConfig, 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[LocalConfig, 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,30 @@ 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 = LocalConfig.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)
+ 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 +3823,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 +3835,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,55 @@ 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 keep the ones we cannot derive outside of the config.
+
+ 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.
+ """
+ from .bip32 import BIP32Node
+ from .lnutil import (DERIVED_LOCAL_BASEPOINTS, LnKeyFamily, generate_keypair,
+ derive_payment_basepoint)
+ 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']
+ 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')
+ self.data['seed_version'] = 74
+
def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
### tests/lnhelpers.py
@@ -19,7 +19,7 @@
from electrum.lnpeer import Peer
from electrum.lnutil import (
LnFeatures, PaymentFeeBudget, LOCAL, REMOTE, ChannelType, LocalConfig, RemoteConfig,
- OnlyPubkeyKeypair, secret_to_pubkey, RECEIVED,
+ ChannelKeys, OnlyPubkeyKeypair, RECEIVED,
)
from electrum.lnchannel import ChannelState, Channel
from electrum.lnrouter import LNPathFinder
@@ -512,13 +512,8 @@ 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'))
+def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig, lkeys: ChannelKeys) -> RemoteConfig:
+ """converts Alice's local config to Bob's remote config"""
rconfig = RemoteConfig(
payment_basepoint=OnlyPubkeyKeypair(pubkey=lconfig.payment_basepoint.pubkey),
multisig_key=OnlyPubkeyKeypair(pubkey=lconfig.multisig_key.pubkey),
@@ -535,7 +530,7 @@ def _convert_to_rconfig_from_lconfig(lconfig: LocalConfig) -> RemoteConfig:
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,
+ next_per_commitment_point=lkeys.per_commitment_point(0),
current_per_commitment_point=None,
)
return rconfig
@@ -550,6 +545,7 @@ def _create_channel_state(
other_node_id: bytes,
channel_type: ChannelType,
local_config: LocalConfig,
+ local_keys: ChannelKeys,
remote_config: RemoteConfig,
):
channel_id, _ = lnpeer.channel_id_from_funding_tx(funding_txid, funding_index)
@@ -559,6 +555,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 +613,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 +622,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 +637,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,7 +653,8 @@ def create_test_channels(
other_node_id=bob_pubkey,
channel_type=channel_type,
local_config=alice_lconfig,
- remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig),
+ local_keys=alice_lkeys,
+ remote_config=_convert_to_rconfig_from_lconfig(bob_lconfig, bob_lkeys),
),
name=f"{alice_name}->{bob_name}",
initial_feerate=feerate,
@@ -671,7 +669,8 @@ def create_test_channels(
other_node_id=alice_pubkey,
channel_type=channel_type,
local_config=bob_lconfig,
- remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig),
+ local_keys=bob_lkeys,
+ remote_config=_convert_to_rconfig_from_lconfig(alice_lconfig, alice_lkeys),
),
name=f"{bob_name}->{alice_name}",
initial_feerate=feerate,
@@ -698,10 +697,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,27 @@ 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())
+
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 31/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.