344 lines
12 KiB
Zig
344 lines
12 KiB
Zig
//! 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);
|
|
}
|