Skip to content

Commit c8bad89

Browse files
feat: RS erasure-coded handshake chunking for PQ key exchange (#30)
* feat(header): add HandshakeIXPSK0Chunked subtype and ChunkHeader Add RS erasure-coded chunk header types for oversized PQ handshakes. The 8-byte ChunkHeader carries handshake_id, noise message number, chunk index, total chunks, and data shard count for reconstruction. * feat: RS erasure-coded chunking in handshake send paths Add Reed-Solomon encoding for oversized handshake messages (PQ handshakes ~9KB). Messages exceeding 1200 bytes are automatically split into k+m chunks where k=ceil(payload/1200) and m=3 parity. Any k of k+m chunks arriving suffices for reconstruction. Send paths modified: handleOutbound (initiator), ixHandshakeStage1 (responder direct + relay), and ErrAlreadySeen cached resend. Non-PQ handshakes below threshold bypass chunking entirely. * feat: RS reassembly buffer in handshake receive path Add ReassemblyManager for reconstructing chunked handshake messages. Chunks are buffered by (handshakeID, noiseMsgNum) key and RS-decoded when k shards arrive. Buffers are bounded (256 max) and expired (5s timeout) for DoS mitigation. HandleIncoming dispatches chunked packets to reassembly and re-injects completed messages. * feat: add length-prefix framing and fix e2e PQ handshake test RS encode now prepends a 4-byte big-endian length prefix before splitting into shards, allowing the decoder to strip RS padding that was corrupting protobuf unmarshal. Updated all unit tests to account for the +4 byte prefix in data shard count calculations. Rewrote TestGoodHandshakePQ to use router-based assertTunnel approach since chunked handshakes produce multiple UDP packets. All unit tests, reassembly tests, PQ e2e test, and non-PQ e2e tests pass -- backward compatibility confirmed. * fix: resolve testifylint CI failures Use assert.LessOrEqual instead of assert.True for comparison, and assert.Empty instead of assert.Len(0) per golangci-lint testifylint rules. --------- Co-authored-by: privsim <excaliberswake@pm.me>
1 parent 07ff68c commit c8bad89

File tree

11 files changed

+1165
-50
lines changed

11 files changed

+1165
-50
lines changed

e2e/handshakes_test.go

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -135,42 +135,30 @@ func TestGoodHandshake(t *testing.T) {
135135
}
136136

137137
func TestGoodHandshakePQ(t *testing.T) {
138-
// Post-quantum handshake test: ML-DSA-87 certificates + hybrid X25519/ML-KEM-1024 key exchange
138+
// Post-quantum handshake test: ML-DSA-87 certificates + hybrid X25519/ML-KEM-1024 key exchange.
139+
// PQ handshakes produce ~9KB messages which are RS-chunked into multiple UDP packets.
140+
// The router handles forwarding all chunks automatically.
139141
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_PQ, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
140142
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me-pq", "10.128.0.1/24", nil)
141143
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them-pq", "10.128.0.2/24", nil)
142144

143145
// Put their info in our lighthouse
144146
myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
147+
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
145148

146149
// Start the servers
147150
myControl.Start()
148151
theirControl.Start()
149152

150-
t.Log("PQ: Send a udp packet through to begin standing up the tunnel")
151-
myControl.InjectTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from PQ me"))
152-
153-
t.Log("PQ: Have them consume my stage 0 packet. They have a tunnel now")
154-
theirControl.InjectUDPPacket(myControl.GetFromUDP(true))
155-
156-
t.Log("PQ: Have me consume their stage 1 packet. I have a tunnel now")
157-
myControl.InjectUDPPacket(theirControl.GetFromUDP(true))
153+
r := router.NewR(t, myControl, theirControl)
154+
defer r.RenderFlow()
158155

159-
t.Log("PQ: Wait until we see my cached packet come through")
160-
myControl.WaitForType(1, 0, theirControl)
156+
t.Log("PQ: Do a bidirectional tunnel test (includes RS-chunked handshake)")
157+
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
161158

162159
t.Log("PQ: Make sure our host infos are correct")
163160
assertHostInfoPair(t, myUdpAddr, theirUdpAddr, myVpnIpNet, theirVpnIpNet, myControl, theirControl)
164161

165-
t.Log("PQ: Get that cached packet and make sure it looks right")
166-
myCachedPacket := theirControl.GetFromTun(true)
167-
assertUdpPacket(t, []byte("Hi from PQ me"), myCachedPacket, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), 80, 80)
168-
169-
t.Log("PQ: Do a bidirectional tunnel test")
170-
r := router.NewR(t, myControl, theirControl)
171-
defer r.RenderFlow()
172-
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
173-
174162
r.RenderHostmaps("PQ Final hostmaps", myControl, theirControl)
175163
myControl.Stop()
176164
theirControl.Stop()

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ require (
4545
github.com/cespare/xxhash/v2 v2.3.0 // indirect
4646
github.com/davecgh/go-spew v1.1.1 // indirect
4747
github.com/google/btree v1.1.2 // indirect
48+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
49+
github.com/klauspost/reedsolomon v1.13.2 // indirect
4850
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
4951
github.com/pmezard/go-difflib v1.0.0 // indirect
5052
github.com/prometheus/client_model v0.6.2 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
7676
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
7777
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
7878
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
79+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
80+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
81+
github.com/klauspost/reedsolomon v1.13.2 h1:9qtQy2tKEVpVB8Pfq87ZljHZb60/LbeTQ1OxV8EGzdE=
82+
github.com/klauspost/reedsolomon v1.13.2/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
7983
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
8084
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
8185
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=

handshake_ix.go

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -402,17 +402,35 @@ func ixHandshakeStage1(f *Interface, via ViaSender, packet []byte, h *header.H)
402402
}
403403

404404
msg = existing.HandshakePacket[2]
405-
f.messageMetrics.Tx(header.Handshake, header.MessageSubType(msg[1]), 1)
405+
cachedChunked := needsChunking(msg)
406+
if cachedChunked {
407+
f.messageMetrics.Tx(header.Handshake, header.HandshakeIXPSK0Chunked, 1)
408+
} else {
409+
f.messageMetrics.Tx(header.Handshake, header.MessageSubType(msg[1]), 1)
410+
}
406411
if !via.IsRelayed {
407-
err := f.outside.WriteTo(msg, via.UdpAddr)
408-
if err != nil {
409-
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
410-
WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).WithField("cached", true).
411-
WithError(err).Error("Failed to send handshake message")
412+
if cachedChunked {
413+
err := sendHandshakeChunked(f.l, f.outside, msg, existing.remoteIndexId, 1, via.UdpAddr)
414+
if err != nil {
415+
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
416+
WithField("handshake", m{"stage": 2, "style": "ix_psk0_chunked"}).WithField("cached", true).
417+
WithError(err).Error("Failed to send chunked handshake message")
418+
} else {
419+
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
420+
WithField("handshake", m{"stage": 2, "style": "ix_psk0_chunked"}).WithField("cached", true).
421+
Info("Handshake message sent")
422+
}
412423
} else {
413-
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
414-
WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).WithField("cached", true).
415-
Info("Handshake message sent")
424+
err := f.outside.WriteTo(msg, via.UdpAddr)
425+
if err != nil {
426+
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
427+
WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).WithField("cached", true).
428+
WithError(err).Error("Failed to send handshake message")
429+
} else {
430+
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("from", via).
431+
WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).WithField("cached", true).
432+
Info("Handshake message sent")
433+
}
416434
}
417435
return
418436
} else {
@@ -421,9 +439,17 @@ func ixHandshakeStage1(f *Interface, via ViaSender, packet []byte, h *header.H)
421439
return
422440
}
423441
hostinfo.relayState.InsertRelayTo(via.relayHI.vpnAddrs[0])
424-
f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false)
442+
if cachedChunked {
443+
sendHandshakeChunkedVia(f, via.relayHI, via.relay, msg, existing.remoteIndexId, 1)
444+
} else {
445+
f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false)
446+
}
447+
hsStyle := "ix_psk0"
448+
if cachedChunked {
449+
hsStyle = "ix_psk0_chunked"
450+
}
425451
f.l.WithField("vpnAddrs", existing.vpnAddrs).WithField("relay", via.relayHI.vpnAddrs[0]).
426-
WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).WithField("cached", true).
452+
WithField("handshake", m{"stage": 2, "style": hsStyle}).WithField("cached", true).
427453
Info("Handshake message sent")
428454
return
429455
}
@@ -471,16 +497,29 @@ func ixHandshakeStage1(f *Interface, via ViaSender, packet []byte, h *header.H)
471497
}
472498

