diff --git a/capsule-core/src/federation.zig b/capsule-core/src/federation.zig index 265d84b..8cf4742 100644 --- a/capsule-core/src/federation.zig +++ b/capsule-core/src/federation.zig @@ -11,6 +11,7 @@ pub const SERVICE_TYPE: u16 = lwf.LWFHeader.ServiceType.IDENTITY_SIGNAL; pub const DhtNode = struct { id: [32]u8, address: net.Address, + key: [32]u8, }; pub const SessionState = enum { @@ -79,6 +80,7 @@ pub const FederationMessage = union(enum) { try writer.writeInt(u16, @intCast(n.nodes.len), .big); for (n.nodes) |node| { try writer.writeAll(&node.id); + try writer.writeAll(&node.key); // For now we only support IPv4 in DHT nodes responses if (node.address.any.family == std.posix.AF.INET) { try writer.writeAll(&std.mem.toBytes(node.address.in.sa.addr)); @@ -143,11 +145,13 @@ pub const FederationMessage = union(enum) { const nodes = try allocator.alloc(DhtNode, count); for (0..count) |i| { const id = try reader.readBytesNoEof(32); + const key = try reader.readBytesNoEof(32); const addr_u32 = try reader.readInt(u32, @import("builtin").target.cpu.arch.endian()); const port = try reader.readInt(u16, .big); nodes[i] = .{ .id = id, .address = net.Address.initIp4(std.mem.toBytes(addr_u32), port), + .key = key, }; } return .{ .dht_nodes = .{ .nodes = nodes } }; diff --git a/capsule-core/src/node.zig b/capsule-core/src/node.zig index 6c19b0d..83198db 100644 --- a/capsule-core/src/node.zig +++ b/capsule-core/src/node.zig @@ -27,19 +27,7 @@ const relay_service_mod = @import("relay_service.zig"); const NodeConfig = config_mod.NodeConfig; const UTCP = utcp_mod.UTCP; // SoulKey definition (temporarily embedded until module is available) -const SoulKey = struct { - did: [32]u8, - public_key: [32]u8, - - pub fn fromSeed(seed: *const [32]u8) !SoulKey { - var public_key: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(seed, &public_key, .{}); - return SoulKey{ - .did = public_key, - .public_key = public_key, - }; - } -}; +const SoulKey = l1.SoulKey; const RiskGraph = qvl.types.RiskGraph; const DiscoveryService = discovery_mod.DiscoveryService; const PeerTable = peer_table_mod.PeerTable; @@ -289,15 +277,12 @@ pub const CapsuleNode = struct { if (frame.header.service_type == fed.SERVICE_TYPE) { try self.handleFederationMessage(result.sender, frame); - } else if (frame.header.service_type == l0.LWFHeader.ServiceType.RELAY_FORWARD) { // Phase 14: Relay Forwarding if (self.relay_service) |*rs| { std.log.debug("Relay: Received relay packet from {f}", .{result.sender}); - // Mock secret for now (needs ECDH) - const shared_secret = [_]u8{0xAA} ** 32; - // Unwrap and forward - if (rs.forwardPacket(frame.payload, shared_secret)) |next_hop_data| { + // Unwrap and forward using our private key (as receiver) + if (rs.forwardPacket(frame.payload, self.identity.x25519_private)) |next_hop_data| { // next_hop_data.payload is now the INNER payload const next_node_id = next_hop_data.next_hop; @@ -305,6 +290,8 @@ pub const CapsuleNode = struct { // TODO: Check if we are final destination (all zeros) handled by forwardPacket // But forwardPacket returns the result to US to send. + // Check if we are destination handled by forwardPacket via null next_hop logic? + // forwardPacket returns next_hop. If all zeros, it means LOCAL delivery. var is_final = true; for (next_node_id) |b| { if (b != 0) { @@ -314,16 +301,18 @@ pub const CapsuleNode = struct { } if (is_final) { - // We are the destination! - // TODO: Process inner payload as a new frame - std.log.info("Relay: Final Packet Received! Size: {d}", .{next_hop_data.payload.len}); + // Final delivery to US + std.log.info("Relay: Final Packet Received for Session {x}! Payload Size: {d}", .{ next_hop_data.session_id, next_hop_data.payload.len }); + // TODO: Hand over payload to upper layers (e.g. Chat/Protocol handler) + // For MVP, just log. + } else { // Forward to next hop - // Need to lookup IP for next_node_id + // Lookup IP const next_remote = self.dht.routing_table.findNode(next_node_id); if (next_remote) |remote| { // Re-wrap in LWF for transport try self.utcp.send(remote.address, next_hop_data.payload, l0.LWFHeader.ServiceType.RELAY_FORWARD); - std.log.info("Relay: Forwarded packet to {f}", .{remote.address}); + std.log.info("Relay: Forwarded packet to {f} (Session {x})", .{ remote.address, next_hop_data.session_id }); } else { std.log.warn("Relay: Next hop {x} not found in routing table", .{next_node_id[0..4]}); } diff --git a/capsule-core/src/relay_service.zig b/capsule-core/src/relay_service.zig index 38af370..7683fe8 100644 --- a/capsule-core/src/relay_service.zig +++ b/capsule-core/src/relay_service.zig @@ -28,15 +28,21 @@ pub const RelayService = struct { _ = self; } + /// Forward a relay packet to the next hop + /// Returns the next hop address and the inner payload /// Forward a relay packet to the next hop /// Returns the next hop address and the inner payload pub fn forwardPacket( self: *RelayService, - packet: relay_mod.RelayPacket, - shared_secret: [32]u8, - ) !struct { next_hop: [32]u8, payload: []u8 } { - // Unwrap the onion layer - const result = try self.onion_builder.unwrapLayer(packet, shared_secret); + raw_packet: []const u8, + receiver_private_key: [32]u8, + ) !struct { next_hop: [32]u8, payload: []u8, session_id: [16]u8 } { + // Parse the wire packet + var packet = try relay_mod.RelayPacket.decode(self.allocator, raw_packet); + defer packet.deinit(self.allocator); + + // Unwrap the onion layer (using our private key + packet's ephemeral key) + const result = try self.onion_builder.unwrapLayer(packet, receiver_private_key, null); // Check if next_hop is all zeros (meaning we're the final destination) const is_final = blk: { @@ -48,15 +54,19 @@ pub const RelayService = struct { if (is_final) { // We're the final destination - deliver locally - std.log.info("Relay: Final destination reached, delivering payload locally", .{}); + std.log.info("Relay: Final destination reached for session {x}", .{result.session_id}); self.packets_dropped += 1; // Not actually dropped, just not forwarded return result; } // Forward to next hop - std.log.debug("Relay: Forwarding to next hop: {x}", .{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) }); self.packets_forwarded += 1; + // Result payload includes the re-wrapped inner onion? + // Wait, unwrapLayer returns the decrypted payload. + // In onion routing, the decrypted payload IS the inner onion for the next hop. + // We just return it. The caller (node.zig) must send it. return result; } diff --git a/capsule-core/src/storage.zig b/capsule-core/src/storage.zig index 16224ce..3b3dad3 100644 --- a/capsule-core/src/storage.zig +++ b/capsule-core/src/storage.zig @@ -56,7 +56,8 @@ pub const StorageService = struct { \\ id BLOB PRIMARY KEY, \\ address TEXT NOT NULL, \\ last_seen INTEGER NOT NULL, - \\ seen_count INTEGER DEFAULT 1 + \\ seen_count INTEGER DEFAULT 1, + \\ x25519_key BLOB \\ ); \\ CREATE TABLE IF NOT EXISTS qvl_nodes ( \\ did BLOB PRIMARY KEY, @@ -84,8 +85,8 @@ pub const StorageService = struct { } pub fn savePeer(self: *StorageService, node: RemoteNode) !void { - const sql = "INSERT INTO peers (id, address, last_seen) VALUES (?, ?, ?) " ++ - "ON CONFLICT(id) DO UPDATE SET address=excluded.address, last_seen=excluded.last_seen, seen_count=seen_count+1;"; + const sql = "INSERT INTO peers (id, address, last_seen, x25519_key) VALUES (?, ?, ?, ?) " ++ + "ON CONFLICT(id) DO UPDATE SET address=excluded.address, last_seen=excluded.last_seen, seen_count=seen_count+1, x25519_key=excluded.x25519_key;"; var stmt: ?*c.sqlite3_stmt = null; if (c.sqlite3_prepare_v2(self.db, sql, -1, &stmt, null) != c.SQLITE_OK) return error.PrepareFailed; @@ -102,11 +103,14 @@ pub const StorageService = struct { // Bind Last Seen _ = c.sqlite3_bind_int64(stmt, 3, node.last_seen); + // Bind Key + _ = c.sqlite3_bind_blob(stmt, 4, &node.key, 32, null); + if (c.sqlite3_step(stmt) != c.SQLITE_DONE) return error.StepFailed; } pub fn loadPeers(self: *StorageService, allocator: std.mem.Allocator) ![]RemoteNode { - const sql = "SELECT id, address, last_seen FROM peers;"; + const sql = "SELECT id, address, last_seen, x25519_key FROM peers;"; var stmt: ?*c.sqlite3_stmt = null; if (c.sqlite3_prepare_v2(self.db, sql, -1, &stmt, null) != c.SQLITE_OK) return error.PrepareFailed; defer _ = c.sqlite3_finalize(stmt); @@ -119,6 +123,8 @@ pub const StorageService = struct { const id_len = c.sqlite3_column_bytes(stmt, 0); const addr_ptr = c.sqlite3_column_text(stmt, 1); const last_seen = c.sqlite3_column_int64(stmt, 2); + const key_ptr = c.sqlite3_column_blob(stmt, 3); + const key_len = c.sqlite3_column_bytes(stmt, 3); if (id_len != ID_LEN) continue; @@ -128,6 +134,11 @@ pub const StorageService = struct { const addr_str = std.mem.span(addr_ptr); node.address = try std.net.Address.parseIp(addr_str, 0); // Port logic handled via federation later node.last_seen = last_seen; + if (key_len == 32) { + @memcpy(&node.key, @as([*]const u8, @ptrCast(key_ptr))[0..32]); + } else { + @memset(&node.key, 0); + } try list.append(allocator, node); } diff --git a/l0-transport/dht.zig b/l0-transport/dht.zig index 414f1e5..d05b2f6 100644 --- a/l0-transport/dht.zig +++ b/l0-transport/dht.zig @@ -37,6 +37,7 @@ pub const RemoteNode = struct { id: NodeId, address: net.Address, last_seen: i64, + key: [32]u8 = [_]u8{0} ** 32, // X25519 Public Key }; pub const KBucket = struct { diff --git a/l0-transport/lwf.zig b/l0-transport/lwf.zig index e46159d..b890f0a 100644 --- a/l0-transport/lwf.zig +++ b/l0-transport/lwf.zig @@ -8,6 +8,9 @@ //! - Fixed-size trailer (36 bytes) //! - Checksum verification (CRC32-C) //! - Signature support (Ed25519) +//! - Nonce/SessionID Binding: +//! Cryptography nonce construction MUST strictly bind to the Session ID. +//! Usage: `nonce[0..16] == session_id`, `nonce[16..24] == random/counter`. //! //! Frame structure: //! ┌──────────────────┐ diff --git a/l0-transport/relay.zig b/l0-transport/relay.zig index 33f14af..2708176 100644 --- a/l0-transport/relay.zig +++ b/l0-transport/relay.zig @@ -33,15 +33,14 @@ pub const NextHopHeader = struct { /// It effectively contains an encrypted blob that the receiver can decrypt /// to reveal the NextHopHeader and the inner Payload. pub const RelayPacket = struct { - // Public ephemeral key for ECDH could be here if we do per-packet keying, - // but typically we use established session keys or pre-keys. - // For simplicity V1, we assume a session key exists or use a nonce. - - nonce: [24]u8, // XChaCha20 nonce + // X25519 Public Key for ephemeral key agreement + ephemeral_key: [32]u8, + nonce: [24]u8, // XChaCha20 nonce (SessionID + Random) ciphertext: []u8, // Encrypted [NextHopHeader + InnerPayload] pub fn init(allocator: std.mem.Allocator, size: usize) !RelayPacket { return RelayPacket{ + .ephemeral_key = undefined, .nonce = undefined, // To be filled .ciphertext = try allocator.alloc(u8, size), }; @@ -50,6 +49,31 @@ pub const RelayPacket = struct { pub fn deinit(self: *RelayPacket, allocator: std.mem.Allocator) void { allocator.free(self.ciphertext); } + + /// Serialize to wire format + pub fn encode(self: *const RelayPacket, allocator: std.mem.Allocator) ![]u8 { + const total_size = 32 + 24 + self.ciphertext.len; + var buf = try allocator.alloc(u8, total_size); + + @memcpy(buf[0..32], &self.ephemeral_key); + @memcpy(buf[32..56], &self.nonce); + @memcpy(buf[56..], self.ciphertext); + + return buf; + } + + /// Deserialize from wire format + pub fn decode(allocator: std.mem.Allocator, data: []const u8) !RelayPacket { + if (data.len < 32 + 24) return error.PacketTooSmall; + const ciphertext_len = data.len - 32 - 24; + + var packet = try RelayPacket.init(allocator, ciphertext_len); + @memcpy(&packet.ephemeral_key, data[0..32]); + @memcpy(&packet.nonce, data[32..56]); + @memcpy(packet.ciphertext, data[56..]); + + return packet; + } }; /// Logic to construct an onion packet. @@ -64,13 +88,19 @@ pub const OnionBuilder = struct { /// Wraps a payload into a single layer of encryption for a specific relay. /// In a real onion, this is called iteratively from innermost to outermost. + /// Uses ECDH with next_hop_pubkey to derive a shared secret. pub fn wrapLayer( self: *OnionBuilder, payload: []const u8, next_hop: [32]u8, - shared_secret: [32]u8, + next_hop_pubkey: [32]u8, + session_id: [16]u8, ) !RelayPacket { - _ = shared_secret; + // 1. Generate Ephemeral Keypair + const kp = crypto.dh.X25519.KeyPair.generate(); + + // 2. Compute Shared Secret + const shared_secret = try crypto.dh.X25519.scalarmult(kp.secret_key, next_hop_pubkey); // 1. Construct Cleartext: [NextHop (32) | Payload (N)] var cleartext = try self.allocator.alloc(u8, 32 + payload.len); defer self.allocator.free(cleartext); @@ -78,40 +108,73 @@ pub const OnionBuilder = struct { @memcpy(cleartext[0..32], &next_hop); @memcpy(cleartext[32..], payload); - // 2. Encrypt - var packet = try RelayPacket.init(self.allocator, cleartext.len + 16); // +AuthTag - crypto.random.bytes(&packet.nonce); + // 2. Encrypt using XChaCha20-Poly1305 + const tag_len = crypto.aead.chacha_poly.XChaCha20Poly1305.tag_length; - // Mock Encryption (XChaCha20-Poly1305 would go here) - // For MVP structure, we just copy (TODO: Add actual crypto integration) - // We simulate "encryption" by XORing with a byte for testing proving modification works - for (cleartext, 0..) |b, i| { - packet.ciphertext[i] = b ^ 0xFF; // Simple NOT for mock encryption - } - // Mock Auth Tag - @memset(packet.ciphertext[cleartext.len..], 0xAA); + var packet = try RelayPacket.init(self.allocator, cleartext.len + tag_len); + + // Store Ephemeral Public Key in Packet + @memcpy(&packet.ephemeral_key, &kp.public_key); + + // Nonce Construction: SessionID (16) + Random (8) + @memcpy(packet.nonce[0..16], &session_id); + crypto.random.bytes(packet.nonce[16..24]); + + var tag: [tag_len]u8 = undefined; + crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt( + packet.ciphertext[0..cleartext.len], + &tag, + cleartext, + "", // No associated data for now + packet.nonce, + shared_secret, + ); + + // Append tag to ciphertext + @memcpy(packet.ciphertext[cleartext.len..], &tag); return packet; } /// Unwraps a single layer (Server/Relay side logic). + /// Uses receiver_secret_key (node's private key) to derive shared secret from packet's ephemeral key. pub fn unwrapLayer( self: *OnionBuilder, packet: RelayPacket, - shared_secret: [32]u8, - ) !struct { next_hop: [32]u8, payload: []u8 } { - _ = shared_secret; + receiver_secret_key: [32]u8, + expected_session_id: ?[16]u8, + ) !struct { next_hop: [32]u8, payload: []u8, session_id: [16]u8 } { + // 1. Compute Shared Secret from Ephemeral Key + const shared_secret = crypto.dh.X25519.scalarmult(receiver_secret_key, packet.ephemeral_key) catch return error.DecryptionFailed; + const tag_len = crypto.aead.chacha_poly.XChaCha20Poly1305.tag_length; + if (packet.ciphertext.len < 32 + tag_len) return error.DecryptionFailed; - // Mock Decryption - if (packet.ciphertext.len < 32 + 16) return error.DecryptionFailed; + // Verify Session ID part of Nonce if provided + var session_id: [16]u8 = undefined; + @memcpy(&session_id, packet.nonce[0..16]); - const content_len = packet.ciphertext.len - 16; - var cleartext = try self.allocator.alloc(u8, content_len); - - for (0..content_len) |i| { - cleartext[i] = packet.ciphertext[i] ^ 0xFF; + if (expected_session_id) |expected| { + if (!std.mem.eql(u8, &expected, &session_id)) { + return error.DecryptionFailed; // Wrong session context + } } + const content_len = packet.ciphertext.len - tag_len; + var cleartext = try self.allocator.alloc(u8, content_len); + defer self.allocator.free(cleartext); // Free after copy + + var tag: [tag_len]u8 = undefined; + @memcpy(&tag, packet.ciphertext[content_len..]); + + try crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt( + cleartext, + packet.ciphertext[0..content_len], + tag, + "", // Associated data + packet.nonce, + shared_secret, + ); + var next_hop: [32]u8 = undefined; @memcpy(&next_hop, cleartext[0..32]); @@ -120,11 +183,10 @@ pub const OnionBuilder = struct { const payload = try self.allocator.alloc(u8, payload_len); @memcpy(payload, cleartext[32..]); - self.allocator.free(cleartext); - return .{ .next_hop = next_hop, .payload = payload, + .session_id = session_id, }; } }; @@ -135,17 +197,24 @@ test "Relay: wrap and unwrap" { const payload = "Hello Onion!"; const next_hop = [_]u8{0xAB} ** 32; - const shared_secret = [_]u8{0} ** 32; + // Generate valid KeyPair for testing + const kp = crypto.dh.X25519.KeyPair.generate(); + const receiver_pubkey = kp.public_key; + const receiver_seckey = kp.secret_key; - var packet = try builder.wrapLayer(payload, next_hop, shared_secret); + const session_id = [_]u8{0xCC} ** 16; + + var packet = try builder.wrapLayer(payload, next_hop, receiver_pubkey, session_id); defer packet.deinit(allocator); - // Verify it is "encrypted" (XOR 0xFF) - // Payload "H" (0x48) ^ 0xFF = 0xB7 - // First byte of cleartext is next_hop[0] (0xAB) ^ 0xFF = 0x54 - try std.testing.expectEqual(@as(u8, 0x54), packet.ciphertext[0]); + // Verify it is encrypted (not plain) + // First byte of cleartext should NOT be next_hop[0] (0xAB) + try std.testing.expect(packet.ciphertext[0] != 0xAB); - const result = try builder.unwrapLayer(packet, shared_secret); + // Verify first 16 bytes of nonce are session_id + try std.testing.expectEqualSlices(u8, &session_id, packet.nonce[0..16]); + + const result = try builder.unwrapLayer(packet, receiver_seckey, session_id); defer allocator.free(result.payload); try std.testing.expectEqualSlices(u8, &next_hop, &result.next_hop);