What changed, and why it matters
This commit hardens Electrum's Lightning onion routing code against timing attacks. It replaces a normal HMAC comparison with a constant-time one, so an attacker can't learn secrets by measuring how quickly checks fail. It also changes how failed onion error messages are decoded so that the number of hops in a payment route is hidden, matching the Lightning network specification.
Review util.constant_time_compare for correctness and ensure it is truly constant-time across supported Python versions. Consider whether other HMAC or equality comparisons in the Lightning path handling should also be hardened. No immediate user action is required beyond applying the update.
Security signals we found
constant-time HMAC comparison added
onion error decoding loop fixed to 27 iterations to hide route length
dummy shared secret used for padding iterations
references BOLT 04 onion-routing specification
timing side-channel mitigation in Lightning onion processing
Evidence from the diff
The patch modifies electrum/lnonion.py in two ways. First, process_onion_packet now uses util.constant_time_compare to compare the received onion HMAC with the calculated one, mitigating a potential timing side-channel. Second, _decode_onion_error now always iterates 27 times (the maximum route length) and uses dummy keys for indices beyond the actual route length, per BOLT 04, so that the real number of hops is not revealed by the number of decryption attempts. The HMAC check is also constant-time, and the function only returns a successful decode after the fixed loop completes.
Changed components
electrum/lnonion.pyLightning onion packet processingLightning onion error decodingInspect captured patch +24 / −8
diff --git a/electrum/lnonion.py b/electrum/lnonion.py
index fa73372..53189cc 100644
--- a/electrum/lnonion.py
+++ b/electrum/lnonion.py
@@ -36,6 +36,7 @@ from .lnutil import (PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH,
NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag)
from .lnmsg import OnionWireSerializer, read_bigsize_int, write_bigsize_int
from . import lnmsg
+from . import util
if TYPE_CHECKING:
from .lnrouter import LNPaymentRoute
@@ -369,7 +370,7 @@ def process_onion_packet(
calculated_mac = hmac_oneshot(
mu_key, msg=onion_packet.hops_data+associated_data,
digest=hashlib.sha256)
- if onion_packet.hmac != calculated_mac:
+ if not util.constant_time_compare(onion_packet.hmac, calculated_mac):
raise InvalidOnionMac()
# peel an onion layer off
rho_key = get_bolt04_onion_key(b'rho', shared_secret)
@@ -484,23 +485,38 @@ def obfuscate_onion_error(error_packet, their_public_key, our_onion_private_key)
def _decode_onion_error(error_packet: bytes, payment_path_pubkeys: Sequence[bytes],
session_key: bytes) -> Tuple[bytes, int]:
- """Returns the decoded error bytes, and the index of the sender of the error."""
+ """
+ Returns the decoded error bytes, and the index of the sender of the error.
+ https://github.com/lightning/bolts/blob/14272b1bd9361750cfdb3e5d35740889a6b510b5/04-onion-routing.md?plain=1#L1096
+ """
num_hops = len(payment_path_pubkeys)
hop_shared_secrets, _ = get_shared_secrets_along_route(payment_path_pubkeys, session_key)
- for i in range(num_hops):
- ammag_key = get_bolt04_onion_key(b'ammag', hop_shared_secrets[i])
- um_key = get_bolt04_onion_key(b'um', hop_shared_secrets[i])
+ result = None
+ dummy_secret = bytes(32)
+ # SHOULD continue decrypting, until the loop has been repeated 27 times
+ for i in range(27):
+ if i < num_hops:
+ ammag_key = get_bolt04_onion_key(b'ammag', hop_shared_secrets[i])
+ um_key = get_bolt04_onion_key(b'um', hop_shared_secrets[i])
+ else:
+ # SHOULD use constant `ammag` and `um` keys to obfuscate the route length.
+ ammag_key = get_bolt04_onion_key(b'ammag', dummy_secret)
+ um_key = get_bolt04_onion_key(b'um', dummy_secret)
+
stream_bytes = generate_cipher_stream(ammag_key, len(error_packet))
error_packet = xor_bytes(error_packet, stream_bytes)
hmac_computed = hmac_oneshot(um_key, msg=error_packet[32:], digest=hashlib.sha256)
hmac_found = error_packet[:32]
- if hmac_computed == hmac_found:
- return error_packet, i
+ if util.constant_time_compare(hmac_found, hmac_computed) and i < num_hops:
+ result = error_packet, i
+
+ if result is not None:
+ return result
raise FailedToDecodeOnionError()
def decode_onion_error(error_packet: bytes, payment_path_pubkeys: Sequence[bytes],
- session_key: bytes) -> (OnionRoutingFailure, int):
+ session_key: bytes) -> Tuple[OnionRoutingFailure, int]:
"""Returns the failure message, and the index of the sender of the error."""
decrypted_error, sender_index = _decode_onion_error(error_packet, payment_path_pubkeys, session_key)
failure_msg = get_failure_msg_from_onion_error(decrypted_error)
Why this scored 49/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.