473499
// Do the send
474-
f.messageMetrics.Tx(header.Handshake, header.MessageSubType(msg[1]), 1)
500+
chunked := needsChunking(msg)
501+
if chunked {
502+
f.messageMetrics.Tx(header.Handshake, header.HandshakeIXPSK0Chunked, 1)
503+
} else {
504+
f.messageMetrics.Tx(header.Handshake, header.MessageSubType(msg[1]), 1)
505+
}
475506
if !via.IsRelayed {
476-
err = f.outside.WriteTo(msg, via.UdpAddr)
507+
if chunked {
508+
err = sendHandshakeChunked(f.l, f.outside, msg, hs.Details.InitiatorIndex, 1, via.UdpAddr)
509+
} else {
510+
err = f.outside.WriteTo(msg, via.UdpAddr)
511+
}
512+
hsStyle := "ix_psk0"
513+
if chunked {
514+
hsStyle = "ix_psk0_chunked"
515+
}
477516
log := f.l.WithField("vpnAddrs", vpnAddrs).WithField("from", via).
478517
WithField("certName", certName).
479518
WithField("certVersion", certVersion).
480519
WithField("fingerprint", fingerprint).
481520
WithField("issuer", issuer).
482521
WithField("initiatorIndex", hs.Details.InitiatorIndex).WithField("responderIndex", hs.Details.ResponderIndex).
483-
WithField("remoteIndex", h.RemoteIndex).WithField("handshake", m{"stage": 2, "style": "ix_psk0"})
522+
WithField("remoteIndex", h.RemoteIndex).WithField("handshake", m{"stage": 2, "style": hsStyle})
484523
if err != nil {
485524
log.WithError(err).Error("Failed to send handshake")
486525
} else {
@@ -495,14 +534,22 @@ func ixHandshakeStage1(f *Interface, via ViaSender, packet []byte, h *header.H)
495534
// I successfully received a handshake. Just in case I marked this tunnel as 'Disestablished', ensure
496535
// it's correctly marked as working.
497536
via.relayHI.relayState.UpdateRelayForByIdxState(via.remoteIdx, Established)
498-
f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false)
537+
if chunked {
538+
sendHandshakeChunkedVia(f, via.relayHI, via.relay, msg, hs.Details.InitiatorIndex, 1)
539+
} else {
540+
f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false)
541+
}
542+
hsStyle := "ix_psk0"
543+
if chunked {
544+
hsStyle = "ix_psk0_chunked"
545+
}
499546
f.l.WithField("vpnAddrs", vpnAddrs).WithField("relay", via.relayHI.vpnAddrs[0]).
500547
WithField("certName", certName).
501548
WithField("certVersion", certVersion).
502549
WithField("fingerprint", fingerprint).
503550
WithField("issuer", issuer).
504551
WithField("initiatorIndex", hs.Details.InitiatorIndex).WithField("responderIndex", hs.Details.ResponderIndex).
505-
WithField("remoteIndex", h.RemoteIndex).WithField("handshake", m{"stage": 2, "style": "ix_psk0"}).
552+
WithField("remoteIndex", h.RemoteIndex).WithField("handshake", m{"stage": 2, "style": hsStyle}).
506553
Info("Handshake message sent")
507554
}
508555

