lnrpc: document reply_path verbatim passthrough on OnionMessageUpdate
What changed, and why it matters
This change is mostly documentation and a small code cleanup for LND's onion message streaming API. It clarifies that one field (the 'introduction_node' in a reply path) can arrive in two different formats: a regular 33-byte public key, or a compact 9-byte 'short channel ID + direction' form. The commit also refactors the code so that when no reply path exists, the API returns a true null instead of an empty placeholder struct. There is no direct security fix here, but the documentation reduces the chance that API consumers will misinterpret the data and build broken or unsafe replies.
No urgent action. Operators and developers using SubscribeOnionMessages should review the updated documentation and ensure client code resolves sciddir introduction_node forms against the local channel graph before attempting replies. Consider adding a client-side or server-side helper for sciddir resolution if one does not already exist, since the commit explicitly leaves that responsibility to subscribers.
Security signals we found
Documentation-only clarification of wire encoding for introduction_node
Nil reply path now returned as nil instead of empty struct (behavioral consistency)
No input validation, parsing, or authorization changes
No CVE, advisory, or vendor security disclosure referenced in commit
Evidence from the diff
The commit updates protobuf, generated Go, and swagger comments for OnionMessageUpdate.reply_path to document that introduction_node is a verbatim wire passthrough and may be either a 33-byte compressed pubkey or a 9-byte sciddir (direction byte + 8-byte SCID). It extracts the existing inline marshaling logic in rpcserver.go’s SubscribeOnionMessages into a new marshallBlindedPath helper. The helper preserves nil inputs as nil, whereas the previous code always allocated an empty &lnrpc.BlindedPath{} even when oMsg.ReplyPath was nil. No cryptographic or wire validation logic is changed; the change is informational and structural.
Changed components
lnrpc.SubscribeOnionMessages RPCrpcserver.go onion message streaming handlerlnrpc OnionMessageUpdate/BlindedPath protobuf definitionsInspect captured patch +46 / −23
diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go
index 30d38bb..72352bd 100644
--- a/lnrpc/lightning.pb.go
+++ b/lnrpc/lightning.pb.go
@@ -1827,7 +1827,12 @@ type OnionMessageUpdate struct {
// along its designated path.
Onion []byte `protobuf:"bytes,3,opt,name=onion,proto3" json:"onion,omitempty"`
// reply_path is the blinded path that should be used when replying to a
- // received message.
+ // received message. The introduction_node field is passed through verbatim
+ // from the wire. It may carry either the 33-byte SEC1 compressed pubkey
+ // form or the 9-byte sciddir form. The sciddir form consists of a 1-byte
+ // direction selector (0x00 or 0x01) followed by an 8-byte short channel ID.
+ // Subscribers that intend to reply resolve the sciddir form against their
+ // local channel graph.
ReplyPath *BlindedPath `protobuf:"bytes,4,opt,name=reply_path,json=replyPath,proto3" json:"reply_path,omitempty"`
// encrypted_recipient_data is the encrypted data that contains the
// forwarding information for an onion message. It contains either
diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto
index be43c88..30b78f6 100644
--- a/lnrpc/lightning.proto
+++ b/lnrpc/lightning.proto
@@ -654,8 +654,15 @@ message OnionMessageUpdate {
// along its designated path.
bytes onion = 3;
- // reply_path is the blinded path that should be used when replying to a
- // received message.
+ /*
+ reply_path is the blinded path that should be used when replying to a
+ received message. The introduction_node field is passed through verbatim
+ from the wire. It may carry either the 33-byte SEC1 compressed pubkey
+ form or the 9-byte sciddir form. The sciddir form consists of a 1-byte
+ direction selector (0x00 or 0x01) followed by an 8-byte short channel ID.
+ Subscribers that intend to reply resolve the sciddir form against their
+ local channel graph.
+ */
BlindedPath reply_path = 4;
// encrypted_recipient_data is the encrypted data that contains the
diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json
index 7be8285..c87fcb1 100644
--- a/lnrpc/lightning.swagger.json
+++ b/lnrpc/lightning.swagger.json
@@ -6506,7 +6506,7 @@
},
"reply_path": {
"$ref": "#/definitions/lnrpcBlindedPath",
- "description": "reply_path is the blinded path that should be used when replying to a\nreceived message."
+ "description": "reply_path is the blinded path that should be used when replying to a\nreceived message. The introduction_node field is passed through verbatim\nfrom the wire. It may carry either the 33-byte SEC1 compressed pubkey\nform or the 9-byte sciddir form. The sciddir form consists of a 1-byte\ndirection selector (0x00 or 0x01) followed by an 8-byte short channel ID.\nSubscribers that intend to reply resolve the sciddir form against their\nlocal channel graph."
},
"encrypted_recipient_data": {
"type": "string",
diff --git a/rpcserver.go b/rpcserver.go
index da164bd..a3899c9 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -8797,25 +8797,8 @@ func (r *rpcServer) SubscribeOnionMessages(
"failed type assertion: %T", update)
}
- bp := &lnrpc.BlindedPath{}
-
- //nolint:ll
- if oMsg.ReplyPath != nil {
- // TODO(bolt12): resolve sciddir intros via
- // sciddirResolver so this field is uniformly a
- // 33-byte pubkey?
- bp.IntroductionNode = oMsg.ReplyPath.IntroductionNode.Bytes()
- bp.BlindingPoint = oMsg.ReplyPath.BlindingPoint.SerializeCompressed()
-
- for _, hop := range oMsg.ReplyPath.Hops {
- bp.BlindedHops = append(
- bp.BlindedHops, &lnrpc.BlindedHop{
- BlindedNode: hop.BlindedNodeID.SerializeCompressed(),
- EncryptedData: hop.EncryptedData,
- },
- )
- }
- }
+ // Perform a verbatim pass-through of any reply path.
+ bp := marshallBlindedPath(oMsg.ReplyPath)
//nolint:ll
err := server.Send(&lnrpc.OnionMessageUpdate{
@@ -8833,6 +8816,34 @@ func (r *rpcServer) SubscribeOnionMessages(
}
}
+// marshallBlindedPath converts a wire-form blinded path into its RPC
+// counterpart. If the input is nil, nil is returned.
+func marshallBlindedPath(p *lnwire.BlindedPath) *lnrpc.BlindedPath {
+ if p == nil {
+ return nil
+ }
+
+ bp := &lnrpc.BlindedPath{
+ // The introduction node may be a short-channel-id direction
+ // rather than a node public key. We pass it through verbatim
+ // instead of resolving it, which would need a graph db query.
+ IntroductionNode: p.IntroductionNode.Bytes(),
+ BlindingPoint: p.BlindingPoint.SerializeCompressed(),
+ }
+
+ for _, hop := range p.Hops {
+ blindedNode := hop.BlindedNodeID.SerializeCompressed()
+ bp.BlindedHops = append(
+ bp.BlindedHops, &lnrpc.BlindedHop{
+ BlindedNode: blindedNode,
+ EncryptedData: hop.EncryptedData,
+ },
+ )
+ }
+
+ return bp
+}
+
// ListAliases returns the set of all aliases we have ever allocated along with
// their base SCIDs and possibly a separate confirmed SCID in the case of
// zero-conf.
Why this scored 19/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.