lightningd: honor `payment-fronting-node` when making bolt12 offers.
What changed, and why it matters
This commit fixes a feature gap in Core Lightning's BOLT12 offer creation. Previously, the `payment-fronting-node` setting—which lets a node hide behind one or more public 'front' nodes for privacy—was ignored when creating BOLT12 offers. The patch makes the offer plugin read the configured fronting nodes and include them all as blinded paths in generated offers, matching the behavior already intended for invoices. It is a correctness/privacy fix rather than a remote-exploitable vulnerability.
Review as a normal feature/privacy fix. No urgent security response required. Users relying on `payment-fronting-node` for BOLT12 offer privacy should upgrade to a release containing this commit.
Security signals we found
Privacy feature not applied to BOLT12 offers before this commit
Adds use of configured `payment-fronting-node` values in offer path generation
Forces blinded paths when fronting nodes are configured
Includes test coverage verifying offer path first_node_id matches configured fronting nodes
No mention of CVE, security advisory, or researcher attribution in commit
Evidence from the diff
The change extends struct offers_data with a fronting_nodes array populated from listconfigs payment-fronting-node. In we_want_blinded_path(), fronting forces blinded paths. In maybe_add_path(), if fronting nodes are configured, the code now builds one-hop blinded paths to each fronting node instead of only auto-selecting the ‘best’ public peer. Helper functions offer_onehop_path(s) refactor the existing single-path construction. Tests confirm that offers from nodes with one or two fronting nodes include the expected offer_paths first_node_ids and that fetchinvoice works.
Changed components
plugins/offers.cplugins/offers.hplugins/offers_invreq_hook.cplugins/offers_offer.ctests/test_invoices.pyInspect captured patch +122 / −36
diff --git a/plugins/offers.c b/plugins/offers.c
index 26686920..79b13065 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -78,6 +78,10 @@ bool we_want_blinded_path(struct plugin *plugin, bool for_payment)
u8 rgb_color[3], alias[32];
struct tlv_node_ann_tlvs *na_tlvs;
+ /* If we're fronting, we always want a blinded path */
+ if (od->fronting_nodes)
+ return true;
+
node_id_from_pubkey(&local_nodeid, &od->id);
node = gossmap_find_node(gossmap, &local_nodeid);
@@ -1614,6 +1618,25 @@ static struct command_result *json_decode(struct command *cmd,
return command_finished(cmd, response);
}
+static struct pubkey *json_to_pubkeys(const tal_t *ctx,
+ const char *buffer,
+ const jsmntok_t *tok)
+{
+ size_t i;
+ const jsmntok_t *t;
+ struct pubkey *arr;
+
+ if (tok->type != JSMN_ARRAY)
+ return NULL;
+
+ arr = tal_arr(ctx, struct pubkey, tok->size);
+ json_for_each_arr(i, t, tok) {
+ if (!json_to_pubkey(buffer, t, &arr[i]))
+ return tal_free(arr);
+ }
+ return arr;
+}
+
static const char *init(struct command *init_cmd,
const char *buf UNUSED,
const jsmntok_t *config UNUSED)
@@ -1631,8 +1654,13 @@ static const char *init(struct command *init_cmd,
rpc_scan(init_cmd, "listconfigs",
take(json_out_obj(NULL, NULL, NULL)),
"{configs:"
- "{cltv-final:{value_int:%}}}",
- JSON_SCAN(json_to_u16, &od->cltv_final));
+ "{cltv-final:{value_int:%},"
+ "payment-fronting-node?:{values_str:%}}}",
+ JSON_SCAN(json_to_u16, &od->cltv_final),
+ JSON_SCAN_TAL(od, json_to_pubkeys, &od->fronting_nodes));
+ /* Keep it simple if no fronting nodes */
+ if (tal_count(od->fronting_nodes) == 0)
+ od->fronting_nodes = tal_free(od->fronting_nodes);
rpc_scan(init_cmd, "makesecret",
take(json_out_obj(NULL, "string", BOLT12_ID_BASE_STRING)),
diff --git a/plugins/offers.h b/plugins/offers.h
index fef29c60..13968a0e 100644
--- a/plugins/offers.h
+++ b/plugins/offers.h
@@ -23,6 +23,8 @@ struct offers_data {
struct secret offerblinding_base;
/* Base for node aliases for invoice requests */
struct secret nodealias_base;
+ /* Any --payment-fronting-node specified */
+ struct pubkey *fronting_nodes;
/* --dev-invoice-bpath-scid */
bool dev_invoice_bpath_scid;
/* --dev-invoice-internal-scid */
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index 3e6b0338..38de98a6 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -250,6 +250,10 @@ static struct command_result *create_invoicereq(struct command *cmd,
return send_outreq(req);
}
+/* FIXME: Allow multihop! */
+/* FIXME: And add padding! */
+
+
/* FIXME: This is naive:
* - Only creates if we have no public channels.
* - Always creates a path from direct neighbor.
diff --git a/plugins/offers_offer.c b/plugins/offers_offer.c
index c6c2acbd..6729c1f8 100644
--- a/plugins/offers_offer.c
+++ b/plugins/offers_offer.c
@@ -251,6 +251,50 @@ static struct command_result *create_offer(struct command *cmd,
return send_outreq(req);
}
+/* Create num_node_ids paths from these node_ids to us (one hop each) */
+static struct blinded_path **offer_onehop_paths(const tal_t *ctx,
+ const struct offers_data *od,
+ const struct tlv_offer *offer,
+ const struct pubkey *neighbors,
+ size_t num_neighbors)
+{
+ struct pubkey *ids = tal_arr(tmpctx, struct pubkey, 2);
+ struct secret blinding_path_secret;
+ struct sha256 offer_id;
+ struct blinded_path **offer_paths;
+
+ /* Note: "id" of offer minus paths */
+ assert(!offer->offer_paths);
+ offer_offer_id(offer, &offer_id);
+
+ offer_paths = tal_arr(ctx, struct blinded_path *, num_neighbors);
+ for (size_t i = 0; i < num_neighbors; i++) {
+ ids[0] = neighbors[i];
+ ids[1] = od->id;
+
+ /* So we recognize this */
+ /* We can check this when they try to take up offer. */
+ bolt12_path_secret(&od->offerblinding_base, &offer_id,
+ &blinding_path_secret);
+
+ offer_paths[i]
+ = incoming_message_blinded_path(offer_paths,
+ ids,
+ NULL,
+ &blinding_path_secret);
+ }
+ return offer_paths;
+}
+
+/* Common case of making a single path */
+static struct blinded_path **offer_onehop_path(const tal_t *ctx,
+ const struct offers_data *od,
+ const struct tlv_offer *offer,
+ const struct pubkey *neighbor)
+{
+ return offer_onehop_paths(ctx, od, offer, neighbor, 1);
+}
+
static struct command_result *found_best_peer(struct command *cmd,
const struct chaninfo *best,
struct offer_info *offinfo)
@@ -267,29 +311,9 @@ static struct command_result *found_best_peer(struct command *cmd,
plugin_log(cmd->plugin, LOG_UNUSUAL,
"No incoming channel to public peer, so no blinded path");
} else {
- struct pubkey *ids;
- struct secret blinding_path_secret;
- struct sha256 offer_id;
-
- /* Note: "id" of offer minus paths */
- offer_offer_id(offinfo->offer, &offer_id);
-
- /* Make a small 1-hop path to us */
- ids = tal_arr(tmpctx, struct pubkey, 2);
- ids[0] = best->id;
- ids[1] = od->id;
-
- /* So we recognize this */
- /* We can check this when they try to take up offer. */
- bolt12_path_secret(&od->offerblinding_base, &offer_id,
- &blinding_path_secret);
-
- offinfo->offer->offer_paths = tal_arr(offinfo->offer, struct blinded_path *, 1);
- offinfo->offer->offer_paths[0]
- = incoming_message_blinded_path(offinfo->offer->offer_paths,
- ids,
- NULL,
- &blinding_path_secret);
+ offinfo->offer->offer_paths
+ = offer_onehop_path(offinfo->offer, od,
+ offinfo->offer, &best->id);
}
return create_offer(cmd, offinfo);
@@ -298,16 +322,31 @@ static struct command_result *found_best_peer(struct command *cmd,
static struct command_result *maybe_add_path(struct command *cmd,
struct offer_info *offinfo)
{
- /* BOLT #12:
- * - if it is connected only by private channels:
- * - MUST include `offer_paths` containing one or more paths to the node from
- * publicly reachable nodes.
- */
+ const struct offers_data *od = get_offers_data(cmd->plugin);
+
+ /* Populate paths assuming not already set by dev_paths */
if (!offinfo->offer->offer_paths) {
- if (we_want_blinded_path(cmd->plugin, false))
- return find_best_peer(cmd, 1ULL << OPT_ONION_MESSAGES,
- found_best_peer, offinfo);
+ /* BOLT #12:
+ * - if it is connected only by private channels:
+ * - MUST include `offer_paths` containing one or more paths to the node from
+ * publicly reachable nodes.
+ */
+ if (we_want_blinded_path(cmd->plugin, false)) {
+ /* We use *all* fronting nodes (not just "best" one)
+ * for offers */
+ if (od->fronting_nodes) {
+ offinfo->offer->offer_paths
+ = offer_onehop_paths(offinfo->offer, od,
+ offinfo->offer,
+ od->fronting_nodes,
+ tal_count(od->fronting_nodes));
+ } else {
+ return find_best_peer(cmd, 1ULL << OPT_ONION_MESSAGES,
+ found_best_peer, offinfo);
+ }
+ }
}
+
return create_offer(cmd, offinfo);
}
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index f174af55..1bb47d46 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -943,9 +943,12 @@ def test_invoice_botched_migration(node_factory, chainparams):
def test_payment_fronting(node_factory):
- l1, l2 = node_factory.get_nodes(2)
- l3, l4 = node_factory.get_nodes(2, opts=[{'payment-fronting-node': l1.info['id']},
- {'payment-fronting-node': [l1.info['id'], l2.info['id']]}])
+ # Nodes will not front for offers if they don't have an advertized address, so allow localhost.
+ l1, l2 = node_factory.get_nodes(2, opts={'dev-allow-localhost': None})
+ l3, l4 = node_factory.get_nodes(2, opts=[{'payment-fronting-node': l1.info['id'],
+ 'dev-allow-localhost': None},
+ {'payment-fronting-node': [l1.info['id'], l2.info['id']],
+ 'dev-allow-localhost': None}])
assert l3.rpc.listconfigs('payment-fronting-node') == {'configs': {'payment-fronting-node': {'sources': ['cmdline'], 'values_str': [l1.info['id']]}}}
assert l4.rpc.listconfigs('payment-fronting-node') == {'configs': {'payment-fronting-node': {'sources': ['cmdline', 'cmdline'], 'values_str': [l1.info['id'], l2.info['id']]}}}
@@ -966,6 +969,16 @@ def test_payment_fronting(node_factory):
l1.rpc.xpay(l3inv)
l1.rpc.xpay(l4inv)
+ # Now test offers.
+ l3offer = l3.rpc.offer(1000, 'l3offer', 'l3offer')['bolt12']
+ assert only_one(l3.rpc.decode(l3offer)['offer_paths'])['first_node_id'] == l1.info['id']
+
+ l4offer = l4.rpc.offer(1000, 'l4offer', 'l4offer')['bolt12']
+ assert [r['first_node_id'] for r in l4.rpc.decode(l4offer)['offer_paths']] == [l1.info['id'], l2.info['id']]
+
+ l1.rpc.fetchinvoice(l3offer)['invoice']
+ l1.rpc.fetchinvoice(l4offer)['invoice']
+
def test_invoice_maxdesc(node_factory, chainparams):
l1, l2 = node_factory.line_graph(2)
Why this scored 36/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.