handshake_manager.go

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type HandshakeManager struct {
6060
metricTimedOut metrics.Counter
6161
f *Interface
6262
l *logrus.Logger
63+
reassembly *ReassemblyManager
6364

6465
// can be used to trigger outbound handshake for the given vpnIp
6566
trigger chan netip.Addr
@@ -117,6 +118,7 @@ func NewHandshakeManager(l *logrus.Logger, mainHostMap *HostMap, lightHouse *Lig
117118
metricInitiated: metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
118119
metricTimedOut: metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
119120
l: l,
121+
reassembly: NewReassemblyManager(l),
120122
}
121123
}
122124

@@ -158,6 +160,19 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
158160
hm.DeleteHostInfo(newHostinfo.hostinfo)
159161
}
160162
}
163+
164+
case header.HandshakeIXPSK0Chunked:
165+
// RS-coded chunk: buffer and attempt reassembly
166+
reassembled, ok := hm.reassembly.HandleChunk(packet, h)
167+
if ok {
168+
// Reassembly complete: re-parse the reconstructed header and process as normal
169+
var reassembledH header.H
170+
if err := reassembledH.Parse(reassembled); err != nil {
171+
hm.l.WithError(err).Error("Failed to parse reassembled handshake header")
172+
return
173+
}
174+
hm.HandleIncoming(via, reassembled, &reassembledH)
175+
}
161176
}
162177
}
163178

