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.
This commit is contained in:
Markus Maiwald 2026-01-31 22:21:53 +01:00
parent 8b115ee2a6
commit 87cd30dbe3
4 changed files with 102 additions and 16 deletions

View File

@ -5,10 +5,19 @@
const std = @import("std"); const std = @import("std");
const relay = @import("relay"); const relay = @import("relay");
const dht = @import("dht"); const dht = @import("dht");
const crypto = std.crypto;
const QvlStore = @import("qvl_store.zig").QvlStore; const QvlStore = @import("qvl_store.zig").QvlStore;
const PeerTable = @import("peer_table.zig").PeerTable; const PeerTable = @import("peer_table.zig").PeerTable;
const DhtService = dht.DhtService; 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{ pub const CircuitError = error{
NoRelaysAvailable, NoRelaysAvailable,
TargetNotFound, TargetNotFound,
@ -89,10 +98,47 @@ pub const CircuitBuilder = struct {
std.crypto.random.bytes(&session_id); std.crypto.random.bytes(&session_id);
// Wrap: Relay Packet -> [ NextHop: Target | Payload ] // 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 }; 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" { test "Circuit: Build 1-Hop" {

View File

@ -656,6 +656,7 @@ pub const CapsuleNode = struct {
self.allocator, self.allocator,
self.qvl_store, self.qvl_store,
&self.peer_table, &self.peer_table,
&self.dht,
); );
} }
self.config.relay_enabled = true; self.config.relay_enabled = true;
@ -690,13 +691,20 @@ pub const CapsuleNode = struct {
}, },
.RelaySend => |args| { .RelaySend => |args| {
if (self.circuit_builder) |*cb| { if (self.circuit_builder) |*cb| {
// MVP: Build circuit returns ONLY the packet. if (cb.buildOneHopCircuit(args.target_did, args.message)) |result| {
// We need to know who the first hop is. var packet = result.packet;
// Let's modify CircuitBuilder to return that info. defer packet.deinit(self.allocator);
// For now, fail with message. const first_hop = result.first_hop;
_ = args;
_ = cb; const encoded = try packet.encode(self.allocator);
response = .{ .Error = "RelaySend not yet implemented: CircuitBuilder API requires update to return next hop address." }; 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 { } else {
response = .{ .Error = "Relay service not enabled" }; response = .{ .Error = "Relay service not enabled" };
} }

View File

@ -8,12 +8,18 @@ const relay_mod = @import("relay");
const dht_mod = @import("dht"); const dht_mod = @import("dht");
pub const RelayService = struct { pub const RelayService = struct {
pub const SessionContext = struct {
packet_count: u64,
last_seen: i64,
};
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
onion_builder: relay_mod.OnionBuilder, onion_builder: relay_mod.OnionBuilder,
// Statistics // Statistics
packets_forwarded: u64, packets_forwarded: u64,
packets_dropped: u64, packets_dropped: u64,
sessions: std.AutoHashMap([16]u8, SessionContext),
pub fn init(allocator: std.mem.Allocator) RelayService { pub fn init(allocator: std.mem.Allocator) RelayService {
return .{ return .{
@ -21,11 +27,12 @@ pub const RelayService = struct {
.onion_builder = relay_mod.OnionBuilder.init(allocator), .onion_builder = relay_mod.OnionBuilder.init(allocator),
.packets_forwarded = 0, .packets_forwarded = 0,
.packets_dropped = 0, .packets_dropped = 0,
.sessions = std.AutoHashMap([16]u8, SessionContext).init(allocator),
}; };
} }
pub fn deinit(self: *RelayService) void { pub fn deinit(self: *RelayService) void {
_ = self; self.sessions.deinit();
} }
/// Forward a relay packet to the next hop /// Forward a relay packet to the next hop
@ -61,6 +68,18 @@ pub const RelayService = struct {
// Forward to next hop // Forward to next hop
std.log.debug("Relay: Forwarding session {x} to next hop: {x}", .{ result.session_id, std.fmt.fmtSliceHexLower(&result.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; self.packets_forwarded += 1;
// Result payload includes the re-wrapped inner onion? // Result payload includes the re-wrapped inner onion?
@ -93,18 +112,30 @@ test "RelayService: Forward packet" {
// Create a test packet // Create a test packet
const payload = "Test payload"; const payload = "Test payload";
const next_hop = [_]u8{0xAB} ** 32; 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 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); defer packet.deinit(allocator);
// Forward the packet const encoded = try packet.encode(allocator);
const result = try relay_service.forwardPacket(packet, shared_secret); defer allocator.free(encoded);
// Forward the packet (pass encoded bytes)
const result = try relay_service.forwardPacket(encoded, receiver_priv);
defer allocator.free(result.payload); defer allocator.free(result.payload);
try std.testing.expectEqualSlices(u8, &next_hop, &result.next_hop); try std.testing.expectEqualSlices(u8, &next_hop, &result.next_hop);
try std.testing.expectEqualSlices(u8, payload, result.payload); try std.testing.expectEqualSlices(u8, payload, result.payload);
try std.testing.expectEqualSlices(u8, &session_id, &result.session_id);
// Check stats // Check stats
const stats = relay_service.getStats(); const stats = relay_service.getStats();

View File

@ -95,9 +95,10 @@ pub const OnionBuilder = struct {
next_hop: [32]u8, next_hop: [32]u8,
next_hop_pubkey: [32]u8, next_hop_pubkey: [32]u8,
session_id: [16]u8, session_id: [16]u8,
ephemeral_keypair: ?crypto.dh.X25519.KeyPair,
) !RelayPacket { ) !RelayPacket {
// 1. Generate Ephemeral Keypair // 1. Generate or Use Ephemeral Keypair
const kp = crypto.dh.X25519.KeyPair.generate(); const kp = ephemeral_keypair orelse crypto.dh.X25519.KeyPair.generate();
// 2. Compute Shared Secret // 2. Compute Shared Secret
const shared_secret = try crypto.dh.X25519.scalarmult(kp.secret_key, next_hop_pubkey); 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; 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); defer packet.deinit(allocator);
// Verify it is encrypted (not plain) // Verify it is encrypted (not plain)