From 7aa5f324a353367ea5e5d8c4c7b910ae023e56ba Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 19 Jun 2025 13:41:27 +0200 Subject: [PATCH 01/15] sphinx: remove dead code and 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 d17321d4b84ec9522466d86ce74d3625b0e6ae86 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Mon, 23 Jun 2025 15:19:14 +0200 Subject: [PATCH 02/15] 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 | 175 +++++++++++++++++- .../blinded-onion-message-onion-test.json | 143 ++++++++++++++ 2 files changed, 316 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..1418092 100644 --- a/path_test.go +++ b/path_test.go @@ -12,8 +12,9 @@ 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" ) // TestBuildBlindedRoute tests BuildBlindedRoute and decryptBlindedHopData against @@ -117,6 +118,128 @@ func TestBuildBlindedRoute(t *testing.T) { } } +// TestBuildOnionMessageBlindedRoute tests BuildBlindedRoute, +// decryptBlindedHopData and NextEphemeral against the spec test vectors. +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 + // blindingJsonTestCase 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, e string) []*HopInfo { + path := make([]*HopInfo, len(h)) + currentHop := e + for i, hop := range h { + // The json test vector only specifies the current node + // ID as the next node id in the payload of the previous + // node, so we get that from the previous hop. + nodeIDStr, _ := hex.DecodeString( + currentHop, + ) + nodeID, _ := btcec.ParsePubKey(nodeIDStr) + payload, _ := hex.DecodeString(hop.EncryptedDataTlv) + + path[i] = &HopInfo{ + NodePub: nodeID, + PlainText: payload, + } + 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:], bolt4PubKeys[1], + ) + pathDB, 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. + aliceBobPath := buildMessagePath( + testCase.Generate.Hops[:1], bolt4PubKeys[0], + ) + senderSessKey := privKeyFromString( + testCase.Generate.Hops[0].PathKeySecret, + ) + pathAB, err := BuildBlindedPath(senderSessKey, aliceBobPath) + require.NoError(t, err) + + // Construct the concatenated path. + path := &BlindedPath{ + IntroductionPoint: pathAB.Path.IntroductionPoint, + BlindingPoint: pathAB.Path.BlindingPoint, + BlindedHops: append(pathAB.Path.BlindedHops, + pathDB.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.True( + t, bytes.Equal(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. + decoded, _ := hex.DecodeString(genData.EncryptedDataTlv) + require.True(t, bytes.Equal(data, decoded)) + + 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 +346,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 +395,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 +413,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 90ace2580f57fcb6631e7faecda0832bae3282f3 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 25 Jun 2025 15:30:58 +0200 Subject: [PATCH 03/15] 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 | 112 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/path_test.go b/path_test.go index 1418092..501d578 100644 --- a/path_test.go +++ b/path_test.go @@ -331,6 +331,106 @@ 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 + // blindingJsonTestCase 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) *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) + ) + + var blindingPoint *btcec.PublicKey + for i, hop := range testCase.Decrypt.Hops { + buff := bytes.NewBuffer(nil) + require.NoError(t, onionPacket.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. + const onionMessageHexHeaderLen = 74 + + require.Equal( + t, hop.OnionMessage[onionMessageHexHeaderLen:], + hex.EncodeToString(buff.Bytes()), + ) + + priv := privKeyFromString(hop.PrivKey) + + switch i { + case 0: + // Onion message routes are always entirely blinded, so + // the first hop will always use the first blinding + // point. + blindingPoint = firstBlinding + case concatIndex: + blindingPoint = blindingOverride + } + + processedPkt := peelOnion(priv, blindingPoint) + + blindingPoint, err = NextEphemeral( + &PrivKeyECDH{priv}, blindingPoint, + ) + require.NoError(t, err) + + onionPacket = processedPkt.NextPacket + } +} + type onionBlindingJsonTestCase struct { Generate generateOnionData `json:"generate"` Decrypt decryptData `json:"decrypt"` @@ -369,10 +469,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 { @@ -387,6 +487,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 255c9b4baf7ac08f32f7c79af18d488368bbef0b Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 26 Jun 2025 18:21:29 +0200 Subject: [PATCH 04/15] 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. --- .gitignore | 1 + go.mod | 18 ++++++++--- go.sum | 20 ++++++++++++ sphinx_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 31e3ac6..033d67f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor/ .idea +.aider* diff --git a/go.mod b/go.mod index eae622f..95dd37f 100644 --- a/go.mod +++ b/go.mod @@ -2,22 +2,30 @@ 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/btcd v0.24.1-0.20240301210420-1a2b599bf1af + github.com/btcsuite/btcd/btcec/v2 v2.3.2 github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f 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/stretchr/testify v1.8.4 github.com/urfave/cli v1.22.5 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 + golang.org/x/crypto v0.16.0 +) + +require ( + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/lightningnetwork/lnd/fn/v2 v2.0.2 // indirect + golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect + golang.org/x/sync v0.7.0 // indirect ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/lightningnetwork/lnd/tlv v1.3.2 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 + golang.org/x/sys v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index af52f16..2836f91 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,14 @@ github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmH 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 v0.24.1-0.20240301210420-1a2b599bf1af h1:F60A3wst4/fy9Yr1Vn8MYmFlfn7DNLxp8o8UTvhqgBE= +github.com/btcsuite/btcd v0.24.1-0.20240301210420-1a2b599bf1af/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= 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/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= +github.com/btcsuite/btcd/btcec/v2 v2.3.2/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-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= @@ -15,6 +21,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/lightningnetwork/lnd/fn/v2 v2.0.2 h1:M7o2lYrh/zCp+lntPB3WP/rWTu5U+4ssyHW+kqNJ0fs= +github.com/lightningnetwork/lnd/fn/v2 v2.0.2/go.mod h1:TOzwrhjB/Azw1V7aa8t21ufcQmdsQOQMDtxVOQWNl8s= +github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= +github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= 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= @@ -28,16 +38,26 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4= +golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/sphinx_test.go b/sphinx_test.go index 4fa1759..ad0637f 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/davecgh/go-spew/spew" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -162,6 +163,89 @@ func TestBolt4Packet(t *testing.T) { } } +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 + records := []tlv.Record{} + + if i == len(testCase.Route.Hops)-1 { + helloBytes := []byte("hello") + records = append(records, tlv.MakePrimitiveRecord( + 1, &helloBytes, + )) + } + + records = append(records, tlv.MakePrimitiveRecord( + 4, &EncryptedRecipientData, + )) + + stream, err := tlv.NewStream(records...) + require.NoError(t, err, "new stream") + + b := new(bytes.Buffer) + require.NoError(t, stream.Encode(b), "encode") + + 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, b.Bytes(), finalPacket, "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 +839,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 5d467fdfe02ee62cc3c1cd846f636c899d3b5c24 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 16:48:50 +0200 Subject: [PATCH 05/15] 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. --- payload.go | 81 ++++++++++++++++++++++++++++++++++-------------------- sphinx.go | 35 +++++++++++++++++------ 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/payload.go b/payload.go index 9e89dad..c1ca71e 100644 --- a/payload.go +++ b/payload.go @@ -87,8 +87,9 @@ func (hp *HopPayload) Encode(w io.Writer) error { } // Decode unpacks an encoded HopPayload from the passed reader into the target -// HopPayload. -func (hp *HopPayload) Decode(r io.Reader) error { +// HopPayload. The isMessage boolean should be set to true if we're parsing a +// payload that is known to be for an onion message. +func (hp *HopPayload) Decode(r io.Reader, isMessage bool) error { bufReader := bufio.NewReader(r) // In order to properly parse the payload, we'll need to check the @@ -99,36 +100,16 @@ 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 + // Per BOLT 7, onion messages MUST use the TLV format. + if isMessage { + return decodeTLVHopPayload(hp, bufReader) } - // 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 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 +127,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 +331,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..fadfe0d 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 an 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 @@ -661,7 +675,8 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // out the payload so we can derive the specified forwarding // instructions. var hopPayload HopPayload - if err := hopPayload.Decode(bytes.NewReader(hopInfo[:])); err != nil { + err := hopPayload.Decode(bytes.NewReader(hopInfo[:]), isOnionMessage) + if err != nil { return nil, nil, err } @@ -683,7 +698,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 +708,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 +718,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 +809,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 } From 987a4c3baa188b4a6bfc59b81f0a7b7881eb8761 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 17:19:20 +0200 Subject: [PATCH 06/15] sphinx_test: Add zero-length payload om test --- sphinx_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/sphinx_test.go b/sphinx_test.go index ad0637f..6e6d80c 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -246,6 +246,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 b4f3edb2850e7ce7c2675f8464bf0850c24e0b5a Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Tue, 8 Jul 2025 17:47:54 +0200 Subject: [PATCH 07/15] 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. --- cmd/main.go | 12 ++++ packetfiller.go | 12 ++-- sphinx.go | 170 +++++++++++++++++++++++++++++++++--------------- sphinx_test.go | 4 +- 4 files changed, 137 insertions(+), 61 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 032738f..30ff842 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,14 @@ func generate(ctx *cli.Context) error { return fmt.Errorf("could not peel onion spec: %v", err) } + var onionOpts []sphinx.OnionPacketOption + if ctx.Bool("onion-message") { + onionOpts = append(onionOpts, sphinx.WithOnionMessage()) + } + msg, err := sphinx.NewOnionPacket( path, sessionKey, assocData, sphinx.DeterministicPacketFiller, + onionOpts..., ) 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 fadfe0d..cb97c04 100644 --- a/sphinx.go +++ b/sphinx.go @@ -41,26 +41,13 @@ 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 + // StandardRoutingInfoSize is the size of the routing info for a standard + // onion packet. + StandardRoutingInfoSize = 1300 + + // 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 +59,15 @@ const ( ) var ( - ErrMaxRoutingInfoSizeExceeded = fmt.Errorf( - "max routing info size of %v bytes exceeded", routingInfoSize) + ErrStandardRoutingInfoSizeExceeded = fmt.Errorf( + "max routing info size of %v bytes exceeded", + StandardRoutingInfoSize, + ) + + ErrJumboRoutingInfoSizeExceeded = 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 +96,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 @@ -190,15 +184,29 @@ func generateSharedSecrets(paymentPath []*btcec.PublicKey, return hopSharedSecrets, lastEphemeralPubKey, nil } +// onionPacketCfg is a struct that holds the configuration for creating a new +// onion packet. +type onionPacketCfg struct { + isOnionMessage bool +} + +// OnionPacketOption is a function that can be used to modify the onion packet +// configuration. +type OnionPacketOption func(*onionPacketCfg) + +// WithOnionMessage is a functional option that signals that the onion packet +// being created is an onion message. +func WithOnionMessage() OnionPacketOption { + return func(cfg *onionPacketCfg) { + cfg.isOnionMessage = true + } +} + // 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, + opts ...OnionPacketOption) (*OnionPacket, error) { // If we don't actually have a partially populated route, then we'll // exit early. @@ -207,6 +215,46 @@ func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, return nil, fmt.Errorf("route of length zero passed in") } + cfg := &onionPacketCfg{} + for _, o := range opts { + o(cfg) + } + + totalPayloadSize := paymentPath.TotalPayloadSize() + + var routingInfoLen int + maxRoutingInfoErr := ErrStandardRoutingInfoSizeExceeded + if cfg.isOnionMessage { + switch { + case totalPayloadSize <= StandardRoutingInfoSize: + routingInfoLen = StandardRoutingInfoSize + default: + routingInfoLen = JumboRoutingInfoSize + maxRoutingInfoErr = ErrJumboRoutingInfoSizeExceeded + } + } else { + routingInfoLen = StandardRoutingInfoSize + } + + // 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 cfg.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 +270,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 +304,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 +335,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 +372,8 @@ 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 +383,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 +391,10 @@ 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, uint(routingInfoLen*2)) xor(filler, filler, streamBytes[fillerStart:fillerEnd]) } @@ -365,7 +416,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 +455,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 +705,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 +720,13 @@ 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), uint(routingInfoLen*2), ) - 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, routingInfoLen*2) + xor(hopInfo, headerWithPadding, streamBytes) // Randomize the DH group element for the next hop using the // deterministic blinding factor. @@ -675,15 +737,15 @@ func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, // out the payload so we can derive the specified forwarding // instructions. var hopPayload HopPayload - err := hopPayload.Decode(bytes.NewReader(hopInfo[:]), isOnionMessage) + err := hopPayload.Decode(bytes.NewReader(hopInfo), isOnionMessage) 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, diff --git a/sphinx_test.go b/sphinx_test.go index 6e6d80c..628a328 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -233,6 +233,7 @@ func TestTLVPayloadMessagePacket(t *testing.T) { pkt, err := NewOnionPacket( &route, sessionKey, nil, DeterministicPacketFiller, + WithOnionMessage(), ) require.NoError(t, err) @@ -280,6 +281,7 @@ func TestProcessOnionMessageZeroLengthPayload(t *testing.T) { // Now, create the onion packet. onionPacket, err := NewOnionPacket( path, sessionKey, nil, DeterministicPacketFiller, + WithOnionMessage(), ) require.NoError(t, err) @@ -786,7 +788,7 @@ func TestSphinxHopVariableSizedPayloads(t *testing.T) { Payload: bytes.Repeat([]byte("a"), 500), }, }, - expectedError: ErrMaxRoutingInfoSizeExceeded, + expectedError: ErrStandardRoutingInfoSizeExceeded, }, } From dd68d4595447bd19772c34d5f10b4d2bcec0f9b9 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 26 Jun 2025 18:38:45 +0200 Subject: [PATCH 08/15] 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. --- sphinx_test.go | 191 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/sphinx_test.go b/sphinx_test.go index 628a328..78493fd 100644 --- a/sphinx_test.go +++ b/sphinx_test.go @@ -2,6 +2,7 @@ package sphinx import ( "bytes" + "crypto/rand" "encoding/hex" "encoding/json" "fmt" @@ -105,6 +106,184 @@ 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 + var tlvStream *tlv.Stream + var err error + if i == len(firstPathNodes)-1 { + secondsSessPub := secondSessionKey.PubKey() + pathKeyOverride := secondsSessPub.SerializeCompressed() + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(4, &nextNodeID), + tlv.MakePrimitiveRecord(8, &pathKeyOverride), + ) + } else { + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(4, &nextNodeID), + ) + } + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to create "+ + "TLV stream: %v", err) + } + if err := tlvStream.Encode(&b); err != nil { + return nil, nil, nil, fmt.Errorf("unable to encode "+ + "TLV stream: %v", err) + } + 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 tlvStream *tlv.Stream + var err error + 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) + } + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(6, &pathID), + ) + } else { + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(4, &nextNodeID), + ) + } + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to create "+ + "TLV stream: %v", err) + } + var b bytes.Buffer + if err := tlvStream.Encode(&b); err != nil { + return nil, nil, nil, fmt.Errorf("unable to encode "+ + "TLV stream: %v", err) + } + + 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 payload []byte + var b bytes.Buffer + var tlvStream *tlv.Stream + var err error + + if i == len(blindedPath.BlindedHops)-1 { + hello := []byte("hello") + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(4, &hop.CipherText), + tlv.MakePrimitiveRecord(65, &hello), + ) + } else { + tlvStream, err = tlv.NewStream( + tlv.MakePrimitiveRecord(4, &hop.CipherText), + ) + } + + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to create "+ + "TLV stream: %v", err) + } + + if err := tlvStream.Encode(&b); err != nil { + return nil, nil, nil, fmt.Errorf("unable to encode "+ + "TLV stream: %v", err) + } + payload = b.Bytes() + route[i] = OnionHop{ + NodePub: *hop.BlindedNodePub, + HopPayload: HopPayload{ + Type: PayloadTLV, + Payload: payload, + }, + } + } + + // Generate the onion packet. + sessionKey, _ := btcec.NewPrivateKey() + onionPacket, err := NewOnionPacket( + &route, sessionKey, nil, DeterministicPacketFiller, + WithOnionMessage(), + ) + 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 @@ -669,6 +848,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 From 63dd4c5705db696bc943daad6a73e7d92e79e879 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:20:07 +0200 Subject: [PATCH 09/15] sphinx: add check for blinding point When we are parsing onion messages, we must ensure that a blinding point is provided. --- sphinx.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sphinx.go b/sphinx.go index cb97c04..4412708 100644 --- a/sphinx.go +++ b/sphinx.go @@ -615,6 +615,12 @@ func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, o(cfg) } + // If this is an onion message, a blinding point must be provided. + if cfg.isOnionMessage && cfg.blindingPoint == nil { + return nil, fmt.Errorf("blinding point must be provided for " + + "onion messages") + } + // Compute the shared secret for this onion packet. sharedSecret, err := r.generateSharedSecret( onionPkt.EphemeralKey, cfg.blindingPoint, From 97ee6ea9781c8e59e92b268531d2bd72d6920cad Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:30:33 +0200 Subject: [PATCH 10/15] temp: redeclare package path To temporarily use this package from my own repo, we redeclare the package path. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 95dd37f..a4cf852 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/lightningnetwork/lightning-onion +module github.com/gijswijs/lightning-onion require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da From bfdbdec7f9d7c4caa93e412dd03b4d0a4153f835 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:44:14 +0200 Subject: [PATCH 11/15] 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 | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/sphinx.go b/sphinx.go index 4412708..d8c9e08 100644 --- a/sphinx.go +++ b/sphinx.go @@ -41,12 +41,25 @@ const ( LegacyHopDataSize = (RealmByteSize + AddressSize + AmtForwardSize + OutgoingCLTVSize + NumPaddingBytes + HMACSize) - // StandardRoutingInfoSize is the size of the routing info for a standard - // onion packet. - StandardRoutingInfoSize = 1300 - - // JumboRoutingInfoSize is the size of the routing info for a jumbo - // onion packet. + // 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. Throughtout 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 onion + // messaging jumbo onion packet. JumboRoutingInfoSize = 32768 // keyLen is the length of the keys used to generate cipher streams and From c3f8db769b8899cf4f18e2947f0aedac562c8786 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 10 Jul 2025 15:50:52 +0200 Subject: [PATCH 12/15] payload: change signature Decode To provide backwards compatibility, the Decode function in sphinx.go has been modified to accept an optional isMessage parameter. This is now not a breaking change. --- payload.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/payload.go b/payload.go index c1ca71e..6c5f837 100644 --- a/payload.go +++ b/payload.go @@ -89,7 +89,11 @@ func (hp *HopPayload) Encode(w io.Writer) error { // Decode unpacks an encoded HopPayload from the passed reader into the target // HopPayload. The isMessage boolean should be set to true if we're parsing a // payload that is known to be for an onion message. -func (hp *HopPayload) Decode(r io.Reader, isMessage bool) error { +func (hp *HopPayload) Decode(r io.Reader, isMessage ...bool) error { + // To preserve backwards compatibility, we'll default to isMessage being + // false if it is not provided. + isMsg := len(isMessage) > 0 && isMessage[0] + bufReader := bufio.NewReader(r) // In order to properly parse the payload, we'll need to check the @@ -101,7 +105,7 @@ func (hp *HopPayload) Decode(r io.Reader, isMessage bool) error { } // Per BOLT 7, onion messages MUST use the TLV format. - if isMessage { + if isMsg { return decodeTLVHopPayload(hp, bufReader) } From e0210d7a3c35ea5b3ef34d97255e47d2ebbfc34d Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 23 Jul 2025 18:06:44 +0200 Subject: [PATCH 13/15] fixup! sphinx_test: add blinded onion message test --- path_test.go | 106 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 75 insertions(+), 31 deletions(-) diff --git a/path_test.go b/path_test.go index 501d578..093ec28 100644 --- a/path_test.go +++ b/path_test.go @@ -17,6 +17,14 @@ const ( 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 // the spec test vectors. func TestBuildBlindedRoute(t *testing.T) { @@ -118,8 +126,35 @@ func TestBuildBlindedRoute(t *testing.T) { } } -// TestBuildOnionMessageBlindedRoute tests BuildBlindedRoute, -// decryptBlindedHopData and NextEphemeral against the spec test vectors. +// 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() @@ -128,43 +163,50 @@ func TestBuildOnionMessageBlindedRoute(t *testing.T) { require.NoError(t, err) // Once we have the raw file, we'll unpack it into our - // blindingJsonTestCase struct defined below. + // 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, e string) []*HopInfo { + buildMessagePath := func(h []hopOnionMessageData, + initialHopID string) []*HopInfo { + path := make([]*HopInfo, len(h)) - currentHop := e + // 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 { - // The json test vector only specifies the current node - // ID as the next node id in the payload of the previous - // node, so we get that from the previous hop. - nodeIDStr, _ := hex.DecodeString( - currentHop, - ) - nodeID, _ := btcec.ParsePubKey(nodeIDStr) - payload, _ := hex.DecodeString(hop.EncryptedDataTlv) + 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( + daveSessKey := privKeyFromString( testCase.Generate.Hops[1].PathKeySecret, ) daveBobPath := buildMessagePath( - testCase.Generate.Hops[1:], bolt4PubKeys[1], + testCase.Generate.Hops[1:], bobPubKey, ) - pathDB, err := BuildBlindedPath(DaveSessKey, daveBobPath) + daveBobBlindedPath, err := BuildBlindedPath(daveSessKey, daveBobPath) require.NoError(t, err) // At this point, Dave will give his blinded path to the Sender who will @@ -174,21 +216,27 @@ func TestBuildOnionMessageBlindedRoute(t *testing.T) { // 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], bolt4PubKeys[0], + testCase.Generate.Hops[:1], alicePubKey, ) senderSessKey := privKeyFromString( testCase.Generate.Hops[0].PathKeySecret, ) - pathAB, err := BuildBlindedPath(senderSessKey, aliceBobPath) + aliceBobBlindedPath, err := BuildBlindedPath( + senderSessKey, aliceBobPath, + ) require.NoError(t, err) // Construct the concatenated path. path := &BlindedPath{ - IntroductionPoint: pathAB.Path.IntroductionPoint, - BlindingPoint: pathAB.Path.BlindingPoint, - BlindedHops: append(pathAB.Path.BlindedHops, - pathDB.Path.BlindedHops...), + 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. @@ -205,9 +253,7 @@ func TestBuildOnionMessageBlindedRoute(t *testing.T) { )) data, _ := hex.DecodeString(hop.EncryptedRecipientData) - require.True( - t, bytes.Equal(data, path.BlindedHops[i].CipherText), - ) + require.Equal(t, data, path.BlindedHops[i].CipherText) } // Assert that each hop is able to decode the encrypted data meant for @@ -215,9 +261,7 @@ func TestBuildOnionMessageBlindedRoute(t *testing.T) { for i, hop := range testCase.Decrypt.Hops { genData := testCase.Generate.Hops[i] priv := privKeyFromString(hop.PrivKey) - ephem := pubKeyFromString( - genData.EphemeralPubKey, - ) + ephem := pubKeyFromString(genData.EphemeralPubKey) // Now we'll decrypt the blinded hop data using the private key // and the ephemeral public key. @@ -227,9 +271,9 @@ func TestBuildOnionMessageBlindedRoute(t *testing.T) { ) require.NoError(t, err) - // check if the decrypted data is what we expect it to be. - decoded, _ := hex.DecodeString(genData.EncryptedDataTlv) - require.True(t, bytes.Equal(data, decoded)) + // 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) From ba3f04ad6d1930c62eb3506a3e63e5300b9f9d89 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 23 Jul 2025 18:08:08 +0200 Subject: [PATCH 14/15] fixup! sphinx_test: add test for blinded route processing --- path_test.go | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/path_test.go b/path_test.go index 093ec28..8f1264a 100644 --- a/path_test.go +++ b/path_test.go @@ -385,7 +385,7 @@ func TestOnionMessageRouteBlinding(t *testing.T) { require.NoError(t, err) // Once we have the raw file, we'll unpack it into our - // blindingJsonTestCase struct defined above. + // onionMessageJsonTestCase struct defined above. testCase := &onionMessageJsonTestCase{} require.NoError(t, json.Unmarshal(jsonBytes, testCase)) @@ -402,9 +402,7 @@ func TestOnionMessageRouteBlinding(t *testing.T) { peelOnion := func(key *btcec.PrivateKey, blindingPoint *btcec.PublicKey) *ProcessedPacket { - r := NewRouter( - &PrivKeyECDH{PrivKey: key}, NewMemoryReplayLog(), - ) + r := NewRouter(&PrivKeyECDH{PrivKey: key}, NewMemoryReplayLog()) require.NoError(t, r.Start()) defer r.Stop() @@ -429,22 +427,30 @@ func TestOnionMessageRouteBlinding(t *testing.T) { // 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) - + firstBlinding = pubKeyFromString(testCase.Route.FirstPathKey) concatIndex = 1 - blindingOverride = pubKeyFromString(hops[0].EncodedOnionMessageTLVs.NextPathKeyOverride) + blindingOverride = pubKeyFromString( + hops[0].EncodedOnionMessageTLVs.NextPathKeyOverride, + ) ) - var blindingPoint *btcec.PublicKey + // 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, onionPacket.Encode(buff)) + 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. + // 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( @@ -454,24 +460,30 @@ func TestOnionMessageRouteBlinding(t *testing.T) { priv := privKeyFromString(hop.PrivKey) - switch i { - case 0: - // Onion message routes are always entirely blinded, so - // the first hop will always use the first blinding - // point. - blindingPoint = firstBlinding - case concatIndex: + 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) + // 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) - onionPacket = processedPkt.NextPacket + // 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 } } From 0c036fae6815622c16402f4c022ddb6900ac6f02 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Wed, 23 Jul 2025 18:09:26 +0200 Subject: [PATCH 15/15] 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 8f1264a..c8e2efa 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 }