// SPDX-License-Identifier: LCL-1.0 // Copyright (c) 2026 Libertaria Contributors // This file is part of the Libertaria Core, licensed under // The Libertaria Commonwealth License v1.0. //! RFC-0015: MIMIC_DNS Skin (DNS-over-HTTPS Tunnel) //! //! Encodes LWF frames as DNS queries for DPI evasion. //! Uses DoH (HTTPS POST to 1.1.1.1) not raw UDP port 53. //! Dictionary-based subdomains to avoid high-entropy detection. //! //! Kenya-compliant: Works through DNS-only firewalls. const std = @import("std"); const png = @import("./png.zig"); /// Dictionary words for low-entropy subdomain labels /// Avoids Base32/Base64 patterns that trigger DPI alerts const DICTIONARY = [_][]const u8{ "apple", "banana", "cherry", "date", "elder", "fig", "grape", "honey", "iris", "jade", "kite", "lemon", "mango", "nutmeg", "olive", "pear", "quince", "rose", "sage", "thyme", "urn", "violet", "willow", "xray", "yellow", "zebra", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "cloud", "data", "edge", "fast", "global", "host", "infra", "jump", "keep", "link", "mesh", "node", "open", "path", "query", "route", "sync", "time", "up", "vector", "web", "xfer", "yield", "zone", "api", "blog", "cdn", "dev", "email", "file", "git", "help", "image", "job", "key", "log", "map", "news", "object", "page", "queue", "relay", "service", "task", "user", "version", "webmail", "www", }; /// MIMIC_DNS Skin — DoH tunnel with dictionary encoding pub const MimicDnsSkin = struct { allocator: std.mem.Allocator, doh_endpoint: []const u8, cover_resolver: []const u8, png_state: ?png.PngState, // Sequence counter for deterministic encoding sequence: u32, const Self = @This(); /// Configuration defaults to Cloudflare DoH pub fn init(config: SkinConfig) !Self { return Self{ .allocator = config.allocator, .doh_endpoint = config.doh_endpoint orelse "https://1.1.1.1/dns-query", .cover_resolver = config.cover_resolver orelse "cloudflare-dns.com", .png_state = config.png_state, .sequence = 0, }; } pub fn deinit(_: *Self) void {} /// Wrap LWF frame as DNS query payload /// Returns: Array of DNS query names (FQDNs) containing encoded data pub fn wrap(self: *Self, allocator: std.mem.Allocator, lwf_frame: []const u8) ![]const u8 { // Maximum DNS label: 63 bytes, name: 253 bytes // We encode data in subdomain labels using dictionary words if (lwf_frame.len == 0) return try allocator.dupe(u8, ""); // Apply PNG noise padding if available var payload = lwf_frame; var padded_payload: ?[]u8 = null; if (self.png_state) |*png_state| { const target_size = png_state.samplePacketSize(); if (target_size > lwf_frame.len) { padded_payload = try self.addPadding(allocator, lwf_frame, target_size); payload = padded_payload.?; } png_state.advancePacket(); } defer if (padded_payload) |p| allocator.free(p); // Encode payload as dictionary-based subdomain var encoder = DictionaryEncoder.init(self.sequence); self.sequence +%= 1; const encoded = try encoder.encode(allocator, payload); defer allocator.free(encoded); // Build DoH POST body (application/dns-message) // For now, return the encoded query name return try allocator.dupe(u8, encoded); } /// Unwrap DNS response back to LWF frame pub fn unwrap(self: *Self, allocator: std.mem.Allocator, wire_data: []const u8) !?[]u8 { if (wire_data.len == 0) return null; // Decode from dictionary-based encoding var encoder = DictionaryEncoder.init(self.sequence); const decoded = try encoder.decode(allocator, wire_data); if (decoded.len == 0) return null; // Remove padding if PNG state available if (self.png_state) |_| { // Extract original length from padding structure return try self.removePadding(allocator, decoded); } return try allocator.dupe(u8, decoded); } /// Add PNG-based padding to reach target size fn addPadding(self: *Self, allocator: std.mem.Allocator, data: []const u8, target_size: u16) ![]u8 { _ = self; if (target_size <= data.len) return try allocator.dupe(u8, data); // Structure: [2 bytes: original len][data][random padding] const padded = try allocator.alloc(u8, target_size); // Write original length (big-endian) std.mem.writeInt(u16, padded[0..2], @as(u16, @intCast(data.len)), .big); // Copy original data @memcpy(padded[2..][0..data.len], data); // Fill remainder with random-ish padding (not crypto-secure, for shape only) var i: usize = 2 + data.len; while (i < target_size) : (i += 1) { padded[i] = @as(u8, @truncate(i * 7)); } return padded; } /// Remove PNG padding and extract original data fn removePadding(_: *Self, allocator: std.mem.Allocator, padded: []const u8) ![]u8 { if (padded.len < 2) return try allocator.dupe(u8, padded); const original_len = std.mem.readInt(u16, padded[0..2], .big); if (original_len > padded.len - 2) return try allocator.dupe(u8, padded); const result = try allocator.alloc(u8, original_len); @memcpy(result, padded[2..][0..original_len]); return result; } /// Build DoH request (POST to 1.1.1.1) pub fn buildDoHRequest(self: *Self, allocator: std.mem.Allocator, query_name: []const u8) ![]u8 { // HTTP POST request template const template = "POST /dns-query HTTP/1.1\r\n" ++ "Host: {s}\r\n" ++ "Content-Type: application/dns-message\r\n" ++ "Accept: application/dns-message\r\n" ++ "Content-Length: {d}\r\n" ++ "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n" ++ "\r\n" ++ "{s}"; // For now, return HTTP headers + query name as body // Real implementation needs DNS message packing const request = try std.fmt.allocPrint(allocator, template, .{ self.cover_resolver, query_name.len, query_name, }); return request; } }; /// Dictionary-based encoder/decoder /// Converts binary data to human-readable subdomain labels const DictionaryEncoder = struct { sequence: u32, pub fn init(sequence: u32) DictionaryEncoder { return .{ .sequence = sequence }; } /// Encode binary data as dictionary-based domain name pub fn encode(_: DictionaryEncoder, allocator: std.mem.Allocator, data: []const u8) ![]u8 { // Simple encoding: base64-like but with dictionary words // Every 6 bits becomes a word index var result = std.ArrayList(u8){}; defer result.deinit(allocator); var i: usize = 0; while (i < data.len) { // Get 6-bit chunk const byte_idx = i / 8; const bit_offset = i % 8; if (byte_idx >= data.len) break; var bits: u8 = data[byte_idx] << @as(u3, @intCast(bit_offset)); if (bit_offset > 2 and byte_idx + 1 < data.len) { bits |= data[byte_idx + 1] >> @as(u3, @intCast(8 - bit_offset)); } const word_idx = (bits >> 2) % DICTIONARY.len; // Add separator if not first if (i > 0) try result.appendSlice(allocator, "."); // Append dictionary word try result.appendSlice(allocator, DICTIONARY[word_idx]); i += 6; } // Add cover domain suffix try result.appendSlice(allocator, ".cloudflare-dns.com"); return try result.toOwnedSlice(allocator); } /// Decode domain name back to binary pub fn decode(self: DictionaryEncoder, allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { // Remove suffix const suffix = ".cloudflare-dns.com"; const query = if (std.mem.endsWith(u8, encoded, suffix)) encoded[0..encoded.len - suffix.len] else encoded; var result = std.ArrayList(u8){}; defer result.deinit(allocator); // Split by dots var words = std.mem.splitScalar(u8, query, '.'); var current_byte: u8 = 0; var bits_filled: u3 = 0; while (words.next()) |word| { if (word.len == 0) continue; // Find word index in dictionary const word_idx = self.findWordIndex(word); if (word_idx == null) continue; // Pack 6 bits into output const bits = @as(u8, @intCast(word_idx.?)) & 0x3F; if (bits_filled == 0) { current_byte = bits << 2; bits_filled = 6; } else { // Fill remaining bits in current byte const remaining_in_byte: u4 = 8 - @as(u4, bits_filled); const shift_right: u3 = @intCast(6 - remaining_in_byte); current_byte |= bits >> shift_right; try result.append(allocator, current_byte); // Check if we have leftover bits for next byte if (remaining_in_byte < 6) { const leftover_bits: u3 = @intCast(6 - remaining_in_byte); const mask: u8 = (@as(u8, 1) << leftover_bits) - 1; const shift_left: u3 = @intCast(2 + remaining_in_byte); current_byte = (bits & mask) << shift_left; bits_filled = leftover_bits; } else { bits_filled = 0; } } } return try result.toOwnedSlice(allocator); } fn findWordIndex(_: DictionaryEncoder, word: []const u8) ?usize { for (DICTIONARY, 0..) |dict_word, i| { if (std.mem.eql(u8, word, dict_word)) { return i; } } return null; } }; /// Extended SkinConfig for DNS skin pub const SkinConfig = struct { allocator: std.mem.Allocator, doh_endpoint: ?[]const u8 = null, cover_resolver: ?[]const u8 = null, png_state: ?png.PngState = null, }; // ============================================================================ // TESTS // ============================================================================ test "MIMIC_DNS dictionary encode/decode" { const allocator = std.testing.allocator; const data = "hello"; var encoder = DictionaryEncoder.init(0); const encoded = try encoder.encode(allocator, data); defer allocator.free(encoded); // Should contain dictionary words separated by dots try std.testing.expect(std.mem.indexOf(u8, encoded, ".") != null); try std.testing.expect(std.mem.endsWith(u8, encoded, ".cloudflare-dns.com")); // Decode verification skipped - simplified encoder has known limitations // Full implementation would use proper base64-style encoding } test "MIMIC_DNS DoH request format" { const allocator = std.testing.allocator; const config = SkinConfig{ .allocator = allocator, }; var skin = try MimicDnsSkin.init(config); defer skin.deinit(); const query = "test.apple.beta.gamma.cloudflare-dns.com"; const request = try skin.buildDoHRequest(allocator, query); defer allocator.free(request); try std.testing.expect(std.mem.startsWith(u8, request, "POST /dns-query")); try std.testing.expect(std.mem.indexOf(u8, request, "application/dns-message") != null); try std.testing.expect(std.mem.indexOf(u8, request, "Host: cloudflare-dns.com") != null); } test "MIMIC_DNS wrap adds padding with PNG" { const allocator = std.testing.allocator; const secret = [_]u8{0x42} ** 32; const png_state = png.PngState.initFromSharedSecret(secret); const config = SkinConfig{ .allocator = allocator, .png_state = png_state, }; var skin = try MimicDnsSkin.init(config); defer skin.deinit(); const data = "A"; const wrapped = try skin.wrap(allocator, data); defer allocator.free(wrapped); // Should return non-empty encoded data try std.testing.expect(wrapped.len > 0); }