╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
95 } 96 97 test "Escape timeout preserves partial packets, arrow keys, and buffered input" { 98 const io = std.testing.io; 99 var pipe_buffer: [256]u8 = undefined; 100 var pipe = Pipe.init(&pipe_buffer); 101 defer pipe.close(io); 102 var read_buffer: [256]u8 = undefined; 103 var pipe_reader = PipeReader.init(io, &pipe, &read_buffer); 104 var ctx: TestSession = .{}; 105 ctx.init(&.{}); 106 defer ctx.deinit(); 107 ctx.conn.reader = &pipe_reader.interface; 108 var terminal_output: [1024]u8 = undefined; 109 var terminal_writer = std.Io.Writer.fixed(&terminal_output); 110 var terminal = try StreamTerminal.init(std.testing.allocator, &terminal_writer, .{ .width = 80, .height = 24 }); 111 defer terminal.deinit(); 112 var encoded: [128]u8 = undefined; 113 var encoded_writer = std.Io.Writer.fixed(&encoded); 114 var sender = proto.Cipher.init(&TestSession.key, 0); 115 // time out inside the length field and just before the tag is complete 116 for ([_]bool{ false, true }) |near_tag| { 117 encoded_writer = .fixed(&encoded); 118 try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, 'x' }); 119 const split = if (near_tag) encoded_writer.buffered().len - 1 else 2; 120 try terminal.writeBytes("\x1b"); 121 try pipe.putAll(io, encoded_writer.buffered()[0..split]); 122 try std.testing.expectEqual(null, try server_impl.nextTuiEvent(&ctx.sess, &terminal)); 123 try std.testing.expectEqual(.escape, std.meta.activeTag(terminal.popKey().?)); 124 try std.testing.expectEqual(null, terminal.popKey()); 125 // rendering is allowed before the rest of the packet arrives 126 try ctx.sess.writeBytes("render"); 127 try pipe.putAll(io, encoded_writer.buffered()[split..]); 128 const event = (try server_impl.nextTuiEvent(&ctx.sess, &terminal)).?; 129 defer std.testing.allocator.free(event.data); 130 try std.testing.expectEqualStrings("x", event.data); 131 try std.testing.expectEqual(sender.seq, ctx.conn.cs_cipher.seq); 132 } 133 134 encoded_writer = .fixed(&encoded); 135 try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, '[' }); 136 try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_DATA, 0, 0, 0, 0, 0, 0, 0, 1, 'A' }); 137 try pipe.putAll(io, encoded_writer.buffered()); 138 try terminal.writeBytes("\x1b"); 139 for (0..2) |_| { 140 const fragment = (try server_impl.nextTuiEvent(&ctx.sess, &terminal)).?; 141 defer std.testing.allocator.free(fragment.data); 142 try terminal.writeBytes(fragment.data); 143 } 144 try std.testing.expectEqual(.arrow_up, std.meta.activeTag(terminal.popKey().?)); 145 try std.testing.expectEqual(null, terminal.popKey()); 146 147 // a complete buffered packet needs neither a clock nor concurrent tasks 148 encoded_writer = .fixed(&encoded); 149 try sender.writePacket(io, &encoded_writer, &.{ proto.SSH_MSG_CHANNEL_EOF, 0, 0, 0, 0 }); 150 ctx.reader = .fixed(encoded_writer.buffered()); 151 ctx.conn.reader = &ctx.reader; 152 ctx.conn.io = .failing; 153 ctx.conn.read_timeout = .{ .deadline = .{ .raw = .zero, .clock = .awake } }; 154 try std.testing.expectEqual(.eof, std.meta.activeTag(try ctx.sess.nextEvent())); 155 } 156 157 test "fingerprint format matches openssh layout" { 158 const seed = [_]u8{0x42} ** Ed25519.KeyPair.seed_length; 159 const keypair = try Ed25519.KeyPair.generateDeterministic(seed); 160 161 // SSH wire-format pubkey blob: string "ssh-ed25519" || string raw_pubkey. 162 const algo = "ssh-ed25519"; 163 var blob: [4 + algo.len + 4 + Ed25519.PublicKey.encoded_length]u8 = undefined; 164 std.mem.writeInt(u32, blob[0..4], algo.len, .big); 165 @memcpy(blob[4 .. 4 + algo.len], algo); 166 std.mem.writeInt(u32, blob[4 + algo.len ..][0..4], Ed25519.PublicKey.encoded_length, .big); 167 @memcpy(blob[4 + algo.len + 4 ..], &keypair.public_key.bytes); 168 169 const formatted = proto.formatFingerprint(&blob); 170 171 try std.testing.expectEqual(50, formatted.len); 172 try std.testing.expectEqualStrings("SHA256:", formatted[0..7]); 173 174 // the body must decode to the SHA256 of the input blob. 175 var decoded: [Sha256.digest_length]u8 = undefined; 176 try std.base64.standard_no_pad.Decoder.decode(&decoded, formatted[7..]); 177 var expected: [Sha256.digest_length]u8 = undefined; 178 Sha256.hash(&blob, &expected, .{}); 179 try std.testing.expectEqualSlices(u8, &expected, &decoded); 180 } 181 182 test "channel data parser rejects malformed payloads" { 183 const allocator = std.testing.allocator; 184 185 var packet = std.ArrayList(u8).empty; 186 defer packet.deinit(allocator); 187 try packet.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 188 try proto.writeU32(&packet, allocator, 0); 189 try proto.writeU32(&packet, allocator, 3); 190 try packet.appendSlice(allocator, "abc"); 191 192 try std.testing.expectEqualStrings("abc", try proto.parseChannelData(packet.items, false, 0)); 193 194 // wrong recipient channel 195 packet.items[4] = 1; 196 try std.testing.expectError(error.UnknownChannel, proto.parseChannelData(packet.items, false, 0)); 197 198 // declared length larger than the packet carries 199 var too_long = std.ArrayList(u8).empty; 200 defer too_long.deinit(allocator); 201 try too_long.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 202 try proto.writeU32(&too_long, allocator, 0); 203 try proto.writeU32(&too_long, allocator, 4); 204 try too_long.appendSlice(allocator, "abc"); 205 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(too_long.items, false, 0)); 206 207 // declared length smaller than the packet carries (trailing junk) 208 var trailing = std.ArrayList(u8).empty; 209 defer trailing.deinit(allocator); 210 try trailing.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 211 try proto.writeU32(&trailing, allocator, 0); 212 try proto.writeU32(&trailing, allocator, 3); 213 try trailing.appendSlice(allocator, "abcd"); 214 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(trailing.items, false, 0)); 215 } 216 217 test "extended channel data parser accepts only stderr type" { 218 const allocator = std.testing.allocator; 219 220 var packet = std.ArrayList(u8).empty; 221 defer packet.deinit(allocator); 222 try packet.append(allocator, proto.SSH_MSG_CHANNEL_EXTENDED_DATA); 223 try proto.writeU32(&packet, allocator, 0); 224 try proto.writeU32(&packet, allocator, proto.SSH_EXTENDED_DATA_STDERR); 225 try proto.writeU32(&packet, allocator, 3); 226 try packet.appendSlice(allocator, "err"); 227 228 try std.testing.expectEqualStrings("err", try proto.parseChannelData(packet.items, true, 0)); 229 230 // any other data_type code is rejected 231 packet.items[8] = 2; 232 try std.testing.expectError(error.UnsupportedExtendedData, proto.parseChannelData(packet.items, true, 0)); 233 } 234 235 test "initial strict kex rejects ignored packets" { 236 const io = std.testing.io; 237 const allocator = std.testing.allocator; 238 239 var encoded_buf: [128]u8 = undefined; 240 var writer = std.Io.Writer.fixed(&encoded_buf); 241 try proto.writePlainPacket(io, &writer, &.{proto.SSH_MSG_IGNORE}); 242 243 var reader = std.Io.Reader.fixed(writer.buffered()); 244 var seqs: proto.PlainSeqs = .{}; 245 const transport = proto.KexTransport{ .plain = &seqs }; 246 try std.testing.expectError(error.StrictKexViolation, transport.readPacket(allocator, &reader, true)); 247 try std.testing.expectEqual(@as(u64, 1), seqs.read); 248 } 249 250 test "ignored packets do not count as connection activity" { 251 const io = std.testing.io; 252 const allocator = std.testing.allocator; 253 const key_material = [_]u8{0x5A} ** 64; 254 255 var sender = proto.Cipher.init(&key_material, 0); 256 var encoded_buf: [128]u8 = undefined; 257 var encoded_writer = std.Io.Writer.fixed(&encoded_buf); 258 try sender.writePacket(io, &encoded_writer, &.{proto.SSH_MSG_IGNORE}); 259 260 var idle = proto.IdleState{}; 261 const receiver = proto.Cipher.init(&key_material, 0); 262 var reader = std.Io.Reader.fixed(encoded_writer.buffered()); 263 var output_buf: [1]u8 = undefined; 264 var output_writer = std.Io.Writer.fixed(&output_buf); 265 var conn = proto.Conn{ 266 .io = io, 267 .allocator = allocator, 268 .reader = &reader, 269 .writer = &output_writer, 270 .cs_cipher = receiver, 271 .sc_cipher = sender, 272 .rekey = undefined, 273 .idle = &idle, 274 }; 275 276 try std.testing.expectError(error.EndOfStream, proto.readSessionPacket(&conn)); 277 try std.testing.expectEqual(@as(u64, 0), idle.activity.load(.monotonic)); 278 } 279 280 test "SSH-2 negotiation: walk through every step (banner -> KEX -> auth -> channel -> exec)" { 281 const io = std.testing.io; 282 const allocator = std.testing.allocator; 283 284 const host_key = try newHostKey(); 285 const user_kp = try newUserKeypair(); 286 287 // c2s = bytes the client sends, server reads. s2c = the other direction. 288 var c2s_buf: [4096]u8 = undefined; 289 var c2s = Pipe.init(&c2s_buf); 290 var s2c_buf: [4096]u8 = undefined; 291 var s2c = Pipe.init(&s2c_buf); 292 293 var server_rd_buf: [4096]u8 = undefined; 294 var server_wr_buf: [4096]u8 = undefined; 295 var client_rd_buf: [4096]u8 = undefined; 296 var client_wr_buf: [4096]u8 = undefined; 297 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 298 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 299 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 300 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 301 const cr = &client_pipe_reader.interface; 302 const cw = &client_pipe_writer.interface; 303 304 var server: ServerTask = .{ 305 .io = io, 306 .allocator = allocator, 307 .host_key = &host_key, 308 .reader = &server_pipe_reader.interface, 309 .writer = &server_pipe_writer.interface, 310 .outgoing = &s2c, 311 }; 312 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 313 defer server_future.await(io); 314 defer c2s.close(io); // unblocks the server even on early test failure 315 316 // STEP 1: VERSION EXCHANGE (RFC 4253 §4.2) 317 // each side writes one "SSH-protoversion-softwareversion\r\n" line. 318 // the exact strings get hashed into the exchange hash later, so they 319 // bind the session's identity. 320 try cw.writeAll(client_version ++ "\r\n"); 321 try cw.flush(); 322 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 323 const v_s = if (v_s_line.len > 0 and v_s_line[v_s_line.len - 1] == '\r') v_s_line[0 .. v_s_line.len - 1] else v_s_line; 324 try std.testing.expect(std.mem.startsWith(u8, v_s, "SSH-2.0-haxy_")); 325 const v_c = client_version; 326 327 // STEP 2: ALGORITHM NEGOTIATION (RFC 4253 §7.1) 328 // both sides send SSH_MSG_KEXINIT listing supported algorithms, in 329 // preference order, for: key exchange, host-key signature, c->s and 330 // s->c ciphers, c->s and s->c MACs (none with chacha20-poly1305 since 331 // it's an AEAD), c->s and s->c compression, plus languages. the first 332 // algorithm in each slot that the *client* prefers and the server also 333 // offers is selected. both raw KEXINITs are hashed into the exchange 334 // hash so tampering is caught at signature verification time. 335 const client_kex_init = try proto.buildKexInit(io, allocator, &.{"curve25519-sha256"}, &proto.our_host_key_algos, &proto.our_ciphers); 336 defer allocator.free(client_kex_init); 337 try proto.writePlainPacket(io, cw, client_kex_init); 338 339 const server_kex_init = try proto.readPlainPacket(allocator, cr); 340 defer allocator.free(server_kex_init); 341 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEXINIT), server_kex_init[0]); 342 343 // STEP 3: ECDH KEY EXCHANGE (RFC 8731) 344 // client generates a fresh X25519 keypair, sends its public ephemeral 345 // q_c in SSH_MSG_KEX_ECDH_INIT. server: 346 // 1. generates its own ephemeral 347 // 2. computes K = X25519(server_priv, q_c) — shared secret 348 // 3. computes H = SHA256(V_C || V_S || I_C || I_S || K_S || q_c || q_s || K) 349 // (each "||" element is length-prefixed; K is an mpint) 350 // 4. signs H with its long-term host key 351 // 5. replies KEX_ECDH_REPLY with K_S (host key blob), q_s, signature 352 // 353 // client verifies the signature against K_S — MITM defense. 354 const client_eph = try newClientEphemeral(); 355 { 356 var pkt: std.ArrayList(u8) = .empty; 357 defer pkt.deinit(allocator); 358 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 359 try proto.writeStringField(&pkt, allocator, &client_eph.public_key); 360 try proto.writePlainPacket(io, cw, pkt.items); 361 } 362 const ecdh_reply = try proto.readPlainPacket(allocator, cr); 363 defer allocator.free(ecdh_reply); 364 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEX_ECDH_REPLY), ecdh_reply[0]); 365 366 var server_eph_pub: [X25519.public_length]u8 = undefined; 367 var k_s: []u8 = undefined; 368 var sig_blob: []u8 = undefined; 369 { 370 var r = std.Io.Reader.fixed(ecdh_reply[1..]); 371 k_s = try proto.takeStringField(allocator, &r, 4096); 372 errdefer allocator.free(k_s); 373 const q_s = try proto.takeStringField(allocator, &r, X25519.public_length); 374 defer allocator.free(q_s); 375 try std.testing.expectEqual(X25519.public_length, q_s.len); 376 @memcpy(&server_eph_pub, q_s); 377 sig_blob = try proto.takeStringField(allocator, &r, 1024); 378 } 379 defer allocator.free(k_s); 380 defer allocator.free(sig_blob); 381 382 // K_S must be the host's ed25519 pubkey we configured. 383 var expected_k_s: std.ArrayList(u8) = .empty; 384 defer expected_k_s.deinit(allocator); 385 try host_key.appendPublicBlob(&expected_k_s, allocator); 386 try std.testing.expectEqualSlices(u8, expected_k_s.items, k_s); 387 388 // K = X25519(client_priv, q_s) — same scalarmult on the other side. 389 const k = try X25519.scalarmult(client_eph.secret_key, server_eph_pub); 390 391 // recompute H independently. if the server lied about any input the 392 // signature won't verify against the trusted host key. 393 const h = try proto.computeExchangeHash(allocator, v_c, v_s, client_kex_init, server_kex_init, k_s, &client_eph.public_key, &server_eph_pub, &k, false); 394 395 // verify the host signature on H. 396 { 397 var r = std.Io.Reader.fixed(sig_blob); 398 const algo = try proto.takeStringField(allocator, &r, 64); 399 defer allocator.free(algo); 400 try std.testing.expectEqualStrings("ssh-ed25519", algo); 401 const raw_sig = try proto.takeStringField(allocator, &r, Ed25519.Signature.encoded_length); 402 defer allocator.free(raw_sig); 403 var sig_bytes: [Ed25519.Signature.encoded_length]u8 = undefined; 404 @memcpy(&sig_bytes, raw_sig); 405 const sig = Ed25519.Signature.fromBytes(sig_bytes); 406 try sig.verify(&h, host_key.keypair.public_key); 407 } 408 409 // STEP 4: NEWKEYS — switch to encrypted mode (RFC 4253 §7.3) 410 // each side sends a one-byte SSH_MSG_NEWKEYS and starts encrypting 411 // from the next packet on. chacha20-poly1305@openssh.com needs 64 412 // bytes per direction (32 for the body cipher, 32 for the length 413 // cipher); only the 'C' (c->s) and 'D' (s->c) RFC 4253 §7.2 outputs 414 // are used since the AEAD covers MAC. 415 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 416 { 417 const server_newkeys = try proto.readPlainPacket(allocator, cr); 418 defer allocator.free(server_newkeys); 419 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 420 } 421 422 var keys: proto.SessionKeys = undefined; 423 try proto.deriveSessionKeys(allocator, &k, &h, &h, &keys, false); 424 // KEXINIT/KEX_ECDH_INIT/NEWKEYS were seqno 0/1/2; first encrypted is 3. 425 var cs_cipher = proto.Cipher.init(&keys.cs_enc, 3); 426 var sc_cipher = proto.Cipher.init(&keys.sc_enc, 3); 427 428 // STEP 5: SERVICE REQUEST (RFC 4253 §10) 429 // first encrypted message: client asks for the "ssh-userauth" service. 430 // server replies SERVICE_ACCEPT. ("ssh-connection" comes after auth.) 431 { 432 var req: std.ArrayList(u8) = .empty; 433 defer req.deinit(allocator); 434 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 435 try proto.writeStringField(&req, allocator, "ssh-userauth"); 436 try cs_cipher.writePacket(io, cw, req.items); 437 } 438 { 439 const accept = try sc_cipher.readPacket(allocator, cr); 440 defer allocator.free(accept); 441 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 442 } 443 444 // STEP 6: PUBLIC-KEY USERAUTH — PROBE (RFC 4252 §7) 445 // two-trip dance. round 1: USERAUTH_REQUEST with method=publickey and 446 // has_signature=false, carrying just the algo + public key blob. the 447 // server says USERAUTH_PK_OK if it would accept that key (haxy 448 // accepts any ed25519) or USERAUTH_FAILURE if not — so the client 449 // doesn't waste a signature on a rejected key. 450 var user_pubkey_blob: std.ArrayList(u8) = .empty; 451 defer user_pubkey_blob.deinit(allocator); 452 try proto.writeStringField(&user_pubkey_blob, allocator, "ssh-ed25519"); 453 try proto.writeStringField(&user_pubkey_blob, allocator, &user_kp.public_key.bytes); 454 455 { 456 var req: std.ArrayList(u8) = .empty; 457 defer req.deinit(allocator); 458 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 459 try proto.writeStringField(&req, allocator, "testuser"); 460 try proto.writeStringField(&req, allocator, "ssh-connection"); 461 try proto.writeStringField(&req, allocator, "publickey"); 462 try req.append(allocator, 0); // has_signature = false 463 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 464 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 465 try cs_cipher.writePacket(io, cw, req.items); 466 } 467 { 468 const pk_ok = try sc_cipher.readPacket(allocator, cr); 469 defer allocator.free(pk_ok); 470 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 471 } 472 473 // STEP 7: PUBLIC-KEY USERAUTH — SIGNED REQUEST 474 // round 2: same USERAUTH_REQUEST, now with has_signature=true and an 475 // ed25519 signature over the canonical signed-data block — see 476 // proto.appendPublickeySignedData for the exact byte layout. the 477 // block includes session_id (== H of the first KEX), so signatures 478 // can't be replayed across sessions. 479 var signed: std.ArrayList(u8) = .empty; 480 defer signed.deinit(allocator); 481 try proto.appendPublickeySignedData(&signed, allocator, &h, "testuser", "ssh-connection", "ssh-ed25519", user_pubkey_blob.items); 482 const sig = try user_kp.sign(signed.items, null); 483 const sig_bytes = sig.toBytes(); 484 485 var sig_wire: std.ArrayList(u8) = .empty; 486 defer sig_wire.deinit(allocator); 487 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 488 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 489 490 { 491 var req: std.ArrayList(u8) = .empty; 492 defer req.deinit(allocator); 493 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 494 try proto.writeStringField(&req, allocator, "testuser"); 495 try proto.writeStringField(&req, allocator, "ssh-connection"); 496 try proto.writeStringField(&req, allocator, "publickey"); 497 try req.append(allocator, 1); 498 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 499 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 500 try proto.writeStringField(&req, allocator, sig_wire.items); 501 try cs_cipher.writePacket(io, cw, req.items); 502 } 503 { 504 const success = try sc_cipher.readPacket(allocator, cr); 505 defer allocator.free(success); 506 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_SUCCESS), success[0]); 507 } 508 509 // STEP 8: CHANNEL_OPEN (RFC 4254 §5.1) 510 // open a "session" channel — SSH's container for shell/exec/subsystem. 511 // client picks its own channel id and announces an initial receive 512 // window + max-packet size; server replies CHANNEL_OPEN_CONFIRMATION 513 // with its own channel id and c->s limits. each side identifies the 514 // channel by *the other's* id when sending. 515 // 516 // haxy supports exactly one channel per connection — a second 517 // CHANNEL_OPEN here gets CHANNEL_OPEN_FAILURE (see the failure tests). 518 const client_channel_id: u32 = 42; 519 { 520 var req: std.ArrayList(u8) = .empty; 521 defer req.deinit(allocator); 522 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 523 try proto.writeStringField(&req, allocator, "session"); 524 try proto.writeU32(&req, allocator, client_channel_id); 525 try proto.writeU32(&req, allocator, 1 << 20); // initial window 526 try proto.writeU32(&req, allocator, 32768); // max packet 527 try cs_cipher.writePacket(io, cw, req.items); 528 } 529 var server_channel_id: u32 = undefined; 530 { 531 const conf = try sc_cipher.readPacket(allocator, cr); 532 defer allocator.free(conf); 533 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION), conf[0]); 534 var r = std.Io.Reader.fixed(conf[1..]); 535 const recipient = try r.takeInt(u32, .big); 536 try std.testing.expectEqual(client_channel_id, recipient); 537 server_channel_id = try r.takeInt(u32, .big); 538 } 539 540 // STEP 9: CHANNEL_REQUEST exec (RFC 4254 §6.5) 541 // ask the server to run a command on this channel. packet is 542 // addressed to the *server's* channel id. with want_reply=true the 543 // server must respond CHANNEL_SUCCESS or CHANNEL_FAILURE before any 544 // CHANNEL_DATA flows. 545 const exec_command = "git-upload-pack 'demo-repo'"; 546 { 547 var req: std.ArrayList(u8) = .empty; 548 defer req.deinit(allocator); 549 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 550 try proto.writeU32(&req, allocator, server_channel_id); 551 try proto.writeStringField(&req, allocator, "exec"); 552 try req.append(allocator, 1); // want_reply 553 try proto.writeStringField(&req, allocator, exec_command); 554 try cs_cipher.writePacket(io, cw, req.items); 555 } 556 { 557 const success = try sc_cipher.readPacket(allocator, cr); 558 defer allocator.free(success); 559 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_SUCCESS), success[0]); 560 } 561 562 // STEP 10: HANDLER RUNS, CHANNEL TEARDOWN 563 // the server is now in handleSession. our recording handler returns 564 // immediately; runChannelLayer's wrap-up then sends: 565 // - CHANNEL_REQUEST "exit-status" (status=0, want_reply=false) 566 // - CHANNEL_EOF 567 // - CHANNEL_CLOSE 568 // …and waits for our CHANNEL_CLOSE in reply (so the underlying socket 569 // isn't closed-with-unread-data, which would surface as TCP RST). 570 var saw_exit_status = false; 571 var saw_server_close = false; 572 while (!saw_server_close) { 573 const pkt = try sc_cipher.readPacket(allocator, cr); 574 defer allocator.free(pkt); 575 switch (pkt[0]) { 576 proto.SSH_MSG_CHANNEL_REQUEST => { 577 var r = std.Io.Reader.fixed(pkt[1..]); 578 _ = try r.takeInt(u32, .big); 579 const req_type = try proto.takeStringField(allocator, &r, 64); 580 defer allocator.free(req_type); 581 if (std.mem.eql(u8, req_type, "exit-status")) { 582 _ = try r.takeByte(); 583 const status = try r.takeInt(u32, .big); 584 try std.testing.expectEqual(@as(u32, 0), status); 585 saw_exit_status = true; 586 } 587 }, 588 proto.SSH_MSG_CHANNEL_EOF => {}, 589 proto.SSH_MSG_CHANNEL_CLOSE => saw_server_close = true, 590 proto.SSH_MSG_CHANNEL_WINDOW_ADJUST => {}, 591 else => {}, 592 } 593 } 594 try std.testing.expect(saw_exit_status); 595 596 { 597 var pkt: std.ArrayList(u8) = .empty; 598 defer pkt.deinit(allocator); 599 try pkt.append(allocator, proto.SSH_MSG_CHANNEL_CLOSE); 600 try proto.writeU32(&pkt, allocator, server_channel_id); 601 try cs_cipher.writePacket(io, cw, pkt.items); 602 } 603 604 // simulate the client's TCP FIN so the server's teardown drain reads EOF 605 // and returns instead of blocking on a packet that never arrives. 606 c2s.close(io); 607 608 server_future.await(io); 609 try std.testing.expect(server.result == null); 610 611 // FINAL ASSERTIONS 612 // the exec command we sent on the wire should have reached the handler 613 // verbatim, and the server should have recorded our pubkey's SHA256 614 // fingerprint as the authenticated identity for this session. 615 try std.testing.expectEqualStrings(exec_command, server.captured_exec_buf[0..server.captured_exec_len]); 616 const expected_fp = proto.formatFingerprint(user_pubkey_blob.items); 617 try std.testing.expectEqualSlices(u8, &expected_fp, &server.captured_fp); 618 } 619 620 // failure modes against a single live session, in increasing severity: 621 // - publickey signed-data binding (session_id and user_name) — server 622 // replies USERAUTH_FAILURE, connection continues 623 // - second CHANNEL_OPEN on the same connection — server replies 624 // CHANNEL_OPEN_FAILURE, connection continues 625 // - MAC-tampered encrypted packet — server bails out of handleConnection 626 // with MacVerificationFailed (terminal) 627 // running all of these against one negotiated session avoids paying the 628 // KEX cost per failure case and keeps the failure tests visually together. 629 test "auth + channel layer failure modes" { 630 const io = std.testing.io; 631 const allocator = std.testing.allocator; 632 633 const host_key = try newHostKey(); 634 635 var c2s_buf: [4096]u8 = undefined; 636 var c2s = Pipe.init(&c2s_buf); 637 var s2c_buf: [4096]u8 = undefined; 638 var s2c = Pipe.init(&s2c_buf); 639 640 var server_rd_buf: [4096]u8 = undefined; 641 var server_wr_buf: [4096]u8 = undefined; 642 var client_rd_buf: [4096]u8 = undefined; 643 var client_wr_buf: [4096]u8 = undefined; 644 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 645 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 646 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 647 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 648 const cr = &client_pipe_reader.interface; 649 const cw = &client_pipe_writer.interface; 650 651 var server: ServerTask = .{ 652 .io = io, 653 .allocator = allocator, 654 .host_key = &host_key, 655 .reader = &server_pipe_reader.interface, 656 .writer = &server_pipe_writer.interface, 657 .outgoing = &s2c, 658 }; 659 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 660 defer server_future.await(io); 661 defer c2s.close(io); 662 663 // bring the connection up to a post-NEWKEYS, ready-to-encrypt state: 664 // banner exchange, KEXINIT exchange, ECDH, NEWKEYS, key derivation. 665 var cs: proto.Cipher = undefined; 666 var sc: proto.Cipher = undefined; 667 var session_id: [Sha256.digest_length]u8 = undefined; 668 { 669 try cw.writeAll(client_version ++ "\r\n"); 670 try cw.flush(); 671 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 672 const v_s_trimmed = if (v_s_line.len > 0 and v_s_line[v_s_line.len - 1] == '\r') v_s_line[0 .. v_s_line.len - 1] else v_s_line; 673 const v_s = try allocator.dupe(u8, v_s_trimmed); 674 defer allocator.free(v_s); 675 676 const ckex = try proto.buildKexInit(io, allocator, &.{"curve25519-sha256"}, &proto.our_host_key_algos, &proto.our_ciphers); 677 defer allocator.free(ckex); 678 try proto.writePlainPacket(io, cw, ckex); 679 const skex = try proto.readPlainPacket(allocator, cr); 680 defer allocator.free(skex); 681 682 const eph = try newClientEphemeral(); 683 { 684 var pkt: std.ArrayList(u8) = .empty; 685 defer pkt.deinit(allocator); 686 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 687 try proto.writeStringField(&pkt, allocator, &eph.public_key); 688 try proto.writePlainPacket(io, cw, pkt.items); 689 } 690 const reply = try proto.readPlainPacket(allocator, cr); 691 defer allocator.free(reply); 692 var rr = std.Io.Reader.fixed(reply[1..]); 693 const k_s = try proto.takeStringField(allocator, &rr, 4096); 694 defer allocator.free(k_s); 695 const q_s = try proto.takeStringField(allocator, &rr, X25519.public_length); 696 defer allocator.free(q_s); 697 var server_pub: [X25519.public_length]u8 = undefined; 698 @memcpy(&server_pub, q_s); 699 const sig_blob = try proto.takeStringField(allocator, &rr, 1024); 700 defer allocator.free(sig_blob); 701 const k = try X25519.scalarmult(eph.secret_key, server_pub); 702 const exchange = try proto.computeExchangeHash(allocator, client_version, v_s, ckex, skex, k_s, &eph.public_key, &server_pub, &k, false); 703 704 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 705 { 706 const server_newkeys = try proto.readPlainPacket(allocator, cr); 707 defer allocator.free(server_newkeys); 708 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 709 } 710 711 var keys: proto.SessionKeys = undefined; 712 try proto.deriveSessionKeys(allocator, &k, &exchange, &exchange, &keys, false); 713 cs = proto.Cipher.init(&keys.cs_enc, 3); 714 sc = proto.Cipher.init(&keys.sc_enc, 3); 715 session_id = exchange; 716 } 717 718 // SERVICE_REQUEST → SERVICE_ACCEPT, then USERAUTH_REQUEST publickey 719 // probe → PK_OK, then three signed USERAUTH_REQUEST attempts that 720 // exercise the cryptographic-binding properties of publickey auth: 721 // 1. signed with a different session_id → USERAUTH_FAILURE 722 // (replay-across-sessions defense — H is bound into the signature) 723 // 2. signed with one user_name but the request carries another → 724 // USERAUTH_FAILURE (user_name is bound into the signature) 725 // 3. legit (correct session_id and user_name) → USERAUTH_SUCCESS 726 // each USERAUTH_FAILURE is non-terminal: the server's auth loop just 727 // increments attempts and waits for the next request. 728 const user_kp = try newUserKeypair(); 729 { 730 { 731 var req: std.ArrayList(u8) = .empty; 732 defer req.deinit(allocator); 733 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 734 try proto.writeStringField(&req, allocator, "ssh-userauth"); 735 try cs.writePacket(io, cw, req.items); 736 } 737 { 738 const accept = try sc.readPacket(allocator, cr); 739 defer allocator.free(accept); 740 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 741 var r = std.Io.Reader.fixed(accept[1..]); 742 const service = try proto.takeStringField(allocator, &r, 64); 743 defer allocator.free(service); 744 try std.testing.expectEqualStrings("ssh-userauth", service); 745 } 746 747 var pubkey_blob: std.ArrayList(u8) = .empty; 748 defer pubkey_blob.deinit(allocator); 749 try proto.writeStringField(&pubkey_blob, allocator, "ssh-ed25519"); 750 try proto.writeStringField(&pubkey_blob, allocator, &user_kp.public_key.bytes); 751 752 { 753 var req: std.ArrayList(u8) = .empty; 754 defer req.deinit(allocator); 755 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 756 try proto.writeStringField(&req, allocator, "testuser"); 757 try proto.writeStringField(&req, allocator, "ssh-connection"); 758 try proto.writeStringField(&req, allocator, "publickey"); 759 try req.append(allocator, 0); 760 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 761 try proto.writeStringField(&req, allocator, pubkey_blob.items); 762 try cs.writePacket(io, cw, req.items); 763 } 764 { 765 const pk_ok = try sc.readPacket(allocator, cr); 766 defer allocator.free(pk_ok); 767 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 768 var r = std.Io.Reader.fixed(pk_ok[1..]); 769 const algo = try proto.takeStringField(allocator, &r, 64); 770 defer allocator.free(algo); 771 try std.testing.expectEqualStrings("ssh-ed25519", algo); 772 const accepted_blob = try proto.takeStringField(allocator, &r, 4096); 773 defer allocator.free(accepted_blob); 774 try std.testing.expectEqualSlices(u8, pubkey_blob.items, accepted_blob); 775 } 776 777 const wrong_session = [_]u8{0xFF} ** Sha256.digest_length; 778 for ([_]struct { 779 sign_session: []const u8, 780 sign_user: []const u8, 781 send_user: []const u8, 782 expect: u8, 783 }{ 784 .{ .sign_session = &wrong_session, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 785 .{ .sign_session = &session_id, .sign_user = "alice", .send_user = "bob", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 786 .{ .sign_session = &session_id, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_SUCCESS }, 787 }) |attempt| { 788 var signed: std.ArrayList(u8) = .empty; 789 defer signed.deinit(allocator); 790 try proto.appendPublickeySignedData(&signed, allocator, attempt.sign_session, attempt.sign_user, "ssh-connection", "ssh-ed25519", pubkey_blob.items); 791 const sig = try user_kp.sign(signed.items, null); 792 const sig_bytes = sig.toBytes(); 793 var sig_wire: std.ArrayList(u8) = .empty; 794 defer sig_wire.deinit(allocator); 795 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 796 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 797 798 var req: std.ArrayList(u8) = .empty; 799 defer req.deinit(allocator); 800 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 801 try proto.writeStringField(&req, allocator, attempt.send_user); 802 try proto.writeStringField(&req, allocator, "ssh-connection"); 803 try proto.writeStringField(&req, allocator, "publickey"); 804 try req.append(allocator, 1); 805 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 806 try proto.writeStringField(&req, allocator, pubkey_blob.items); 807 try proto.writeStringField(&req, allocator, sig_wire.items); 808 try cs.writePacket(io, cw, req.items); 809 810 const reply = try sc.readPacket(allocator, cr); 811 defer allocator.free(reply); 812 try std.testing.expectEqual(attempt.expect, reply[0]); 813 } 814 } 815 816 // open two channels back to back: server confirms the first, then 817 // refuses the second with OPEN_FAILURE and stays running. 818 for ([_]struct { id: u32, expect: u8 }{ 819 .{ .id = 7, .expect = proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION }, 820 .{ .id = 8, .expect = proto.SSH_MSG_CHANNEL_OPEN_FAILURE }, 821 }) |case| { 822 var req: std.ArrayList(u8) = .empty; 823 defer req.deinit(allocator); 824 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 825 try proto.writeStringField(&req, allocator, "session"); 826 try proto.writeU32(&req, allocator, case.id); 827 try proto.writeU32(&req, allocator, 1 << 20); 828 try proto.writeU32(&req, allocator, 32768); 829 try cs.writePacket(io, cw, req.items); 830 831 const reply = try sc.readPacket(allocator, cr); 832 defer allocator.free(reply); 833 try std.testing.expectEqual(case.expect, reply[0]); 834 } 835 836 // now break the connection: build any encrypted packet, flip one bit of 837 // its trailing Poly1305 tag, and ship the corrupted bytes. server must 838 // surface MacVerificationFailed out of handleConnection. 839 var req: std.ArrayList(u8) = .empty; 840 defer req.deinit(allocator); 841 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 842 try proto.writeU32(&req, allocator, 0); 843 try proto.writeStringField(&req, allocator, "shell"); 844 try req.append(allocator, 0); 845 846 var tmp_buf: [512]u8 = undefined; 847 var sink = std.Io.Writer.fixed(&tmp_buf); 848 try cs.writePacket(io, &sink, req.items); 849 const written = sink.buffered(); 850 tmp_buf[written.len - 1] ^= 0x01; 851 try cw.writeAll(tmp_buf[0..written.len]); 852 try cw.flush(); 853 854 server_future.await(io); 855 try std.testing.expectEqual(@as(?anyerror, error.MacVerificationFailed), server.result); 856 } 857 858 const client_version = "SSH-2.0-haxy_test"; 859 860 // in-memory bidirectional pipe — drives the server side of handleConnection 861 // without a socket. one Pipe carries bytes in one direction. 862 863 const Pipe = std.Io.Queue(u8); 864 865 const PipeReader = struct { 866 interface: std.Io.Reader, 867 io: std.Io, 868 pipe: *Pipe, 869 870 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeReader { 871 return .{ 872 .interface = .{ 873 .vtable = &.{ .readVec = readVec, .stream = stream }, 874 .buffer = buf, 875 .seek = 0, 876 .end = 0, 877 }, 878 .io = io, 879 .pipe = pipe, 880 }; 881 } 882 883 fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { 884 // tests don't drive any code path that calls stream, but the vtable 885 // requires a non-default impl. read into a small staging buffer and 886 // forward, so the implementation is correct if it ever fires. 887 const self: *PipeReader = @fieldParentPtr("interface", r); 888 var stage: [256]u8 = undefined; 889 const cap = limit.minInt(stage.len); 890 if (cap == 0) return 0; 891 const n = self.pipe.get(self.io, stage[0..cap], 1) catch |err| switch (err) { 892 error.Closed => return error.EndOfStream, 893 error.Canceled => return error.ReadFailed, 894 }; 895 return try w.write(stage[0..n]); 896 } 897 898 fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { 899 const self: *PipeReader = @fieldParentPtr("interface", r); 900 // vtable contract: if data[0] is empty, fill r.buffer[r.end..] instead. 901 const dest = if (data.len == 0 or data[0].len == 0) r.buffer[r.end..] else data[0]; 902 if (dest.len == 0) return 0; 903 const n = self.pipe.get(self.io, dest, 1) catch |err| switch (err) { 904 error.Closed => return error.EndOfStream, 905 error.Canceled => return error.ReadFailed, 906 }; 907 if (data.len == 0 or data[0].len == 0) { 908 r.end += n; 909 return 0; 910 } 911 return n; 912 } 913 }; 914 915 const PipeWriter = struct { 916 interface: std.Io.Writer, 917 io: std.Io, 918 pipe: *Pipe, 919 920 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeWriter { 921 return .{ 922 .interface = .{ 923 .vtable = &.{ .drain = drain }, 924 .buffer = buf, 925 }, 926 .io = io, 927 .pipe = pipe, 928 }; 929 } 930 931 fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 932 const self: *PipeWriter = @fieldParentPtr("interface", w); 933 const buffered = w.buffered(); 934 if (buffered.len > 0) self.pipe.putAll(self.io, buffered) catch return error.WriteFailed; 935 936 var extra: usize = 0; 937 if (data.len > 1) { 938 for (data[0 .. data.len - 1]) |chunk| { 939 if (chunk.len == 0) continue; 940 self.pipe.putAll(self.io, chunk) catch return error.WriteFailed; 941 extra += chunk.len; 942 } 943 } 944 if (data.len > 0) { 945 const pattern = data[data.len - 1]; 946 if (pattern.len > 0) { 947 var i: usize = 0; 948 while (i < splat) : (i += 1) { 949 self.pipe.putAll(self.io, pattern) catch return error.WriteFailed; 950 extra += pattern.len; 951 } 952 } 953 } 954 return w.consume(buffered.len + extra); 955 } 956 }; 957 958 // runs proto.handleConnection in a concurrent task (spawned via std.Io.concurrent). 959 // also acts as the session handler — captures the authenticated key's 960 // fingerprint and returns immediately, leaving runChannelLayer's wrap-up to 961 // send exit-status / EOF / CLOSE. records any error so the test can assert 962 // on it after awaiting the future. 963 const ServerTask = struct { 964 io: std.Io, 965 allocator: std.mem.Allocator, 966 host_key: *const proto.HostKey, 967 reader: *std.Io.Reader, 968 writer: *std.Io.Writer, 969 outgoing: *Pipe, // closed on exit so the client's reads return EOF 970 captured_fp: [proto.fingerprint_len]u8 = undefined, 971 captured_exec_buf: [256]u8 = undefined, 972 captured_exec_len: usize = 0, 973 result: ?anyerror = null, 974 975 pub fn handleSession(self: *ServerTask, sess: *proto.SessionCtx, request: proto.Request) !void { 976 @memcpy(&self.captured_fp, &sess.fingerprint); 977 switch (request) { 978 .exec => |cmd| { 979 // copy out of the protocol's stack-scoped buffer so the 980 // test can assert on it after the handler returns. 981 @memcpy(self.captured_exec_buf[0..cmd.len], cmd); 982 self.captured_exec_len = cmd.len; 983 }, 984 .shell => {}, 985 } 986 } 987 988 fn run(self: *ServerTask) void { 989 defer self.outgoing.close(self.io); 990 proto.handleConnection( 991 self.io, 992 self.allocator, 993 self.reader, 994 self.writer, 995 self.host_key, 996 null, 997 self, 998 ) catch |e| { 999 self.result = e; 1000 }; 1001 } 1002 }; 1003 1004 // deterministic keys so failures don't depend on RNG state. 1005 1006 fn newHostKey() !proto.HostKey { 1007 const seed = [_]u8{0xAA} ** Ed25519.KeyPair.seed_length; 1008 return .{ .keypair = try Ed25519.KeyPair.generateDeterministic(seed) }; 1009 } 1010 1011 fn newUserKeypair() !Ed25519.KeyPair { 1012 const seed = [_]u8{0xBB} ** Ed25519.KeyPair.seed_length; 1013 return try Ed25519.KeyPair.generateDeterministic(seed); 1014 } 1015 1016 fn newClientEphemeral() !X25519.KeyPair { 1017 const seed = [_]u8{0xCC} ** X25519.seed_length; 1018 return try X25519.KeyPair.generateDeterministic(seed); 1019 } 1020