diff --git a/src/tls/handshake.zig b/src/tls/handshake.zig index 0b62e3ab..d137def0 100644 --- a/src/tls/handshake.zig +++ b/src/tls/handshake.zig @@ -269,7 +269,7 @@ test "buildCertificateVerify produces valid structure" { linux_platform.randomBytes(&transcript); const kp = EcdsaP256.KeyPair.generate(std.testing.io); - const len = try buildCertificateVerify(&buf, transcript, kp.secret_key); + const len = try buildCertificateVerify(&buf, .server, transcript, kp.secret_key); try std.testing.expectEqual(@as(u8, 0x0F), buf[0]); try std.testing.expectEqual(@as(u8, 0x04), buf[4]); @@ -286,8 +286,8 @@ test "buildCertificateVerify different transcripts produce different output" { linux_platform.randomBytes(&t2); const kp = EcdsaP256.KeyPair.generate(std.testing.io); - const len1 = try buildCertificateVerify(&buf1, t1, kp.secret_key); - const len2 = try buildCertificateVerify(&buf2, t2, kp.secret_key); + const len1 = try buildCertificateVerify(&buf1, .server, t1, kp.secret_key); + const len2 = try buildCertificateVerify(&buf2, .server, t2, kp.secret_key); try std.testing.expect(!std.mem.eql(u8, buf1[0..len1], buf2[0..len2])); } @@ -300,7 +300,7 @@ test "buildCertificateVerify buffer too small" { const kp = EcdsaP256.KeyPair.generate(std.testing.io); try std.testing.expectError( HandshakeError.BufferTooSmall, - buildCertificateVerify(&buf, transcript, kp.secret_key), + buildCertificateVerify(&buf, .server, transcript, kp.secret_key), ); } diff --git a/src/tls/handshake/message_build.zig b/src/tls/handshake/message_build.zig index 1eb270e9..6d65477b 100644 --- a/src/tls/handshake/message_build.zig +++ b/src/tls/handshake/message_build.zig @@ -1,6 +1,176 @@ const std = @import("std"); const common = @import("common.zig"); +/// build a ClientHello compatible with what the existing server parses. +/// emits the AES-256-GCM cipher only, X25519 only, TLS 1.3 only, ALPN +/// (h2 + http/1.1), signature_algorithms (ecdsa_secp256r1_sha256), and +/// optionally an SNI extension. session_id is left empty (TLS 1.3 doesn't +/// resume via session IDs). +pub fn buildClientHello( + buf: []u8, + client_random: [32]u8, + client_x25519_pub: [32]u8, + server_name: ?[]const u8, +) common.HandshakeError!usize { + // count extension bytes up front so we can write the u16 length later. + var ext_len: usize = 0; + if (server_name) |sni| ext_len += 2 + 2 + 2 + 1 + 2 + sni.len; // server_name + ext_len += 2 + 2 + 1 + 2; // supported_versions: list_len + TLS 1.3 + ext_len += 2 + 2 + 2 + 2; // supported_groups: list_len + X25519 + ext_len += 2 + 2 + 2 + 2 + 2 + 32; // key_share: list_len + (group + len + 32 bytes) + ext_len += 2 + 2 + 2 + 2; // signature_algorithms: list_len + scheme + ext_len += 2 + 2 + 2 + (1 + 2) + (1 + 8); // alpn: list_len + ("h2" + "http/1.1") + + // body = legacy_version(2) + random(32) + session_id_len(1) + + // cipher_suites(2 len + 2) + comp(1 len + 1) + extensions(2 len + ext_len) + const body_len: usize = 2 + 32 + 1 + 2 + 2 + 1 + 1 + 2 + ext_len; + const total = 4 + body_len; + if (buf.len < total) return common.HandshakeError.BufferTooSmall; + + var pos: usize = 0; + buf[pos] = 0x01; + pos += 1; + common.writeU24(buf[pos..], @intCast(body_len)); + pos += 3; + + // legacy_version = TLS 1.2 (real version is in supported_versions) + buf[pos] = 0x03; + buf[pos + 1] = 0x03; + pos += 2; + + @memcpy(buf[pos .. pos + 32], &client_random); + pos += 32; + + // empty session_id + buf[pos] = 0; + pos += 1; + + // cipher_suites: one entry, AES-256-GCM + common.writeU16(buf[pos..], 2); + pos += 2; + common.writeU16(buf[pos..], common.cipher_suite_aes_256_gcm); + pos += 2; + + // legacy_compression_methods: [null] + buf[pos] = 1; + buf[pos + 1] = 0; + pos += 2; + + // extensions length + common.writeU16(buf[pos..], @intCast(ext_len)); + pos += 2; + + if (server_name) |sni| { + common.writeU16(buf[pos..], 0x0000); // server_name + pos += 2; + common.writeU16(buf[pos..], @intCast(2 + 1 + 2 + sni.len)); + pos += 2; + // ServerNameList length + common.writeU16(buf[pos..], @intCast(1 + 2 + sni.len)); + pos += 2; + buf[pos] = 0; // name_type = host_name + pos += 1; + common.writeU16(buf[pos..], @intCast(sni.len)); + pos += 2; + @memcpy(buf[pos .. pos + sni.len], sni); + pos += sni.len; + } + + // supported_versions: list_len(1) + TLS 1.3 + common.writeU16(buf[pos..], 0x002B); + pos += 2; + common.writeU16(buf[pos..], 1 + 2); + pos += 2; + buf[pos] = 2; + pos += 1; + common.writeU16(buf[pos..], 0x0304); + pos += 2; + + // supported_groups: list_len(u16) + X25519 + common.writeU16(buf[pos..], 0x000A); + pos += 2; + common.writeU16(buf[pos..], 2 + 2); + pos += 2; + common.writeU16(buf[pos..], 2); + pos += 2; + common.writeU16(buf[pos..], 0x001D); + pos += 2; + + // key_share: list_len(u16) + (group + key_len + key) + common.writeU16(buf[pos..], 0x0033); + pos += 2; + common.writeU16(buf[pos..], 2 + 2 + 2 + 32); + pos += 2; + common.writeU16(buf[pos..], 2 + 2 + 32); + pos += 2; + common.writeU16(buf[pos..], 0x001D); + pos += 2; + common.writeU16(buf[pos..], 32); + pos += 2; + @memcpy(buf[pos .. pos + 32], &client_x25519_pub); + pos += 32; + + // signature_algorithms: list_len(u16) + scheme + common.writeU16(buf[pos..], 0x000D); + pos += 2; + common.writeU16(buf[pos..], 2 + 2); + pos += 2; + common.writeU16(buf[pos..], 2); + pos += 2; + common.writeU16(buf[pos..], 0x0403); // ecdsa_secp256r1_sha256 + pos += 2; + + // ALPN: list_len(u16) + (proto_len(u8) + proto_bytes) for h2 and http/1.1 + const alpn_inner_len: u16 = (1 + 2) + (1 + 8); + common.writeU16(buf[pos..], 0x0010); + pos += 2; + common.writeU16(buf[pos..], 2 + alpn_inner_len); + pos += 2; + common.writeU16(buf[pos..], alpn_inner_len); + pos += 2; + buf[pos] = 2; + pos += 1; + @memcpy(buf[pos .. pos + 2], "h2"); + pos += 2; + buf[pos] = 8; + pos += 1; + @memcpy(buf[pos .. pos + 8], "http/1.1"); + pos += 8; + + return pos; +} + +/// build a CertificateRequest advertising just ECDSA P-256 + SHA-256 in +/// the signature_algorithms extension. context is empty (TLS 1.3 default). +pub fn buildCertificateRequest(buf: []u8) common.HandshakeError!usize { + // body = context_len(1) + extensions_len(2) + signature_algorithms ext + // sig_algs ext = type(2) + len(2) + list_len(2) + scheme(2) + const sig_ext_len: usize = 2 + 2 + 2 + 2; + const body_len: usize = 1 + 2 + sig_ext_len; + const total = 4 + body_len; + if (buf.len < total) return common.HandshakeError.BufferTooSmall; + + var pos: usize = 0; + buf[pos] = 0x0D; + pos += 1; + common.writeU24(buf[pos..], @intCast(body_len)); + pos += 3; + buf[pos] = 0; // certificate_request_context = empty + pos += 1; + common.writeU16(buf[pos..], @intCast(sig_ext_len)); + pos += 2; + common.writeU16(buf[pos..], 0x000D); // signature_algorithms + pos += 2; + common.writeU16(buf[pos..], 2 + 2); // ext_data_len + pos += 2; + common.writeU16(buf[pos..], 2); // SignatureSchemeList length + pos += 2; + common.writeU16(buf[pos..], 0x0403); // ecdsa_secp256r1_sha256 + pos += 2; + + return pos; +} + pub fn buildServerHello( buf: []u8, client_random: [32]u8, @@ -117,8 +287,16 @@ pub fn buildCertificate(buf: []u8, cert_der: []const u8) common.HandshakeError!u return pos; } +/// which side of the TLS handshake is signing. picks the context string +/// the spec mandates for that side — the bytes differ and the bug is +/// silent (handshake completes but no other implementation accepts). +pub const CertVerifySide = enum { server, client }; + +/// build a CertificateVerify. the context string is selected from `side`; +/// the rest of the message is identical across sides. pub fn buildCertificateVerify( buf: []u8, + side: CertVerifySide, transcript_hash: [common.hash_len]u8, private_key: common.EcdsaP256.SecretKey, ) common.HandshakeError!usize { @@ -126,7 +304,10 @@ pub fn buildCertificateVerify( defer std.crypto.secureZero(u8, &signed_content); @memset(signed_content[0..64], 0x20); - const context = "TLS 1.3, server CertificateVerify"; + const context = switch (side) { + .server => "TLS 1.3, server CertificateVerify", + .client => "TLS 1.3, client CertificateVerify", + }; @memcpy(signed_content[64 .. 64 + context.len], context); signed_content[64 + context.len] = 0x00; @memcpy(signed_content[64 + context.len + 1 ..], &transcript_hash); diff --git a/src/tls/handshake/message_parse.zig b/src/tls/handshake/message_parse.zig index 8cb6b08d..529cdc33 100644 --- a/src/tls/handshake/message_parse.zig +++ b/src/tls/handshake/message_parse.zig @@ -380,6 +380,60 @@ test "Truncated message returns error" { try std.testing.expectError(ParseError.Truncated, nextMessage(&[_]u8{ 0x08, 0x00, 0x00, 0x05, 0x00 }, &pos2)); } +test "ClientHello from buildClientHello parses with the existing server parser" { + const client_hello = @import("client_hello.zig"); + var buf: [512]u8 = undefined; + const random = [_]u8{0xAA} ** 32; + const pub_key = [_]u8{0x42} ** 32; + const len = try message_build.buildClientHello(&buf, random, pub_key, "billing.svc"); + + // parseClientHelloFields takes the body bytes (after the 4-byte header) + const info = try client_hello.parseClientHelloFields(buf[4..len]); + try std.testing.expectEqualSlices(u8, &random, &info.client_random); + try std.testing.expect(info.has_aes_256_gcm); + try std.testing.expect(info.supported_versions_has_tls13); + try std.testing.expect(info.x25519_key_share != null); + try std.testing.expectEqualSlices(u8, &pub_key, &info.x25519_key_share.?); + try std.testing.expect(info.offers_h2_alpn); + try std.testing.expect(info.offers_http11_alpn); +} + +test "ClientHello without SNI still parses" { + const client_hello = @import("client_hello.zig"); + var buf: [512]u8 = undefined; + const random = [_]u8{0x11} ** 32; + const pub_key = [_]u8{0x22} ** 32; + const len = try message_build.buildClientHello(&buf, random, pub_key, null); + const info = try client_hello.parseClientHelloFields(buf[4..len]); + try std.testing.expect(info.has_aes_256_gcm); + try std.testing.expect(info.supported_versions_has_tls13); +} + +test "CertificateRequest from builder round-trips" { + var buf: [128]u8 = undefined; + const len = try message_build.buildCertificateRequest(&buf); + const got = try parseCertificateRequest(buf[4..len]); + try std.testing.expectEqual(@as(usize, 0), got.context.len); + try std.testing.expect(certRequestOffersEcdsaP256Sha256(got)); +} + +test "buildCertificateVerify picks the per-side context string" { + var server_buf: [256]u8 = undefined; + var client_buf: [256]u8 = undefined; + const transcript = [_]u8{0xCC} ** common.hash_len; + const kp = common.EcdsaP256.KeyPair.generate(std.testing.io); + + const slen = try message_build.buildCertificateVerify(&server_buf, .server, transcript, kp.secret_key); + const clen = try message_build.buildCertificateVerify(&client_buf, .client, transcript, kp.secret_key); + + // signatures differ because the signed prefix differs + try std.testing.expect(!std.mem.eql(u8, server_buf[0..slen], client_buf[0..clen])); + + // structure is identical: same type, same algorithm code + try std.testing.expectEqual(@as(u8, 0x0F), server_buf[0]); + try std.testing.expectEqual(@as(u8, 0x0F), client_buf[0]); +} + test "CertificateRequest with ecdsa-sha256 offer parses and matches" { // hand-build a CertificateRequest body: empty context, single // signature_algorithms extension carrying just 0x0403. diff --git a/src/tls/proxy/session_runtime.zig b/src/tls/proxy/session_runtime.zig index f939387f..37d83917 100644 --- a/src/tls/proxy/session_runtime.zig +++ b/src/tls/proxy/session_runtime.zig @@ -99,7 +99,7 @@ pub fn handleTlsSession( const cv_transcript_hash = transcript.peek(); var cv_buf: [512]u8 = undefined; - const cv_len = handshake.buildCertificateVerify(&cv_buf, cv_transcript_hash, private_key) catch + const cv_len = handshake.buildCertificateVerify(&cv_buf, .server, cv_transcript_hash, private_key) catch return error.HandshakeFailed; transcript.update(cv_buf[0..cv_len]); try sendEncryptedHandshake(client_fd, cv_buf[0..cv_len], server_hs_traffic, &server_seq);