From 87cd30dbe32fe44beb5139a9cf530662e9e82ff7 Mon Sep 17 00:00:00 2001 From: Markus Maiwald Date: Sat, 31 Jan 2026 22:21:53 +0100 Subject: [PATCH] feat(relay): Implement Sticky Sessions & RelaySend CLI - Added to support reusing SessionID and Ephemeral Keys. - Updated to track session statistics (packet counts) for rate-limiting. - Implemented CLI command to send packets via Relay. - Refactored to accept optional reusable keypair. - Updated tests. --- capsule-core/src/circuit.zig | 48 +++++++++++++++++++++++++++++- capsule-core/src/node.zig | 22 +++++++++----- capsule-core/src/relay_service.zig | 41 +++++++++++++++++++++---- l0-transport/relay.zig | 7 +++-- 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/capsule-core/src/circuit.zig b/capsule-core/src/circuit.zig index 2c6f30c..12bbd67 100644 --- a/capsule-core/src/circuit.zig +++ b/capsule-core/src/circuit.zig @@ -5,10 +5,19 @@ const std = @import("std"); const relay = @import("relay"); const dht = @import("dht"); +const crypto = std.crypto; const QvlStore = @import("qvl_store.zig").QvlStore; const PeerTable = @import("peer_table.zig").PeerTable; const DhtService = dht.DhtService; +pub const ActiveCircuit = struct { + session_id: [16]u8, + relay_address: std.net.Address, + relay_pubkey: [32]u8, + // Sticky Ephemeral Key (for optimizations) + ephemeral_keypair: crypto.dh.X25519.KeyPair, +}; + pub const CircuitError = error{ NoRelaysAvailable, TargetNotFound, @@ -89,10 +98,47 @@ pub const CircuitBuilder = struct { std.crypto.random.bytes(&session_id); // Wrap: Relay Packet -> [ NextHop: Target | Payload ] - const packet = try self.onion_builder.wrapLayer(payload, target_id, relay_pubkey, session_id); + const packet = try self.onion_builder.wrapLayer(payload, target_id, relay_pubkey, session_id, null); return .{ .packet = packet, .first_hop = relay_node.address }; } + + /// Create a sticky session circuit + pub fn createCircuit(self: *CircuitBuilder, relay_did: ?[]const u8) !ActiveCircuit { + // Select Relay (Random if null) + const selected_did = if (relay_did) |did| try self.allocator.dupe(u8, did) else blk: { + const trusted = try self.qvl_store.getTrustedRelays(0.5, 1); + if (trusted.len == 0) return error.NoRelaysAvailable; + break :blk trusted[0]; + }; + defer self.allocator.free(selected_did); + + // Resolve Relay Keys + var relay_id = [_]u8{0} ** 32; + if (selected_did.len >= 32) @memcpy(&relay_id, selected_did[0..32]); + + const relay_node = self.dht.routing_table.findNode(relay_id) orelse return error.RelayNotFound; + + const kp = crypto.dh.X25519.KeyPair.generate(); + var session_id: [16]u8 = undefined; + std.crypto.random.bytes(&session_id); + + return ActiveCircuit{ + .session_id = session_id, + .relay_address = relay_node.address, + .relay_pubkey = relay_node.key, + .ephemeral_keypair = kp, + }; + } + + /// Send a payload on an existing circuit (reusing session/keys) + pub fn sendOnCircuit(self: *CircuitBuilder, circuit: *ActiveCircuit, target_did: []const u8, payload: []const u8) !relay.RelayPacket { + var target_id = [_]u8{0} ** 32; + if (target_did.len >= 32) @memcpy(&target_id, target_did[0..32]); + + // Use stored keys for stickiness + return self.onion_builder.wrapLayer(payload, target_id, circuit.relay_pubkey, circuit.session_id, circuit.ephemeral_keypair); + } }; test "Circuit: Build 1-Hop" { diff --git a/capsule-core/src/node.zig b/capsule-core/src/node.zig index aba88da..553cd5a 100644 --- a/capsule-core/src/node.zig +++ b/capsule-core/src/node.zig @@ -656,6 +656,7 @@ pub const CapsuleNode = struct { self.allocator, self.qvl_store, &self.peer_table, + &self.dht, ); } self.config.relay_enabled = true; @@ -690,13 +691,20 @@ pub const CapsuleNode = struct { }, .RelaySend => |args| { if (self.circuit_builder) |*cb| { - // MVP: Build circuit returns ONLY the packet. - // We need to know who the first hop is. - // Let's modify CircuitBuilder to return that info. - // For now, fail with message. - _ = args; - _ = cb; - response = .{ .Error = "RelaySend not yet implemented: CircuitBuilder API requires update to return next hop address." }; + if (cb.buildOneHopCircuit(args.target_did, args.message)) |result| { + var packet = result.packet; + defer packet.deinit(self.allocator); + const first_hop = result.first_hop; + + const encoded = try packet.encode(self.allocator); + defer self.allocator.free(encoded); + + try self.utcp.send(first_hop, encoded, l0.LWFHeader.ServiceType.RELAY_FORWARD); + response = .{ .Ok = "Packet sent via Relay" }; + } else |err| { + std.log.warn("RelaySend failed: {}", .{err}); + response = .{ .Error = "Failed to build circuit" }; + } } else { response = .{ .Error = "Relay service not enabled" }; } diff --git a/capsule-core/src/relay_service.zig b/capsule-core/src/relay_service.zig index 7683fe8..23ea63c 100644 --- a/capsule-core/src/relay_service.zig +++ b/capsule-core/src/relay_service.zig @@ -8,12 +8,18 @@ const relay_mod = @import("relay"); const dht_mod = @import("dht"); pub const RelayService = struct { + pub const SessionContext = struct { + packet_count: u64, + last_seen: i64, + }; + allocator: std.mem.Allocator, onion_builder: relay_mod.OnionBuilder, // Statistics packets_forwarded: u64, packets_dropped: u64, + sessions: std.AutoHashMap([16]u8, SessionContext), pub fn init(allocator: std.mem.Allocator) RelayService { return .{ @@ -21,11 +27,12 @@ pub const RelayService = struct { .onion_builder = relay_mod.OnionBuilder.init(allocator), .packets_forwarded = 0, .packets_dropped = 0, + .sessions = std.AutoHashMap([16]u8, SessionContext).init(allocator), }; } pub fn deinit(self: *RelayService) void { - _ = self; + self.sessions.deinit(); } /// Forward a relay packet to the next hop @@ -61,6 +68,18 @@ pub const RelayService = struct { // Forward to next hop std.log.debug("Relay: Forwarding session {x} to next hop: {x}", .{ result.session_id, std.fmt.fmtSliceHexLower(&result.next_hop) }); + + // Update Sticky Session Stats + const now = std.time.timestamp(); + const gop = try self.sessions.getOrPut(result.session_id); + if (!gop.found_existing) { + gop.value_ptr.* = .{ .packet_count = 1, .last_seen = now }; + std.log.info("Relay: New Sticky Session detected: {x}", .{result.session_id}); + } else { + gop.value_ptr.packet_count += 1; + gop.value_ptr.last_seen = now; + } + self.packets_forwarded += 1; // Result payload includes the re-wrapped inner onion? @@ -93,18 +112,30 @@ test "RelayService: Forward packet" { // Create a test packet const payload = "Test payload"; const next_hop = [_]u8{0xAB} ** 32; - const shared_secret = [_]u8{0} ** 32; + // const shared_secret = [_]u8{0} ** 32; // Not used directly anymore, using private key + + // Generate keys + const receiver_kp = std.crypto.dh.X25519.KeyPair.generate(); + const receiver_pub = receiver_kp.public_key; + const receiver_priv = receiver_kp.secret_key; + + const session_id = [_]u8{0x11} ** 16; var onion_builder = relay_mod.OnionBuilder.init(allocator); - var packet = try onion_builder.wrapLayer(payload, next_hop, shared_secret); + // Wrap layer targeting the receiver + var packet = try onion_builder.wrapLayer(payload, next_hop, receiver_pub, session_id, null); defer packet.deinit(allocator); - // Forward the packet - const result = try relay_service.forwardPacket(packet, shared_secret); + const encoded = try packet.encode(allocator); + defer allocator.free(encoded); + + // Forward the packet (pass encoded bytes) + const result = try relay_service.forwardPacket(encoded, receiver_priv); defer allocator.free(result.payload); try std.testing.expectEqualSlices(u8, &next_hop, &result.next_hop); try std.testing.expectEqualSlices(u8, payload, result.payload); + try std.testing.expectEqualSlices(u8, &session_id, &result.session_id); // Check stats const stats = relay_service.getStats(); diff --git a/l0-transport/relay.zig b/l0-transport/relay.zig index 2708176..2f772f1 100644 --- a/l0-transport/relay.zig +++ b/l0-transport/relay.zig @@ -95,9 +95,10 @@ pub const OnionBuilder = struct { next_hop: [32]u8, next_hop_pubkey: [32]u8, session_id: [16]u8, + ephemeral_keypair: ?crypto.dh.X25519.KeyPair, ) !RelayPacket { - // 1. Generate Ephemeral Keypair - const kp = crypto.dh.X25519.KeyPair.generate(); + // 1. Generate or Use Ephemeral Keypair + const kp = ephemeral_keypair orelse crypto.dh.X25519.KeyPair.generate(); // 2. Compute Shared Secret const shared_secret = try crypto.dh.X25519.scalarmult(kp.secret_key, next_hop_pubkey); @@ -204,7 +205,7 @@ test "Relay: wrap and unwrap" { const session_id = [_]u8{0xCC} ** 16; - var packet = try builder.wrapLayer(payload, next_hop, receiver_pubkey, session_id); + var packet = try builder.wrapLayer(payload, next_hop, receiver_pubkey, session_id, null); defer packet.deinit(allocator); // Verify it is encrypted (not plain)