╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │618 return try allocator.dupe(u8, trimmed); │ │619 } │ │620 } │ │621 + return error.TooManyBannerLines; │ │622 } │ │623 │ │624 // ---------------------------------------------------------------------------│ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │660 return msg_type == SSH_MSG_IGNORE or msg_type == SSH_MSG_UNIMPLEMENTED or msg_type == SSH_MSG_DEBUG; │ │661 } │ │662 │ │653 -// read a plaintext packet, skipping ignorable messages. counts every packet │ │654 -// consumed in `seq` so the caller knows the peer's next sequence number. │ │655 -fn readPlainKexPacket(allocator: std.mem.Allocator, reader: *std.Io.Reader, seq: *u64) ![]u8 { │ │656 - while (true) { │ │657 - const packet = try readPlainPacket(allocator, reader); │ │658 - seq.* += 1; │ │659 - if (packet.len >= 1 and isIgnorableMsg(packet[0])) { │ │660 - allocator.free(packet); │ │661 - continue; │ │663 +// transport for KEX packets: the initial exchange runs in plaintext, a rekey │ │664 +// runs under the current session ciphers. │ │665 +const KexTransport = union(enum) { │ │666 + plain: *u64, // counts packets read so the caller can seed cipher seqnos │ │667 + encrypted: struct { cs: *Cipher, sc: *Cipher }, │ │668 + │ │669 + // read one packet, skipping ignorable messages — unless strict kex is in │ │670 + // force, which bans them mid-handshake │ │671 + fn readPacket(self: KexTransport, allocator: std.mem.Allocator, reader: *std.Io.Reader, strict: bool) ![]u8 { │ │672 + while (true) { │ │673 + const packet = switch (self) { │ │674 + .plain => |count| packet: { │ │675 + const packet = try readPlainPacket(allocator, reader); │ │676 + count.* += 1; │ │677 + break :packet packet; │ │678 + }, │ │679 + .encrypted => |ciphers| try ciphers.cs.readPacket(allocator, reader), │ │680 + }; │ │681 + if (packet.len >= 1 and isIgnorableMsg(packet[0])) { │ │682 + allocator.free(packet); │ │683 + if (strict) return error.StrictKexViolation; │ │684 + continue; │ │685 + } │ │686 + return packet; │ │687 + } │ │688 + } │ │689 + │ │690 + fn writePacket(self: KexTransport, io: std.Io, allocator: std.mem.Allocator, writer: *std.Io.Writer, payload: []const u8) !void {│ │691 + switch (self) { │ │692 + .plain => try writePlainPacket(io, writer, payload), │ │693 + .encrypted => |ciphers| try ciphers.sc.writePacket(io, allocator, writer, payload), │ │694 } │ │663 - return packet; │ │695 } │ │665 -} │ │696 +}; │ │697 │ │667 -// read an encrypted packet, skipping ignorable messages │ │668 -fn readNonIgnoredPacket(cipher: *Cipher, allocator: std.mem.Allocator, reader: *std.Io.Reader) ![]u8 { │ │698 +// read one encrypted packet for the auth and channel layers, transparently │ │699 +// skipping ignorable messages and servicing client-initiated rekeys. │ │700 +fn readSessionPacket( │ │701 + io: std.Io, │ │702 + allocator: std.mem.Allocator, │ │703 + reader: *std.Io.Reader, │ │704 + writer: *std.Io.Writer, │ │705 + cs_cipher: *Cipher, │ │706 + sc_cipher: *Cipher, │ │707 + rekey: *const RekeyState, │ │708 +) ![]u8 { │ │709 while (true) { │ │670 - const packet = try cipher.readPacket(allocator, reader); │ │710 + const packet = try cs_cipher.readPacket(allocator, reader); │ │711 if (packet.len >= 1 and isIgnorableMsg(packet[0])) { │ │712 allocator.free(packet); │ │713 + continue; │ │714 + } │ │715 + if (packet.len >= 1 and packet[0] == SSH_MSG_KEXINIT) { │ │716 + defer allocator.free(packet); │ │717 + try runRekey(io, allocator, reader, writer, cs_cipher, sc_cipher, rekey, packet); │ │718 continue; │ │719 } │ │720 return packet; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │790 │ │791 pub const our_kex_algos = [_][]const u8{ "mlkem768x25519-sha256", "curve25519-sha256", "curve25519-sha256@libssh.org" };│ │792 │ │793 +// strict kex markers (Terrapin / CVE-2023-48795 mitigation). pseudo-algorithms │ │794 +// carried in the initial KEXINIT's kex list: we advertise the -s form, the │ │795 +// client advertises the -c form. they are never selected as an actual kex │ │796 +// algorithm because the matcher only picks from our_kex_algos. │ │797 +pub const kex_strict_server = "kex-strict-s-v00@openssh.com"; │ │798 +pub const kex_strict_client = "kex-strict-c-v00@openssh.com"; │ │799 + │ │800 const MLKem768 = std.crypto.kem.ml_kem.MLKem768; │ │801 const hybrid_client_blob_len = MLKem768.PublicKey.encoded_length + X25519.public_length; // 1216 │ │802 const hybrid_server_blob_len = MLKem768.ciphertext_length + X25519.public_length; // 1120 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │839 } │ │840 │ │841 fn buildServerKexInit(io: std.Io, allocator: std.mem.Allocator) ![]u8 { │ │790 - return buildKexInit(io, allocator, &our_kex_algos, &our_host_key_algos, &our_ciphers); │ │842 + return buildKexInit(io, allocator, &(our_kex_algos ++ [_][]const u8{kex_strict_server}), &our_host_key_algos, &our_ciphers);│ │843 } │ │844 │ │845 const ParsedKexInit = struct { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │896 cs_key: [64]u8, // client → server cipher key material (K_2 || K_1) │ │897 sc_key: [64]u8, // server → client cipher key material │ │898 session_id: [Sha256.digest_length]u8, │ │847 - next_cs_seq: u64, // packets the client has sent, including skipped ones│ │899 + next_cs_seq: u64, │ │900 + next_sc_seq: u64, │ │901 + strict: bool, │ │902 }; │ │903 │ │904 fn runKex( │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────╮ │910 client_version: []const u8, │ │911 ) !KexResult { │ │912 var client_seq: u64 = 0; │ │913 + const transport = KexTransport{ .plain = &client_seq }; │ │914 │ │915 // exchange KEXINITs │ │916 const server_kex_init = try buildServerKexInit(io, allocator);│ ╰───────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │ 917 defer allocator.free(server_kex_init); │ │ 918 try writePlainPacket(io, writer, server_kex_init); │ │ 919 │ │ 865 - const client_kex_init = try readPlainKexPacket(allocator, reader, &client_seq); │ │ 920 + const client_kex_init = try transport.readPacket(allocator, reader, false); │ │ 921 defer allocator.free(client_kex_init); │ │ 922 + │ │ 923 + // strict kex: when the client advertises it, its KEXINIT must be the │ │ 924 + // first packet, ignorable messages are banned until NEWKEYS, and both │ │ 925 + // seqnos reset to 0 after every NEWKEYS. │ │ 926 + const strict = strict: { │ │ 927 + var parsed = try parseClientKexInit(allocator, client_kex_init); │ │ 928 + defer parsed.deinit(allocator); │ │ 929 + break :strict nameListContainsAny(parsed.kex_algos, &.{kex_strict_client}); │ │ 930 + }; │ │ 931 + if (strict and client_seq != 1) return error.StrictKexViolation; │ │ 932 + │ │ 933 + const negotiated = try exchangeKeys( │ │ 934 + io, │ │ 935 + allocator, │ │ 936 + reader, │ │ 937 + writer, │ │ 938 + transport, │ │ 939 + strict, │ │ 940 + host_key, │ │ 941 + client_version, │ │ 942 + client_kex_init, │ │ 943 + server_kex_init, │ │ 944 + null, │ │ 945 + ); │ │ 946 + │ │ 947 + return .{ │ │ 948 + .cs_key = negotiated.keys.cs_enc, │ │ 949 + .sc_key = negotiated.keys.sc_enc, │ │ 950 + .session_id = negotiated.exchange_hash, │ │ 951 + // the server sent exactly KEXINIT, KEX_ECDH_REPLY and NEWKEYS; the │ │ 952 + // client's count includes any skipped IGNORE/DEBUG packets. │ │ 953 + .next_cs_seq = if (strict) 0 else client_seq, │ │ 954 + .next_sc_seq = if (strict) 0 else 3, │ │ 955 + .strict = strict, │ │ 956 + }; │ │ 957 +} │ │ 958 + │ │ 959 +/// everything needed to service a client-initiated rekey mid-session. │ │ 960 +pub const RekeyState = struct { │ │ 961 + host_key: *const HostKey, │ │ 962 + client_version: []const u8, │ │ 963 + session_id: [Sha256.digest_length]u8, │ │ 964 + strict: bool, │ │ 965 +}; │ │ 966 + │ │ 967 +// service a rekey whose triggering KEXINIT payload has just been read: run │ │ 968 +// the whole exchange under the current keys, then swap the new keys into │ │ 969 +// both ciphers in place. │ │ 970 +fn runRekey( │ │ 971 + io: std.Io, │ │ 972 + allocator: std.mem.Allocator, │ │ 973 + reader: *std.Io.Reader, │ │ 974 + writer: *std.Io.Writer, │ │ 975 + cs_cipher: *Cipher, │ │ 976 + sc_cipher: *Cipher, │ │ 977 + rekey: *const RekeyState, │ │ 978 + client_kex_init: []const u8, │ │ 979 +) !void { │ │ 980 + const server_kex_init = try buildServerKexInit(io, allocator); │ │ 981 + defer allocator.free(server_kex_init); │ │ 982 + try sc_cipher.writePacket(io, allocator, writer, server_kex_init); │ │ 983 + │ │ 984 + const negotiated = try exchangeKeys( │ │ 985 + io, │ │ 986 + allocator, │ │ 987 + reader, │ │ 988 + writer, │ │ 989 + .{ .encrypted = .{ .cs = cs_cipher, .sc = sc_cipher } }, │ │ 990 + false, │ │ 991 + rekey.host_key, │ │ 992 + rekey.client_version, │ │ 993 + client_kex_init, │ │ 994 + server_kex_init, │ │ 995 + &rekey.session_id, │ │ 996 + ); │ │ 997 │ │ 998 + // seqnos continue across a rekey unless strict kex was negotiated, │ │ 999 + // which resets them after every NEWKEYS │ │1000 + cs_cipher.setKeys(&negotiated.keys.cs_enc, if (rekey.strict) 0 else cs_cipher.seq);│ │1001 + sc_cipher.setKeys(&negotiated.keys.sc_enc, if (rekey.strict) 0 else sc_cipher.seq);│ │1002 +} │ │1003 + │ │1004 +const NegotiatedKeys = struct { │ │1005 + keys: SessionKeys, │ │1006 + exchange_hash: [Sha256.digest_length]u8, │ │1007 +}; │ │1008 + │ │1009 +// the KEX core shared by the initial exchange and rekeys: pick algorithms, │ │1010 +// run the (hybrid) ECDH round trip, exchange NEWKEYS, derive keys. both │ │1011 +// KEXINIT payloads have already been exchanged by the caller. session_id is │ │1012 +// null on the initial exchange (where it becomes the exchange hash) and the │ │1013 +// original session id on a rekey. │ │1014 +fn exchangeKeys( │ │1015 + io: std.Io, │ │1016 + allocator: std.mem.Allocator, │ │1017 + reader: *std.Io.Reader, │ │1018 + writer: *std.Io.Writer, │ │1019 + transport: KexTransport, │ │1020 + strict: bool, │ │1021 + host_key: *const HostKey, │ │1022 + client_version: []const u8, │ │1023 + client_kex_init: []const u8, │ │1024 + server_kex_init: []const u8, │ │1025 + session_id: ?*const [Sha256.digest_length]u8, │ │1026 +) !NegotiatedKeys { │ │1027 var parsed = try parseClientKexInit(allocator, client_kex_init); │ │1028 defer parsed.deinit(allocator); │ │1029 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1045 │ │1046 // receive KEX_ECDH_INIT (mlkem768x25519 reuses message code 30 — same │ │1047 // wire shape, only the string size differs). │ │ 889 - const ecdh_init = try readPlainKexPacket(allocator, reader, &client_seq); │ │1048 + const ecdh_init = try transport.readPacket(allocator, reader, strict); │ │1049 defer allocator.free(ecdh_init); │ │1050 if (ecdh_init.len < 1 or ecdh_init[0] != SSH_MSG_KEX_ECDH_INIT) return error.UnexpectedMessage;│ │1051 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1121 try writeStringField(&reply, allocator, host_key_blob.items); │ │1122 try writeStringField(&reply, allocator, server_blob); │ │1123 try writeStringField(&reply, allocator, signature_blob.items); │ │ 965 - try writePlainPacket(io, writer, reply.items); │ │1124 + try transport.writePacket(io, allocator, writer, reply.items); │ │1125 │ │ 967 - // NEWKEYS — both sides switch to encrypted mode after this is sent and │ │1126 + // NEWKEYS — both sides switch to the new keys after this is sent and │ │1127 // the peer's NEWKEYS is received. │ │ 969 - try writePlainPacket(io, writer, &[_]u8{SSH_MSG_NEWKEYS}); │ │1128 + try transport.writePacket(io, allocator, writer, &[_]u8{SSH_MSG_NEWKEYS}); │ │1129 │ │ 971 - const peer_newkeys = try readPlainKexPacket(allocator, reader, &client_seq); │ │1130 + const peer_newkeys = try transport.readPacket(allocator, reader, strict); │ │1131 defer allocator.free(peer_newkeys); │ │1132 if (peer_newkeys.len < 1 or peer_newkeys[0] != SSH_MSG_NEWKEYS) return error.UnexpectedMessage;│ │1133 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 975 - // derive session keys per RFC 4253 §7.2. session_id == H for the first │ │ 976 - // KEX. only the encrypt keys are needed for chacha20-poly1305 (no │ │ 977 - // separate MAC/IV). │ │1134 + // derive session keys per RFC 4253 §7.2. only the encrypt keys are │ │1135 + // needed for chacha20-poly1305 (no separate MAC/IV). │ │1136 var keys: SessionKeys = undefined; │ │ 979 - try deriveSessionKeys(allocator, k, &exchange_hash, &exchange_hash, &keys, hybrid); │ │1137 + try deriveSessionKeys(allocator, k, &exchange_hash, if (session_id) |sid| sid else &exchange_hash, &keys, hybrid);│ │1138 │ │ 981 - return .{ │ │ 982 - .cs_key = keys.cs_enc, │ │ 983 - .sc_key = keys.sc_enc, │ │ 984 - .session_id = exchange_hash, │ │ 985 - .next_cs_seq = client_seq, │ │ 986 - }; │ │1139 + return .{ .keys = keys, .exchange_hash = exchange_hash }; │ │1140 } │ │1141 │ │1142 // --------------------------------------------------------------------------- │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