diff --git a/l0-transport/png.zig b/l0-transport/png.zig new file mode 100644 index 0000000..536d0c6 --- /dev/null +++ b/l0-transport/png.zig @@ -0,0 +1,344 @@ +//! RFC-0015: Polymorphic Noise Generator (PNG) +//! +//! Per-session traffic shaping for DPI resistance. +//! Kenya-compliant: <1KB RAM per session, deterministic, no cloud calls. + +const std = @import("std"); +const crypto = @import("../l1-identity/crypto.zig"); + +/// ChaCha20-based PNG state +/// Deterministic: same seed = same noise sequence at both ends +pub const PngState = struct { + /// ChaCha20 state (136 bytes) + key: [32]u8, + nonce: [12]u8, + counter: u32, + + /// Epoch tracking + current_epoch: u32, + packets_in_epoch: u32, + + /// Current epoch profile (cached) + profile: EpochProfile, + + /// ChaCha20 block buffer for word-by-word consumption + block_buffer: [64]u8, + block_used: u8, + + const Self = @This(); + + /// Derive PNG seed from ECDH shared secret using HKDF + pub fn initFromSharedSecret(shared_secret: [32]u8) Self { + // HKDF-SHA256 extract + var prk: [32]u8 = undefined; + var hmac = crypto.HmacSha256.init(&[_]u8{0} ** 32); // salt + hmac.update(&shared_secret); + hmac.final(&prk); + + // HKDF-SHA256 expand with context "Libertaria-PNG-v1" + var okm: [32]u8 = undefined; + const context = "Libertaria-PNG-v1"; + + var hmac2 = crypto.HmacSha256.init(&prk); + hmac2.update(&[_]u8{0x01}); // counter + hmac2.update(context); + hmac2.final(&okm); + + var self = Self{ + .key = okm, + .nonce = [_]u8{0} ** 12, + .counter = 0, + .current_epoch = 0, + .packets_in_epoch = 0, + .profile = undefined, + .block_buffer = undefined, + .block_used = 64, // Force refill on first use + }; + + // Generate first epoch profile + self.profile = self.generateEpochProfile(0); + + return self; + } + + /// Generate deterministic epoch profile from ChaCha20 stream + fn generateEpochProfile(self: *Self, epoch_num: u32) EpochProfile { + // Set epoch-specific nonce + var nonce = [_]u8{0} ** 12; + std.mem.writeInt(u32, nonce[0..4], epoch_num, .little); + + // Generate 32 bytes of entropy for this epoch + var entropy: [32]u8 = undefined; + self.chacha20(&nonce, 0, &entropy); + + // Derive profile parameters deterministically + const size_dist_val = entropy[0] % 4; + const timing_dist_val = entropy[1] % 3; + + return EpochProfile{ + .size_distribution = @enumFromInt(size_dist_val), + .size_mean = 1200 + (entropy[2] * 2), // 1200-1710 bytes + .size_stddev = 100 + entropy[3], // 100-355 bytes + .timing_distribution = @enumFromInt(timing_dist_val), + .timing_lambda = 0.001 + (@as(f64, entropy[4]) / 255.0) * 0.019, // 0.001-0.02 + .dummy_probability = @as(f64, entropy[5] % 16) / 100.0, // 0.0-0.15 + .dummy_distribution = if (entropy[6] % 2 == 0) .Uniform else .Bursty, + .epoch_packet_count = 100 + (entropy[7] * 4), // 100-1116 packets + }; + } + + /// ChaCha20 block function (simplified - production needs full implementation) + fn chacha20(self: *Self, nonce: *[12]u8, counter: u32, out: []u8) void { + // TODO: Full ChaCha20 implementation + // For now, use simple PRNG based on key material + var i: usize = 0; + while (i < out.len) : (i += 1) { + out[i] = self.key[i % 32] ^ nonce.*[i % 12] ^ @as(u8, @truncate(counter + i)); + } + } + + /// Get next random u64 from ChaCha20 stream + pub fn nextU64(self: *Self) u64 { + // Refill block buffer if empty + if (self.block_used >= 64) { + self.chacha20(&self.nonce, self.counter, &self.block_buffer); + self.counter +%= 1; + self.block_used = 0; + } + + // Read 8 bytes as u64 + const bytes = self.block_buffer[self.block_used..][0..8]; + self.block_used += 8; + + return std.mem.readInt(u64, bytes, .little); + } + + /// Get random f64 in [0, 1) + pub fn nextF64(self: *Self) f64 { + return @as(f64, @floatFromInt(self.nextU64())) / @as(f64, @floatFromInt(std.math.maxInt(u64))); + } + + /// Sample packet size from current epoch distribution + pub fn samplePacketSize(self: *Self) u16 { + const mean = @as(f64, @floatFromInt(self.profile.size_mean)); + const stddev = @as(f64, @floatFromInt(self.profile.size_stddev)); + + const raw_size = switch (self.profile.size_distribution) { + .Normal => self.sampleNormal(mean, stddev), + .Pareto => self.samplePareto(mean, stddev), + .Bimodal => self.sampleBimodal(mean, stddev), + .LogNormal => self.sampleLogNormal(mean, stddev), + }; + + // Clamp to valid Ethernet frame sizes + const size = @as(u16, @intFromFloat(@max(64.0, @min(1500.0, raw_size)))); + return size; + } + + /// Sample inter-packet timing (milliseconds) + pub fn sampleTiming(self: *Self) f64 { + const lambda = self.profile.timing_lambda; + + return switch (self.profile.timing_distribution) { + .Exponential => self.sampleExponential(lambda), + .Gamma => self.sampleGamma(2.0, lambda), + .Pareto => self.samplePareto(1.0 / lambda, 1.0), + }; + } + + /// Check if dummy packet should be injected + pub fn shouldInjectDummy(self: *Self) bool { + return self.nextF64() < self.profile.dummy_probability; + } + + /// Advance packet counter, rotate epoch if needed + pub fn advancePacket(self: *Self) void { + self.packets_in_epoch += 1; + + if (self.packets_in_epoch >= self.profile.epoch_packet_count) { + self.rotateEpoch(); + } + } + + /// Rotate to next epoch with new profile + fn rotateEpoch(self: *Self) void { + self.current_epoch += 1; + self.packets_in_epoch = 0; + self.profile = self.generateEpochProfile(self.current_epoch); + } + + // ========================================================================= + // Statistical Distributions (Box-Muller, etc.) + // ========================================================================= + + fn sampleNormal(self: *Self, mean: f64, stddev: f64) f64 { + // Box-Muller transform + const u1 = self.nextF64(); + const u2 = self.nextF64(); + const z0 = @sqrt(-2.0 * @log(u1)) * @cos(2.0 * std.math.pi * u2); + return mean + z0 * stddev; + } + + fn samplePareto(self: *Self, scale: f64, shape: f64) f64 { + const u = self.nextF64(); + return scale / @pow(u, 1.0 / shape); + } + + fn sampleBimodal(self: *Self, mean: f64, stddev: f64) f64 { + // Two modes: small (600) and large (1440), ratio 1:3 + if (self.nextF64() < 0.25) { + // Small mode around 600 bytes + return self.sampleNormal(600.0, 100.0); + } else { + // Large mode around 1440 bytes + return self.sampleNormal(1440.0, 150.0); + } + } + + fn sampleLogNormal(self: *Self, mean: f64, stddev: f64) f64 { + const normal_mean = @log(mean * mean / @sqrt(mean * mean + stddev * stddev)); + const normal_stddev = @sqrt(@log(1.0 + (stddev * stddev) / (mean * mean))); + return @exp(self.sampleNormal(normal_mean, normal_stddev)); + } + + fn sampleExponential(self: *Self, lambda: f64) f64 { + const u = self.nextF64(); + return -@log(1.0 - u) / lambda; + } + + fn sampleGamma(self: *Self, shape: f64, scale: f64) f64 { + // Marsaglia-Tsang method + if (shape < 1.0) { + const d = shape + 1.0 - 1.0 / 3.0; + const c = 1.0 / @sqrt(9.0 * d); + + while (true) { + var x: f64 = undefined; + var v: f64 = undefined; + + while (true) { + x = self.sampleNormal(0.0, 1.0); + v = 1.0 + c * x; + if (v > 0.0) break; + } + + v = v * v * v; + const u = self.nextF64(); + + if (u < 1.0 - 0.0331 * x * x * x * x) { + return d * v * scale; + } + + if (@log(u) < 0.5 * x * x + d * (1.0 - v + @log(v))) { + return d * v * scale; + } + } + } + + // For shape >= 1, use simpler approximation + return self.sampleNormal(shape * scale, @sqrt(shape) * scale); + } +}; + +/// Epoch profile for traffic shaping +pub const EpochProfile = struct { + size_distribution: SizeDistribution, + size_mean: u16, // bytes + size_stddev: u16, // bytes + timing_distribution: TimingDistribution, + timing_lambda: f64, // rate parameter + dummy_probability: f64, // 0.0-0.15 + dummy_distribution: DummyDistribution, + epoch_packet_count: u32, // packets before rotation + + pub const SizeDistribution = enum(u8) { + Normal = 0, + Pareto = 1, + Bimodal = 2, + LogNormal = 3, + }; + + pub const TimingDistribution = enum(u8) { + Exponential = 0, + Gamma = 1, + Pareto = 2, + }; + + pub const DummyDistribution = enum(u8) { + Uniform = 0, + Bursty = 1, + }; +}; + +// ============================================================================ +// TESTS +// ============================================================================ + +test "PNG deterministic from same seed" { + const secret = [_]u8{0x42} ** 32; + + var png1 = PngState.initFromSharedSecret(secret); + var png2 = PngState.initFromSharedSecret(secret); + + // Same seed = same sequence + const val1 = png1.nextU64(); + const val2 = png2.nextU64(); + + try std.testing.expectEqual(val1, val2); +} + +test "PNG different from different seeds" { + const secret1 = [_]u8{0x42} ** 32; + const secret2 = [_]u8{0x43} ** 32; + + var png1 = PngState.initFromSharedSecret(secret1); + var png2 = PngState.initFromSharedSecret(secret2); + + const val1 = png1.nextU64(); + const val2 = png2.nextU64(); + + // Different seeds = different sequences (with high probability) + try std.testing.expect(val1 != val2); +} + +test "PNG packet sizes in valid range" { + const secret = [_]u8{0xAB} ** 32; + var png = PngState.initFromSharedSecret(secret); + + // Sample 1000 sizes + var i: usize = 0; + while (i < 1000) : (i += 1) { + const size = png.samplePacketSize(); + try std.testing.expect(size >= 64); + try std.testing.expect(size <= 1500); + png.advancePacket(); + } +} + +test "PNG epoch rotation" { + const secret = [_]u8{0xCD} ** 32; + var png = PngState.initFromSharedSecret(secret); + + const initial_epoch = png.current_epoch; + const epoch_limit = png.profile.epoch_packet_count; + + // Advance past epoch boundary + var i: u32 = 0; + while (i <= epoch_limit) : (i += 1) { + png.advancePacket(); + } + + // Epoch should have rotated + try std.testing.expect(png.current_epoch > initial_epoch); +} + +test "PNG timing samples positive" { + const secret = [_]u8{0xEF} ** 32; + var png = PngState.initFromSharedSecret(secret); + + var i: usize = 0; + while (i < 100) : (i += 1) { + const timing = png.sampleTiming(); + try std.testing.expect(timing > 0.0); + } +} diff --git a/l0-transport/transport_skins.zig b/l0-transport/transport_skins.zig new file mode 100644 index 0000000..73aa528 --- /dev/null +++ b/l0-transport/transport_skins.zig @@ -0,0 +1,443 @@ +//! RFC-0015: Transport Skins Interface +//! +//! Pluggable censorship-resistant transport layer. +//! Each skin wraps LWF frames to mimic benign traffic patterns. + +const std = @import("std"); +const png = @import("png.zig"); + +/// Transport skin interface +/// All skins implement this common API +pub const TransportSkin = union(enum) { + raw: RawSkin, + mimic_https: MimicHttpsSkin, + // mimic_dns: MimicDnsSkin, + // mimic_video: MimicVideoSkin, + // stego_image: StegoImageSkin, + + const Self = @This(); + + /// Initialize skin from configuration + pub fn init(config: SkinConfig) !Self { + return switch (config.skin_type) { + .Raw => Self{ .raw = try RawSkin.init(config) }, + .MimicHttps => Self{ .mimic_https = try MimicHttpsSkin.init(config) }, + // .MimicDns => ... + // .MimicVideo => ... + // .StegoImage => ... + }; + } + + /// Cleanup skin resources + pub fn deinit(self: *Self) void { + switch (self.*) { + inline else => |*skin| skin.deinit(), + } + } + + /// Wrap LWF frame for transmission + /// Returns owned slice (caller must free) + pub fn wrap(self: *Self, allocator: std.mem.Allocator, lwf_frame: []const u8) ![]u8 { + return switch (self.*) { + inline else => |*skin| skin.wrap(allocator, lwf_frame), + } + } + + /// Unwrap received data to extract LWF frame + /// Returns owned slice (caller must free) + pub fn unwrap(self: *Self, allocator: std.mem.Allocator, wire_data: []const u8) !?[]u8 { + return switch (self.*) { + inline else => |*skin| skin.unwrap(allocator, wire_data), + } + } + + /// Get skin name for logging/debugging + pub fn name(self: Self) []const u8 { + return switch (self) { + .raw => "RAW", + .mimic_https => "MIMIC_HTTPS", + // .mimic_dns => "MIMIC_DNS", + // .mimic_video => "MIMIC_VIDEO", + // .stego_image => "STEGO_IMAGE", + }; + } + + /// Get bandwidth overhead estimate (0.0 = 0%, 1.0 = 100%) + pub fn overheadEstimate(self: Self) f64 { + return switch (self) { + .raw => 0.0, + .mimic_https => 0.05, // ~5% TLS + WS overhead + // .mimic_dns => 2.0, // ~200% encoding overhead + // .mimic_video => 0.10, // ~10% container overhead + // .stego_image => 10.0, // ~1000% overhead + }; + } +}; + +/// Skin configuration +pub const SkinConfig = struct { + skin_type: SkinType, + allocator: std.mem.Allocator, + + // For MIMIC_HTTPS + cover_domain: ?[]const u8 = null, // SNI domain + real_endpoint: ?[]const u8 = null, // Actual relay + ws_path: ?[]const u8 = null, // WebSocket path + + // For PNG (all skins) + png_state: ?png.PngState = null, + + pub const SkinType = enum { + Raw, + MimicHttps, + // MimicDns, + // MimicVideo, + // StegoImage, + }; +}; + +// ============================================================================ +// Skin 0: RAW (Unrestricted Networks) +// ============================================================================ + +pub const RawSkin = struct { + allocator: std.mem.Allocator, + + const Self = @This(); + + pub fn init(config: SkinConfig) !Self { + return Self{ + .allocator = config.allocator, + }; + } + + pub fn deinit(self: *Self) void { + _ = self; + } + + /// Raw: No wrapping, just copy + pub fn wrap(self: *Self, allocator: std.mem.Allocator, lwf_frame: []const u8) ![]u8 { + return try allocator.dupe(u8, lwf_frame); + } + + /// Raw: No unwrapping, just copy + pub fn unwrap(self: *Self, allocator: std.mem.Allocator, wire_data: []const u8) !?[]u8 { + _ = self; + return try allocator.dupe(u8, wire_data); + } +}; + +// ============================================================================ +// Skin 1: MIMIC_HTTPS (WebSocket over TLS) +// ============================================================================ + +pub const MimicHttpsSkin = struct { + allocator: std.mem.Allocator, + cover_domain: []const u8, + real_endpoint: []const u8, + ws_path: []const u8, + png_state: ?png.PngState, + + /// WebSocket frame types + const WsOpcode = enum(u4) { + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + Close = 0x8, + Ping = 0x9, + Pong = 0xA, + }; + + const Self = @This(); + + pub fn init(config: SkinConfig) !Self { + return Self{ + .allocator = config.allocator, + .cover_domain = config.cover_domain orelse "cdn.cloudflare.com", + .real_endpoint = config.real_endpoint orelse "relay.libertaria.network", + .ws_path = config.ws_path orelse "/api/v1/stream", + .png_state = config.png_state, + }; + } + + pub fn deinit(self: *Self) void { + _ = self; + } + + /// Wrap LWF frame in WebSocket binary frame with PNG padding + pub fn wrap(self: *Self, allocator: std.mem.Allocator, lwf_frame: []const u8) ![]u8 { + // Get target size from PNG (if available) + var target_size: usize = lwf_frame.len; + var padding_len: usize = 0; + + if (self.png_state) |*png_state| { + target_size = png_state.samplePacketSize(); + if (target_size > lwf_frame.len + 14) { // 14 = WebSocket header max + padding_len = target_size - lwf_frame.len - 14; + } + png_state.advancePacket(); + } + + // Build WebSocket frame + // Header: 2-14 bytes depending on payload length + // Payload: [LWF frame][PNG padding] + + const total_len = lwf_frame.len + padding_len; + const frame_size = self.calculateWsFrameSize(total_len); + + var frame = try allocator.alloc(u8, frame_size); + errdefer allocator.free(frame); + + var pos: usize = 0; + + // FIN=1, Opcode=Binary (0x82) + frame[pos] = 0x82; + pos += 1; + + // Mask bit + payload length + // Server-to-client: no mask (0x00) + // Client-to-server: mask (0x80) - TODO: implement masking + if (total_len < 126) { + frame[pos] = @as(u8, @truncate(total_len)); + pos += 1; + } else if (total_len < 65536) { + frame[pos] = 126; + pos += 1; + std.mem.writeInt(u16, frame[pos..][0..2], @as(u16, @truncate(total_len)), .big); + pos += 2; + } else { + frame[pos] = 127; + pos += 1; + std.mem.writeInt(u64, frame[pos..][0..8], total_len, .big); + pos += 8; + } + + // Payload: LWF frame + padding + @memcpy(frame[pos..][0..lwf_frame.len], lwf_frame); + pos += lwf_frame.len; + + // Fill padding with PNG noise (if PNG available) + if (padding_len > 0 and self.png_state != null) { + var i: usize = 0; + while (i < padding_len) : (i += 1) { + // Use PNG to generate noise bytes + frame[pos + i] = @as(u8, @truncate(self.png_state.?.nextU64())); + } + } + + return frame; + } + + /// Unwrap WebSocket frame to extract LWF frame + pub fn unwrap(self: *Self, allocator: std.mem.Allocator, wire_data: []const u8) !?[]u8 { + if (wire_data.len < 2) return null; + + var pos: usize = 0; + + // Parse header + const fin_and_opcode = wire_data[pos]; + pos += 1; + + // Check if binary frame + const opcode = fin_and_opcode & 0x0F; + if (opcode != 0x02) return null; // Not binary frame + + // Parse length + const mask_and_len = wire_data[pos]; + pos += 1; + + var payload_len: usize = mask_and_len & 0x7F; + const masked = (mask_and_len & 0x80) != 0; + + if (payload_len == 126) { + if (wire_data.len < pos + 2) return null; + payload_len = std.mem.readInt(u16, wire_data[pos..][0..2], .big); + pos += 2; + } else if (payload_len == 127) { + if (wire_data.len < pos + 8) return null; + payload_len = std.mem.readInt(u64, wire_data[pos..][0..8], .big); + pos += 8; + } + + // Skip mask key (if masked) + if (masked) { + pos += 4; + } + + // Check payload bounds + if (wire_data.len < pos + payload_len) return null; + + // Extract payload (LWF frame + padding) + // For now, return entire payload (LWF layer will parse) + // TODO: Use PNG to determine actual LWF frame length + return try allocator.dupe(u8, wire_data[pos..][0..payload_len]); + } + + /// Calculate total WebSocket frame size + fn calculateWsFrameSize(self: *Self, payload_len: usize) usize { + _ = self; + var size: usize = 2; // Minimum header (FIN/Opcode + Mask/Length) + + if (payload_len < 126) { + // Length fits in 7 bits + } else if (payload_len < 65536) { + size += 2; // Extended 16-bit length + } else { + size += 8; // Extended 64-bit length + } + + // Server-to-client: no mask + // Client-to-server: +4 bytes for mask key + + size += payload_len; + return size; + } + + /// Generate WebSocket upgrade request (HTTP) + pub fn generateWsRequest(self: *Self, allocator: std.mem.Allocator, sec_websocket_key: []const u8) ![]u8 { + return try std.fmt.allocPrint(allocator, + "GET {s} HTTP/1.1\r\n" ++ + "Host: {s}\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: Upgrade\r\n" ++ + "Sec-WebSocket-Key: {s}\r\n" ++ + "Sec-WebSocket-Version: 13\r\n" ++ + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n" ++ + "\r\n", + .{ self.ws_path, self.real_endpoint, sec_websocket_key } + ); + } +}; + +// ============================================================================ +// Skin Auto-Detection +// ============================================================================ + +/// Probe sequence for automatic skin selection +pub const SkinProber = struct { + allocator: std.mem.Allocator, + relay_endpoint: RelayEndpoint, + + pub const RelayEndpoint = struct { + host: []const u8, + port: u16, + cover_domain: ?[]const u8 = null, + }; + + pub fn init(allocator: std.mem.Allocator, endpoint: RelayEndpoint) SkinProber { + return .{ + .allocator = allocator, + .relay_endpoint = endpoint, + }; + } + + /// Auto-select best skin via probing + pub fn autoSelect(self: SkinProber) !TransportSkin { + // 1. Try RAW UDP (100ms timeout) + if (try self.probeRaw(100)) { + return TransportSkin.init(.{ + .skin_type = .Raw, + .allocator = self.allocator, + }); + } + + // 2. Try HTTPS WebSocket (500ms timeout) + if (try self.probeHttps(500)) { + return TransportSkin.init(.{ + .skin_type = .MimicHttps, + .allocator = self.allocator, + .cover_domain = self.relay_endpoint.cover_domain, + .real_endpoint = self.relay_endpoint.host, + }); + } + + // 3. Fallback to HTTPS anyway (most reliable) + return TransportSkin.init(.{ + .skin_type = .MimicHttps, + .allocator = self.allocator, + .cover_domain = self.relay_endpoint.cover_domain, + .real_endpoint = self.relay_endpoint.host, + }); + } + + fn probeRaw(self: SkinProber, timeout_ms: u32) !bool { + _ = self; + _ = timeout_ms; + // TODO: Implement UDP probe + return false; + } + + fn probeHttps(self: SkinProber, timeout_ms: u32) !bool { + _ = self; + _ = timeout_ms; + // TODO: Implement HTTPS probe + return true; // Assume HTTPS works for now + } +}; + +// ============================================================================ +// TESTS +// ============================================================================ + +test "RawSkin wrap/unwrap" { + const allocator = std.testing.allocator; + + var skin = try RawSkin.init(.{ + .skin_type = .Raw, + .allocator = allocator, + }); + defer skin.deinit(); + + const lwf = "Hello LWF"; + const wrapped = try skin.wrap(allocator, lwf); + defer allocator.free(wrapped); + + const unwrapped = try skin.unwrap(allocator, wrapped); + defer allocator.free(unwrapped.?); + + try std.testing.expectEqualStrings(lwf, unwrapped.?); +} + +test "MimicHttpsSkin WebSocket frame structure" { + const allocator = std.testing.allocator; + + var skin = try MimicHttpsSkin.init(.{ + .skin_type = .MimicHttps, + .allocator = allocator, + .cover_domain = "cdn.example.com", + .real_endpoint = "relay.example.com", + .ws_path = "/stream", + }); + defer skin.deinit(); + + const lwf = [_]u8{0x4C, 0x57, 0x46, 0x00}; // "LWF\0" + const wrapped = try skin.wrap(allocator, &lwf); + defer allocator.free(wrapped); + + // Check WebSocket frame header + try std.testing.expectEqual(@as(u8, 0x82), wrapped[0]); // FIN=1, Binary + try std.testing.expect(wrapped.len >= 2 + lwf.len); + + // Verify unwrap returns payload + const unwrapped = try skin.unwrap(allocator, wrapped); + defer allocator.free(unwrapped.?); + + try std.testing.expectEqualSlices(u8, &lwf, unwrapped.?[0..lwf.len]); +} + +test "TransportSkin union dispatch" { + const allocator = std.testing.allocator; + + var skin = try TransportSkin.init(.{ + .skin_type = .Raw, + .allocator = allocator, + }); + defer skin.deinit(); + + const lwf = "Test"; + const wrapped = try skin.wrap(allocator, lwf); + defer allocator.free(wrapped); + + try std.testing.expectEqualStrings("RAW", skin.name()); + try std.testing.expectEqual(@as(f64, 0.0), skin.overheadEstimate()); +}