mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
417 lines
11 KiB
Go
417 lines
11 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package link
|
|
|
|
import (
|
|
"bytes"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"quad4/reticulum-go/pkg/blackhole"
|
|
"quad4/reticulum-go/pkg/common"
|
|
"quad4/reticulum-go/pkg/destination"
|
|
"quad4/reticulum-go/pkg/identity"
|
|
"quad4/reticulum-go/pkg/packet"
|
|
"quad4/reticulum-go/pkg/transport"
|
|
)
|
|
|
|
// TestRegression_EstablishDoesNotDeadlockWithWatchdog guards against spawning
|
|
// the link watchdog while Establish still holds the link mutex (watchdog then
|
|
// blocks forever trying to acquire the same lock).
|
|
func TestRegression_EstablishDoesNotDeadlockWithWatchdog(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, _, cleanup := establishInteropLinkAsync(t)
|
|
defer cleanup()
|
|
|
|
if initLink.GetStatus() != StatusActive {
|
|
t.Fatalf("initiator status = %d, want Active", initLink.GetStatus())
|
|
}
|
|
if !initLink.watchdogActive.Load() {
|
|
t.Fatal("watchdog should be running on established initiator link")
|
|
}
|
|
}
|
|
|
|
// TestRegression_RequestOnActiveLinkDoesNotSelfDeadlock guards against Request
|
|
// calling encrypt() (RLock) while already holding the link write lock.
|
|
func TestRegression_RequestOnActiveLinkDoesNotSelfDeadlock(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, respLink, cleanup := establishInteropLink(t)
|
|
defer cleanup()
|
|
|
|
respLink.destination.RegisterRequestHandler("echo", func(_ string, data []byte, _ []byte, _ []byte, _ *identity.Identity, _ int64) []byte {
|
|
return append([]byte("echo:"), data...)
|
|
}, destination.AllowAll, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
receipt, err := initLink.Request("echo", []byte("ping"), 3*time.Second)
|
|
if err != nil {
|
|
t.Errorf("Request: %v", err)
|
|
return
|
|
}
|
|
respCh := make(chan []byte, 1)
|
|
receipt.SetResponseCallback(func(r *RequestReceipt) {
|
|
respCh <- append([]byte(nil), r.GetResponse()...)
|
|
})
|
|
select {
|
|
case got := <-respCh:
|
|
if !bytes.Equal(got, []byte("echo:ping")) {
|
|
t.Errorf("response = %q, want echo:ping", got)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Error("response timeout")
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Request path deadlocked")
|
|
}
|
|
}
|
|
|
|
// TestRegression_TeardownWhileWatchdogActive guards against Teardown and the
|
|
// watchdog both calling encrypt under the link write lock.
|
|
func TestRegression_TeardownWhileWatchdogActive(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, _, cleanup := establishInteropLink(t)
|
|
defer cleanup()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
initLink.Teardown()
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Teardown deadlocked with watchdog active")
|
|
}
|
|
if initLink.GetStatus() != StatusClosed {
|
|
t.Fatalf("status after teardown = %d, want Closed", initLink.GetStatus())
|
|
}
|
|
}
|
|
|
|
// TestRegression_WatchdogStaleTeardownDoesNotDeadlock guards against the
|
|
// watchdog calling sendTeardownPacket -> encrypt while holding the write lock.
|
|
func TestRegression_WatchdogStaleTeardownDoesNotDeadlock(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, _, cleanup := establishInteropLink(t)
|
|
defer cleanup()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
initLink.mutex.Lock()
|
|
initLink.status.Store(int32(StatusStale))
|
|
_ = initLink.sendTeardownPacket()
|
|
initLink.status.Store(int32(StatusClosed))
|
|
initLink.mutex.Unlock()
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("stale teardown path deadlocked")
|
|
}
|
|
if initLink.GetStatus() != StatusClosed {
|
|
t.Fatalf("status after stale teardown = %d, want Closed", initLink.GetStatus())
|
|
}
|
|
}
|
|
|
|
// TestRegression_ValidateLinkProofSafeUnderConcurrentInbound guards against
|
|
// transport.handleProofPacket calling ValidateLinkProof without the link mutex
|
|
// while HandleInbound mutates the same link state.
|
|
func TestRegression_ValidateLinkProofSafeUnderConcurrentInbound(t *testing.T) {
|
|
srvIdent, err := identity.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cliIdent, err := identity.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg := &common.ReticulumConfig{EnableTransport: true}
|
|
tr := transport.NewTransport(cfg)
|
|
defer tr.Close()
|
|
tr.SetIdentity(cliIdent)
|
|
|
|
iface := newNoopIface("wan")
|
|
if err := tr.RegisterInterface("wan", iface); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dest, err := destination.New(srvIdent, destination.Out, destination.Single, "app", tr, "peer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr.UpdatePath(dest.GetHash(), bytes.Repeat([]byte{0x77}, 16), "wan", 1)
|
|
|
|
l := NewLink(dest, tr, iface, nil, nil)
|
|
if err := l.generateEphemeralKeys(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.linkID = bytes.Repeat([]byte{0x03}, 16)
|
|
l.initiator = true
|
|
l.status.Store(int32(StatusPending))
|
|
tr.RegisterLink(l.linkID, l)
|
|
|
|
badProof := &packet.Packet{
|
|
PacketType: packet.PacketTypeProof,
|
|
Context: packet.ContextLRProof,
|
|
DestinationType: DestTypeLink,
|
|
DestinationHash: l.linkID,
|
|
Data: bytes.Repeat([]byte{0xEE}, identity.SigLength/8+KeySize),
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
const workers = 16
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for range workers {
|
|
wg.Go(func() {
|
|
for time.Now().Before(deadline) {
|
|
_ = l.ValidateLinkProof(badProof, iface)
|
|
_ = l.HandleInbound(&packet.Packet{
|
|
PacketType: packet.PacketTypeData,
|
|
Context: packet.ContextKeepalive,
|
|
DestinationType: DestTypeLink,
|
|
DestinationHash: l.linkID,
|
|
Data: []byte{KeepaliveRequestByte},
|
|
})
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
if l.GetStatus() != StatusClosed {
|
|
t.Fatalf("link status want Closed after bad proof storm, got %d", l.GetStatus())
|
|
}
|
|
}
|
|
|
|
// TestRegression_CryptoSafeDuringHandshakeKeyInstall guards against torn reads
|
|
// of session/hmac key material while performHandshake writes them.
|
|
func TestRegression_CryptoSafeDuringHandshakeKeyInstall(t *testing.T) {
|
|
l := &Link{linkID: bytes.Repeat([]byte{0x01}, 16)}
|
|
if err := l.generateEphemeralKeys(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.peerPub = bytes.Repeat([]byte{0x02}, KeySize)
|
|
l.mode = ModeAES256CBC
|
|
l.linkID = bytes.Repeat([]byte{0x03}, 16)
|
|
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
_ = l.performHandshake()
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
_, _ = l.encrypt([]byte("probe"))
|
|
_, _ = l.decrypt(bytes.Repeat([]byte{0x00}, 64))
|
|
}
|
|
}
|
|
}()
|
|
time.Sleep(200 * time.Millisecond)
|
|
close(stop)
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestRegression_SessionKeysNotAliasedToDerivedKeyBuffer guards against
|
|
// encrypt/decrypt observing mutated HKDF output after handshake.
|
|
func TestRegression_SessionKeysNotAliasedToDerivedKeyBuffer(t *testing.T) {
|
|
l := &Link{linkID: bytes.Repeat([]byte{0x0A}, 16)}
|
|
if err := l.generateEphemeralKeys(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.peerPub = l.pub
|
|
l.mode = ModeAES256CBC
|
|
if err := l.performHandshake(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plain := []byte("session-key-isolation-check")
|
|
ct, err := l.encrypt(plain)
|
|
if err != nil {
|
|
t.Fatalf("encrypt before mutation: %v", err)
|
|
}
|
|
|
|
for i := range l.derivedKey.Bytes() {
|
|
l.derivedKey.Bytes()[i] ^= 0xFF
|
|
}
|
|
|
|
pt, err := l.decrypt(ct)
|
|
if err != nil {
|
|
t.Fatalf("decrypt after derivedKey mutation: %v", err)
|
|
}
|
|
if !bytes.Equal(pt, plain) {
|
|
t.Fatalf("plaintext mismatch after derivedKey mutation: got %q", pt)
|
|
}
|
|
|
|
ct2, err := l.encrypt(plain)
|
|
if err != nil {
|
|
t.Fatalf("encrypt after derivedKey mutation: %v", err)
|
|
}
|
|
pt2, err := l.decrypt(ct2)
|
|
if err != nil {
|
|
t.Fatalf("second decrypt after derivedKey mutation: %v", err)
|
|
}
|
|
if !bytes.Equal(pt2, plain) {
|
|
t.Fatalf("second plaintext mismatch: got %q", pt2)
|
|
}
|
|
}
|
|
|
|
// TestRegression_EncryptLockedRoundTrip verifies encryptLocked works when the
|
|
// caller already holds the link mutex (used by Request, SendPacketWithContext,
|
|
// watchdog keepalive/teardown paths).
|
|
func TestRegression_EncryptLockedRoundTrip(t *testing.T) {
|
|
l := &Link{linkID: bytes.Repeat([]byte{0x0B}, 16)}
|
|
if err := l.generateEphemeralKeys(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l.peerPub = l.pub
|
|
l.mode = ModeAES256CBC
|
|
if err := l.performHandshake(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plain := []byte("encrypt-locked-roundtrip")
|
|
l.mutex.Lock()
|
|
ct, err := l.encryptLocked(plain)
|
|
l.mutex.Unlock()
|
|
if err != nil {
|
|
t.Fatalf("encryptLocked: %v", err)
|
|
}
|
|
pt, err := l.decrypt(ct)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
if !bytes.Equal(pt, plain) {
|
|
t.Fatalf("roundtrip mismatch: got %q want %q", pt, plain)
|
|
}
|
|
}
|
|
|
|
// TestRegression_SendPacketWithContextUnderLock guards the inbound resource
|
|
// retry path, which sends encrypted resource requests while holding locks.
|
|
func TestRegression_SendPacketWithContextUnderLock(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, _, cleanup := establishInteropLink(t)
|
|
defer cleanup()
|
|
|
|
payload := bytes.Repeat([]byte{0xCD}, min(initLink.mdu, 128))
|
|
done := make(chan error, 1)
|
|
go func() { done <- initLink.SendPacketWithContext(payload, packet.ContextResourceReq) }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("SendPacketWithContext: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("SendPacketWithContext deadlocked")
|
|
}
|
|
}
|
|
|
|
// TestRegression_BlackholeIdentifyTearsDownLink pins HandleIdentification:
|
|
// when the remote peer Identifys as a blackholed identity the link must close.
|
|
// Python only sends LINKIDENTIFY as initiator so the initiator Identifys here.
|
|
func TestRegression_BlackholeIdentifyTearsDownLink(t *testing.T) {
|
|
skipHeavyLinkTestsIfShort(t)
|
|
initLink, respLink, cleanup := establishInteropLink(t)
|
|
defer cleanup()
|
|
|
|
peerID, err := identity.New()
|
|
if err != nil {
|
|
t.Fatalf("identity: %v", err)
|
|
}
|
|
localID, err := identity.New()
|
|
if err != nil {
|
|
t.Fatalf("local identity: %v", err)
|
|
}
|
|
blackhole.SetLocalIdentityHash(localID.Hash())
|
|
tab := blackhole.New("")
|
|
if _, err := tab.Add(peerID.Hash(), 0, "regression"); err != nil {
|
|
t.Fatalf("blackhole add: %v", err)
|
|
}
|
|
respLink.transport.SetBlackholeTable(tab)
|
|
|
|
closed := make(chan struct{}, 1)
|
|
respLink.SetLinkClosedCallback(func(_ *Link) {
|
|
select {
|
|
case closed <- struct{}{}:
|
|
default:
|
|
}
|
|
})
|
|
|
|
if err := initLink.Identify(peerID); err != nil {
|
|
t.Fatalf("Identify: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-closed:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("expected responder teardown after blackholed LINKIDENTIFY")
|
|
}
|
|
if respLink.GetStatus() != StatusClosed {
|
|
t.Fatalf("responder status=%d want Closed", respLink.GetStatus())
|
|
}
|
|
}
|
|
|
|
// TestHandleInboundLRRTTNoNestedLockDeadlock ensures LRRTT handling unlocks
|
|
// before decrypt, matching data and resource-proof inbound paths.
|
|
func TestHandleInboundLRRTTNoNestedLockDeadlock(t *testing.T) {
|
|
cfg := &common.ReticulumConfig{}
|
|
tr := transport.NewTransport(cfg)
|
|
defer tr.Close()
|
|
id, err := identity.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dest, err := destination.New(id, destination.In, destination.Single, "rttnest", tr, "svc")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l := NewLink(dest, tr, nil, nil, nil)
|
|
l.linkID = make([]byte, 16)
|
|
_ = setSecBuf(&l.sessionKey, make([]byte, 32))
|
|
_ = setSecBuf(&l.hmacKey, make([]byte, 32))
|
|
l.status.Store(int32(StatusHandshake))
|
|
l.initiator = false
|
|
l.requestTime = time.Now()
|
|
|
|
pkt := &packet.Packet{
|
|
PacketType: packet.PacketTypeProof,
|
|
Context: packet.ContextLRRTT,
|
|
DestinationType: DestTypeLink,
|
|
DestinationHash: l.linkID,
|
|
Data: make([]byte, 96),
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = l.HandleInbound(pkt)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("LRRTT HandleInbound deadlocked on nested mutex")
|
|
}
|
|
}
|