@@ -236,18 +251,32 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
236251
}
237252

238253
// Send the handshake to all known ips, stage 2 takes care of assigning the hostinfo.remote based on the first to reply
254+
chunked := needsChunking(hostinfo.HandshakePacket[0])
239255
var sentTo []netip.AddrPort
240256
hostinfo.remotes.ForEach(hm.mainHostMap.GetPreferredRanges(), func(addr netip.AddrPort, _ bool) {
241-
hm.messageMetrics.Tx(header.Handshake, header.MessageSubType(hostinfo.HandshakePacket[0][1]), 1)
242-
err := hm.outside.WriteTo(hostinfo.HandshakePacket[0], addr)
243-
if err != nil {
244-
hostinfo.logger(hm.l).WithField("udpAddr", addr).
245-
WithField("initiatorIndex", hostinfo.localIndexId).
246-
WithField("handshake", m{"stage": 1, "style": "ix_psk0"}).
247-
WithError(err).Error("Failed to send handshake message")
248-
257+
if chunked {
258+
hm.messageMetrics.Tx(header.Handshake, header.HandshakeIXPSK0Chunked, 1)
259+
err := sendHandshakeChunked(hm.l, hm.outside, hostinfo.HandshakePacket[0],
260+
hostinfo.localIndexId, 0, addr)
261+
if err != nil {
262+
hostinfo.logger(hm.l).WithField("udpAddr", addr).
263+
WithField("initiatorIndex", hostinfo.localIndexId).
264+
WithField("handshake", m{"stage": 1, "style": "ix_psk0_chunked"}).
265+
WithError(err).Error("Failed to send chunked handshake message")
266+
} else {
267+
sentTo = append(sentTo, addr)
268+
}
249269
} else {
250-
sentTo = append(sentTo, addr)
270+
hm.messageMetrics.Tx(header.Handshake, header.MessageSubType(hostinfo.HandshakePacket[0][1]), 1)
271+
err := hm.outside.WriteTo(hostinfo.HandshakePacket[0], addr)
272+
if err != nil {
273+
hostinfo.logger(hm.l).WithField("udpAddr", addr).
274+
WithField("initiatorIndex", hostinfo.localIndexId).
275+
WithField("handshake", m{"stage": 1, "style": "ix_psk0"}).
276+
WithError(err).Error("Failed to send handshake message")
277+
} else {
278+
sentTo = append(sentTo, addr)
279+
}
251280
}
252281
})
253282

@@ -345,7 +374,12 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
345374
switch existingRelay.State {
346375
case Established:
347376
hostinfo.logger(hm.l).WithField("relay", relay.String()).Info("Send handshake via relay")
348-
hm.f.SendVia(relayHostInfo, existingRelay, hostinfo.HandshakePacket[0], make([]byte, 12), make([]byte, mtu), false)
377+
if chunked {
378+
sendHandshakeChunkedVia(hm.f, relayHostInfo, existingRelay, hostinfo.HandshakePacket[0],
379+
hostinfo.localIndexId, 0)
380+
} else {
381+
hm.f.SendVia(relayHostInfo, existingRelay, hostinfo.HandshakePacket[0], make([]byte, 12), make([]byte, mtu), false)
382+
}
349383
case Disestablished:
350384
// Mark this relay as 'requested'
351385
relayHostInfo.relayState.UpdateRelayForByIpState(vpnIp, Requested)

header/header.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ const (
6060
)
6161

6262
const (
63-
HandshakeIXPSK0 MessageSubType = 0
64-
HandshakeXXPSK0 MessageSubType = 1
63+
HandshakeIXPSK0 MessageSubType = 0
64+
HandshakeXXPSK0 MessageSubType = 1
65+
HandshakeIXPSK0Chunked MessageSubType = 2
6566
)
6667

6768
var ErrHeaderTooShort = errors.New("header is too short")
@@ -83,7 +84,8 @@ var subTypeMap = map[MessageType]*map[MessageSubType]string{
8384
Test: &subTypeTestMap,
8485
CloseTunnel: &subTypeNoneMap,
8586
Handshake: {
86-
HandshakeIXPSK0: "ix_psk0",
87+
HandshakeIXPSK0: "ix_psk0",
88+
HandshakeIXPSK0Chunked: "ix_psk0_chunked",
8789
},
8890
Control: &subTypeNoneMap,
8991
}
@@ -193,3 +195,67 @@ func NewHeader(b []byte) (*H, error) {
193195
}
194196
return h, nil
195197
}
198+
199+
// ChunkHeader is an 8-byte header prepended to each RS-coded chunk of an
200+
// oversized handshake message. It sits between the Nebula header and the
201+
// chunk payload.
202+
//
203+
// 0 8 16 24 32
204+
// |-------|-------|-------|-------|
205+
// | handshake_id (uint32) |
206+
// |-------|-------|-------|-------|
207+
// |msg_num|chunk_i| total | data |
208+
// |-------|-------|-------|-------|
209+
const ChunkHeaderLen = 8
210+
211+
type ChunkHeader struct {
212+
HandshakeID uint32 // Matches InitiatorIndex for session disambiguation
213+
NoiseMsgNum uint8 // 0 = message 1 (initiator), 1 = message 2 (responder)
214+
ChunkIdx uint8 // 0..TotalChunks-1
215+
TotalChunks uint8 // Total number of chunks (k + m)
216+
DataShards uint8 // Number of data shards (k), reconstruction threshold
217+
}
218+
219+
var ErrChunkHeaderTooShort = errors.New("chunk header is too short")
220+
221+
// EncodeChunkHeader writes an 8-byte chunk header into the provided slice.
222+
// The slice must have capacity for at least ChunkHeaderLen bytes.
223+
func EncodeChunkHeader(b []byte, handshakeID uint32, noiseMsgNum, chunkIdx, totalChunks, dataShards uint8) []byte {
224+
b = b[:ChunkHeaderLen]
225+
binary.BigEndian.PutUint32(b[0:4], handshakeID)
226+
b[4] = noiseMsgNum
227+
b[5] = chunkIdx
228+
b[6] = totalChunks
229+
b[7] = dataShards
230+
return b
231+
}
232+
233+
// ParseChunkHeader reads an 8-byte chunk header from the provided bytes.
234+
func (ch *ChunkHeader) Parse(b []byte) error {
235+
if len(b) < ChunkHeaderLen {
236+
return ErrChunkHeaderTooShort
237+
}
238+
ch.HandshakeID = binary.BigEndian.Uint32(b[0:4])
239+
ch.NoiseMsgNum = b[4]
240+
ch.ChunkIdx = b[5]
241+
ch.TotalChunks = b[6]
242+
ch.DataShards = b[7]
243+
return nil
244+
}
245+
246+
// Encode writes the chunk header into the provided byte slice.
247+
func (ch *ChunkHeader) Encode(b []byte) ([]byte, error) {
248+
if ch == nil {
249+
return nil, errors.New("nil chunk header")
250+
}
251+
return EncodeChunkHeader(b, ch.HandshakeID, ch.NoiseMsgNum, ch.ChunkIdx, ch.TotalChunks, ch.DataShards), nil
252+
}
253+
254+
// String creates a readable string representation of a chunk header.
255+
func (ch *ChunkHeader) String() string {
256+
if ch == nil {
257+
return "<nil>"
258+
}
259+
return fmt.Sprintf("handshake_id=%d noise_msg=%d chunk=%d/%d data_shards=%d",
260+
ch.HandshakeID, ch.NoiseMsgNum, ch.ChunkIdx, ch.TotalChunks, ch.DataShards)
261+
}

0 commit comments

Comments
 (0)