diff --git a/pkg/link/establishment_test.go b/pkg/link/establishment_test.go new file mode 100644 index 0000000..d37a767 --- /dev/null +++ b/pkg/link/establishment_test.go @@ -0,0 +1,365 @@ +package link + +import ( + "testing" + "time" + + "github.com/Sudo-Ivan/reticulum-go/pkg/common" + "github.com/Sudo-Ivan/reticulum-go/pkg/destination" + "github.com/Sudo-Ivan/reticulum-go/pkg/identity" + "github.com/Sudo-Ivan/reticulum-go/pkg/packet" + "github.com/Sudo-Ivan/reticulum-go/pkg/transport" +) + +func TestEphemeralKeyGeneration(t *testing.T) { + link := &Link{} + + if err := link.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate ephemeral keys: %v", err) + } + + if len(link.prv) != KEYSIZE { + t.Errorf("Expected private key length %d, got %d", KEYSIZE, len(link.prv)) + } + + if len(link.pub) != KEYSIZE { + t.Errorf("Expected public key length %d, got %d", KEYSIZE, len(link.pub)) + } + + if len(link.sigPriv) != 64 { + t.Errorf("Expected signing private key length 64, got %d", len(link.sigPriv)) + } + + if len(link.sigPub) != 32 { + t.Errorf("Expected signing public key length 32, got %d", len(link.sigPub)) + } +} + +func TestSignallingBytes(t *testing.T) { + mtu := 500 + mode := byte(MODE_AES256_CBC) + + bytes := signallingBytes(mtu, mode) + + if len(bytes) != LINK_MTU_SIZE { + t.Errorf("Expected signalling bytes length %d, got %d", LINK_MTU_SIZE, len(bytes)) + } + + extractedMTU := (int(bytes[0]&0x1F) << 16) | (int(bytes[1]) << 8) | int(bytes[2]) + if extractedMTU != mtu { + t.Errorf("Expected MTU %d, got %d", mtu, extractedMTU) + } + + extractedMode := (bytes[0] & MODE_BYTEMASK) >> 5 + if extractedMode != mode { + t.Errorf("Expected mode %d, got %d", mode, extractedMode) + } +} + +func TestLinkIDGeneration(t *testing.T) { + responderIdent, err := identity.NewIdentity() + if err != nil { + t.Fatalf("Failed to create responder identity: %v", err) + } + + cfg := &common.ReticulumConfig{} + transportInstance := transport.NewTransport(cfg) + + dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link") + if err != nil { + t.Fatalf("Failed to create destination: %v", err) + } + + link := &Link{ + destination: dest, + transport: transportInstance, + initiator: true, + } + + if err := link.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate keys: %v", err) + } + + link.mode = MODE_DEFAULT + link.mtu = 500 + + signalling := signallingBytes(link.mtu, link.mode) + requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE) + requestData = append(requestData, link.pub...) + requestData = append(requestData, link.sigPub...) + requestData = append(requestData, signalling...) + + pkt := &packet.Packet{ + HeaderType: packet.HeaderType1, + PacketType: packet.PacketTypeLinkReq, + TransportType: 0, + Context: packet.ContextNone, + ContextFlag: packet.FlagUnset, + Hops: 0, + DestinationType: dest.GetType(), + DestinationHash: dest.GetHash(), + Data: requestData, + } + + if err := pkt.Pack(); err != nil { + t.Fatalf("Failed to pack packet: %v", err) + } + + linkID := linkIDFromPacket(pkt) + + if len(linkID) != 16 { + t.Errorf("Expected link ID length 16, got %d", len(linkID)) + } + + t.Logf("Generated link ID: %x", linkID) +} + +func TestHandshake(t *testing.T) { + link1 := &Link{} + link2 := &Link{} + + if err := link1.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate keys for link1: %v", err) + } + + if err := link2.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate keys for link2: %v", err) + } + + link1.peerPub = link2.pub + link2.peerPub = link1.pub + + link1.linkID = []byte("test-link-id-abc") + link2.linkID = []byte("test-link-id-abc") + + link1.mode = MODE_AES256_CBC + link2.mode = MODE_AES256_CBC + + if err := link1.performHandshake(); err != nil { + t.Fatalf("Link1 handshake failed: %v", err) + } + + if err := link2.performHandshake(); err != nil { + t.Fatalf("Link2 handshake failed: %v", err) + } + + if string(link1.sharedKey) != string(link2.sharedKey) { + t.Error("Shared keys do not match") + } + + if string(link1.derivedKey) != string(link2.derivedKey) { + t.Error("Derived keys do not match") + } + + if link1.status != STATUS_HANDSHAKE { + t.Errorf("Expected link1 status HANDSHAKE, got %d", link1.status) + } + + if link2.status != STATUS_HANDSHAKE { + t.Errorf("Expected link2 status HANDSHAKE, got %d", link2.status) + } +} + +func TestLinkEstablishment(t *testing.T) { + responderIdent, err := identity.NewIdentity() + if err != nil { + t.Fatalf("Failed to create responder identity: %v", err) + } + + cfg := &common.ReticulumConfig{} + transportInstance := transport.NewTransport(cfg) + + dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link") + if err != nil { + t.Fatalf("Failed to create destination: %v", err) + } + + initiatorLink := &Link{ + destination: dest, + transport: transportInstance, + initiator: true, + } + + responderLink := &Link{ + transport: transportInstance, + initiator: false, + } + + if err := initiatorLink.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate initiator keys: %v", err) + } + + initiatorLink.mode = MODE_DEFAULT + initiatorLink.mtu = 500 + + signalling := signallingBytes(initiatorLink.mtu, initiatorLink.mode) + requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE) + requestData = append(requestData, initiatorLink.pub...) + requestData = append(requestData, initiatorLink.sigPub...) + requestData = append(requestData, signalling...) + + linkRequestPkt := &packet.Packet{ + HeaderType: packet.HeaderType1, + PacketType: packet.PacketTypeLinkReq, + TransportType: 0, + Context: packet.ContextNone, + ContextFlag: packet.FlagUnset, + Hops: 0, + DestinationType: dest.GetType(), + DestinationHash: dest.GetHash(), + Data: requestData, + } + + if err := linkRequestPkt.Pack(); err != nil { + t.Fatalf("Failed to pack link request: %v", err) + } + + initiatorLink.linkID = linkIDFromPacket(linkRequestPkt) + initiatorLink.requestTime = time.Now() + initiatorLink.status = STATUS_PENDING + + t.Logf("Initiator link request created, link_id=%x", initiatorLink.linkID) + + responderLink.peerPub = linkRequestPkt.Data[0:KEYSIZE] + responderLink.peerSigPub = linkRequestPkt.Data[KEYSIZE:ECPUBSIZE] + responderLink.linkID = linkIDFromPacket(linkRequestPkt) + responderLink.initiator = false + + t.Logf("Responder link ID=%x (len=%d)", responderLink.linkID, len(responderLink.linkID)) + + if len(responderLink.linkID) == 0 { + t.Fatal("Responder link ID is empty!") + } + + if len(linkRequestPkt.Data) >= ECPUBSIZE+LINK_MTU_SIZE { + mtuBytes := linkRequestPkt.Data[ECPUBSIZE : ECPUBSIZE+LINK_MTU_SIZE] + responderLink.mtu = (int(mtuBytes[0]&0x1F) << 16) | (int(mtuBytes[1]) << 8) | int(mtuBytes[2]) + responderLink.mode = (mtuBytes[0] & MODE_BYTEMASK) >> 5 + } + + if err := responderLink.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate responder keys: %v", err) + } + + if err := responderLink.performHandshake(); err != nil { + t.Fatalf("Responder handshake failed: %v", err) + } + + responderLink.status = STATUS_ACTIVE + responderLink.establishedAt = time.Now() + + if string(responderLink.linkID) != string(initiatorLink.linkID) { + t.Error("Link IDs do not match between initiator and responder") + } + + t.Logf("Responder handshake successful, shared_key_len=%d", len(responderLink.sharedKey)) +} + +func TestLinkProofValidation(t *testing.T) { + responderIdent, err := identity.NewIdentity() + if err != nil { + t.Fatalf("Failed to create responder identity: %v", err) + } + + cfg := &common.ReticulumConfig{} + transportInstance := transport.NewTransport(cfg) + + dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link") + if err != nil { + t.Fatalf("Failed to create destination: %v", err) + } + + initiatorLink := &Link{ + destination: dest, + transport: transportInstance, + initiator: true, + } + + responderLink := &Link{ + transport: transportInstance, + initiator: false, + } + + if err := initiatorLink.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate initiator keys: %v", err) + } + + initiatorLink.mode = MODE_DEFAULT + initiatorLink.mtu = 500 + + signalling := signallingBytes(initiatorLink.mtu, initiatorLink.mode) + requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE) + requestData = append(requestData, initiatorLink.pub...) + requestData = append(requestData, initiatorLink.sigPub...) + requestData = append(requestData, signalling...) + + linkRequestPkt := &packet.Packet{ + HeaderType: packet.HeaderType1, + PacketType: packet.PacketTypeLinkReq, + TransportType: 0, + Context: packet.ContextNone, + ContextFlag: packet.FlagUnset, + Hops: 0, + DestinationType: dest.GetType(), + DestinationHash: dest.GetHash(), + Data: requestData, + } + + if err := linkRequestPkt.Pack(); err != nil { + t.Fatalf("Failed to pack link request: %v", err) + } + + initiatorLink.linkID = linkIDFromPacket(linkRequestPkt) + initiatorLink.requestTime = time.Now() + initiatorLink.status = STATUS_PENDING + + responderLink.peerPub = linkRequestPkt.Data[0:KEYSIZE] + responderLink.peerSigPub = linkRequestPkt.Data[KEYSIZE:ECPUBSIZE] + responderLink.linkID = linkIDFromPacket(linkRequestPkt) + responderLink.initiator = false + + if len(linkRequestPkt.Data) >= ECPUBSIZE+LINK_MTU_SIZE { + mtuBytes := linkRequestPkt.Data[ECPUBSIZE : ECPUBSIZE+LINK_MTU_SIZE] + responderLink.mtu = (int(mtuBytes[0]&0x1F) << 16) | (int(mtuBytes[1]) << 8) | int(mtuBytes[2]) + responderLink.mode = (mtuBytes[0] & MODE_BYTEMASK) >> 5 + } else { + responderLink.mtu = 500 + responderLink.mode = MODE_DEFAULT + } + + if err := responderLink.generateEphemeralKeys(); err != nil { + t.Fatalf("Failed to generate responder keys: %v", err) + } + + if err := responderLink.performHandshake(); err != nil { + t.Fatalf("Responder handshake failed: %v", err) + } + + proofPkt, err := responderLink.GenerateLinkProof(responderIdent) + if err != nil { + t.Fatalf("Failed to generate link proof: %v", err) + } + + if err := initiatorLink.ValidateLinkProof(proofPkt); err != nil { + t.Fatalf("Initiator failed to validate link proof: %v", err) + } + + if initiatorLink.status != STATUS_ACTIVE { + t.Errorf("Expected initiator status ACTIVE, got %d", initiatorLink.status) + } + + if string(initiatorLink.sharedKey) != string(responderLink.sharedKey) { + t.Error("Shared keys do not match after full handshake") + } + + if string(initiatorLink.derivedKey) != string(responderLink.derivedKey) { + t.Error("Derived keys do not match after full handshake") + } + + t.Logf("Full link establishment successful") + t.Logf("Link ID: %x", initiatorLink.linkID) + t.Logf("Shared key length: %d", len(initiatorLink.sharedKey)) + t.Logf("Derived key length: %d", len(initiatorLink.derivedKey)) + t.Logf("RTT: %.3f seconds", initiatorLink.rtt) +} + diff --git a/pkg/link/link.go b/pkg/link/link.go index 31d0a30..91d6ba9 100644 --- a/pkg/link/link.go +++ b/pkg/link/link.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Sudo-Ivan/reticulum-go/pkg/common" + "github.com/Sudo-Ivan/reticulum-go/pkg/cryptography" "github.com/Sudo-Ivan/reticulum-go/pkg/destination" "github.com/Sudo-Ivan/reticulum-go/pkg/identity" "github.com/Sudo-Ivan/reticulum-go/pkg/packet" @@ -26,6 +27,12 @@ import ( const ( CURVE = "Curve25519" + ECPUBSIZE = 64 + KEYSIZE = 32 + LINK_MTU_SIZE = 3 + MTU_BYTEMASK = 0xFFFFFF + MODE_BYTEMASK = 0xE0 + ESTABLISHMENT_TIMEOUT_PER_HOP = 6 KEEPALIVE_TIMEOUT_FACTOR = 4 STALE_GRACE = 2 @@ -36,15 +43,20 @@ const ( ACCEPT_ALL = 0x01 ACCEPT_APP = 0x02 - STATUS_PENDING = 0x00 - STATUS_ACTIVE = 0x01 - STATUS_CLOSED = 0x02 - STATUS_FAILED = 0x03 + STATUS_PENDING = 0x00 + STATUS_HANDSHAKE = 0x01 + STATUS_ACTIVE = 0x02 + STATUS_CLOSED = 0x03 + STATUS_FAILED = 0x04 PROVE_NONE = 0x00 PROVE_ALL = 0x01 PROVE_APP = 0x02 + MODE_AES128_CBC = 0x00 + MODE_AES256_CBC = 0x01 + MODE_DEFAULT = MODE_AES256_CBC + WATCHDOG_MIN_SLEEP = 0.025 WATCHDOG_INTERVAL = 0.1 ) @@ -94,6 +106,19 @@ type Link struct { keepalive time.Duration staleTime time.Duration initiator bool + + prv []byte + sigPriv ed25519.PrivateKey + pub []byte + sigPub ed25519.PublicKey + peerPub []byte + peerSigPub ed25519.PublicKey + sharedKey []byte + derivedKey []byte + mode byte + mtu int + requestTime time.Time + requestPacket *packet.Packet } func NewLink(dest *destination.Destination, transport *transport.Transport, networkIface common.NetworkInterface, establishedCallback func(*Link), closedCallback func(*Link)) *Link { @@ -892,3 +917,306 @@ func (l *Link) watchdog() { } l.watchdogActive = false } + +func (l *Link) Validate(signature, message []byte) bool { + l.mutex.RLock() + defer l.mutex.RUnlock() + + if l.remoteIdentity == nil { + return false + } + + return l.remoteIdentity.Verify(message, signature) +} + +func (l *Link) generateEphemeralKeys() error { + priv, pub, err := cryptography.GenerateKeyPair() + if err != nil { + return fmt.Errorf("failed to generate X25519 keypair: %w", err) + } + l.prv = priv + l.pub = pub + + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate Ed25519 keypair: %w", err) + } + l.sigPriv = privKey + l.sigPub = pubKey + + return nil +} + +func signallingBytes(mtu int, mode byte) []byte { + bytes := make([]byte, LINK_MTU_SIZE) + bytes[0] = byte((mtu >> 16) & 0xFF) + bytes[1] = byte((mtu >> 8) & 0xFF) + bytes[2] = byte(mtu & 0xFF) + bytes[0] |= (mode << 5) + return bytes +} + +func (l *Link) SendLinkRequest() error { + if err := l.generateEphemeralKeys(); err != nil { + return err + } + + l.mode = MODE_DEFAULT + l.mtu = 500 + + signalling := signallingBytes(l.mtu, l.mode) + requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE) + requestData = append(requestData, l.pub...) + requestData = append(requestData, l.sigPub...) + requestData = append(requestData, signalling...) + + pkt := &packet.Packet{ + HeaderType: packet.HeaderType1, + PacketType: packet.PacketTypeLinkReq, + TransportType: 0, + Context: packet.ContextNone, + ContextFlag: packet.FlagUnset, + Hops: 0, + DestinationType: l.destination.GetType(), + DestinationHash: l.destination.GetHash(), + Data: requestData, + CreateReceipt: false, + } + + if err := pkt.Pack(); err != nil { + return fmt.Errorf("failed to pack link request: %w", err) + } + + l.linkID = linkIDFromPacket(pkt) + l.requestPacket = pkt + l.requestTime = time.Now() + l.status = STATUS_PENDING + + if err := l.transport.SendPacket(pkt); err != nil { + return fmt.Errorf("failed to send link request: %w", err) + } + + log.Printf("[DEBUG-3] Link request sent, link_id=%x", l.linkID) + return nil +} + +func linkIDFromPacket(pkt *packet.Packet) []byte { + hashablePart := make([]byte, 0, 1+16+1+ECPUBSIZE) + hashablePart = append(hashablePart, pkt.Raw[0]) + + if pkt.HeaderType == packet.HeaderType2 { + startIndex := 18 + endIndex := startIndex + 16 + 1 + ECPUBSIZE + if len(pkt.Raw) >= endIndex { + hashablePart = append(hashablePart, pkt.Raw[startIndex:endIndex]...) + } + } else { + startIndex := 2 + endIndex := startIndex + 16 + 1 + ECPUBSIZE + if len(pkt.Raw) >= endIndex { + hashablePart = append(hashablePart, pkt.Raw[startIndex:endIndex]...) + } + } + return identity.TruncatedHash(hashablePart) +} + +func (l *Link) HandleLinkRequest(pkt *packet.Packet, ownerIdentity *identity.Identity) error { + if len(pkt.Data) < ECPUBSIZE { + return errors.New("link request data too short") + } + + peerPub := pkt.Data[0:KEYSIZE] + peerSigPub := pkt.Data[KEYSIZE : ECPUBSIZE] + + l.peerPub = peerPub + l.peerSigPub = peerSigPub + l.linkID = linkIDFromPacket(pkt) + l.initiator = false + + if len(pkt.Data) >= ECPUBSIZE+LINK_MTU_SIZE { + mtuBytes := pkt.Data[ECPUBSIZE : ECPUBSIZE+LINK_MTU_SIZE] + l.mtu = (int(mtuBytes[0]&0x1F) << 16) | (int(mtuBytes[1]) << 8) | int(mtuBytes[2]) + l.mode = (mtuBytes[0] & MODE_BYTEMASK) >> 5 + log.Printf("[DEBUG-4] Link request includes MTU: %d, mode: %d", l.mtu, l.mode) + } else { + l.mtu = 500 + l.mode = MODE_DEFAULT + } + + if err := l.generateEphemeralKeys(); err != nil { + return err + } + + if err := l.performHandshake(); err != nil { + return fmt.Errorf("handshake failed: %w", err) + } + + if err := l.sendLinkProof(ownerIdentity); err != nil { + return fmt.Errorf("failed to send link proof: %w", err) + } + + l.status = STATUS_ACTIVE + l.establishedAt = time.Now() + log.Printf("[DEBUG-3] Link established (responder), link_id=%x", l.linkID) + + if l.establishedCallback != nil { + go l.establishedCallback(l) + } + + return nil +} + +func (l *Link) performHandshake() error { + if len(l.peerPub) != KEYSIZE { + return errors.New("invalid peer public key length") + } + + sharedSecret, err := cryptography.DeriveSharedSecret(l.prv, l.peerPub) + if err != nil { + return fmt.Errorf("ECDH failed: %w", err) + } + l.sharedKey = sharedSecret + + var derivedKeyLength int + if l.mode == MODE_AES128_CBC { + derivedKeyLength = 32 + } else if l.mode == MODE_AES256_CBC { + derivedKeyLength = 64 + } else { + return fmt.Errorf("invalid link mode: %d", l.mode) + } + + derivedKey, err := cryptography.DeriveKey(l.sharedKey, l.linkID, nil, derivedKeyLength) + if err != nil { + return fmt.Errorf("HKDF failed: %w", err) + } + l.derivedKey = derivedKey + + if len(derivedKey) >= 32 { + l.sessionKey = derivedKey[0:32] + } + if len(derivedKey) >= 64 { + l.hmacKey = derivedKey[32:64] + } + + l.status = STATUS_HANDSHAKE + log.Printf("[DEBUG-4] Handshake completed, derived %d bytes of key material", len(derivedKey)) + return nil +} + +func (l *Link) sendLinkProof(ownerIdentity *identity.Identity) error { + proofPkt, err := l.GenerateLinkProof(ownerIdentity) + if err != nil { + return err + } + + if l.transport != nil { + if err := l.transport.SendPacket(proofPkt); err != nil { + return fmt.Errorf("failed to send link proof: %w", err) + } + log.Printf("[DEBUG-3] Link proof sent, link_id=%x", l.linkID) + } + + return nil +} + +func (l *Link) GenerateLinkProof(ownerIdentity *identity.Identity) (*packet.Packet, error) { + signalling := signallingBytes(l.mtu, l.mode) + + ownerSigPub := ownerIdentity.GetPublicKey()[KEYSIZE:ECPUBSIZE] + + signedData := make([]byte, 0, len(l.linkID)+KEYSIZE+len(ownerSigPub)+len(signalling)) + signedData = append(signedData, l.linkID...) + signedData = append(signedData, l.pub...) + signedData = append(signedData, ownerSigPub...) + signedData = append(signedData, signalling...) + + signature := ownerIdentity.Sign(signedData) + + proofData := make([]byte, 0, len(signature)+KEYSIZE+len(signalling)) + proofData = append(proofData, signature...) + proofData = append(proofData, l.pub...) + proofData = append(proofData, signalling...) + + proofPkt := &packet.Packet{ + HeaderType: packet.HeaderType1, + PacketType: packet.PacketTypeProof, + TransportType: 0, + Context: packet.ContextLRProof, + ContextFlag: packet.FlagUnset, + Hops: 0, + DestinationType: 0x03, + DestinationHash: l.linkID, + Data: proofData, + CreateReceipt: false, + Link: l, + } + + if err := proofPkt.Pack(); err != nil { + return nil, fmt.Errorf("failed to pack link proof: %w", err) + } + + return proofPkt, nil +} + +func (l *Link) ValidateLinkProof(pkt *packet.Packet) error { + if l.status != STATUS_PENDING { + return fmt.Errorf("invalid link status for proof validation: %d", l.status) + } + + if len(pkt.Data) < identity.SIGLENGTH/8+KEYSIZE { + return errors.New("link proof data too short") + } + + signature := pkt.Data[0 : identity.SIGLENGTH/8] + peerPub := pkt.Data[identity.SIGLENGTH/8 : identity.SIGLENGTH/8+KEYSIZE] + + signalling := []byte{0, 0, 0} + if len(pkt.Data) >= identity.SIGLENGTH/8+KEYSIZE+LINK_MTU_SIZE { + signalling = pkt.Data[identity.SIGLENGTH/8+KEYSIZE : identity.SIGLENGTH/8+KEYSIZE+LINK_MTU_SIZE] + mtu := (int(signalling[0]&0x1F) << 16) | (int(signalling[1]) << 8) | int(signalling[2]) + mode := (signalling[0] & MODE_BYTEMASK) >> 5 + l.mtu = mtu + l.mode = mode + log.Printf("[DEBUG-4] Link proof includes MTU: %d, mode: %d", mtu, mode) + } + + l.peerPub = peerPub + if l.destination != nil && l.destination.GetIdentity() != nil { + destIdent := l.destination.GetIdentity() + pubKey := destIdent.GetPublicKey() + if len(pubKey) >= ECPUBSIZE { + l.peerSigPub = pubKey[KEYSIZE:ECPUBSIZE] + } + } + + signedData := make([]byte, 0, len(l.linkID)+KEYSIZE+len(l.peerSigPub)+len(signalling)) + signedData = append(signedData, l.linkID...) + signedData = append(signedData, peerPub...) + signedData = append(signedData, l.peerSigPub...) + signedData = append(signedData, signalling...) + + if l.destination == nil || l.destination.GetIdentity() == nil { + return errors.New("no destination identity for proof validation") + } + + if !l.destination.GetIdentity().Verify(signedData, signature) { + return errors.New("link proof signature validation failed") + } + + if err := l.performHandshake(); err != nil { + return fmt.Errorf("handshake failed: %w", err) + } + + l.rtt = time.Since(l.requestTime).Seconds() + l.status = STATUS_ACTIVE + l.establishedAt = time.Now() + + log.Printf("[DEBUG-3] Link established (initiator), link_id=%x, RTT=%.3fs", l.linkID, l.rtt) + + if l.establishedCallback != nil { + go l.establishedCallback(l) + } + + return nil +}