From 6ab4f0422c292880adfe9d653f0f6e8b346fe724 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 19 Jun 2025 13:41:27 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 70da3f4da72653e1daca8d6fc4eb3951a918465b Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 25 Jul 2025 14:29:41 +0200 Subject: [PATCH 4/9] go.mod update --- go.mod | 28 ++++++++++++++---------- go.sum | 69 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/go.mod b/go.mod index eae622f..4897cfe 100644 --- a/go.mod +++ b/go.mod @@ -2,22 +2,28 @@ 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 ) 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 9cb70df7f85ff8142186bc27b0f6ccf20a75856e Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Fri, 25 Jul 2025 14:35:55 +0200 Subject: [PATCH 5/9] .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 3b4580066e006d3cac8bbb5d039bfc25f1fe67e3 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 26 Jun 2025 18:21:29 +0200 Subject: [PATCH 6/9] 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 56fbdfb70a1cf4d1d3c7c81b68913afaca23031f Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 16:48:50 +0200 Subject: [PATCH 7/9] 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 | 50 ++++++++++++++++++++++++++-------------------- sphinx.go | 44 ++++++++++++++++++++++++++++++---------- sphinx_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/payload.go b/payload.go index 9e89dad..ba2f2b0 100644 --- a/payload.go +++ b/payload.go @@ -99,36 +99,24 @@ func (hp *HopPayload) Decode(r io.Reader) error { return err } - var ( - legacyPayload = isLegacyPayloadByte(peekByte[0]) - payloadSize uint16 - ) + var payloadSize uint16 - if legacyPayload { + // 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]) { payloadSize = legacyPayloadSize() hp.Type = PayloadLegacy } else { + // If the first byte doesn't indicate a legacy payload, then it + // *must* be a TLV payload. 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 - } - - return nil + return readPayloadAndHMAC(hp, bufReader, payloadSize) } // HopData attempts to extract a set of forwarding instructions from the target @@ -146,6 +134,22 @@ 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 +} + // tlvPayloadSize uses the passed reader to extract the payload length encoded // as a var-int. func tlvPayloadSize(r io.Reader) (uint16, error) { @@ -314,8 +318,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..afdd3e2 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 + tlvPayloadOnly bool } // ProcessOnionOpt defines the signature of a function option that can be used @@ -525,6 +526,14 @@ func WithBlindingPoint(point *btcec.PublicKey) ProcessOnionOpt { } } +// WithTLVPayloadOnly is a functional option that signals that the onion packet +// being processed is from onion message. +func WithTLVPayloadOnly() ProcessOnionOpt { + return func(cfg *processOnionCfg) { + cfg.tlvPayloadOnly = 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.tlvPayloadOnly, + ) 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.tlvPayloadOnly, + ) } // 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, tlvPayloadOnly 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 tlvPayloadOnly { + // If the payload is assured to be TLV, 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, tlvPayloadOnly 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, tlvPayloadOnly, ) 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.tlvPayloadOnly, + ) if err != nil { return err } diff --git a/sphinx_test.go b/sphinx_test.go index c93e10e..f2a6b3d 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, WithTLVPayloadOnly(), + ) + 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 d3ff556d8971d56575fe48346f6266f156ab7f05 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 17:47:54 +0200 Subject: [PATCH 8/9] multi: Support custom size onion 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 custom size packets and the tests for this feature. NewOnionPacket now allows for a final variadic argument payloadSizes. The sizes passed are then compared to the actual payload size of the entire path, and the first value that fits the actual payload size will then be used as the size of the routing info. We use this to fix the size of onion messages at 1300 or 32768 bytes as suggested by BOLT-0004 but it can be used to fix the size at any value. If no values are passed the func defaults to MaxRoutingPayloadSize. MaxRoutingPayloadSize and MaxOnionMessagePayloadSize are exposed to facilitate easy usage of this library. sphinx_test now has 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. --- cmd/main.go | 16 ++++ packetfiller.go | 12 +-- sphinx.go | 175 ++++++++++++++++++++++++++++-------------- sphinx_test.go | 200 +++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 340 insertions(+), 63 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 032738f..3344b4e 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.", + }, }, }, { @@ -203,8 +209,18 @@ func generate(ctx *cli.Context) error { return fmt.Errorf("could not peel onion spec: %v", err) } + payloadSizes := []int{ + sphinx.MaxRoutingPayloadSize, + } + if ctx.Bool("onion-message") { + payloadSizes = append( + payloadSizes, + sphinx.MaxOnionMessagePayloadSize, + ) + } msg, err := sphinx.NewOnionPacket( path, sessionKey, assocData, sphinx.DeterministicPacketFiller, + payloadSizes..., ) 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 afdd3e2..ce7a367 100644 --- a/sphinx.go +++ b/sphinx.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "fmt" "io" + "sort" "sync" "github.com/btcsuite/btcd/btcec/v2" @@ -41,26 +42,16 @@ 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 - // 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 - - // 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 + // 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 @@ -72,8 +63,7 @@ const ( ) var ( - ErrMaxRoutingInfoSizeExceeded = fmt.Errorf( - "max routing info size of %v bytes exceeded", routingInfoSize) + ErrPayloadSizeExceeded = fmt.Errorf("max custom payload size exceeded") ) // OnionPacket is the onion wrapped hop-to-hop routing information necessary to @@ -102,7 +92,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 @@ -191,14 +181,13 @@ 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'. +// routing a message through the mix-net path outline by 'paymentPath'. The +// total size of the onion 'clicks' to the first value in payloadSizes that is +// bigger than the total payload size of the path. If no size is given, it +// defaults to the maximum routing payload size. 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, + payloadSizes ...int) (*OnionPacket, error) { // If we don't actually have a partially populated route, then we'll // exit early. @@ -207,6 +196,52 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, return nil, fmt.Errorf("route of length zero passed in") } + totalPayloadSize := paymentPath.TotalPayloadSize() + + // We default to the maximum routing payload size if the caller didn't + // provide any payload sizes. + if len(payloadSizes) == 0 { + payloadSizes = []int{MaxRoutingPayloadSize} + } + + sort.Ints(payloadSizes) + + // We'll now select the smallest payload size that is large enough to + // fit the entire onion payload. If no such size exists, then we'll + // return an error + var payloadSize int + found := false + for _, size := range payloadSizes { + if size >= totalPayloadSize { + payloadSize = size + found = true + break + } + } + + // Return an error if we couldn't find a suitable payload size. + if !found { + return nil, ErrPayloadSizeExceeded + } + + // If the payload size is not equal to MaxRoutingPayloadSize, then we + // check if any of the hops have a legacy payload. If so, we return an + // error as legacy payloads are not supported for those payload sizes. + if payloadSize != MaxRoutingPayloadSize { + for i := 0; i < numHops; i++ { + hopPayload := (*paymentPath)[i].HopPayload + isLegacy := hopPayload.Type == PayloadLegacy + + // For any onion size other than MaxRoutingPayloadSize, + // we only expect TLV payloads. + if isLegacy { + return nil, fmt.Errorf("hop %d has legacy "+ + "payload, but this payload size "+ + "requires 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). @@ -218,22 +253,25 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, paymentPath.NodeKeys(), sessionKey, ) if err != nil { - return nil, fmt.Errorf("error generating shared secret: %v", err) + return nil, fmt.Errorf("error generating shared secret: %v", + err) } // Generate the padding, called "filler strings" in the paper. - filler := generateHeaderPadding("rho", paymentPath, hopSharedSecrets) + filler := generateHeaderPadding( + "rho", paymentPath, hopSharedSecrets, payloadSize, + ) // 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, payloadSize) 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 +292,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(payloadSize)) 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 +323,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 +360,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 +372,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 +380,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 +407,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 +446,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 +696,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 +711,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 +736,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 +907,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 f2a6b3d..150592b 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -2,6 +2,7 @@ package sphinx import ( "bytes" + "crypto/rand" "encoding/hex" "encoding/json" "fmt" @@ -147,6 +148,147 @@ 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, + MaxRoutingPayloadSize, MaxOnionMessagePayloadSize, + ) + 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 @@ -709,6 +851,62 @@ 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) { + onionPacket, 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") + + require.Equal(t, MaxOnionMessagePayloadSize, + len(onionPacket.RoutingInfo), "RoutingInfo length should "+ + "be equal to MaxOnionMessagePayloadSize") +} + +// TestCustomPayloadSize tests that we can create an onion packet with any size +// of routing info. +func TestCustomPayloadSize(t *testing.T) { + t.Parallel() + + customPayloadSize := 1234 + + // First, create a privKey that will act as the destination hop in the + // path. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + // 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 extremely simple + payload, err := NewTLVHopPayload([]byte{1, 2, 3}) + require.NoError(t, err) + path[0].HopPayload = payload + + // Now, create the onion packet. + onionPacket, err := NewOnionPacket( + path, sessionKey, nil, DeterministicPacketFiller, + customPayloadSize, + ) + require.NoError(t, err) + + require.Equal(t, customPayloadSize, len(onionPacket.RoutingInfo), + "RoutingInfo length should be equal to customPayloadSize") + +} + // 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 +1026,7 @@ func TestSphinxHopVariableSizedPayloads(t *testing.T) { Payload: bytes.Repeat([]byte("a"), 500), }, }, - expectedError: ErrMaxRoutingInfoSizeExceeded, + expectedError: ErrPayloadSizeExceeded, }, } From dcb8d6dfe25f274cf6033056b5be7879e8380325 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 23 Jul 2025 18:09:26 +0200 Subject: [PATCH 9/9] 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 }