offers: only use blinded path nodes from offers when creating invoice for invoice_request.
What changed, and why it matters
This change fixes how Core Lightning creates invoices in response to BOLT12 offers. Previously, when replying to an invoice_request, the node used its own configured 'fronting nodes' rather than the ones actually listed in the offer. That meant an offer that specified a particular privacy/fronting path could be ignored, potentially exposing the recipient's real node identity in the invoice. The patch makes the invoice reuse the offer's blinded-path fronting nodes, falling back to defaults only if the offer did not specify any. It also adds tests showing offers can now enforce specific fronting nodes or no fronting at all.
Treat as a privacy bugfix. Review whether prior behavior could have caused real node IDs to leak in invoices for offers that requested fronting; consider backporting if BOLT12 offers are enabled in production releases. No immediate exploit code is evident, but operators relying on offer-level privacy should upgrade.
Security signals we found
Privacy-path misrouting: prior code ignored offer-specified blinded-path fronting nodes and used node defaults, which could deanonymize the invoice issuer
Blinded path first-hop selection now derived from offer_paths instead of plugin-wide fronting_nodes
Added validation/failure path when offer fronting nodes are unusable
New test coverage for explicit offer fronting, no-fronting, and multiple fronting nodes
Evidence from the diff
In plugins/offers_invreq_hook.c, the code previously read od->fronting_nodes (plugin-wide defaults) during add_blindedpaths() and found_best_peer(). The patch introduces an invreq->fronting_nodes field, populates it from ir->invreq->offer_paths in listoffers_done(), and uses that per-request list thereafter. It resolves each offer path’s first_node_id to a pubkey via gossmap_scidd_pubkey(), skips self-paths, and fails the invoice request if offer paths were present but none usable. If the offer has no paths, it falls back to od->fronting_nodes. Tests confirm offer fronting nodes propagate into the resulting invoice’s blinded paths.
Changed components
plugins/offers_invreq_hook.cBOLT12 offer/invoice_request handlingBlinded path (route blinding / onion messaging) constructionInspect captured patch +80 / −6
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index 537605ab..ad598daa 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -36,6 +36,9 @@ struct invreq {
/* Optional secret. */
const struct secret *secret;
+
+ /* Fronting nodes to use for invoice. */
+ const struct pubkey *fronting_nodes;
};
static struct command_result *WARN_UNUSED_RESULT
@@ -275,9 +278,12 @@ static struct command_result *found_best_peer(struct command *cmd,
*/
if (!best) {
/* Don't allow bare invoices if they explicitly told us to front */
- if (od->fronting_nodes) {
+ if (ir->fronting_nodes) {
return fail_invreq(cmd, ir,
- "Could not find path from payment-fronting-node");
+ "Could not find path from %zu nodes (%s%s)",
+ tal_count(ir->fronting_nodes),
+ fmt_pubkey(tmpctx, &ir->fronting_nodes[0]),
+ tal_count(ir->fronting_nodes) > 1 ? ", ..." : "");
}
/* Note: since we don't make one, createinvoice adds a dummy. */
@@ -390,9 +396,7 @@ static struct command_result *found_best_peer(struct command *cmd,
static struct command_result *add_blindedpaths(struct command *cmd,
struct invreq *ir)
{
- const struct offers_data *od = get_offers_data(cmd->plugin);
-
- if (!we_want_blinded_path(cmd->plugin, od->fronting_nodes, true))
+ if (!we_want_blinded_path(cmd->plugin, ir->fronting_nodes, true))
return create_invoicereq(cmd, ir);
/* Technically, this only needs OPT_ROUTE_BLINDING, but we have a report
@@ -401,7 +405,7 @@ static struct command_result *add_blindedpaths(struct command *cmd,
* us onion messaging. */
return find_best_peer(cmd,
(1ULL << OPT_ROUTE_BLINDING) | (1ULL << OPT_ONION_MESSAGES),
- od->fronting_nodes, found_best_peer, ir);
+ ir->fronting_nodes, found_best_peer, ir);
}
static struct command_result *cancel_invoice(struct command *cmd,
@@ -830,6 +834,7 @@ static struct command_result *listoffers_done(struct command *cmd,
struct command_result *err;
struct amount_msat amt;
struct tlv_invoice_request_invreq_recurrence_cancel *cancel;
+ struct pubkey *offer_fronts;
/* BOLT #12:
*
@@ -915,6 +920,37 @@ static struct command_result *listoffers_done(struct command *cmd,
return fail_invreq(cmd, ir, "Offer expired");
}
+ /* If offer used fronting nodes, we use them too. */
+ offer_fronts = tal_arr(ir, struct pubkey, 0);
+ for (size_t i = 0; i < tal_count(ir->invreq->offer_paths); i++) {
+ const struct blinded_path *p = ir->invreq->offer_paths[i];
+ struct sciddir_or_pubkey first = p->first_node_id;
+
+ /* In dev mode we could set this. Ignore if we can't map */
+ if (!first.is_pubkey && !gossmap_scidd_pubkey(get_gossmap(cmd->plugin), &first)) {
+ plugin_log(cmd->plugin, LOG_UNUSUAL,
+ "Can't find front %s, ignoring in %s",
+ fmt_sciddir_or_pubkey(tmpctx, &p->first_node_id),
+ invrequest_encode(tmpctx, ir->invreq));
+ continue;
+ }
+ assert(first.is_pubkey);
+ /* Self-paths are not fronting nodes */
+ if (!pubkey_eq(&od->id, &first.pubkey))
+ tal_arr_expand(&offer_fronts, first.pubkey);
+ }
+ if (tal_count(offer_fronts) != 0)
+ ir->fronting_nodes = offer_fronts;
+ else {
+ /* Get upset if none from offer (via invreq) were usable! */
+ if (tal_count(ir->invreq->offer_paths) != 0)
+ return fail_invreq(cmd, ir, "Fronting failed, could not find any fronts");
+
+ /* Otherwise, use defaults */
+ tal_free(offer_fronts);
+ ir->fronting_nodes = od->fronting_nodes;
+ }
+
/* BOLT-recurrence #12:
* - if `offer_quantity_max` is present:
* - MUST reject the invoice request if `invreq_recurrence_cancel`
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index 12654b7f..4b67c9aa 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -995,6 +995,44 @@ def test_payment_fronting(node_factory):
assert only_one(l3.rpc.decode(l3invreq)['invreq_paths'])['first_node_id'] == l1.info['id']
l4.rpc.sendinvoice(invreq=l3invreq, label='l3invreq')
+ # We can explicitly override offers: make it use a specific node
+ l4offer_front2 = l4.rpc.offer(1000, 'l4offer', 'l4offer', fronting_nodes=[l2.info['id']])['bolt12']
+ assert [r['first_node_id'] for r in l4.rpc.decode(l4offer_front2)['offer_paths']] == [l2.info['id']]
+
+ # ... or make it not front at all
+ l4offer_nofront = l4.rpc.offer(1000, 'l4offer', 'l4offer', fronting_nodes=[])['bolt12']
+ assert 'offer_paths' not in l4.rpc.decode(l4offer_nofront)
+
+
+def test_offer_fronting(node_factory):
+ # l1 -> l2 -> l3
+ # \ /
+ # l4
+ # Nodes will not front for offers if they don't have an advertized address.
+ l1, l2, l3, l4 = node_factory.get_nodes(4, opts={'dev-allow-localhost': None})
+ node_factory.join_nodes([l1, l2, l3], wait_for_announce=True)
+ node_factory.join_nodes([l2, l4], wait_for_announce=True)
+ node_factory.join_nodes([l3, l4], wait_for_announce=True)
+
+ offer_nofront = l4.rpc.offer("any", "nofront")['bolt12']
+ assert 'offer_paths' not in l1.rpc.decode(offer_nofront)
+ offer_front_l2 = l4.rpc.offer("any", "frontl2", fronting_nodes=[l2.info['id']])['bolt12']
+ assert only_one(l1.rpc.decode(offer_front_l2)['offer_paths'])['first_node_id'] == l2.info['id']
+ offer_front_l2l3 = l4.rpc.offer("any", "frontl2l3", fronting_nodes=[l2.info['id'], l3.info['id']])['bolt12']
+ assert [p['first_node_id'] for p in l1.rpc.decode(offer_front_l2l3)['offer_paths']] == [l2.info['id'], l3.info['id']]
+
+ inv_nofront = l1.rpc.fetchinvoice(offer_nofront, 1)['invoice']
+ assert only_one(l1.rpc.decode(inv_nofront)['invoice_paths'])['first_node_id'] == l4.info['id']
+ l1.rpc.xpay(inv_nofront)
+
+ inv_front_l2 = l1.rpc.fetchinvoice(offer_front_l2, 2)['invoice']
+ assert only_one(l1.rpc.decode(inv_front_l2)['invoice_paths'])['first_node_id'] == l2.info['id']
+ l1.rpc.xpay(inv_front_l2)
+
+ inv_front_l2l3 = l1.rpc.fetchinvoice(offer_front_l2l3, 3)['invoice']
+ assert only_one(l1.rpc.decode(inv_front_l2l3)['invoice_paths'])['first_node_id'] in (l2.info['id'], l3.info['id'])
+ l1.rpc.xpay(inv_front_l2l3)
+
def test_invoice_maxdesc(node_factory, chainparams):
l1, l2 = node_factory.line_graph(2)
Why this scored 35/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.