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