╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │689 // --------------------------------------------------------------------------- │ │690 // KEXINIT (RFC 4253 §7.1) │ │691 // --------------------------------------------------------------------------- │ │692 + │ │693 +pub const our_kex_algos = [_][]const u8{ "mlkem768x25519-sha256", "curve25519-sha256", "curve25519-sha256@libssh.org" };│ │694 │ │693 -pub const our_kex_algos = [_][]const u8{ "curve25519-sha256", "curve25519-sha256@libssh.org" }; │ │695 +const MLKem768 = std.crypto.kem.ml_kem.MLKem768; │ │696 +const hybrid_client_blob_len = MLKem768.PublicKey.encoded_length + X25519.public_length; // 1216 │ │697 +const hybrid_server_blob_len = MLKem768.ciphertext_length + X25519.public_length; // 1120 │ │698 pub const our_host_key_algos = [_][]const u8{"ssh-ed25519"}; │ │699 pub const our_ciphers = [_][]const u8{"chacha20-poly1305@openssh.com"}; │ │700 const our_macs = [_][]const u8{}; // none — implicit in the AEAD cipher │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │812 var parsed = try parseClientKexInit(allocator, client_kex_init); │ │813 defer parsed.deinit(allocator); │ │814 │ │811 - if (!nameListContainsAny(parsed.kex_algos, &our_kex_algos)) return error.NoCommonKexAlgorithm; │ │815 + // pick the first kex algo in the client's preference list that we also │ │816 + // support (RFC 4253 §7.1.3). │ │817 + const kex_algo = blk: { │ │818 + var iter = std.mem.splitScalar(u8, parsed.kex_algos, ','); │ │819 + while (iter.next()) |name| { │ │820 + for (our_kex_algos) |opt| { │ │821 + if (std.mem.eql(u8, name, opt)) break :blk opt; │ │822 + } │ │823 + } │ │824 + return error.NoCommonKexAlgorithm; │ │825 + }; │ │826 if (!nameListContainsAny(parsed.host_key_algos, &our_host_key_algos)) return error.NoCommonHostKeyAlgorithm;│ │827 if (!nameListContainsAny(parsed.cs_cipher, &our_ciphers)) return error.NoCommonCipher; │ │828 if (!nameListContainsAny(parsed.sc_cipher, &our_ciphers)) return error.NoCommonCipher; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │829 + const hybrid = std.mem.eql(u8, kex_algo, "mlkem768x25519-sha256"); │ │830 │ │816 - // receive KEX_ECDH_INIT — client's ephemeral curve25519 pubkey │ │831 + // receive KEX_ECDH_INIT (mlkem768x25519 reuses message code 30 — same │ │832 + // wire shape, only the string size differs). │ │833 const ecdh_init = try readPlainPacket(allocator, reader); │ │834 defer allocator.free(ecdh_init); │ │835 if (ecdh_init.len < 1 or ecdh_init[0] != SSH_MSG_KEX_ECDH_INIT) return error.UnexpectedMessage;│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │836 │ │837 var ecdh_init_reader = std.Io.Reader.fixed(ecdh_init[1..]); │ │822 - const client_ephemeral_alloc = try takeStringField(allocator, &ecdh_init_reader, X25519.public_length);│ │823 - defer allocator.free(client_ephemeral_alloc); │ │824 - if (client_ephemeral_alloc.len != X25519.public_length) return error.InvalidEphemeralKey; │ │825 - var client_ephemeral: [X25519.public_length]u8 = undefined; │ │826 - @memcpy(&client_ephemeral, client_ephemeral_alloc); │ │838 + const expected_client_len: u32 = if (hybrid) hybrid_client_blob_len else X25519.public_length; │ │839 + const client_blob = try takeStringField(allocator, &ecdh_init_reader, expected_client_len); │ │840 + defer allocator.free(client_blob); │ │841 + if (client_blob.len != expected_client_len) return error.InvalidEphemeralKey; │ │842 + │ │843 + // derive server reply blob (S_REPLY) + shared secret K. │ │844 + // ECDH: server_blob = X25519 server pub (32B); K = X25519(s, c_pub). │ │845 + // hybrid: server_blob = MLKEM ciphertext (1088B) || X25519 server pub (32B); │ │846 + // K = SHA-256(K_MLKEM || K_X25519). │ │847 + var server_blob: []u8 = undefined; │ │848 + var k: []u8 = undefined; │ │849 + { │ │850 + const x25519_offset: usize = if (hybrid) MLKem768.PublicKey.encoded_length else 0; │ │851 + var x25519_client_pub: [X25519.public_length]u8 = undefined; │ │852 + @memcpy(&x25519_client_pub, client_blob[x25519_offset..]); │ │853 + const server_kp = X25519.KeyPair.generate(io); │ │854 + const x25519_shared = try X25519.scalarmult(server_kp.secret_key, x25519_client_pub); │ │855 │ │828 - // our ephemeral keypair + shared secret │ │829 - const server_ephemeral_kp = X25519.KeyPair.generate(io); │ │830 - const shared_secret = try X25519.scalarmult(server_ephemeral_kp.secret_key, client_ephemeral); │ │856 + if (hybrid) { │ │857 + var pq_pub_bytes: [MLKem768.PublicKey.encoded_length]u8 = undefined; │ │858 + @memcpy(&pq_pub_bytes, client_blob[0..MLKem768.PublicKey.encoded_length]); │ │859 + const pq_pub = try MLKem768.PublicKey.fromBytes(&pq_pub_bytes); │ │860 + const encap = pq_pub.encaps(io); │ │861 + │ │862 + server_blob = try allocator.alloc(u8, hybrid_server_blob_len); │ │863 + @memcpy(server_blob[0..MLKem768.ciphertext_length], &encap.ciphertext); │ │864 + @memcpy(server_blob[MLKem768.ciphertext_length..], &server_kp.public_key); │ │865 + │ │866 + var h = Sha256.init(.{}); │ │867 + h.update(&encap.shared_secret); │ │868 + h.update(&x25519_shared); │ │869 + k = try allocator.dupe(u8, &h.finalResult()); │ │870 + } else { │ │871 + server_blob = try allocator.dupe(u8, &server_kp.public_key); │ │872 + k = try allocator.dupe(u8, &x25519_shared); │ │873 + } │ │874 + } │ │875 + defer allocator.free(server_blob); │ │876 + defer allocator.free(k); │ │877 │ │878 // build host key blob (K_S) │ │879 var host_key_blob: std.ArrayList(u8) = .empty; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │888 client_kex_init, │ │889 server_kex_init, │ │890 host_key_blob.items, │ │845 - &client_ephemeral, │ │846 - &server_ephemeral_kp.public_key, │ │847 - &shared_secret, │ │891 + client_blob, │ │892 + server_blob, │ │893 + k, │ │894 + hybrid, │ │895 ); │ │896 │ │897 // sign H with the host key, format as ssh signature blob│ ╰──────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │899 defer signature_blob.deinit(allocator); │ │900 try host_key.appendSignatureBlob(&signature_blob, allocator, &exchange_hash);│ │901 │ │855 - // build & send KEX_ECDH_REPLY │ │902 + // build & send KEX_ECDH_REPLY (msg code 31 reused as KEX_HYBRID_REPLY) │ │903 var reply: std.ArrayList(u8) = .empty; │ │904 defer reply.deinit(allocator); │ │905 try reply.append(allocator, SSH_MSG_KEX_ECDH_REPLY); │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │906 try writeStringField(&reply, allocator, host_key_blob.items); │ │860 - try writeStringField(&reply, allocator, &server_ephemeral_kp.public_key);│ │907 + try writeStringField(&reply, allocator, server_blob); │ │908 try writeStringField(&reply, allocator, signature_blob.items); │ │909 try writePlainPacket(io, writer, reply.items); │ │910 │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │917 if (peer_newkeys.len < 1 or peer_newkeys[0] != SSH_MSG_NEWKEYS) return error.UnexpectedMessage;│ │918 │ │919 // derive session keys per RFC 4253 §7.2. session_id == H for the first │ │873 - // KEX. K is encoded as mpint in each derivation input. only the encrypt │ │874 - // keys are needed for chacha20-poly1305 (no separate MAC/IV). │ │920 + // KEX. only the encrypt keys are needed for chacha20-poly1305 (no │ │921 + // separate MAC/IV). │ │922 var keys: SessionKeys = undefined; │ │876 - try deriveSessionKeys(allocator, &shared_secret, &exchange_hash, &exchange_hash, &keys); │ │923 + try deriveSessionKeys(allocator, k, &exchange_hash, &exchange_hash, &keys, hybrid); │ │924 │ │925 return .{ │ │926 .cs_key = keys.cs_enc, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │943 client_ephemeral: []const u8, │ │944 server_ephemeral: []const u8, │ │945 shared_secret: []const u8, │ │946 + /// curve25519-sha256 encodes K as mpint; mlkem768x25519-sha256 encodes it│ │947 + /// as a plain SSH string (length-prefixed bytes). │ │948 + k_is_string: bool, │ │949 ) ![Sha256.digest_length]u8 { │ │950 var buf: std.ArrayList(u8) = .empty; │ │951 defer buf.deinit(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │957 try writeStringField(&buf, allocator, host_key_blob); │ │958 try writeStringField(&buf, allocator, client_ephemeral); │ │959 try writeStringField(&buf, allocator, server_ephemeral); │ │910 - try writeMpint(&buf, allocator, shared_secret); │ │960 + if (k_is_string) { │ │961 + try writeStringField(&buf, allocator, shared_secret);│ │962 + } else { │ │963 + try writeMpint(&buf, allocator, shared_secret); │ │964 + } │ │965 │ │966 var hasher = Sha256.init(.{}); │ │967 hasher.update(buf.items); │ ╰──────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