From aebfd0f8ff3999af5513be6fecbc13fe581527bf Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 8 Jan 2026 16:33:09 +0100 Subject: [PATCH 1/3] chore: add CLAUDE.md to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 033d67f..46e3388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor/ .idea .aider* +CLAUDE.md From 743401ec2a997bf918eabe41e426fa6ba0f5b696 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 8 Jan 2026 16:57:42 +0100 Subject: [PATCH 2/3] multi: add NoOpReplayLog for optional replay protection Add NoOpReplayLog, a ReplayLog implementation that performs no replay protection. This allows using the Router without replay protection when it is not needed, such as for onion messaging. --- replaylog.go | 50 ++++++++++++++++++++++++++++- replaylog_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/replaylog.go b/replaylog.go index 52a8444..b70749b 100644 --- a/replaylog.go +++ b/replaylog.go @@ -184,5 +184,53 @@ func (rl *MemoryReplayLog) PutBatch(batch *Batch) (*ReplaySet, error) { return replays, nil } -// A compile time asserting *MemoryReplayLog implements the RelayLog interface. +// A compile time asserting *MemoryReplayLog implements the ReplayLog interface. var _ ReplayLog = (*MemoryReplayLog)(nil) + +// NoOpReplayLog is a ReplayLog implementation that performs no replay +// protection. This can be used when replay protection is handled externally +// or is not needed (e.g., for onion messaging where replay protection is +// not required). +type NoOpReplayLog struct{} + +// NewNoOpReplayLog constructs a new NoOpReplayLog, which is an implementation +// of ReplayLog that performs no replay protection. +func NewNoOpReplayLog() *NoOpReplayLog { + return &NoOpReplayLog{} +} + +// Start is a no-op. +func (NoOpReplayLog) Start() error { + return nil +} + +// Stop is a no-op. +func (NoOpReplayLog) Stop() error { + return nil +} + +// Get always returns ErrLogEntryNotFound since no entries are ever stored. +func (NoOpReplayLog) Get(*HashPrefix) (uint32, error) { + return 0, ErrLogEntryNotFound +} + +// Put is a no-op and always returns nil, allowing all packets through. +func (NoOpReplayLog) Put(*HashPrefix, uint32) error { + return nil +} + +// Delete is a no-op. +func (NoOpReplayLog) Delete(*HashPrefix) error { + return nil +} + +// PutBatch marks the batch as committed and returns an empty replay set, +// indicating no replays were detected. +func (NoOpReplayLog) PutBatch(batch *Batch) (*ReplaySet, error) { + batch.IsCommitted = true + return NewReplaySet(), nil +} + +// A compile time assertion that *NoOpReplayLog implements the ReplayLog +// interface. +var _ ReplayLog = (*NoOpReplayLog)(nil) diff --git a/replaylog_test.go b/replaylog_test.go index 0657760..2247cf5 100644 --- a/replaylog_test.go +++ b/replaylog_test.go @@ -2,6 +2,8 @@ package sphinx import ( "testing" + + "github.com/stretchr/testify/require" ) // TestMemoryReplayLogStorageAndRetrieval tests that the non-batch methods on @@ -130,3 +132,81 @@ func TestMemoryReplayLogPutBatch(t *testing.T) { t.Fatalf("Unexpected replay set after adding batch 2 to log: %v", err) } } + +// TestNoOpReplayLog tests that NoOpReplayLog performs no replay protection, +// allowing all packets through without storing any state. +func TestNoOpReplayLog(t *testing.T) { + t.Parallel() + + rl := NewNoOpReplayLog() + + // Start and Stop should succeed without error. + require.NoError(t, rl.Start()) + defer func() { + require.NoError(t, rl.Stop()) + }() + + var hashPrefix HashPrefix + + hashPrefix[0] = 1 + + // Get should always return ErrLogEntryNotFound since nothing is stored. + _, err := rl.Get(&hashPrefix) + require.ErrorIs(t, err, ErrLogEntryNotFound) + + // Put should always succeed. + require.NoError(t, rl.Put(&hashPrefix, 1)) + + // Put the same packet again - should still succeed (no replay + // detection). + require.NoError(t, rl.Put(&hashPrefix, 1)) + + // Get should still return ErrLogEntryNotFound (nothing is stored). + _, err = rl.Get(&hashPrefix) + require.ErrorIs(t, err, ErrLogEntryNotFound) + + // Delete should succeed. + require.NoError(t, rl.Delete(&hashPrefix)) +} + +// TestNoOpReplayLogPutBatch tests that NoOpReplayLog's PutBatch marks batches +// as committed and never reports replays. +func TestNoOpReplayLogPutBatch(t *testing.T) { + t.Parallel() + + rl := NewNoOpReplayLog() + + var hashPrefix1, hashPrefix2 HashPrefix + + hashPrefix1[0] = 1 + hashPrefix2[0] = 2 + + // Create a batch with duplicate packets. + batch1 := NewBatch([]byte{1}) + require.NoError(t, batch1.Put(1, &hashPrefix1, 1)) + require.NoError(t, batch1.Put(2, &hashPrefix1, 1)) + + replays, err := rl.PutBatch(batch1) + require.NoError(t, err) + require.True(t, batch1.IsCommitted, "Batch should be marked as "+ + "committed") + + // NoOpReplayLog doesn't detect intra-batch replays (that's done by + // Batch itself), but it should return an empty set from its own + // detection. + require.NotNil(t, replays) + + // Create another batch with the same hash prefix - should not detect + // replay since NoOpReplayLog doesn't store anything. + batch2 := NewBatch([]byte{2}) + require.NoError(t, batch2.Put(1, &hashPrefix1, 1)) + require.NoError(t, batch2.Put(2, &hashPrefix2, 2)) + + replays, err = rl.PutBatch(batch2) + require.NoError(t, err) + require.True(t, batch2.IsCommitted, "Batch should be marked as "+ + "committed") + + // Should report no replays since NoOpReplayLog doesn't track state. + require.Equal(t, 0, replays.Size(), "Expected empty replay set") +} From f1b6fcf44eb153b96b9d5f40b922233f9b992e10 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Thu, 8 Jan 2026 17:16:16 +0100 Subject: [PATCH 3/3] sphinx: rename incomingCltv to replayData in ProcessOnionPacket Rename the parameter to be more generic since it is passed to the ReplayLog and can store any auxiliary data, not just CLTV values. Update godoc to explain usage with and without replay protection. --- sphinx.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/sphinx.go b/sphinx.go index b35cd66..475f189 100644 --- a/sphinx.go +++ b/sphinx.go @@ -606,11 +606,17 @@ func WithTLVPayloadOnly() ProcessOnionOpt { // in the onion packet to derive the shared secret. Finally, if the MAC doesn't // check the packet is again rejected. // -// In the case of a successful packet processing, and ProcessedPacket struct is +// The replayData parameter is passed to the ReplayLog and can be used by +// implementations to store auxiliary data alongside the packet hash. For +// example, in HTLC forwarding this could be the incoming CLTV value. When using +// NoOpReplayLog (no replay protection), this value is ignored and can be set +// to 0. +// +// In the case of a successful packet processing, a ProcessedPacket struct is // returned which houses the newly parsed packet, along with instructions on // what to do next. func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, - incomingCltv uint32, opts ...ProcessOnionOpt) (*ProcessedPacket, + replayData uint32, opts ...ProcessOnionOpt) (*ProcessedPacket, error) { cfg := &processOnionCfg{} @@ -642,7 +648,8 @@ func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, // Atomically compare this hash prefix with the contents of the on-disk // log, persisting it only if this entry was not detected as a replay. - if err := r.log.Put(hashPrefix, incomingCltv); err != nil { + err = r.log.Put(hashPrefix, replayData) + if err != nil { return nil, err } @@ -853,11 +860,17 @@ func (r *Router) BeginTxn(id []byte, nels int) *Tx { // in the onion packet to derive the shared secret. Finally, if the MAC doesn't // check the packet is again rejected. // -// In the case of a successful packet processing, and ProcessedPacket struct is +// The replayData parameter is passed to the ReplayLog and can be used by +// implementations to store auxiliary data alongside the packet hash. For +// example, in HTLC forwarding this could be the incoming CLTV value. When using +// NoOpReplayLog (no replay protection), this value is ignored and can be set +// to 0. +// +// In the case of a successful packet processing, a ProcessedPacket struct is // returned which houses the newly parsed packet, along with instructions on // what to do next. func (t *Tx) ProcessOnionPacket(seqNum uint16, onionPkt *OnionPacket, - assocData []byte, incomingCltv uint32, opts ...ProcessOnionOpt) error { + assocData []byte, replayData uint32, opts ...ProcessOnionOpt) error { cfg := &processOnionCfg{} for _, o := range opts { @@ -891,7 +904,7 @@ func (t *Tx) ProcessOnionPacket(seqNum uint16, onionPkt *OnionPacket, // Add the hash prefix to pending batch of shared secrets that will be // written later via Commit(). - err = t.batch.Put(seqNum, hashPrefix, incomingCltv) + err = t.batch.Put(seqNum, hashPrefix, replayData) if err != nil { return err }