From 6ab4f0422c292880adfe9d653f0f6e8b346fe724 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 19 Jun 2025 13:41:27 +0200 Subject: [PATCH 01/11] sphinx: remove dead code and tiny refactor This commit removes an unused var and changes bytes.Compare to the idiomatic bytes.Equal. --- sphinx_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sphinx_test.go b/sphinx_test.go index 485ac5d..4fa1759 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -106,8 +106,7 @@ func newTestRoute(numHops int) ([]*Router, *PaymentPath, *[]HopData, *OnionPacke func TestBolt4Packet(t *testing.T) { var ( - route PaymentPath - hopsData []HopData + route PaymentPath ) for i, pubKeyHex := range bolt4PubKeys { pubKeyBytes, err := hex.DecodeString(pubKeyHex) @@ -125,7 +124,6 @@ func TestBolt4Packet(t *testing.T) { OutgoingCltv: uint32(i), } copy(hopData.NextAddress[:], bytes.Repeat([]byte{byte(i)}, 8)) - hopsData = append(hopsData, hopData) hopPayload, err := NewLegacyHopPayload(&hopData) if err != nil { @@ -157,7 +155,7 @@ func TestBolt4Packet(t *testing.T) { t.Fatalf("unable to decode onion packet: %v", err) } - if bytes.Compare(b.Bytes(), finalPacket) != 0 { + if !bytes.Equal(b.Bytes(), finalPacket) { t.Fatalf("final packet does not match expected BOLT 4 packet, "+ "want: %s, got %s", hex.EncodeToString(finalPacket), hex.EncodeToString(b.Bytes())) From 11b2f79e6b8d0fec2b6930ae654e1eadfb3b07ef Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Mon, 23 Jun 2025 15:19:14 +0200 Subject: [PATCH 02/11] sphinx_test: add blinded onion message test This commit adds the spec test vector for blinded onion messages. It also adds a test that tests BuildBlindedRoute, decryptBlindedHopData and NextEphemeral against this vector. --- path_test.go | 219 +++++++++++++++++- .../blinded-onion-message-onion-test.json | 143 ++++++++++++ 2 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 testdata/blinded-onion-message-onion-test.json diff --git a/path_test.go b/path_test.go index 9a301d9..ad625cd 100644 --- a/path_test.go +++ b/path_test.go @@ -12,8 +12,17 @@ import ( ) const ( - routeBlindingTestFileName = "testdata/route-blinding-test.json" - onionRouteBlindingTestFileName = "testdata/onion-route-blinding-test.json" + routeBlindingTestFileName = "testdata/route-blinding-test.json" + onionRouteBlindingTestFileName = "testdata/onion-route-blinding-test.json" + blindedOnionMessageOnionTestFileName = "testdata/blinded-onion-message-onion-test.json" +) + +var ( + // bolt4PubKeys contains the public keys used in the Bolt 4 spec test + // vectors. We convert them variables named after the commonly used + // names in cryptography. + alicePubKey = bolt4PubKeys[0] + bobPubKey = bolt4PubKeys[1] ) // TestBuildBlindedRoute tests BuildBlindedRoute and decryptBlindedHopData against @@ -117,6 +126,164 @@ func TestBuildBlindedRoute(t *testing.T) { } } +// TestBuildOnionMessageBlindedRoute tests the construction of a blinded route +// for an onion message, specifically the concatenation of two blinded paths, +// against the spec test vectors in `blinded-onion-message-onion-test.json`. It +// verifies the correctness of BuildBlindedPath, decryptBlindedHopData, and +// NextEphemeral. +// +// The test setup involves several parties and two distinct blinded paths that +// are combined to form the full route: +// +// 1. Path from Dave: Dave (the receiver) first constructs a blinded path for a +// message to be sent from Bob to himself (Dave). +// The path is: Bob -> Carol -> Dave +// +// 2. Path from Sender: Dave gives his blinded path to a Sender. The Sender +// then creates their own blinded path from themselves to Bob, passing +// through Alice. The path is: Sender -> Alice -> Bob +// +// 3. Path Concatenation: The Sender prepends their path to Dave's path, +// creating a final, concatenated route: +// Sender -> Alice -> Bob -> Carol -> Dave +// To link the two paths, the Sender includes a `next_path_key_override` +// in the payload for Alice. This override is set to the first path key +// (blinding point) of Dave's path, instructing Alice to use it for the next +// hop (Bob) instead of the key that she could derive herself. +// +// The test then asserts that the generated concatenated path matches the test +// vector's expected route. Finally, it simulates the decryption process at each +// hop, verifying that each node can correctly decrypt its payload and derive +// the correct next ephemeral key. +func TestBuildOnionMessageBlindedRoute(t *testing.T) { + t.Parallel() + + // First, we'll read out the raw Json file at the target location. + jsonBytes, err := os.ReadFile(blindedOnionMessageOnionTestFileName) + require.NoError(t, err) + + // Once we have the raw file, we'll unpack it into our + // onionMessageJsonTestCase struct defined below. + testCase := &onionMessageJsonTestCase{} + require.NoError(t, json.Unmarshal(jsonBytes, testCase)) + require.Len(t, testCase.Generate.Hops, 4) + + // buildMessagePath is a helper closure used to convert + // hopOnionMessageData objects into HopInfo objects. + buildMessagePath := func(h []hopOnionMessageData, + initialHopID string) []*HopInfo { + + path := make([]*HopInfo, len(h)) + // The json test vector doesn't properly specify the current + // node id, so we need the initial Node ID as a starting point. + currentHop := initialHopID + for i, hop := range h { + nodeIDStr, err := hex.DecodeString(currentHop) + require.NoError(t, err) + nodeID, err := btcec.ParsePubKey(nodeIDStr) + require.NoError(t, err) + payload, err := hex.DecodeString(hop.EncryptedDataTlv) + require.NoError(t, err) + + path[i] = &HopInfo{ + NodePub: nodeID, + PlainText: payload, + } + + // The json test vector doesn't properly specify the + // current node id. It does specify the next node id. So + // to get the current node id for the next iteration, we + // get the next node id here. + currentHop = hop.EncodedOnionMessageTLVs.NextNodeID + } + return path + } + + // First, Dave will build a blinded path from Bob to itself. + daveSessKey := privKeyFromString( + testCase.Generate.Hops[1].PathKeySecret, + ) + daveBobPath := buildMessagePath( + testCase.Generate.Hops[1:], bobPubKey, + ) + daveBobBlindedPath, err := BuildBlindedPath(daveSessKey, daveBobPath) + require.NoError(t, err) + + // At this point, Dave will give his blinded path to the Sender who will + // then build its own blinded route from itself to Bob via Alice. The + // sender will then concatenate the two paths. Note that in the payload + // for Alice, the `next_path_key_override` field is added which is set + // to the first path key in Dave's blinded route. This will indicate to + // Alice that she should use this point for the next path key instead of + // the next path key that she derives. + // Path created by Dave: Bob -> Carol -> Dave + // Path that the Sender will build: Sender -> Alice -> Bob + aliceBobPath := buildMessagePath( + testCase.Generate.Hops[:1], alicePubKey, + ) + senderSessKey := privKeyFromString( + testCase.Generate.Hops[0].PathKeySecret, + ) + aliceBobBlindedPath, err := BuildBlindedPath( + senderSessKey, aliceBobPath, + ) + require.NoError(t, err) + + // Construct the concatenated path. + path := &BlindedPath{ + IntroductionPoint: aliceBobBlindedPath.Path.IntroductionPoint, + BlindingPoint: aliceBobBlindedPath.Path.BlindingPoint, + BlindedHops: append( + aliceBobBlindedPath.Path.BlindedHops, + daveBobBlindedPath.Path.BlindedHops..., + ), + } + + // Check that the constructed path is equal to the test vector path. + require.True(t, equalPubKeys( + testCase.Route.FirstNodeId, path.IntroductionPoint, + )) + require.True(t, equalPubKeys( + testCase.Route.FirstPathKey, path.BlindingPoint, + )) + + for i, hop := range testCase.Route.Hops { + require.True(t, equalPubKeys( + hop.BlindedNodeID, path.BlindedHops[i].BlindedNodePub, + )) + + data, _ := hex.DecodeString(hop.EncryptedRecipientData) + require.Equal(t, data, path.BlindedHops[i].CipherText) + } + + // Assert that each hop is able to decode the encrypted data meant for + // it. + for i, hop := range testCase.Decrypt.Hops { + genData := testCase.Generate.Hops[i] + priv := privKeyFromString(hop.PrivKey) + ephem := pubKeyFromString(genData.EphemeralPubKey) + + // Now we'll decrypt the blinded hop data using the private key + // and the ephemeral public key. + data, err := decryptBlindedHopData( + &PrivKeyECDH{PrivKey: priv}, ephem, + path.BlindedHops[i].CipherText, + ) + require.NoError(t, err) + + // Check if the decrypted data is what we expect it to be. + dataExpected, _ := hex.DecodeString(genData.EncryptedDataTlv) + require.Equal(t, data, dataExpected) + + nextEphem, err := NextEphemeral(&PrivKeyECDH{priv}, ephem) + require.NoError(t, err) + + nextE := privKeyFromString(genData.NextEphemeralPrivKey) + + require.Equal(t, nextE.PubKey(), nextEphem) + } +} + // TestOnionRouteBlinding tests that an onion packet can correctly be processed // by a node in a blinded route. func TestOnionRouteBlinding(t *testing.T) { @@ -223,24 +390,47 @@ type decryptData struct { Hops []decryptHops `json:"hops"` } +type decryptOnionMessageData struct { + Hops []decryptOnionMessageHops `json:"hops"` +} + type decryptHops struct { Onion string `json:"onion"` NodePrivKey string `json:"node_privkey"` NextBlinding string `json:"next_blinding"` } +type decryptOnionMessageHops struct { + OnionMessage string `json:"onion_message"` + PrivKey string `json:"privkey"` + NextNodeID string `json:"next_node_id"` +} + type blindingJsonTestCase struct { Generate generateData `json:"generate"` Route routeData `json:"route"` Unblind unblindData `json:"unblind"` } +type onionMessageJsonTestCase struct { + Generate generateOnionMessageData `json:"generate"` + Route routeOnionMessageData `json:"route"` + // OnionMessage onionMessageData `json:"onionmessage"` + Decrypt decryptOnionMessageData `json:"decrypt"` +} + type routeData struct { IntroductionNodeID string `json:"introduction_node_id"` Blinding string `json:"blinding"` Hops []blindedHop `json:"hops"` } +type routeOnionMessageData struct { + FirstNodeId string `json:"first_node_id"` + FirstPathKey string `json:"first_path_key"` + Hops []blindedOnionMessageHop `json:"hops"` +} + type unblindData struct { Hops []unblindedHop `json:"hops"` } @@ -249,6 +439,11 @@ type generateData struct { Hops []hopData `json:"hops"` } +type generateOnionMessageData struct { + SessionKey string `json:"session_key"` + Hops []hopOnionMessageData `json:"hops"` +} + type unblindedHop struct { NodePrivKey string `json:"node_privkey"` EphemeralPubKey string `json:"ephemeral_pubkey"` @@ -262,11 +457,31 @@ type hopData struct { EncodedTLVs string `json:"encoded_tlvs"` } +type hopOnionMessageData struct { + PathKeySecret string `json:"path_key_secret"` + EncodedOnionMessageTLVs encodedOnionMessageTLVs `json:"tlvs"` + EncryptedDataTlv string `json:"encrypted_data_tlv"` + EphemeralPubKey string `json:"E"` + NextEphemeralPrivKey string `json:"next_e"` +} + +type encodedOnionMessageTLVs struct { + NextNodeID string `json:"next_node_id"` + NextPathKeyOverride string `json:"next_path_key_override"` + PathKeyOverrideSecret string `json:"path_key_override_secret"` + PathID string `json:"path_id"` +} + type blindedHop struct { BlindedNodeID string `json:"blinded_node_id"` EncryptedData string `json:"encrypted_data"` } +type blindedOnionMessageHop struct { + BlindedNodeID string `json:"blinded_node_id"` + EncryptedRecipientData string `json:"encrypted_recipient_data"` +} + func equalPubKeys(pkStr string, pk *btcec.PublicKey) bool { return hex.EncodeToString(pk.SerializeCompressed()) == pkStr } diff --git a/testdata/blinded-onion-message-onion-test.json b/testdata/blinded-onion-message-onion-test.json new file mode 100644 index 0000000..fe5191e --- /dev/null +++ b/testdata/blinded-onion-message-onion-test.json @@ -0,0 +1,143 @@ +{ + "comment": "Test vector creating an onionmessage, including joining an existing one", + "generate": { + "comment": "This sections contains test data for Dave's blinded path Bob->Dave; sender has to prepend a hop to Alice to reach Bob", + "session_key": "0303030303030303030303030303030303030303030303030303030303030303", + "hops": [ + { + "alias": "Alice", + "comment": "Alice->Bob: note next_path_key_override to match that give by Dave for Bob", + "path_key_secret": "6363636363636363636363636363636363636363636363636363636363636363", + "tlvs": { + "next_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "path_key_override_secret": "0101010101010101010101010101010101010101010101010101010101010101" + }, + "encrypted_data_tlv": "04210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "ss": "c04d2a4c518241cb49f2800eea92554cb543f268b4c73f85693541e86d649205", + "HMAC256('blinded_node_id', ss)": "bc5388417c8db33af18ab7ba43f6a5641861f7b0ecb380e501a739af446a7bf4", + "blinded_node_id": "02d1c3d73f8cac67e7c5b6ec517282d5ba0a52b06a29ec92ff01e12decf76003c1", + "E": "031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd99", + "H(E || ss)": "83377bd6096f82df3a46afec20d68f3f506168f2007f6e86c2dc267417de9e34", + "next_e": "bf3e8999518c0bb6e876abb0ae01d44b9ba211720048099a2ba5a83afd730cad01", + "rho": "6926df9d4522b26ad4330a51e3481208e4816edd9ae4feaf311ea0342eb90c44", + "encrypted_recipient_data": "49531cf38d3280b7f4af6d6461a2b32e3df50acfd35176fc61422a1096eed4dfc3806f29bf74320f712a61c766e7f7caac0c42f86040125fbaeec0c7613202b206dbdd31fda56394367b66a711bfd7d5bedbe20bed1b" + }, + { + "alias": "Bob", + "comment": "Bob->Carol", + "path_key_secret": "0101010101010101010101010101010101010101010101010101010101010101", + "tlvs": { + "next_node_id": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", + "unknown_tag_561": "123456" + }, + "encrypted_data_tlv": "0421027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007fd023103123456", + "ss": "196f1f3e0be9d65f88463c1ab63e07f41b4e7c0368c28c3e6aa290cc0d22eaed", + "HMAC256('blinded_node_id', ss)": "c331d35827bdd509a02f1e64d48c7f0d7b2603355abbb1a3733c86e50135608e", + "blinded_node_id": "03f1465ca5cf3ec83f16f9343d02e6c24b76993a93e1dea2398f3147a9be893d7a", + "E": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "H(E || ss)": "1889a6cf337d9b34f80bb23a91a2ca194e80d7614f0728bdbda153da85e46b69", + "next_e": "f7ab6dca6152f7b6b0c9d7c82d716af063d72d8eef8816dfc51a8ae828fa7dce01", + "rho": "db991242ce366ab44272f38383476669b713513818397a00d4808d41ea979827", + "encrypted_recipient_data": "adf6771d3983b7f543d1b3d7a12b440b2bd3e1b3b8d6ec1023f6dec4f0e7548a6f57f6dbe9573b0a0f24f7c5773a7dd7a7bdb6bd0ee686d759f5" + }, + { + "alias": "Carol", + "comment": "Carol->Dave", + "path_key_secret": "f7ab6dca6152f7b6b0c9d7c82d716af063d72d8eef8816dfc51a8ae828fa7dce", + "tlvs": { + "padding": "0000000000", + "next_node_id": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + }, + "encrypted_data_tlv": "010500000000000421032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991", + "ss": "c7b33d74a723e26331a91c15ae5bc77db28a18b801b6bc5cd5bba98418303a9d", + "HMAC256('blinded_node_id', ss)": "a684c7495444a8cc2a6dfdecdf0819f3cdf4e86b81cc14e39825a40872ecefff", + "blinded_node_id": "035dbc0493aa4e7eea369d6a06e8013fd03e66a5eea91c455ed65950c4942b624b", + "E": "02b684babfd400c8dd48b367e9754b8021a3594a34dc94d7101776c7f6a86d0582", + "H(E || ss)": "2d80c5619a5a68d22dd3d784cab584c2718874922735d36cb36a179c10a796ca", + "next_e": "5de52bb427cc148bf23e509fdc18012004202517e80abcfde21612ae408e6cea01", + "rho": "739851e89b61cab34ee9ba7d5f3c342e4adc8b91a72991664026f68a685f0bdc", + "encrypted_recipient_data": "d8903df7a79ac799a0b59f4ba22f6a599fa32e7ff1a8325fc22b88d278ce3e4840af02adfb82d6145a189ba50c2219c9e4351e634d198e0849ac" + }, + { + "alias": "Dave", + "comment": "Dave is final node, hence path_id", + "path_key_secret": "5de52bb427cc148bf23e509fdc18012004202517e80abcfde21612ae408e6cea", + "tlvs": { + "padding": "", + "path_id": "deadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0", + "unknown_tag_65535": "06c1" + }, + "encrypted_data_tlv": "01000620deadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0fdffff0206c1", + "ss": "024955ed0d4ebbfab13498f5d7aacd00bf096c8d9ed0473cdfc96d90053c86b7", + "HMAC256('blinded_node_id', ss)": "3f5612df60f050ac571aeaaf76655e138529bea6d23293ebe15659f2588cd039", + "blinded_node_id": "0237bf019fa0fbecde8b4a1c7b197c9c1c76f9a23d67dd55bb5e42e1f50bb771a6", + "E": "025aaca62db7ce6b46386206ef9930daa32e979a35cb185a41cb951aa7d254b03c", + "H(E || ss)": "db5719e79919d706eab17eebaad64bd691e56476a42f0e26ae60caa9082f56fa", + "next_e": "ae31d2fbbf2f59038542c13287b9b624ea1a212c82be87c137c3d92aa30a185d01", + "rho": "c47cde57edc790df7b9b6bf921aff5e5eee43f738ab8fa9103ef675495f3f50e", + "encrypted_recipient_data": "bdc03f088764c6224c8f939e321bf096f363b2092db381fc8787f891c8e6dc9284991b98d2a63d9f91fe563065366dd406cd8e112cdaaa80d0e6" + } + ] + }, + "route": { + "comment": "The resulting blinded route Alice to Dave.", + "first_node_id": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + "first_path_key": "031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd99", + "hops": [ + { + "blinded_node_id": "02d1c3d73f8cac67e7c5b6ec517282d5ba0a52b06a29ec92ff01e12decf76003c1", + "encrypted_recipient_data": "49531cf38d3280b7f4af6d6461a2b32e3df50acfd35176fc61422a1096eed4dfc3806f29bf74320f712a61c766e7f7caac0c42f86040125fbaeec0c7613202b206dbdd31fda56394367b66a711bfd7d5bedbe20bed1b" + }, + { + "blinded_node_id": "03f1465ca5cf3ec83f16f9343d02e6c24b76993a93e1dea2398f3147a9be893d7a", + "encrypted_recipient_data": "adf6771d3983b7f543d1b3d7a12b440b2bd3e1b3b8d6ec1023f6dec4f0e7548a6f57f6dbe9573b0a0f24f7c5773a7dd7a7bdb6bd0ee686d759f5" + }, + { + "blinded_node_id": "035dbc0493aa4e7eea369d6a06e8013fd03e66a5eea91c455ed65950c4942b624b", + "encrypted_recipient_data": "d8903df7a79ac799a0b59f4ba22f6a599fa32e7ff1a8325fc22b88d278ce3e4840af02adfb82d6145a189ba50c2219c9e4351e634d198e0849ac" + }, + { + "blinded_node_id": "0237bf019fa0fbecde8b4a1c7b197c9c1c76f9a23d67dd55bb5e42e1f50bb771a6", + "encrypted_recipient_data": "bdc03f088764c6224c8f939e321bf096f363b2092db381fc8787f891c8e6dc9284991b98d2a63d9f91fe563065366dd406cd8e112cdaaa80d0e6" + } + ] + }, + "onionmessage": { + "comment": "An onion message which sends a 'hello' to Dave", + "unknown_tag_1": "68656c6c6f", + "onion_message_packet": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe33793b828776d70aabbd8cef1a5b52d5a397ae1a20f20435ff6057cd8be339d5aee226660ef73b64afa45dbf2e6e8e26eb96a259b2db5aeecda1ce2e768bbc35d389d7f320ca3d2bd14e2689bef2f5ac0307eaaabc1924eb972c1563d4646ae131accd39da766257ed35ea36e4222527d1db4fa7b2000aab9eafcceed45e28b5560312d4e2299bd8d1e7fe27d10925966c28d497aec400b4630485e82efbabc00550996bdad5d6a9a8c75952f126d14ad2cff91e16198691a7ef2937de83209285f1fb90944b4e46bca7c856a9ce3da10cdf2a7d00dc2bf4f114bc4d3ed67b91cbde558ce9af86dc81fbdc37f8e301b29e23c1466659c62bdbf8cff5d4c20f0fb0851ec72f5e9385dd40fdd2e3ed67ca4517117825665e50a3e26f73c66998daf18e418e8aef9ce2d20da33c3629db2933640e03e7b44c2edf49e9b482db7b475cfd4c617ae1d46d5c24d697846f9f08561eac2b065f9b382501f6eabf07343ed6c602f61eab99cdb52adf63fd44a8db2d3016387ea708fc1c08591e19b4d9984ebe31edbd684c2ea86526dd8c7732b1d8d9117511dc1b643976d356258fce8313b1cb92682f41ab72dedd766f06de375f9edacbcd0ca8c99b865ea2b7952318ea1fd20775a28028b5cf59dece5de14f615b8df254eee63493a5111ea987224bea006d8f1b60d565eef06ac0da194dba2a6d02e79b2f2f34e9ca6e1984a507319d86e9d4fcaeea41b4b9144e0b1826304d4cc1da61cfc5f8b9850697df8adc5e9d6f3acb3219b02764b4909f2b2b22e799fd66c383414a84a7d791b899d4aa663770009eb122f90282c8cb9cda16aba6897edcf9b32951d0080c0f52be3ca011fbec3fb16423deb47744645c3b05fdbd932edf54ba6efd26e65340a8e9b1d1216582e1b30d64524f8ca2d6c5ba63a38f7120a3ed71bed8960bcac2feee2dd41c90be48e3c11ec518eb3d872779e4765a6cc28c6b0fa71ab57ced73ae963cc630edae4258cba2bf25821a6ae049fec2fca28b5dd1bb004d92924b65701b06dcf37f0ccd147a13a03f9bc0f98b7d78fe9058089756931e2cd0e0ed92ec6759d07b248069526c67e9e6ce095118fd3501ba0f858ef030b76c6f6beb11a09317b5ad25343f4b31aef02bc555951bc7791c2c289ecf94d5544dcd6ad3021ed8e8e3db34b2a73e1eedb57b578b068a5401836d6e382110b73690a94328c404af25e85a8d6b808893d1b71af6a31fadd8a8cc6e31ecc0d9ff7e6b91fd03c274a5c1f1ccd25b61150220a3fddb04c91012f5f7a83a5c90deb2470089d6e38cd5914b9c946eca6e9d31bbf8667d36cf87effc3f3ff283c21dd4137bd569fe7cf758feac94053e4baf7338bb592c8b7c291667fadf4a9bf9a2a154a18f612cbc7f851b3f8f2070e0a9d180622ee4f8e81b0ab250d504cef24116a3ff188cc829fcd8610b56343569e8dc997629410d1967ca9dd1d27eec5e01e4375aad16c46faba268524b154850d0d6fe3a76af2c6aa3e97647c51036049ac565370028d6a439a2672b6face56e1b171496c0722cfa22d9da631be359661617c5d5a2d286c5e19db9452c1e21a0107b6400debda2decb0c838f342dd017cdb2dccdf1fe97e3df3f881856b546997a3fed9e279c720145101567dd56be21688fed66bf9759e432a9aa89cbbd225d13cdea4ca05f7a45cfb6a682a3d5b1e18f7e6cf934fae5098108bae9058d05c3387a01d8d02a656d2bfff67e9f46b2d8a6aac28129e52efddf6e552214c3f8a45bc7a912cca9a7fec1d7d06412c6972cb9e3dc518983f56530b8bffe7f92c4b6eb47d4aef59fb513c4653a42de61bc17ad7728e7fc7590ff05a9e991de03f023d0aaf8688ed6170def5091c66576a424ac1cb" + }, + "decrypt": { + "comment": "This section contains the internal values generated by intermediate nodes when decrypting the onion.", + "hops": [ + { + "alias": "Alice", + "privkey": "4141414141414141414141414141414141414141414141414141414141414141", + "onion_message": "0201031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd9905560002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe33793b828776d70aabbd8cef1a5b52d5a397ae1a20f20435ff6057cd8be339d5aee226660ef73b64afa45dbf2e6e8e26eb96a259b2db5aeecda1ce2e768bbc35d389d7f320ca3d2bd14e2689bef2f5ac0307eaaabc1924eb972c1563d4646ae131accd39da766257ed35ea36e4222527d1db4fa7b2000aab9eafcceed45e28b5560312d4e2299bd8d1e7fe27d10925966c28d497aec400b4630485e82efbabc00550996bdad5d6a9a8c75952f126d14ad2cff91e16198691a7ef2937de83209285f1fb90944b4e46bca7c856a9ce3da10cdf2a7d00dc2bf4f114bc4d3ed67b91cbde558ce9af86dc81fbdc37f8e301b29e23c1466659c62bdbf8cff5d4c20f0fb0851ec72f5e9385dd40fdd2e3ed67ca4517117825665e50a3e26f73c66998daf18e418e8aef9ce2d20da33c3629db2933640e03e7b44c2edf49e9b482db7b475cfd4c617ae1d46d5c24d697846f9f08561eac2b065f9b382501f6eabf07343ed6c602f61eab99cdb52adf63fd44a8db2d3016387ea708fc1c08591e19b4d9984ebe31edbd684c2ea86526dd8c7732b1d8d9117511dc1b643976d356258fce8313b1cb92682f41ab72dedd766f06de375f9edacbcd0ca8c99b865ea2b7952318ea1fd20775a28028b5cf59dece5de14f615b8df254eee63493a5111ea987224bea006d8f1b60d565eef06ac0da194dba2a6d02e79b2f2f34e9ca6e1984a507319d86e9d4fcaeea41b4b9144e0b1826304d4cc1da61cfc5f8b9850697df8adc5e9d6f3acb3219b02764b4909f2b2b22e799fd66c383414a84a7d791b899d4aa663770009eb122f90282c8cb9cda16aba6897edcf9b32951d0080c0f52be3ca011fbec3fb16423deb47744645c3b05fdbd932edf54ba6efd26e65340a8e9b1d1216582e1b30d64524f8ca2d6c5ba63a38f7120a3ed71bed8960bcac2feee2dd41c90be48e3c11ec518eb3d872779e4765a6cc28c6b0fa71ab57ced73ae963cc630edae4258cba2bf25821a6ae049fec2fca28b5dd1bb004d92924b65701b06dcf37f0ccd147a13a03f9bc0f98b7d78fe9058089756931e2cd0e0ed92ec6759d07b248069526c67e9e6ce095118fd3501ba0f858ef030b76c6f6beb11a09317b5ad25343f4b31aef02bc555951bc7791c2c289ecf94d5544dcd6ad3021ed8e8e3db34b2a73e1eedb57b578b068a5401836d6e382110b73690a94328c404af25e85a8d6b808893d1b71af6a31fadd8a8cc6e31ecc0d9ff7e6b91fd03c274a5c1f1ccd25b61150220a3fddb04c91012f5f7a83a5c90deb2470089d6e38cd5914b9c946eca6e9d31bbf8667d36cf87effc3f3ff283c21dd4137bd569fe7cf758feac94053e4baf7338bb592c8b7c291667fadf4a9bf9a2a154a18f612cbc7f851b3f8f2070e0a9d180622ee4f8e81b0ab250d504cef24116a3ff188cc829fcd8610b56343569e8dc997629410d1967ca9dd1d27eec5e01e4375aad16c46faba268524b154850d0d6fe3a76af2c6aa3e97647c51036049ac565370028d6a439a2672b6face56e1b171496c0722cfa22d9da631be359661617c5d5a2d286c5e19db9452c1e21a0107b6400debda2decb0c838f342dd017cdb2dccdf1fe97e3df3f881856b546997a3fed9e279c720145101567dd56be21688fed66bf9759e432a9aa89cbbd225d13cdea4ca05f7a45cfb6a682a3d5b1e18f7e6cf934fae5098108bae9058d05c3387a01d8d02a656d2bfff67e9f46b2d8a6aac28129e52efddf6e552214c3f8a45bc7a912cca9a7fec1d7d06412c6972cb9e3dc518983f56530b8bffe7f92c4b6eb47d4aef59fb513c4653a42de61bc17ad7728e7fc7590ff05a9e991de03f023d0aaf8688ed6170def5091c66576a424ac1cb", + "next_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + }, + { + "alias": "Bob", + "privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "onion_message": "0201031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f05560002536d53f93796cad550b6c68662dca41f7e8c221c31022c64dd1a627b2df3982b25eac261e88369cfc66e1e3b6d9829cb3dcd707046e68a7796065202a7904811bf2608c5611cf74c9eb5371c7eb1a4428bb39a041493e2a568ddb0b2482a6cc6711bc6116cef144ebf988073cb18d9dd4ce2d3aa9de91a7dc6d7c6f11a852024626e66b41ba1158055505dff9cb15aa51099f315564d9ee3ed6349665dc3e209eedf9b5805ee4f69d315df44c80e63d0e2efbdab60ec96f44a3447c6a6ddb1efb6aa4e072bde1dab974081646bfddf3b02daa2b83847d74dd336465e76e9b8fecc2b0414045eeedfc39939088a76820177dd1103c99939e659beb07197bab9f714b30ba8dc83738e9a6553a57888aaeda156c68933a2f4ff35e3f81135076b944ed9856acbfee9c61299a5d1763eadd14bf5eaf71304c8e165e590d7ecbcd25f1650bf5b6c2ad1823b2dc9145e168974ecf6a2273c94decff76d94bc6708007a17f22262d63033c184d0166c14f41b225a956271947aae6ce65890ed8f0d09c6ffe05ec02ee8b9de69d7077a0c5adeb813aabcc1ba8975b73ab06ddea5f4db3c23a1de831602de2b83f990d4133871a1a81e53f86393e6a7c3a7b73f0c099fa72afe26c3027bb9412338a19303bd6e6591c04fb4cde9b832b5f41ae199301ea8c303b5cef3aca599454273565de40e1148156d1f97c1aa9e58459ab318304075e034f5b7899c12587b86776a18a1da96b7bcdc22864fccc4c41538ebce92a6f054d53bf46770273a70e75fe0155cd6d2f2e937465b0825ce3123b8c206fac4c30478fa0f08a97ade7216dce11626401374993213636e93545a31f500562130f2feb04089661ad8c34d5a4cbd2e4e426f37cb094c786198a220a2646ecadc38c04c29ee67b19d662c209a7b30bfecc7fe8bf7d274de0605ee5df4db490f6d32234f6af639d3fce38a2801bcf8d51e9c090a6c6932355a83848129a378095b34e71cb8f51152dc035a4fe8e802fec8de221a02ba5afd6765ce570bef912f87357936ea0b90cb2990f56035e89539ec66e8dbd6ed50835158614096990e019c3eba3d7dd6a77147641c6145e8b17552cd5cf7cd163dd40b9eaeba8c78e03a2cd8c0b7997d6f56d35f38983a202b4eb8a54e14945c4de1a6dde46167e11708b7a5ff5cb9c0f7fc12fae49a012aa90bb1995c038130b749c48e6f1ffb732e92086def42af10fbc460d94abeb7b2fa744a5e9a491d62a08452be8cf2fdef573deedc1fe97098bce889f98200b26f9bb99da9aceddda6d793d8e0e44a2601ef4590cfbb5c3d0197aac691e3d31c20fd8e38764962ca34dabeb85df28feabaf6255d4d0df3d814455186a84423182caa87f9673df770432ad8fdfe78d4888632d460d36d2719e8fa8e4b4ca10d817c5d6bc44a8b2affab8c2ba53b8bf4994d63286c2fad6be04c28661162fa1a67065ecda8ba8c13aee4a8039f4f0110e0c0da2366f178d8903e19136dad6df9d8693ce71f3a270f9941de2a93d9b67bc516207ac1687bf6e00b29723c42c7d9c90df9d5e599dbeb7b73add0a6a2b7aba82f98ac93cb6e60494040445229f983a81c34f7f686d166dfc98ec23a6318d4a02a311ac28d655ea4e0f9c3014984f31e621ef003e98c373561d9040893feece2e0fa6cd2dd565e6fbb2773a2407cb2c3273c306cf71f427f2e551c4092e067cf9869f31ac7c6c80dd52d4f85be57a891a41e34be0d564e39b4af6f46b85339254a58b205fb7e10e7d0470ee73622493f28c08962118c23a1198467e72c4ae1cd482144b419247a5895975ea90d135e2a46ef7e5794a1551a447ff0a0d299b66a7f565cd86531f5e7af5408d85d877ce95b1df12b88b7d5954903a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2", + "next_node_id": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007" + }, + { + "alias": "Carol", + "privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "onion_message": "020102b684babfd400c8dd48b367e9754b8021a3594a34dc94d7101776c7f6a86d0582055600029a77e8523162efa1f4208f4f2050cd5c386ddb6ce6d36235ea569d217ec52209fb85fdf7dbc4786c373eebdba0ddc184cfbe6da624f610e93f62c70f2c56be1090b926359969f040f932c03f53974db5656233bd60af375517d4323002937d784c2c88a564bcefe5c33d3fc21c26d94dfacab85e2e19685fd2ff4c543650958524439b6da68779459aee5ffc9dc543339acec73ff43be4c44ddcbe1c11d50e2411a67056ba9db7939d780f5a86123fdd3abd6f075f7a1d78ab7daf3a82798b7ec1e9f1345bc0d1e935098497067e2ae5a51ece396fcb3bb30871ad73aee51b2418b39f00c8e8e22be4a24f4b624e09cb0414dd46239de31c7be035f71e8da4f5a94d15b44061f46414d3f355069b5c5b874ba56704eb126148a22ec873407fe118972127e63ff80e682e410f297f23841777cec0517e933eaf49d7e34bd203266b42081b3a5193b51ccd34b41342bc67cf73523b741f5c012ba2572e9dda15fbe131a6ac2ff24dc2a7622d58b9f3553092cfae7fae3c8864d95f97aa49ec8edeff5d9f5782471160ee412d82ff6767030fc63eec6a93219a108cd41433834b26676a39846a944998796c79cd1cc460531b8ded659cedfd8aecefd91944f00476f1496daafb4ea6af3feacac1390ea510709783c2aa81a29de27f8959f6284f4684102b17815667cbb0645396ac7d542b878d90c42a1f7f00c4c4eedb2a22a219f38afadb4f1f562b6e000a94e75cc38f535b43a3c0384ccef127fde254a9033a317701c710b2b881065723486e3f4d3eea5e12f374a41565fe43fa137c1a252c2153dde055bb343344c65ad0529010ece29bbd405effbebfe3ba21382b94a60ac1a5ffa03f521792a67b30773cb42e862a8a02a8bbd41b842e115969c87d1ff1f8c7b5726b9f20772dd57fe6e4ea41f959a2a673ffad8e2f2a472c4c8564f3a5a47568dd75294b1c7180c500f7392a7da231b1fe9e525ea2d7251afe9ca52a17fe54a116cb57baca4f55b9b6de915924d644cba9dade4ccc01939d7935749c008bafc6d3ad01cd72341ce5ddf7a5d7d21cf0465ab7a3233433aef21f9acf2bfcdc5a8cc003adc4d82ac9d72b36eb74e05c9aa6ccf439ac92e6b84a3191f0764dd2a2e0b4cc3baa08782b232ad6ecd3ca6029bc08cc094aef3aebddcaddc30070cb6023a689641de86cfc6341c8817215a4650f844cd2ca60f2f10c6e44cfc5f23912684d4457bf4f599879d30b79bf12ef1ab8d34dddc15672b82e56169d4c770f0a2a7a960b1e8790773f5ff7fce92219808f16d061cc85e053971213676d28fb48925e9232b66533dbd938458eb2cc8358159df7a2a2e4cf87500ede2afb8ce963a845b98978edf26a6948d4932a6b95d022004556d25515fe158092ce9a913b4b4a493281393ca731e8d8e5a3449b9d888fc4e73ffcbb9c6d6d66e88e03cf6e81a0496ede6e4e4172b08c000601993af38f80c7f68c9d5fff9e0e215cff088285bf039ca731744efcb7825a272ca724517736b4890f47e306b200aa2543c363e2c9090bcf3cf56b5b86868a62471c7123a41740392fc1d5ab28da18dca66618e9af7b42b62b23aba907779e73ca03ec60e6ab9e0484b9cae6578e0fddb6386cb3468506bf6420298bf4a690947ab582255551d82487f271101c72e19e54872ab47eae144db66bc2f8194a666a5daec08d12822cb83a61946234f2dfdbd6ca7d8763e6818adee7b401fcdb1ac42f9df1ac5cc5ac131f2869013c8d6cd29d4c4e3d05bccd34ca83366d616296acf854fa05149bfd763a25b9938e96826a037fdcb85545439c76df6beed3bdbd01458f9cf984997cc4f0a7ac3cc3f5e1eeb59c09cadcf5a537f16e444149c8f17d4bdaef16c9fbabc5ef06eb0f0bf3a07a1beddfeacdaf1df5582d6dbd6bb808d6ab31bc22e5d7", + "next_node_id": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991" + }, + { + "alias": "Dave", + "privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "onion_message": "0201025aaca62db7ce6b46386206ef9930daa32e979a35cb185a41cb951aa7d254b03c055600025550b2910294fa73bda99b9de9c851be9cbb481e23194a1743033630efba546b86e7d838d0f6e9cc0ed088dbf6889f0dceca3bfc745bd77d013a31311fa932a8bf1d28387d9ff521eabc651dee8f861fed609a68551145a451f017ec44978addeee97a423c08445531da488fd1ddc998e9cdbfcea59517b53fbf1833f0bbe6188dba6ca773a247220ec934010daca9cc185e1ceb136803469baac799e27a0d82abe53dc48a06a55d1f643885cc7894677dd20a4e4152577d1ba74b870b9279f065f9b340cedb3ca13b7df218e853e10ccd1b59c42a2acf93f489e170ee4373d30ab158b60fc20d3ba73a1f8c750951d69fb5b9321b968ddc8114936412346aff802df65516e1c09c51ef19849ff36c0199fd88c8bec301a30fef0c7cb497901c038611303f64e4174b5daf42832aa5586b84d2c9b95f382f4269a5d1bd4be898618dc78dfd451170f72ca16decac5b03e60702112e439cadd104fb3bbb3d5023c9b80823fdcd0a212a7e1aaa6eeb027adc7f8b3723031d135a09a979a4802788bb7861c6cc85501fb91137768b70aeab309b27b885686604ffc387004ac4f8c44b101c39bc0597ef7fd957f53fc5051f534b10eb3852100962b5e58254e5558689913c26ad6072ea41f5c5db10077cfc91101d4ae393be274c74297da5cc381cd88d54753aaa7df74b2f9da8d88a72bc9218fcd1f19e4ff4aace182312b9509c5175b6988f044c5756d232af02a451a02ca752f3c52747773acff6fd07d2032e6ce562a2c42105d106eba02d0b1904182cdc8c74875b082d4989d3a7e9f0e73de7c75d357f4af976c28c0b206c5e8123fc2391d078592d0d5ff686fd245c0a2de2e535b7cca99c0a37d432a8657393a9e3ca53eec1692159046ba52cb9bc97107349d8673f74cbc97e231f1108005c8d03e24ca813cea2294b39a7a493bcc062708f1f6cf0074e387e7d50e0666ce784ef4d31cb860f6cad767438d9ea5156ff0ae86e029e0247bf94df75ee0cda4f2006061455cb2eaff513d558863ae334cef7a3d45f55e7cc13153c6719e9901c1d4db6c03f643b69ea4860690305651794284d9e61eb848ccdf5a77794d376f0af62e46d4835acce6fd9eef5df73ebb8ea3bb48629766967f446e744ecc57ff3642c4aa1ccee9a2f72d5caa75fa05787d08b79408fce792485fdecdc25df34820fb061275d70b84ece540b0fc47b2453612be34f2b78133a64e812598fbe225fd85415f8ffe5340ce955b5fd9d67dd88c1c531dde298ed25f96df271558c812c26fa386966c76f03a6ebccbca49ac955916929bd42e134f982dde03f924c464be5fd1ba44f8dc4c3cbc8162755fd1d8f7dc044b15b1a796c53df7d8769bb167b2045b49cc71e08908796c92c16a235717cabc4bb9f60f8f66ff4fff1f9836388a99583acebdff4a7fb20f48eedcd1f4bdcc06ec8b48e35307df51d9bc81d38a94992dd135b30079e1f592da6e98dff496cb1a7776460a26b06395b176f585636ebdf7eab692b227a31d6979f5a6141292698e91346b6c806b90c7c6971e481559cae92ee8f4136f2226861f5c39ddd29bbdb118a35dece03f49a96804caea79a3dacfbf09d65f2611b5622de51d98e18151acb3bb84c09caaa0cc80edfa743a4679f37d6167618ce99e73362fa6f213409931762618a61f1738c071bba5afc1db24fe94afb70c40d731908ab9a505f76f57a7d40e708fd3df0efc5b7cbb2a7b75cd23449e09684a2f0e2bfa0d6176c35f96fe94d92fc9fa4103972781f81cb6e8df7dbeb0fc529c600d768bed3f08828b773d284f69e9a203459d88c12d6df7a75be2455fec128f07a497a2b2bf626cc6272d0419ca663e9dc66b8224227eb796f0246dcae9c5b0b6cfdbbd40c3245a610481c92047c968c9fc92c04b89cc41a0c15355a8f", + "tlvs": { + "unknown_tag_1": "68656c6c6f", + "encrypted_recipient_data": "bdc03f088764c6224c8f939e321bf096f363b2092db381fc8787f891c8e6dc9284991b98d2a63d9f91fe563065366dd406cd8e112cdaaa80d0e6" + } + } + ] + } +} From 58458e8dd113a2984df46c93b114dc41d8126756 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 25 Jun 2025 15:30:58 +0200 Subject: [PATCH 03/11] sphinx_test: add test for blinded route processing We add TestOnionMessageRouteBlinding which verifies that the onion message packet from the test vector can be processed correctly by the nodes in a blinded route. --- path_test.go | 127 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/path_test.go b/path_test.go index ad625cd..6246eaf 100644 --- a/path_test.go +++ b/path_test.go @@ -375,6 +375,121 @@ func TestOnionRouteBlinding(t *testing.T) { } } +// TestOnionMessageRouteBlinding tests that an onion message packet can +// correctly be processed by a node in a blinded route. +func TestOnionMessageRouteBlinding(t *testing.T) { + t.Parallel() + + // First, we'll read out the raw Json file at the target location. + jsonBytes, err := os.ReadFile(blindedOnionMessageOnionTestFileName) + require.NoError(t, err) + + // Once we have the raw file, we'll unpack it into our + // onionMessageJsonTestCase struct defined above. + testCase := &onionMessageJsonTestCase{} + require.NoError(t, json.Unmarshal(jsonBytes, testCase)) + + // Extract the original onion message packet to be processed. + onion, err := hex.DecodeString(testCase.OnionMessage.OnionMessagePacket) + require.NoError(t, err) + + onionBytes := bytes.NewReader(onion) + onionPacket := &OnionPacket{} + require.NoError(t, onionPacket.Decode(onionBytes)) + + // peelOnion is a helper closure that can be used to set up a Router + // and use it to process the given onion packet. + peelOnion := func(key *btcec.PrivateKey, + blindingPoint *btcec.PublicKey, + onionPacket *OnionPacket) *ProcessedPacket { + + r := NewRouter(&PrivKeyECDH{PrivKey: key}, NewMemoryReplayLog()) + + require.NoError(t, r.Start()) + defer r.Stop() + + res, err := r.ProcessOnionPacket( + onionPacket, nil, 10, + WithBlindingPoint(blindingPoint), + ) + require.NoError(t, err) + + return res + } + + hops := testCase.Generate.Hops + + // There are some things that the processor of the onion packet will + // only be able to determine from the actual contents of the encrypted + // data it receives. These things include the next_blinding_point for + // the introduction point and the next_blinding_override. The decryption + // of this data is dependent on the encoding chosen by higher layers. + // The test uses TLVs. Since the extraction of this data is dependent + // on layers outside the scope of this library, we provide handle these + // cases manually for the sake of the test. + var ( + firstBlinding = pubKeyFromString(testCase.Route.FirstPathKey) + concatIndex = 1 + blindingOverride = pubKeyFromString( + hops[0].EncodedOnionMessageTLVs.NextPathKeyOverride, + ) + ) + + // Onion message routes are always entirely blinded, so + // the first hop will always use the first blinding + // point. + blindingPoint := firstBlinding + currentOnionPacket := onionPacket + for i, hop := range testCase.Decrypt.Hops { + // We encode the onion message packet to a buffer at each hop to + // compare it to the onion message packet in the test vector. + buff := bytes.NewBuffer(nil) + require.NoError(t, currentOnionPacket.Encode(buff)) + + // hop.OnionMessage contains the onion_message hex string. This + // contains the type 513 (two bytes), the path_key (33 bytes) + // and the length of the onion_message_packet (two bytes). We + // are only interested in the onion_message_packet so we only + // check that part. 2 + 33 + 2 = 37 bytes, so we skip the first + // 37 bytes, which equals 74 hex characters. + const onionMessageHexHeaderLen = 74 + + require.Equal( + t, hop.OnionMessage[onionMessageHexHeaderLen:], + hex.EncodeToString(buff.Bytes()), + ) + + priv := privKeyFromString(hop.PrivKey) + + if i == concatIndex { + blindingPoint = blindingOverride + } + + // With peelOnion we call into ProcessOnionPacket (with the + // functional option WithBlindingPoint) and we expect that the + // onion message packet for this hop is processed without error, + // otherwise peelOnion fails the test. + processedPkt := peelOnion( + priv, blindingPoint, currentOnionPacket, + ) + + // We derive the next blinding point from the current blinding + // point and the private key of the current hop. The new + // blindingPoint will be used to peel the next hop's onion + // unless it is overridden by a blinding override. + blindingPoint, err = NextEphemeral( + &PrivKeyECDH{priv}, blindingPoint, + ) + require.NoError(t, err) + + // We set the current onion packet to the next packet in the + // processed packet. This is the packet that the next hop will + // process. During the next iteration we will run all the above + // checks on this packet. + currentOnionPacket = processedPkt.NextPacket + } +} + type onionBlindingJsonTestCase struct { Generate generateOnionData `json:"generate"` Decrypt decryptData `json:"decrypt"` @@ -413,10 +528,10 @@ type blindingJsonTestCase struct { } type onionMessageJsonTestCase struct { - Generate generateOnionMessageData `json:"generate"` - Route routeOnionMessageData `json:"route"` - // OnionMessage onionMessageData `json:"onionmessage"` - Decrypt decryptOnionMessageData `json:"decrypt"` + Generate generateOnionMessageData `json:"generate"` + Route routeOnionMessageData `json:"route"` + OnionMessage onionMessageData `json:"onionmessage"` + Decrypt decryptOnionMessageData `json:"decrypt"` } type routeData struct { @@ -431,6 +546,10 @@ type routeOnionMessageData struct { Hops []blindedOnionMessageHop `json:"hops"` } +type onionMessageData struct { + OnionMessagePacket string `json:"onion_message_packet"` +} + type unblindData struct { Hops []unblindedHop `json:"hops"` } From b04ff958506783a4c20aece4380236fe0e1054d9 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 25 Jul 2025 14:29:41 +0200 Subject: [PATCH 04/11] go.mod update --- go.mod | 34 +++++++++++++++++++---------- go.sum | 69 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index eae622f..98539aa 100644 --- a/go.mod +++ b/go.mod @@ -2,23 +2,33 @@ module github.com/lightningnetwork/lightning-onion require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da - github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4 - github.com/btcsuite/btcd/btcec/v2 v2.1.0 - github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f + github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6 + github.com/btcsuite/btcd/btcec/v2 v2.3.4 + github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c github.com/davecgh/go-spew v1.1.1 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 - github.com/stretchr/testify v1.8.2 - github.com/urfave/cli v1.22.5 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 + github.com/stretchr/testify v1.10.0 + github.com/urfave/cli v1.22.9 + golang.org/x/crypto v0.33.0 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/russross/blackfriday/v2 v2.0.1 // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + golang.org/x/sys v0.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.21.4 +replace github.com/lightningnetwork/lightning-onion => ./ + +go 1.22.0 + +toolchain go1.23.9 diff --git a/go.sum b/go.sum index af52f16..b3fdd5e 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,50 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= -github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4 h1:CEGr/598C/0LZQUoioaT6sdGGcJgu4+ck0PDeJ/QkKs= -github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4/go.mod h1:7alexyj/lHlOtr2PJK7L/+HDJZpcGDn/pAU98r7DY08= -github.com/btcsuite/btcd/btcec/v2 v2.1.0 h1:Whmbo9yShKKG+WrUfYGFfgj77vYBiwhwBSJnM66TMKI= -github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6 h1:8n9k3I7e8DkpdQ5YAP4j8ly/LSsbe6qX9vmVbrUGvVw= +github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6/go.mod h1:OmM4kFtB0klaG/ZqT86rQiyw/1iyXlJgc3UHClPhhbs= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c h1:4HxD1lBUGUddhzgaNgrCPsFWd7cGYNpeFUgd9ZIgyM0= +github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= -github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed h1:J22ig1FUekjjkmZUM7pTKixYm8DvrYsvrBZdunYeIuQ= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw= +github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 45c8e38158be9718170a9a59123325b8389e0b29 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 25 Jul 2025 14:35:55 +0200 Subject: [PATCH 05/11] .gitignore: add .aider* --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 31e3ac6..033d67f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor/ .idea +.aider* From fcae597c82d3c17554423d097a458132e2143da7 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 26 Jun 2025 18:21:29 +0200 Subject: [PATCH 06/11] sphinx_test: onion message packet creation TestTLVPayloadMessagePacket creates a onion message with payload and the blinded route from the test vector. It then checks if the onion packet we create is equal to the one provided in the test vector. --- sphinx_test.go | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/sphinx_test.go b/sphinx_test.go index 4fa1759..c93e10e 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -39,6 +39,49 @@ var ( testLegacyRouteNumHops = 20 ) +// encodeTLVRecord encodes a TLV record with the given type and value. +func encodeTLVRecord(recordType uint64, value []byte) []byte { + var buf bytes.Buffer + + // Encode type as varint + writeVarInt(&buf, recordType) + + // Encode length as varint + writeVarInt(&buf, uint64(len(value))) + + // Write value + buf.Write(value) + + return buf.Bytes() +} + +// writeVarInt writes a variable-length integer to the buffer. +func writeVarInt(buf *bytes.Buffer, n uint64) { + if n < 0xfd { + buf.WriteByte(byte(n)) + } else if n <= 0xffff { + buf.WriteByte(0xfd) + buf.WriteByte(byte(n)) + buf.WriteByte(byte(n >> 8)) + } else if n <= 0xffffffff { + buf.WriteByte(0xfe) + buf.WriteByte(byte(n)) + buf.WriteByte(byte(n >> 8)) + buf.WriteByte(byte(n >> 16)) + buf.WriteByte(byte(n >> 24)) + } else { + buf.WriteByte(0xff) + buf.WriteByte(byte(n)) + buf.WriteByte(byte(n >> 8)) + buf.WriteByte(byte(n >> 16)) + buf.WriteByte(byte(n >> 24)) + buf.WriteByte(byte(n >> 32)) + buf.WriteByte(byte(n >> 40)) + buf.WriteByte(byte(n >> 48)) + buf.WriteByte(byte(n >> 56)) + } +} + func newTestRoute(numHops int) ([]*Router, *PaymentPath, *[]HopData, *OnionPacket, error) { nodes := make([]*Router, numHops) @@ -162,6 +205,89 @@ func TestBolt4Packet(t *testing.T) { } } +// TestTLVPayloadMessagePacket tests the creation and encoding of an onion +// message packet that uses a TLV payload for each hop in the route. This test +// uses the test vectors defined in the BOLT 4 specification. The test reads a +// JSON file containing a predefined route, session key, and the expected final +// onion packet. It then constructs the route hop-by-hop, manually creating the +// TLV payload for each, before creating a new onion packet with NewOnionPacket. +// The test concludes by asserting that the newly encoded packet is identical to +// the one specified in the test vector. +func TestTLVPayloadMessagePacket(t *testing.T) { + t.Parallel() + + // First, we'll read out the raw JSON file at the target location. + jsonBytes, err := os.ReadFile(testOnionMessageFileName) + require.NoError(t, err) + + // Once we have the raw file, we'll unpack it into our jsonTestCase + // struct defined above. + testCase := &onionMessageJsonTestCase{} + require.NoError(t, json.Unmarshal(jsonBytes, testCase)) + + // Next, we'll populate a new OnionHop using the information included + // in this test case. + var route PaymentPath + for i, hop := range testCase.Route.Hops { + pubKeyBytes, err := hex.DecodeString(hop.BlindedNodeID) + require.NoError(t, err) + + pubKey, err := btcec.ParsePubKey(pubKeyBytes) + require.NoError(t, err) + + encryptedRecipientData, err := hex.DecodeString( + hop.EncryptedRecipientData, + ) + require.NoError(t, err) + + // Manually encode our onion payload + var b bytes.Buffer + + if i == len(testCase.Route.Hops)-1 { + helloBytes := []byte("hello") + // Encode TLV record for type 1 (hello message) + b.Write(encodeTLVRecord(1, helloBytes)) + } + + // Encode TLV record for type 4 (encrypted recipient data) + b.Write(encodeTLVRecord(4, encryptedRecipientData)) + + route[i] = OnionHop{ + NodePub: *pubKey, + HopPayload: HopPayload{ + Type: PayloadTLV, + Payload: b.Bytes(), + }, + } + } + + finalPacket, err := hex.DecodeString( + testCase.OnionMessage.OnionMessagePacket, + ) + require.NoError(t, err) + + sessionKeyBytes, err := hex.DecodeString(testCase.Generate.SessionKey) + + require.NoError(t, err) + + // With all the required data assembled, we'll craft a new packet. + sessionKey, _ := btcec.PrivKeyFromBytes(sessionKeyBytes) + + pkt, err := NewOnionPacket( + &route, sessionKey, nil, DeterministicPacketFiller, + ) + require.NoError(t, err) + + var b bytes.Buffer + require.NoError(t, pkt.Encode(&b)) + + // Finally, we expect that our packet matches the packet included in + // the spec's test vectors. + require.Equalf(t, finalPacket, b.Bytes(), "final packet does not "+ + "match expected BOLT 4 packet, want: %s, got %s", + hex.EncodeToString(finalPacket), hex.EncodeToString(b.Bytes())) +} + func TestSphinxCorrectness(t *testing.T) { nodes, _, hopDatas, fwdMsg, err := newTestRoute(testLegacyRouteNumHops) if err != nil { @@ -755,6 +881,9 @@ const ( // testTLVFileName is the name of the tlv-payload-only onion test file. testTLVFileName = "testdata/onion-test.json" + + // testOnionMessageFileName is the name of the onion message test file. + testOnionMessageFileName = "testdata/blinded-onion-message-onion-test.json" ) type jsonHop struct { From da7260ec9fa21cfb34906276ca8f33587304f030 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 16:48:50 +0200 Subject: [PATCH 07/11] multi: decode zero-length onion message payloads Since the onion message payload can be zero-length, we need to decode it correctly. This commit adds a boolean flag to the HopPayload Decode that tells whether the payload is an onion message payload or not. If it is, the payload is decoded as a tlv payload also if the first byte is 0x00. sphinx_test: Add zero-length payload om test --- payload.go | 77 ++++++++++++++++++++++++++++++-------------------- sphinx.go | 44 ++++++++++++++++++++++------- sphinx_test.go | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 40 deletions(-) diff --git a/payload.go b/payload.go index 9e89dad..496867c 100644 --- a/payload.go +++ b/payload.go @@ -99,36 +99,13 @@ func (hp *HopPayload) Decode(r io.Reader) error { return err } - var ( - legacyPayload = isLegacyPayloadByte(peekByte[0]) - payloadSize uint16 - ) - - if legacyPayload { - payloadSize = legacyPayloadSize() - hp.Type = PayloadLegacy - } else { - payloadSize, err = tlvPayloadSize(bufReader) - if err != nil { - return err - } - - hp.Type = PayloadTLV - } - - // Now that we know the payload size, we'll create a new buffer to - // read it out in full. - // - // TODO(roasbeef): can avoid all these copies - hp.Payload = make([]byte, payloadSize) - if _, err := io.ReadFull(bufReader, hp.Payload[:]); err != nil { - return err - } - if _, err := io.ReadFull(bufReader, hp.HMAC[:]); err != nil { - return err + // If the HopPayload isn't guaranteed to be a TLV payload, we check the + // first byte to see if it is a legacy payload. + if hp.Type != PayloadTLV && isLegacyPayloadByte(peekByte[0]) { + return decodeLegacyHopPayload(hp, bufReader) } - return nil + return decodeTLVHopPayload(hp, bufReader) } // HopData attempts to extract a set of forwarding instructions from the target @@ -146,6 +123,42 @@ func (hp *HopPayload) HopData() (*HopData, error) { return nil, nil } +// readPayloadAndHMAC reads the payload and HMAC from the reader into the +// HopPayload. +func readPayloadAndHMAC(hp *HopPayload, r io.Reader, payloadSize uint16) error { + // Now that we know the payload size, we'll create a new buffer to read + // it out in full. + hp.Payload = make([]byte, payloadSize) + if _, err := io.ReadFull(r, hp.Payload[:]); err != nil { + return err + } + if _, err := io.ReadFull(r, hp.HMAC[:]); err != nil { + return err + } + + return nil +} + +// decodeTLVHopPayload decodes a TLV hop payload from the passed reader. +func decodeTLVHopPayload(hp *HopPayload, r io.Reader) error { + payloadSize, err := tlvPayloadSize(r) + if err != nil { + return err + } + + hp.Type = PayloadTLV + + return readPayloadAndHMAC(hp, r, payloadSize) +} + +// decodeLegacyHopPayload decodes a legacy hop payload from the passed reader. +func decodeLegacyHopPayload(hp *HopPayload, r io.Reader) error { + payloadSize := legacyPayloadSize() + hp.Type = PayloadLegacy + + return readPayloadAndHMAC(hp, r, payloadSize) +} + // tlvPayloadSize uses the passed reader to extract the payload length encoded // as a var-int. func tlvPayloadSize(r io.Reader) (uint16, error) { @@ -314,8 +327,12 @@ func legacyNumBytes() int { return LegacyHopDataSize } -// isLegacyPayload returns true if the given byte is equal to the 0x00 byte -// which indicates that the payload should be decoded as a legacy payload. +// isLegacyPayloadByte determines if the first byte of a hop payload indicates +// that it is a legacy payload. The first byte of a legacy payload will always +// be 0x00, as this is the realm. For TLV payloads, the first byte is a +// var-int encoding the length of the payload. A TLV stream can be empty, in +// which case its length is 0, which is also encoded as a 0x00 byte. This +// creates an ambiguity between a legacy payload and an empty TLV payload. func isLegacyPayloadByte(b byte) bool { return b == 0x00 } diff --git a/sphinx.go b/sphinx.go index 8e16b23..0322f2d 100644 --- a/sphinx.go +++ b/sphinx.go @@ -510,7 +510,8 @@ func (r *Router) Stop() { // processOnionCfg is a set of config values that can be used to modify how an // onion is processed. type processOnionCfg struct { - blindingPoint *btcec.PublicKey + blindingPoint *btcec.PublicKey + isOnionMessage bool } // ProcessOnionOpt defines the signature of a function option that can be used @@ -525,6 +526,14 @@ func WithBlindingPoint(point *btcec.PublicKey) ProcessOnionOpt { } } +// WithIsOnionMessage is a functional option that signals that the onion packet +// being processed is from onion message. +func WithIsOnionMessage() ProcessOnionOpt { + return func(cfg *processOnionCfg) { + cfg.isOnionMessage = true + } +} + // ProcessOnionPacket processes an incoming onion packet which has been forward // to the target Sphinx router. If the encoded ephemeral key isn't on the // target Elliptic Curve, then the packet is rejected. Similarly, if the @@ -560,7 +569,9 @@ func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, // Continue to optimistically process this packet, deferring replay // protection until the end to reduce the penalty of multiple IO // operations. - packet, err := processOnionPacket(onionPkt, &sharedSecret, assocData) + packet, err := processOnionPacket( + onionPkt, &sharedSecret, assocData, cfg.isOnionMessage, + ) if err != nil { return nil, err } @@ -594,7 +605,9 @@ func (r *Router) ReconstructOnionPacket(onionPkt *OnionPacket, assocData []byte, return nil, err } - return processOnionPacket(onionPkt, &sharedSecret, assocData) + return processOnionPacket( + onionPkt, &sharedSecret, assocData, cfg.isOnionMessage, + ) } // DecryptBlindedHopData uses the router's private key to decrypt data encrypted @@ -625,7 +638,8 @@ func (r *Router) OnionPublicKey() *btcec.PublicKey { // packet. This function returns the next inner onion packet layer, along with // the hop data extracted from the outer onion packet. func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, - assocData []byte) (*OnionPacket, *HopPayload, error) { + assocData []byte, isOnionMessage bool) (*OnionPacket, *HopPayload, + error) { dhKey := onionPkt.EphemeralKey routeInfo := onionPkt.RoutingInfo @@ -660,8 +674,16 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // With the MAC checked, and the payload decrypted, we can now parse // out the payload so we can derive the specified forwarding // instructions. - var hopPayload HopPayload - if err := hopPayload.Decode(bytes.NewReader(hopInfo[:])); err != nil { + hopPayload := HopPayload{} + if isOnionMessage { + // If this is an onion message, we don't have to support legacy + // payloads, but we do support zero-length payloads. By + // specifically setting the type to TLV, we ensure that the + // payload is treated as such. + hopPayload.Type = PayloadTLV + } + err := hopPayload.Decode(bytes.NewReader(hopInfo[:])) + if err != nil { return nil, nil, err } @@ -683,7 +705,7 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // packets. The processed packets returned from this method should only be used // if the packet was not flagged as a replayed packet. func processOnionPacket(onionPkt *OnionPacket, sharedSecret *Hash256, - assocData []byte) (*ProcessedPacket, error) { + assocData []byte, isOnionMessage bool) (*ProcessedPacket, error) { // First, we'll unwrap an initial layer of the onion packet. Typically, // we'll only have a single layer to unwrap, However, if the sender has @@ -693,7 +715,7 @@ func processOnionPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // they can properly check the HMAC and unwrap a layer for their // handoff hop. innerPkt, outerHopPayload, err := unwrapPacket( - onionPkt, sharedSecret, assocData, + onionPkt, sharedSecret, assocData, isOnionMessage, ) if err != nil { return nil, err @@ -703,7 +725,7 @@ func processOnionPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // However if the uncovered 'nextMac' is all zeroes, then this // indicates that we're the final hop in the route. var action ProcessCode = MoreHops - if bytes.Compare(zeroHMAC[:], outerHopPayload.HMAC[:]) == 0 { + if bytes.Equal(zeroHMAC[:], outerHopPayload.HMAC[:]) { action = ExitNode } @@ -794,7 +816,9 @@ func (t *Tx) ProcessOnionPacket(seqNum uint16, onionPkt *OnionPacket, // Continue to optimistically process this packet, deferring replay // protection until the end to reduce the penalty of multiple IO // operations. - packet, err := processOnionPacket(onionPkt, &sharedSecret, assocData) + packet, err := processOnionPacket( + onionPkt, &sharedSecret, assocData, cfg.isOnionMessage, + ) if err != nil { return err } diff --git a/sphinx_test.go b/sphinx_test.go index c93e10e..601b053 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -288,6 +288,60 @@ func TestTLVPayloadMessagePacket(t *testing.T) { hex.EncodeToString(finalPacket), hex.EncodeToString(b.Bytes())) } +// TestProcessOnionMessageZeroLengthPayload tests that we can properly process an +// onion message that has a zero-length payload. +func TestProcessOnionMessageZeroLengthPayload(t *testing.T) { + t.Parallel() + + // First, create a router that will be the destination of the onion + // message. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + router := NewRouter(&PrivKeyECDH{privKey}, NewMemoryReplayLog()) + err = router.Start() + require.NoError(t, err) + defer router.Stop() + + // Next, create a session key for the onion packet. + sessionKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + // We'll create a simple one-hop path. + path := &PaymentPath{ + { + NodePub: *privKey.PubKey(), + }, + } + + // The hop payload will be an empty TLV payload. + payload, err := NewTLVHopPayload(nil) + require.NoError(t, err) + path[0].HopPayload = payload + + // Now, create the onion packet. + onionPacket, err := NewOnionPacket( + path, sessionKey, nil, DeterministicPacketFiller, + ) + require.NoError(t, err) + + // We'll now process the packet, making sure to indicate that this is + // an onion message. + processedPacket, err := router.ProcessOnionPacket( + onionPacket, nil, 0, WithIsOnionMessage(), + ) + require.NoError(t, err) + + // The packet should be decoded as an exit node. + require.EqualValues(t, ExitNode, processedPacket.Action) + + // The payload should be of type TLV. + require.Equal(t, PayloadTLV, processedPacket.Payload.Type) + + // And the payload should be empty. + require.Empty(t, processedPacket.Payload.Payload) +} + func TestSphinxCorrectness(t *testing.T) { nodes, _, hopDatas, fwdMsg, err := newTestRoute(testLegacyRouteNumHops) if err != nil { From d48ed41480567598b773139ecaaceb008c0e5765 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 17:47:54 +0200 Subject: [PATCH 08/11] multi: Support jumbo size om packets Onion messages allow for payloads that exceed 1300 bytes, in which case the payload should become 32768 bytes. This commit introduces support for those jumbo packets. sphinx_test: test jumbo size onion message packets This commit adds a helper function to create onion messages of a specified length. This helper is then used to test the handling of packets larger than 1300 bytes specifically for onion messages. --- bench_test.go | 2 +- cmd/main.go | 7 ++ packetfiller.go | 12 ++-- sphinx.go | 158 +++++++++++++++++++++++++++++++-------------- sphinx_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 283 insertions(+), 64 deletions(-) diff --git a/bench_test.go b/bench_test.go index 38b9f3f..f8a8917 100644 --- a/bench_test.go +++ b/bench_test.go @@ -52,7 +52,7 @@ func BenchmarkPathPacketConstruction(b *testing.B) { for i := 0; i < b.N; i++ { sphinxPacket, err = NewOnionPacket( - &route, d, nil, BlankPacketFiller, + &route, d, nil, BlankPacketFiller, false, ) if err != nil { b.Fatalf("unable to create packet: %v", err) diff --git a/cmd/main.go b/cmd/main.go index 032738f..1babee8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -70,6 +70,12 @@ func main() { "data.", Value: defaultHopDataPath, }, + cli.BoolFlag{ + Name: "onion-message", + Usage: "Create an onion message " + + "packet rather than a " + + "payment onion.", + }, }, }, { @@ -205,6 +211,7 @@ func generate(ctx *cli.Context) error { msg, err := sphinx.NewOnionPacket( path, sessionKey, assocData, sphinx.DeterministicPacketFiller, + ctx.Bool("onion-message"), ) if err != nil { return fmt.Errorf("error creating message: %v", err) diff --git a/packetfiller.go b/packetfiller.go index 79c1441..f02bab5 100644 --- a/packetfiller.go +++ b/packetfiller.go @@ -12,16 +12,16 @@ import ( // in order to ensure we don't leak information on the true route length to the // receiver. The packet filler may also use the session key to generate a set // of filler bytes if it wishes to be deterministic. -type PacketFiller func(*btcec.PrivateKey, *[routingInfoSize]byte) error +type PacketFiller func(*btcec.PrivateKey, []byte) error // RandPacketFiller is a packet filler that reads a set of random bytes from a // CSPRNG. -func RandPacketFiller(_ *btcec.PrivateKey, mixHeader *[routingInfoSize]byte) error { +func RandPacketFiller(_ *btcec.PrivateKey, mixHeader []byte) error { // Read out random bytes to fill out the rest of the starting packet // after the hop payload for the final node. This mitigates a privacy // leak that may reveal a lower bound on the true path length to the // receiver. - if _, err := rand.Read(mixHeader[:]); err != nil { + if _, err := rand.Read(mixHeader); err != nil { return err } @@ -31,7 +31,7 @@ func RandPacketFiller(_ *btcec.PrivateKey, mixHeader *[routingInfoSize]byte) err // BlankPacketFiller is a packet filler that doesn't attempt to fill out the // packet at all. It should ONLY be used for generating test vectors or other // instances that required deterministic packet generation. -func BlankPacketFiller(_ *btcec.PrivateKey, _ *[routingInfoSize]byte) error { +func BlankPacketFiller(_ *btcec.PrivateKey, _ []byte) error { return nil } @@ -39,7 +39,7 @@ func BlankPacketFiller(_ *btcec.PrivateKey, _ *[routingInfoSize]byte) error { // set of filler bytes by using chacha20 with a key derived from the session // key. func DeterministicPacketFiller(sessionKey *btcec.PrivateKey, - mixHeader *[routingInfoSize]byte) error { + mixHeader []byte) error { // First, we'll generate a new key that'll be used to generate some // random bytes for our padding purposes. To derive this new key, we @@ -55,7 +55,7 @@ func DeterministicPacketFiller(sessionKey *btcec.PrivateKey, if err != nil { return err } - padCipher.XORKeyStream(mixHeader[:], mixHeader[:]) + padCipher.XORKeyStream(mixHeader, mixHeader) return nil } diff --git a/sphinx.go b/sphinx.go index 0322f2d..863783f 100644 --- a/sphinx.go +++ b/sphinx.go @@ -41,26 +41,26 @@ const ( LegacyHopDataSize = (RealmByteSize + AddressSize + AmtForwardSize + OutgoingCLTVSize + NumPaddingBytes + HMACSize) - // MaxPayloadSize is the maximum size a payload for a single hop can be. - // This is the worst case scenario of a single hop, consuming all - // available space. We need to know this in order to generate a - // sufficiently long stream of pseudo-random bytes when - // encrypting/decrypting the payload. - MaxPayloadSize = routingInfoSize - - // routingInfoSize is the fixed size of the the routing info. This - // consists of a addressSize byte address and a HMACSize byte HMAC for + // MaxPayloadSize is the maximum size an `update_add_htlc` payload for a + // single hop can be. This is the worst case scenario of a single hop, + // consuming all available space. We need to know this in order to + // generate a sufficiently long stream of pseudo-random bytes when + // encrypting/decrypting the payload. This field is here for backwards + // compatibility. Throughout the code we use StandardRoutingInfoSize + // because of the more apt naming. + MaxPayloadSize = standardRoutingInfoSize + StandardRoutingInfoSize = standardRoutingInfoSize + + // standardRoutingInfoSize is the fixed size of the the routing info. This + // consists of an addressSize byte address and a HMACSize byte HMAC for // each hop of the route, the first pair in cleartext and the following // pairs increasingly obfuscated. If not all space is used up, the // remainder is padded with null-bytes, also obfuscated. - routingInfoSize = 1300 + standardRoutingInfoSize = 1300 - // numStreamBytes is the number of bytes produced by our CSPRG for the - // key stream implementing our stream cipher to encrypt/decrypt the mix - // header. The MaxPayloadSize bytes at the end are used to - // encrypt/decrypt the fillers when processing the packet of generating - // the HMACs when creating the packet. - numStreamBytes = routingInfoSize * 2 + // JumboRoutingInfoSize is the size of the routing info for a jumbo + // onion packet. + JumboRoutingInfoSize = 32768 // keyLen is the length of the keys used to generate cipher streams and // encrypt payloads. Since we use SHA256 to generate the keys, the @@ -72,8 +72,15 @@ const ( ) var ( - ErrMaxRoutingInfoSizeExceeded = fmt.Errorf( - "max routing info size of %v bytes exceeded", routingInfoSize) + ErrStandardRoutingPayloadSizeExceeded = fmt.Errorf( + "max routing info size of %v bytes exceeded", + StandardRoutingInfoSize, + ) + + ErrMessageRoutingPayloadSizeExceeded = fmt.Errorf( + "max onion message routing info size of %v bytes exceeded", + JumboRoutingInfoSize, + ) ) // OnionPacket is the onion wrapped hop-to-hop routing information necessary to @@ -102,7 +109,7 @@ type OnionPacket struct { // RoutingInfo is the full routing information for this onion packet. // This encodes all the forwarding instructions for this current hop // and all the hops in the route. - RoutingInfo [routingInfoSize]byte + RoutingInfo []byte // HeaderMAC is an HMAC computed with the shared secret of the routing // data and the associated data for this route. Including the @@ -193,12 +200,8 @@ func generateSharedSecrets(paymentPath []*btcec.PublicKey, // NewOnionPacket creates a new onion packet which is capable of obliviously // routing a message through the mix-net path outline by 'paymentPath'. func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, - assocData []byte, pktFiller PacketFiller) (*OnionPacket, error) { - - // Check whether total payload size doesn't exceed the hard maximum. - if paymentPath.TotalPayloadSize() > routingInfoSize { - return nil, ErrMaxRoutingInfoSizeExceeded - } + assocData []byte, pktFiller PacketFiller, + isOnionMessage bool) (*OnionPacket, error) { // If we don't actually have a partially populated route, then we'll // exit early. @@ -207,6 +210,34 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, return nil, fmt.Errorf("route of length zero passed in") } + totalPayloadSize := paymentPath.TotalPayloadSize() + + routingInfoLen := StandardRoutingInfoSize + maxRoutingInfoErr := ErrStandardRoutingPayloadSizeExceeded + if isOnionMessage && totalPayloadSize > StandardRoutingInfoSize { + routingInfoLen = JumboRoutingInfoSize + maxRoutingInfoErr = ErrMessageRoutingPayloadSizeExceeded + } + + // Check whether total payload size doesn't exceed the hard maximum. + if totalPayloadSize > routingInfoLen { + return nil, maxRoutingInfoErr + } + + // Before we proceed, we'll check that the payload types of each hop + // in the payment path match the type of onion packet we're creating. + for i := 0; i < numHops; i++ { + hopPayload := (*paymentPath)[i].HopPayload + isLegacy := hopPayload.Type == PayloadLegacy + + // If this is an onion message, we only expect TLV + // payloads. + if isOnionMessage && isLegacy { + return nil, fmt.Errorf("hop %d has legacy payload, "+ + "but onion messages require TLV", i) + } + } + // We'll force the caller to provide a packet filler, as otherwise we // may default to an insecure filling method (which should only really // be used to generate test vectors). @@ -222,18 +253,20 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, } // Generate the padding, called "filler strings" in the paper. - filler := generateHeaderPadding("rho", paymentPath, hopSharedSecrets) + filler := generateHeaderPadding( + "rho", paymentPath, hopSharedSecrets, routingInfoLen, + ) // Allocate zero'd out byte slices to store the final mix header packet // and the hmac for each hop. var ( - mixHeader [routingInfoSize]byte + mixHeader = make([]byte, routingInfoLen) nextHmac [HMACSize]byte hopPayloadBuf bytes.Buffer ) // Fill the packet using the caller specified methodology. - if err := pktFiller(sessionKey, &mixHeader); err != nil { + if err := pktFiller(sessionKey, mixHeader); err != nil { return nil, err } @@ -254,26 +287,26 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, // Next, using the key dedicated for our stream cipher, we'll // generate enough bytes to obfuscate this layer of the onion // packet. - streamBytes := generateCipherStream(rhoKey, routingInfoSize) + streamBytes := generateCipherStream(rhoKey, uint(routingInfoLen)) payload := paymentPath[i].HopPayload // Before we assemble the packet, we'll shift the current // mix-header to the right in order to make room for this next // per-hop data. shiftSize := payload.NumBytes() - rightShift(mixHeader[:], shiftSize) + rightShift(mixHeader, shiftSize) err := payload.Encode(&hopPayloadBuf) if err != nil { return nil, err } - copy(mixHeader[:], hopPayloadBuf.Bytes()) + copy(mixHeader, hopPayloadBuf.Bytes()) // Once the packet for this hop has been assembled, we'll // re-encrypt the packet by XOR'ing with a stream of bytes // generated using our shared secret. - xor(mixHeader[:], mixHeader[:], streamBytes[:]) + xor(mixHeader, mixHeader, streamBytes) // If this is the "last" hop, then we'll override the tail of // the hop data. @@ -285,7 +318,7 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, // calculating the MAC, we'll also include the optional // associated data which can allow higher level applications to // prevent replay attacks. - packet := append(mixHeader[:], assocData...) + packet := append(mixHeader, assocData...) nextHmac = calcMac(muKey, packet) hopPayloadBuf.Reset() @@ -322,7 +355,9 @@ func rightShift(slice []byte, num int) { // leaving only the original "filler" bytes produced by this function at the // last hop. Using this methodology, the size of the field stays constant at // each hop. -func generateHeaderPadding(key string, path *PaymentPath, sharedSecrets []Hash256) []byte { +func generateHeaderPadding(key string, path *PaymentPath, + sharedSecrets []Hash256, routingInfoLen int) []byte { + numHops := path.TrueRouteLength() // We have to generate a filler that matches all but the last hop (the @@ -332,7 +367,7 @@ func generateHeaderPadding(key string, path *PaymentPath, sharedSecrets []Hash25 for i := 0; i < numHops-1; i++ { // Sum up how many frames were used by prior hops. - fillerStart := routingInfoSize + fillerStart := routingInfoLen for _, p := range path[:i] { fillerStart -= p.HopPayload.NumBytes() } @@ -340,10 +375,12 @@ func generateHeaderPadding(key string, path *PaymentPath, sharedSecrets []Hash25 // The filler is the part dangling off of the end of the // routingInfo, so offset it from there, and use the current // hop's frame count as its size. - fillerEnd := routingInfoSize + path[i].HopPayload.NumBytes() + fillerEnd := routingInfoLen + path[i].HopPayload.NumBytes() streamKey := generateKey(key, &sharedSecrets[i]) - streamBytes := generateCipherStream(streamKey, numStreamBytes) + streamBytes := generateCipherStream( + streamKey, numStreamBytes(routingInfoLen), + ) xor(filler, filler, streamBytes[fillerStart:fillerEnd]) } @@ -365,7 +402,7 @@ func (f *OnionPacket) Encode(w io.Writer) error { return err } - if _, err := w.Write(f.RoutingInfo[:]); err != nil { + if _, err := w.Write(f.RoutingInfo); err != nil { return err } @@ -404,14 +441,24 @@ func (f *OnionPacket) Decode(r io.Reader) error { return ErrInvalidOnionKey } - if _, err := io.ReadFull(r, f.RoutingInfo[:]); err != nil { + // To figure out the length of the routing info, we'll read all the + // remaining bytes from the reader. + routingInfoAndMAC, err := io.ReadAll(r) + if err != nil { return err } - if _, err := io.ReadFull(r, f.HeaderMAC[:]); err != nil { - return err + // The packet must have at least enough bytes for the HMAC. + if len(routingInfoAndMAC) < HMACSize { + return fmt.Errorf("onion packet is too small, missing HMAC") } + // With the remainder of the packet read, we can now properly slice the + // routing information and the MAC. + routingInfoLen := len(routingInfoAndMAC) - HMACSize + f.RoutingInfo = routingInfoAndMAC[:routingInfoLen] + copy(f.HeaderMAC[:], routingInfoAndMAC[routingInfoLen:]) + return nil } @@ -644,11 +691,12 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, dhKey := onionPkt.EphemeralKey routeInfo := onionPkt.RoutingInfo headerMac := onionPkt.HeaderMAC + routingInfoLen := len(routeInfo) // Using the derived shared secret, ensure the integrity of the routing // information by checking the attached MAC without leaking timing // information. - message := append(routeInfo[:], assocData...) + message := append(routeInfo, assocData...) calculatedMac := calcMac(generateKey("mu", sharedSecret), message) if !hmac.Equal(headerMac[:], calculatedMac[:]) { return nil, nil, ErrInvalidOnionHMAC @@ -658,13 +706,14 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // layer off the routing info revealing the routing information for the // next hop. streamBytes := generateCipherStream( - generateKey("rho", sharedSecret), numStreamBytes, + generateKey("rho", sharedSecret), + numStreamBytes(routingInfoLen), ) - zeroBytes := bytes.Repeat([]byte{0}, MaxPayloadSize) - headerWithPadding := append(routeInfo[:], zeroBytes...) + zeroBytes := bytes.Repeat([]byte{0}, routingInfoLen) + headerWithPadding := append(routeInfo, zeroBytes...) - var hopInfo [numStreamBytes]byte - xor(hopInfo[:], headerWithPadding, streamBytes) + hopInfo := make([]byte, numStreamBytes(routingInfoLen)) + xor(hopInfo, headerWithPadding, streamBytes) // Randomize the DH group element for the next hop using the // deterministic blinding factor. @@ -682,15 +731,15 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // payload is treated as such. hopPayload.Type = PayloadTLV } - err := hopPayload.Decode(bytes.NewReader(hopInfo[:])) + err := hopPayload.Decode(bytes.NewReader(hopInfo)) if err != nil { return nil, nil, err } // With the necessary items extracted, we'll copy of the onion packet // for the next node, snipping off our per-hop data. - var nextMixHeader [routingInfoSize]byte - copy(nextMixHeader[:], hopInfo[hopPayload.NumBytes():]) + var nextMixHeader = make([]byte, routingInfoLen) + copy(nextMixHeader, hopInfo[hopPayload.NumBytes():]) innerPkt := &OnionPacket{ Version: onionPkt.Version, EphemeralKey: nextDHKey, @@ -853,3 +902,12 @@ func (t *Tx) Commit() ([]ProcessedPacket, *ReplaySet, error) { return t.packets, rs, err } + +// numStreamBytes is the number of bytes that needs to be produced by our CSPRG +// for the key stream implementing our stream cipher to encrypt/decrypt the mix +// header. The routingInfoSize bytes at the end are used to encrypt/decrypt the +// fillers when processing the packet of generating the HMACs when creating the +// packet. +func numStreamBytes(routingInfoSize int) uint { + return uint(routingInfoSize * 2) +} diff --git a/sphinx_test.go b/sphinx_test.go index 601b053..3ab6830 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -2,6 +2,7 @@ package sphinx import ( "bytes" + "crypto/rand" "encoding/hex" "encoding/json" "fmt" @@ -126,7 +127,7 @@ func newTestRoute(numHops int) ([]*Router, *PaymentPath, *[]HopData, *OnionPacke // adding padding so parsing still works. sessionKey, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{'A'}, 32)) fwdMsg, err := NewOnionPacket( - &route, sessionKey, nil, DeterministicPacketFiller, + &route, sessionKey, nil, DeterministicPacketFiller, false, ) if err != nil { return nil, nil, nil, nil, fmt.Errorf("unable to create "+ @@ -147,6 +148,146 @@ func newTestRoute(numHops int) ([]*Router, *PaymentPath, *[]HopData, *OnionPacke return nodes, &route, &hopsData, fwdMsg, nil } +func newOnionMessageRoute(numHops int) (*OnionPacket, *PaymentPath, []*Router, + error) { + + if numHops < 2 { + return nil, nil, nil, fmt.Errorf("at least 2 hops are " + + "required to create an onion message route") + } + + // Create routers for each hop. + nodes := make([]*Router, numHops) + for i := 0; i < numHops; i++ { + privKey, err := btcec.NewPrivateKey() + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to generate "+ + "random key for sphinx node: %v", err) + } + nodes[i] = NewRouter( + &PrivKeyECDH{PrivKey: privKey}, NewMemoryReplayLog(), + ) + } + + // Split the nodes into two parts for creating two blinded paths. + mid := numHops / 2 + firstPathNodes := nodes[:mid] + secondPathNodes := nodes[mid:] + + // Create the sessions keys for the two blinded paths. + firstSessionKey, _ := btcec.NewPrivateKey() + secondSessionKey, _ := btcec.NewPrivateKey() + + // Create the first blinded path, adding a next_path_key_override TLV + // at the last node. + firstPathInfos := make([]*HopInfo, len(firstPathNodes)) + for i, node := range firstPathNodes { + nextNodeID := node.onionKey.PubKey().SerializeCompressed() + var b bytes.Buffer + if i == len(firstPathNodes)-1 { + secondsSessPub := secondSessionKey.PubKey() + pathKeyOverride := secondsSessPub.SerializeCompressed() + // Encode TLV record for type 4 (next node ID) + b.Write(encodeTLVRecord(4, nextNodeID)) + // Encode TLV record for type 8 (path key override) + b.Write(encodeTLVRecord(8, pathKeyOverride)) + } else { + // Encode TLV record for type 4 (next node ID) + b.Write(encodeTLVRecord(4, nextNodeID)) + } + firstPathInfos[i] = &HopInfo{ + NodePub: node.onionKey.PubKey(), + PlainText: b.Bytes(), + } + } + firstBlindedPath, err := BuildBlindedPath( + firstSessionKey, firstPathInfos, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("error generating first "+ + "blinded path: %v", err) + } + + // Create the second blinded path, omitting the next_node_id TLV for the + // last node. + secondPathInfos := make([]*HopInfo, len(secondPathNodes)) + for i, node := range secondPathNodes { + nextNodeID := node.onionKey.PubKey().SerializeCompressed() + var b bytes.Buffer + if i == len(secondPathNodes)-1 { + pathID := make([]byte, 20) + if _, err := rand.Read(pathID); err != nil { + return nil, nil, nil, fmt.Errorf("unable to "+ + "generate random path ID: %v", err) + } + // Encode TLV record for type 6 (path ID) + b.Write(encodeTLVRecord(6, pathID)) + } else { + // Encode TLV record for type 4 (next node ID) + b.Write(encodeTLVRecord(4, nextNodeID)) + } + + secondPathInfos[i] = &HopInfo{ + NodePub: node.onionKey.PubKey(), + PlainText: b.Bytes(), + } + } + secondBlindedPath, err := BuildBlindedPath( + secondSessionKey, secondPathInfos, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("error generating second "+ + "blinded path: %v", err) + } + + blindedPath := &BlindedPath{ + IntroductionPoint: firstBlindedPath.Path.IntroductionPoint, + BlindingPoint: firstBlindedPath.Path.BlindingPoint, + BlindedHops: append( + firstBlindedPath.Path.BlindedHops, + secondBlindedPath.Path.BlindedHops..., + ), + } + + // Create the route from the blinded path, always adding the + // hop.CipherText as a TLV field type 4. + var route PaymentPath + for i, hop := range blindedPath.BlindedHops { + var b bytes.Buffer + + if i == len(blindedPath.BlindedHops)-1 { + hello := []byte("hello") + // Encode TLV record for type 4 (cipher text) + b.Write(encodeTLVRecord(4, hop.CipherText)) + // Encode TLV record for type 65 (hello message) + b.Write(encodeTLVRecord(65, hello)) + } else { + // Encode TLV record for type 4 (cipher text) + b.Write(encodeTLVRecord(4, hop.CipherText)) + } + + route[i] = OnionHop{ + NodePub: *hop.BlindedNodePub, + HopPayload: HopPayload{ + Type: PayloadTLV, + Payload: b.Bytes(), + }, + } + } + + // Generate the onion packet. + sessionKey, _ := btcec.NewPrivateKey() + onionPacket, err := NewOnionPacket( + &route, sessionKey, nil, DeterministicPacketFiller, true, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to create onion "+ + "packet: %v", err) + } + + return onionPacket, &route, nodes, nil +} + func TestBolt4Packet(t *testing.T) { var ( route PaymentPath @@ -188,6 +329,7 @@ func TestBolt4Packet(t *testing.T) { sessionKey, _ := btcec.PrivKeyFromBytes(bolt4SessionKey) pkt, err := NewOnionPacket( &route, sessionKey, bolt4AssocData, DeterministicPacketFiller, + false, ) if err != nil { t.Fatalf("unable to construct onion packet: %v", err) @@ -274,7 +416,7 @@ func TestTLVPayloadMessagePacket(t *testing.T) { sessionKey, _ := btcec.PrivKeyFromBytes(sessionKeyBytes) pkt, err := NewOnionPacket( - &route, sessionKey, nil, DeterministicPacketFiller, + &route, sessionKey, nil, DeterministicPacketFiller, true, ) require.NoError(t, err) @@ -321,7 +463,7 @@ func TestProcessOnionMessageZeroLengthPayload(t *testing.T) { // Now, create the onion packet. onionPacket, err := NewOnionPacket( - path, sessionKey, nil, DeterministicPacketFiller, + path, sessionKey, nil, DeterministicPacketFiller, true, ) require.NoError(t, err) @@ -691,7 +833,7 @@ func newEOBRoute(numHops uint32, // adding padding so parsing still works. sessionKey, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{'A'}, 32)) fwdMsg, err := NewOnionPacket( - &route, sessionKey, nil, DeterministicPacketFiller, + &route, sessionKey, nil, DeterministicPacketFiller, false, ) if err != nil { return nil, nil, err @@ -709,6 +851,18 @@ func mustNewLegacyHopPayload(hopData *HopData) HopPayload { return payload } +// TestPaymentPathTotalPayloadSizeExceeds1300 tests that a PaymentPath can have +// a TotalPayloadSize greater than 1300 bytes. +func TestPaymentPathTotalPayloadSizeExceeds1300(t *testing.T) { + _, route, _, err := newOnionMessageRoute(15) + require.NoError(t, err, "newOnionMessageRoute should not return an "+ + "error") + + totalSize := route.TotalPayloadSize() + require.Greater(t, totalSize, 1300, "TotalPayloadSize should be "+ + "greater than 1300") +} + // TestSphinxHopVariableSizedPayloads tests that we're able to fully decode an // EOB payload that was targeted at the final hop in a route, and also when // intermediate nodes have EOB data encoded as well. Additionally, we test that @@ -828,7 +982,7 @@ func TestSphinxHopVariableSizedPayloads(t *testing.T) { Payload: bytes.Repeat([]byte("a"), 500), }, }, - expectedError: ErrMaxRoutingInfoSizeExceeded, + expectedError: ErrStandardRoutingPayloadSizeExceeded, }, } @@ -1048,7 +1202,7 @@ func TestTLVPayloadOnion(t *testing.T) { // With all the required data assembled, we'll craft a new packet. sessionKey, _ := btcec.PrivKeyFromBytes(sessionKeyBytes) pkt, err := NewOnionPacket( - &route, sessionKey, assocData, DeterministicPacketFiller, + &route, sessionKey, assocData, DeterministicPacketFiller, false, ) require.NoError(t, err) @@ -1123,7 +1277,7 @@ func TestVariablePayloadOnion(t *testing.T) { // With all the required data assembled, we'll craft a new packet. sessionKey, _ := btcec.PrivKeyFromBytes(sessionKeyBytes) pkt, err := NewOnionPacket( - &route, sessionKey, assocData, DeterministicPacketFiller, + &route, sessionKey, assocData, DeterministicPacketFiller, false, ) require.NoError(t, err) From a1ebe156962e22d6a11591dcf0026ea24582b107 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:20:07 +0200 Subject: [PATCH 09/11] sphinx: add check for blinding point When we are parsing onion messages, we must ensure that a blinding point is provided. --- sphinx.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sphinx.go b/sphinx.go index 863783f..88d9d9b 100644 --- a/sphinx.go +++ b/sphinx.go @@ -601,6 +601,13 @@ func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, o(cfg) } + // If this is an onion message, a blinding point must be provided and + // associated data must be nil. + if cfg.isOnionMessage && cfg.blindingPoint == nil && assocData != nil { + return nil, fmt.Errorf("blinding point must be provided for " + + "onion messages, and associated data must be nil") + } + // Compute the shared secret for this onion packet. sharedSecret, err := r.generateSharedSecret( onionPkt.EphemeralKey, cfg.blindingPoint, From 072dec95b5b2bf595556b9740040a9767dbbc373 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:44:14 +0200 Subject: [PATCH 10/11] sphinx: add MaxPayloadSize for backwards comp The field MaxPayloadSize is added to the sphinx package to allow for backwards compatibility with the old sphinx package. Removing it would have been a breaking change. --- sphinx.go | 58 +++++++++++++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/sphinx.go b/sphinx.go index 88d9d9b..0efdd6d 100644 --- a/sphinx.go +++ b/sphinx.go @@ -41,26 +41,16 @@ const ( LegacyHopDataSize = (RealmByteSize + AddressSize + AmtForwardSize + OutgoingCLTVSize + NumPaddingBytes + HMACSize) - // MaxPayloadSize is the maximum size an `update_add_htlc` payload for a - // single hop can be. This is the worst case scenario of a single hop, - // consuming all available space. We need to know this in order to - // generate a sufficiently long stream of pseudo-random bytes when - // encrypting/decrypting the payload. This field is here for backwards - // compatibility. Throughout the code we use StandardRoutingInfoSize - // because of the more apt naming. - MaxPayloadSize = standardRoutingInfoSize - StandardRoutingInfoSize = standardRoutingInfoSize - - // standardRoutingInfoSize is the fixed size of the the routing info. This - // consists of an addressSize byte address and a HMACSize byte HMAC for - // each hop of the route, the first pair in cleartext and the following - // pairs increasingly obfuscated. If not all space is used up, the - // remainder is padded with null-bytes, also obfuscated. - standardRoutingInfoSize = 1300 - - // JumboRoutingInfoSize is the size of the routing info for a jumbo - // onion packet. - JumboRoutingInfoSize = 32768 + // MaxRoutingPayloadSize is the maximum size an `update_add_htlc` + // payload for a single hop can be. This is the worst case scenario of a + // single hop, consuming all available space. We need to know this in + // order to generate a sufficiently long stream of pseudo-random bytes + // when encrypting/decrypting the payload. + MaxRoutingPayloadSize = 1300 + + // MaxOnionMessagePayloadSize is the size of the routing info for a + // onion messaging jumbo onion packet. + MaxOnionMessagePayloadSize = 32768 // keyLen is the length of the keys used to generate cipher streams and // encrypt payloads. Since we use SHA256 to generate the keys, the @@ -73,13 +63,13 @@ const ( var ( ErrStandardRoutingPayloadSizeExceeded = fmt.Errorf( - "max routing info size of %v bytes exceeded", - StandardRoutingInfoSize, + "max routing payload size of %v bytes exceeded", + MaxRoutingPayloadSize, ) ErrMessageRoutingPayloadSizeExceeded = fmt.Errorf( - "max onion message routing info size of %v bytes exceeded", - JumboRoutingInfoSize, + "max onion message routing payload size of %v bytes exceeded", + MaxOnionMessagePayloadSize, ) ) @@ -212,16 +202,16 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, totalPayloadSize := paymentPath.TotalPayloadSize() - routingInfoLen := StandardRoutingInfoSize - maxRoutingInfoErr := ErrStandardRoutingPayloadSizeExceeded - if isOnionMessage && totalPayloadSize > StandardRoutingInfoSize { - routingInfoLen = JumboRoutingInfoSize - maxRoutingInfoErr = ErrMessageRoutingPayloadSizeExceeded + routingPayloadLen := MaxRoutingPayloadSize + maxRoutingPayloadErr := ErrStandardRoutingPayloadSizeExceeded + if isOnionMessage && totalPayloadSize > MaxRoutingPayloadSize { + routingPayloadLen = MaxOnionMessagePayloadSize + maxRoutingPayloadErr = ErrMessageRoutingPayloadSizeExceeded } // Check whether total payload size doesn't exceed the hard maximum. - if totalPayloadSize > routingInfoLen { - return nil, maxRoutingInfoErr + if totalPayloadSize > routingPayloadLen { + return nil, maxRoutingPayloadErr } // Before we proceed, we'll check that the payload types of each hop @@ -254,13 +244,13 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, // Generate the padding, called "filler strings" in the paper. filler := generateHeaderPadding( - "rho", paymentPath, hopSharedSecrets, routingInfoLen, + "rho", paymentPath, hopSharedSecrets, routingPayloadLen, ) // Allocate zero'd out byte slices to store the final mix header packet // and the hmac for each hop. var ( - mixHeader = make([]byte, routingInfoLen) + mixHeader = make([]byte, routingPayloadLen) nextHmac [HMACSize]byte hopPayloadBuf bytes.Buffer ) @@ -287,7 +277,7 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, // Next, using the key dedicated for our stream cipher, we'll // generate enough bytes to obfuscate this layer of the onion // packet. - streamBytes := generateCipherStream(rhoKey, uint(routingInfoLen)) + streamBytes := generateCipherStream(rhoKey, uint(routingPayloadLen)) payload := paymentPath[i].HopPayload // Before we assemble the packet, we'll shift the current From 8b3f9c24cb1348cef862ceaf2b89ca68962bc639 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 23 Jul 2025 18:09:26 +0200 Subject: [PATCH 11/11] chore: refactor if-then into switch case --- path_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/path_test.go b/path_test.go index 6246eaf..6b466ba 100644 --- a/path_test.go +++ b/path_test.go @@ -357,9 +357,10 @@ func TestOnionRouteBlinding(t *testing.T) { priv := privKeyFromString(hop.NodePrivKey) - if i == introPointIndex { + switch i { + case introPointIndex: blindingPoint = firstBlinding - } else if i == concatIndex { + case concatIndex: blindingPoint = blindingOverride }