╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
251 var r = std.Io.Reader.fixed(sig_blob); 252 const algo = try proto.takeStringField(allocator, &r, 64); 253 defer allocator.free(algo); 254 try std.testing.expectEqualStrings("ssh-ed25519", algo); 255 const raw_sig = try proto.takeStringField(allocator, &r, Ed25519.Signature.encoded_length); 256 defer allocator.free(raw_sig); 257 var sig_bytes: [Ed25519.Signature.encoded_length]u8 = undefined; 258 @memcpy(&sig_bytes, raw_sig); 259 const sig = Ed25519.Signature.fromBytes(sig_bytes); 260 try sig.verify(&h, host_key.keypair.public_key); 261 } 262 263 // STEP 4: NEWKEYS — switch to encrypted mode (RFC 4253 §7.3) 264 // each side sends a one-byte SSH_MSG_NEWKEYS and starts encrypting 265 // from the next packet on. chacha20-poly1305@openssh.com needs 64 266 // bytes per direction (32 for the body cipher, 32 for the length 267 // cipher); only the 'C' (c->s) and 'D' (s->c) RFC 4253 §7.2 outputs 268 // are used since the AEAD covers MAC. 269 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 270 { 271 const server_newkeys = try proto.readPlainPacket(allocator, cr); 272 defer allocator.free(server_newkeys); 273 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 274 } 275 276 var keys: proto.SessionKeys = undefined; 277 try proto.deriveSessionKeys(allocator, &k, &h, &h, &keys, false); 278 // KEXINIT/KEX_ECDH_INIT/NEWKEYS were seqno 0/1/2; first encrypted is 3. 279 var cs_cipher = proto.Cipher.init(&keys.cs_enc, 3); 280 var sc_cipher = proto.Cipher.init(&keys.sc_enc, 3); 281 282 // STEP 5: SERVICE REQUEST (RFC 4253 §10) 283 // first encrypted message: client asks for the "ssh-userauth" service. 284 // server replies SERVICE_ACCEPT. ("ssh-connection" comes after auth.) 285 { 286 var req: std.ArrayList(u8) = .empty; 287 defer req.deinit(allocator); 288 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 289 try proto.writeStringField(&req, allocator, "ssh-userauth"); 290 try cs_cipher.writePacket(io, cw, req.items); 291 } 292 { 293 const accept = try sc_cipher.readPacket(allocator, cr); 294 defer allocator.free(accept); 295 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 296 } 297 298 // STEP 6: PUBLIC-KEY USERAUTH — PROBE (RFC 4252 §7) 299 // two-trip dance. round 1: USERAUTH_REQUEST with method=publickey and 300 // has_signature=false, carrying just the algo + public key blob. the 301 // server says USERAUTH_PK_OK if it would accept that key (haxy 302 // accepts any ed25519) or USERAUTH_FAILURE if not — so the client 303 // doesn't waste a signature on a rejected key. 304 var user_pubkey_blob: std.ArrayList(u8) = .empty; 305 defer user_pubkey_blob.deinit(allocator); 306 try proto.writeStringField(&user_pubkey_blob, allocator, "ssh-ed25519"); 307 try proto.writeStringField(&user_pubkey_blob, allocator, &user_kp.public_key.bytes); 308 309 { 310 var req: std.ArrayList(u8) = .empty; 311 defer req.deinit(allocator); 312 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 313 try proto.writeStringField(&req, allocator, "testuser"); 314 try proto.writeStringField(&req, allocator, "ssh-connection"); 315 try proto.writeStringField(&req, allocator, "publickey"); 316 try req.append(allocator, 0); // has_signature = false 317 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 318 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 319 try cs_cipher.writePacket(io, cw, req.items); 320 } 321 { 322 const pk_ok = try sc_cipher.readPacket(allocator, cr); 323 defer allocator.free(pk_ok); 324 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 325 } 326 327 // STEP 7: PUBLIC-KEY USERAUTH — SIGNED REQUEST 328 // round 2: same USERAUTH_REQUEST, now with has_signature=true and an 329 // ed25519 signature over the canonical signed-data block — see 330 // proto.appendPublickeySignedData for the exact byte layout. the 331 // block includes session_id (== H of the first KEX), so signatures 332 // can't be replayed across sessions. 333 var signed: std.ArrayList(u8) = .empty; 334 defer signed.deinit(allocator); 335 try proto.appendPublickeySignedData(&signed, allocator, &h, "testuser", "ssh-connection", "ssh-ed25519", user_pubkey_blob.items); 336 const sig = try user_kp.sign(signed.items, null); 337 const sig_bytes = sig.toBytes(); 338 339 var sig_wire: std.ArrayList(u8) = .empty; 340 defer sig_wire.deinit(allocator); 341 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 342 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 343 344 { 345 var req: std.ArrayList(u8) = .empty; 346 defer req.deinit(allocator); 347 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 348 try proto.writeStringField(&req, allocator, "testuser"); 349 try proto.writeStringField(&req, allocator, "ssh-connection"); 350 try proto.writeStringField(&req, allocator, "publickey"); 351 try req.append(allocator, 1); 352 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 353 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 354 try proto.writeStringField(&req, allocator, sig_wire.items); 355 try cs_cipher.writePacket(io, cw, req.items); 356 } 357 { 358 const success = try sc_cipher.readPacket(allocator, cr); 359 defer allocator.free(success); 360 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_SUCCESS), success[0]); 361 } 362 363 // STEP 8: CHANNEL_OPEN (RFC 4254 §5.1) 364 // open a "session" channel — SSH's container for shell/exec/subsystem. 365 // client picks its own channel id and announces an initial receive 366 // window + max-packet size; server replies CHANNEL_OPEN_CONFIRMATION 367 // with its own channel id and c->s limits. each side identifies the 368 // channel by *the other's* id when sending. 369 // 370 // haxy supports exactly one channel per connection — a second 371 // CHANNEL_OPEN here gets CHANNEL_OPEN_FAILURE (see the failure tests). 372 const client_channel_id: u32 = 42; 373 { 374 var req: std.ArrayList(u8) = .empty; 375 defer req.deinit(allocator); 376 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 377 try proto.writeStringField(&req, allocator, "session"); 378 try proto.writeU32(&req, allocator, client_channel_id); 379 try proto.writeU32(&req, allocator, 1 << 20); // initial window 380 try proto.writeU32(&req, allocator, 32768); // max packet 381 try cs_cipher.writePacket(io, cw, req.items); 382 } 383 var server_channel_id: u32 = undefined; 384 { 385 const conf = try sc_cipher.readPacket(allocator, cr); 386 defer allocator.free(conf); 387 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION), conf[0]); 388 var r = std.Io.Reader.fixed(conf[1..]); 389 const recipient = try r.takeInt(u32, .big); 390 try std.testing.expectEqual(client_channel_id, recipient); 391 server_channel_id = try r.takeInt(u32, .big); 392 } 393 394 // STEP 9: CHANNEL_REQUEST exec (RFC 4254 §6.5) 395 // ask the server to run a command on this channel. packet is 396 // addressed to the *server's* channel id. with want_reply=true the 397 // server must respond CHANNEL_SUCCESS or CHANNEL_FAILURE before any 398 // CHANNEL_DATA flows. 399 const exec_command = "git-upload-pack 'demo-repo'"; 400 { 401 var req: std.ArrayList(u8) = .empty; 402 defer req.deinit(allocator); 403 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 404 try proto.writeU32(&req, allocator, server_channel_id); 405 try proto.writeStringField(&req, allocator, "exec"); 406 try req.append(allocator, 1); // want_reply 407 try proto.writeStringField(&req, allocator, exec_command); 408 try cs_cipher.writePacket(io, cw, req.items); 409 } 410 { 411 const success = try sc_cipher.readPacket(allocator, cr); 412 defer allocator.free(success); 413 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_SUCCESS), success[0]); 414 } 415 416 // STEP 10: HANDLER RUNS, CHANNEL TEARDOWN 417 // the server is now in handleSession. our recording handler returns 418 // immediately; runChannelLayer's wrap-up then sends: 419 // - CHANNEL_REQUEST "exit-status" (status=0, want_reply=false) 420 // - CHANNEL_EOF 421 // - CHANNEL_CLOSE 422 // …and waits for our CHANNEL_CLOSE in reply (so the underlying socket 423 // isn't closed-with-unread-data, which would surface as TCP RST). 424 var saw_exit_status = false; 425 var saw_server_close = false; 426 while (!saw_server_close) { 427 const pkt = try sc_cipher.readPacket(allocator, cr); 428 defer allocator.free(pkt); 429 switch (pkt[0]) { 430 proto.SSH_MSG_CHANNEL_REQUEST => { 431 var r = std.Io.Reader.fixed(pkt[1..]); 432 _ = try r.takeInt(u32, .big); 433 const req_type = try proto.takeStringField(allocator, &r, 64); 434 defer allocator.free(req_type); 435 if (std.mem.eql(u8, req_type, "exit-status")) { 436 _ = try r.takeByte(); 437 const status = try r.takeInt(u32, .big); 438 try std.testing.expectEqual(@as(u32, 0), status); 439 saw_exit_status = true; 440 } 441 }, 442 proto.SSH_MSG_CHANNEL_EOF => {}, 443 proto.SSH_MSG_CHANNEL_CLOSE => saw_server_close = true, 444 proto.SSH_MSG_CHANNEL_WINDOW_ADJUST => {}, 445 else => {}, 446 } 447 } 448 try std.testing.expect(saw_exit_status); 449 450 { 451 var pkt: std.ArrayList(u8) = .empty; 452 defer pkt.deinit(allocator); 453 try pkt.append(allocator, proto.SSH_MSG_CHANNEL_CLOSE); 454 try proto.writeU32(&pkt, allocator, server_channel_id); 455 try cs_cipher.writePacket(io, cw, pkt.items); 456 } 457 458 // simulate the client's TCP FIN so the server's teardown drain reads EOF 459 // and returns instead of blocking on a packet that never arrives. 460 c2s.close(io); 461 462 server_future.await(io); 463 try std.testing.expect(server.result == null); 464 465 // FINAL ASSERTIONS 466 // the exec command we sent on the wire should have reached the handler 467 // verbatim, and the server should have recorded our pubkey's SHA256 468 // fingerprint as the authenticated identity for this session. 469 try std.testing.expectEqualStrings(exec_command, server.captured_exec_buf[0..server.captured_exec_len]); 470 const expected_fp = proto.formatFingerprint(user_pubkey_blob.items); 471 try std.testing.expectEqualSlices(u8, &expected_fp, &server.captured_fp); 472 } 473 474 // failure modes against a single live session, in increasing severity: 475 // - publickey signed-data binding (session_id and user_name) — server 476 // replies USERAUTH_FAILURE, connection continues 477 // - second CHANNEL_OPEN on the same connection — server replies 478 // CHANNEL_OPEN_FAILURE, connection continues 479 // - MAC-tampered encrypted packet — server bails out of handleConnection 480 // with MacVerificationFailed (terminal) 481 // running all of these against one negotiated session avoids paying the 482 // KEX cost per failure case and keeps the failure tests visually together. 483 test "auth + channel layer failure modes" { 484 const io = std.testing.io; 485 const allocator = std.testing.allocator; 486 487 const host_key = try newHostKey(); 488 489 var c2s_buf: [4096]u8 = undefined; 490 var c2s = Pipe.init(&c2s_buf); 491 var s2c_buf: [4096]u8 = undefined; 492 var s2c = Pipe.init(&s2c_buf); 493 494 var server_rd_buf: [4096]u8 = undefined; 495 var server_wr_buf: [4096]u8 = undefined; 496 var client_rd_buf: [4096]u8 = undefined; 497 var client_wr_buf: [4096]u8 = undefined; 498 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 499 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 500 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 501 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 502 const cr = &client_pipe_reader.interface; 503 const cw = &client_pipe_writer.interface; 504 505 var server: ServerTask = .{ 506 .io = io, 507 .allocator = allocator, 508 .host_key = &host_key, 509 .reader = &server_pipe_reader.interface, 510 .writer = &server_pipe_writer.interface, 511 .outgoing = &s2c, 512 }; 513 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 514 defer server_future.await(io); 515 defer c2s.close(io); 516 517 // bring the connection up to a post-NEWKEYS, ready-to-encrypt state: 518 // banner exchange, KEXINIT exchange, ECDH, NEWKEYS, key derivation. 519 var cs: proto.Cipher = undefined; 520 var sc: proto.Cipher = undefined; 521 var session_id: [Sha256.digest_length]u8 = undefined; 522 { 523 try cw.writeAll(client_version ++ "\r\n"); 524 try cw.flush(); 525 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 526 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; 527 const v_s = try allocator.dupe(u8, v_s_trimmed); 528 defer allocator.free(v_s); 529 530 const ckex = try proto.buildKexInit(io, allocator, &.{"curve25519-sha256"}, &proto.our_host_key_algos, &proto.our_ciphers); 531 defer allocator.free(ckex); 532 try proto.writePlainPacket(io, cw, ckex); 533 const skex = try proto.readPlainPacket(allocator, cr); 534 defer allocator.free(skex); 535 536 const eph = try newClientEphemeral(); 537 { 538 var pkt: std.ArrayList(u8) = .empty; 539 defer pkt.deinit(allocator); 540 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 541 try proto.writeStringField(&pkt, allocator, &eph.public_key); 542 try proto.writePlainPacket(io, cw, pkt.items); 543 } 544 const reply = try proto.readPlainPacket(allocator, cr); 545 defer allocator.free(reply); 546 var rr = std.Io.Reader.fixed(reply[1..]); 547 const k_s = try proto.takeStringField(allocator, &rr, 4096); 548 defer allocator.free(k_s); 549 const q_s = try proto.takeStringField(allocator, &rr, X25519.public_length); 550 defer allocator.free(q_s); 551 var server_pub: [X25519.public_length]u8 = undefined; 552 @memcpy(&server_pub, q_s); 553 const sig_blob = try proto.takeStringField(allocator, &rr, 1024); 554 defer allocator.free(sig_blob); 555 const k = try X25519.scalarmult(eph.secret_key, server_pub); 556 const exchange = try proto.computeExchangeHash(allocator, client_version, v_s, ckex, skex, k_s, &eph.public_key, &server_pub, &k, false); 557 558 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 559 { 560 const server_newkeys = try proto.readPlainPacket(allocator, cr); 561 defer allocator.free(server_newkeys); 562 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 563 } 564 565 var keys: proto.SessionKeys = undefined; 566 try proto.deriveSessionKeys(allocator, &k, &exchange, &exchange, &keys, false); 567 cs = proto.Cipher.init(&keys.cs_enc, 3); 568 sc = proto.Cipher.init(&keys.sc_enc, 3); 569 session_id = exchange; 570 } 571 572 // SERVICE_REQUEST → SERVICE_ACCEPT, then USERAUTH_REQUEST publickey 573 // probe → PK_OK, then three signed USERAUTH_REQUEST attempts that 574 // exercise the cryptographic-binding properties of publickey auth: 575 // 1. signed with a different session_id → USERAUTH_FAILURE 576 // (replay-across-sessions defense — H is bound into the signature) 577 // 2. signed with one user_name but the request carries another → 578 // USERAUTH_FAILURE (user_name is bound into the signature) 579 // 3. legit (correct session_id and user_name) → USERAUTH_SUCCESS 580 // each USERAUTH_FAILURE is non-terminal: the server's auth loop just 581 // increments attempts and waits for the next request. 582 const user_kp = try newUserKeypair(); 583 { 584 { 585 var req: std.ArrayList(u8) = .empty; 586 defer req.deinit(allocator); 587 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 588 try proto.writeStringField(&req, allocator, "ssh-userauth"); 589 try cs.writePacket(io, cw, req.items); 590 } 591 { 592 const accept = try sc.readPacket(allocator, cr); 593 defer allocator.free(accept); 594 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 595 var r = std.Io.Reader.fixed(accept[1..]); 596 const service = try proto.takeStringField(allocator, &r, 64); 597 defer allocator.free(service); 598 try std.testing.expectEqualStrings("ssh-userauth", service); 599 } 600 601 var pubkey_blob: std.ArrayList(u8) = .empty; 602 defer pubkey_blob.deinit(allocator); 603 try proto.writeStringField(&pubkey_blob, allocator, "ssh-ed25519"); 604 try proto.writeStringField(&pubkey_blob, allocator, &user_kp.public_key.bytes); 605 606 { 607 var req: std.ArrayList(u8) = .empty; 608 defer req.deinit(allocator); 609 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 610 try proto.writeStringField(&req, allocator, "testuser"); 611 try proto.writeStringField(&req, allocator, "ssh-connection"); 612 try proto.writeStringField(&req, allocator, "publickey"); 613 try req.append(allocator, 0); 614 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 615 try proto.writeStringField(&req, allocator, pubkey_blob.items); 616 try cs.writePacket(io, cw, req.items); 617 } 618 { 619 const pk_ok = try sc.readPacket(allocator, cr); 620 defer allocator.free(pk_ok); 621 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 622 var r = std.Io.Reader.fixed(pk_ok[1..]); 623 const algo = try proto.takeStringField(allocator, &r, 64); 624 defer allocator.free(algo); 625 try std.testing.expectEqualStrings("ssh-ed25519", algo); 626 const accepted_blob = try proto.takeStringField(allocator, &r, 4096); 627 defer allocator.free(accepted_blob); 628 try std.testing.expectEqualSlices(u8, pubkey_blob.items, accepted_blob); 629 } 630 631 const wrong_session = [_]u8{0xFF} ** Sha256.digest_length; 632 for ([_]struct { 633 sign_session: []const u8, 634 sign_user: []const u8, 635 send_user: []const u8, 636 expect: u8, 637 }{ 638 .{ .sign_session = &wrong_session, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 639 .{ .sign_session = &session_id, .sign_user = "alice", .send_user = "bob", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 640 .{ .sign_session = &session_id, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_SUCCESS }, 641 }) |attempt| { 642 var signed: std.ArrayList(u8) = .empty; 643 defer signed.deinit(allocator); 644 try proto.appendPublickeySignedData(&signed, allocator, attempt.sign_session, attempt.sign_user, "ssh-connection", "ssh-ed25519", pubkey_blob.items); 645 const sig = try user_kp.sign(signed.items, null); 646 const sig_bytes = sig.toBytes(); 647 var sig_wire: std.ArrayList(u8) = .empty; 648 defer sig_wire.deinit(allocator); 649 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 650 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 651 652 var req: std.ArrayList(u8) = .empty; 653 defer req.deinit(allocator); 654 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 655 try proto.writeStringField(&req, allocator, attempt.send_user); 656 try proto.writeStringField(&req, allocator, "ssh-connection"); 657 try proto.writeStringField(&req, allocator, "publickey"); 658 try req.append(allocator, 1); 659 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 660 try proto.writeStringField(&req, allocator, pubkey_blob.items); 661 try proto.writeStringField(&req, allocator, sig_wire.items); 662 try cs.writePacket(io, cw, req.items); 663 664 const reply = try sc.readPacket(allocator, cr); 665 defer allocator.free(reply); 666 try std.testing.expectEqual(attempt.expect, reply[0]); 667 } 668 } 669 670 // open two channels back to back: server confirms the first, then 671 // refuses the second with OPEN_FAILURE and stays running. 672 for ([_]struct { id: u32, expect: u8 }{ 673 .{ .id = 7, .expect = proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION }, 674 .{ .id = 8, .expect = proto.SSH_MSG_CHANNEL_OPEN_FAILURE }, 675 }) |case| { 676 var req: std.ArrayList(u8) = .empty; 677 defer req.deinit(allocator); 678 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 679 try proto.writeStringField(&req, allocator, "session"); 680 try proto.writeU32(&req, allocator, case.id); 681 try proto.writeU32(&req, allocator, 1 << 20); 682 try proto.writeU32(&req, allocator, 32768); 683 try cs.writePacket(io, cw, req.items); 684 685 const reply = try sc.readPacket(allocator, cr); 686 defer allocator.free(reply); 687 try std.testing.expectEqual(case.expect, reply[0]); 688 } 689 690 // now break the connection: build any encrypted packet, flip one bit of 691 // its trailing Poly1305 tag, and ship the corrupted bytes. server must 692 // surface MacVerificationFailed out of handleConnection. 693 var req: std.ArrayList(u8) = .empty; 694 defer req.deinit(allocator); 695 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 696 try proto.writeU32(&req, allocator, 0); 697 try proto.writeStringField(&req, allocator, "shell"); 698 try req.append(allocator, 0); 699 700 var tmp_buf: [512]u8 = undefined; 701 var sink = std.Io.Writer.fixed(&tmp_buf); 702 try cs.writePacket(io, &sink, req.items); 703 const written = sink.buffered(); 704 tmp_buf[written.len - 1] ^= 0x01; 705 try cw.writeAll(tmp_buf[0..written.len]); 706 try cw.flush(); 707 708 server_future.await(io); 709 try std.testing.expectEqual(@as(?anyerror, error.MacVerificationFailed), server.result); 710 } 711 712 const client_version = "SSH-2.0-haxy_test"; 713 714 // in-memory bidirectional pipe — drives the server side of handleConnection 715 // without a socket. one Pipe carries bytes in one direction. 716 717 const Pipe = std.Io.Queue(u8); 718 719 const PipeReader = struct { 720 interface: std.Io.Reader, 721 io: std.Io, 722 pipe: *Pipe, 723 724 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeReader { 725 return .{ 726 .interface = .{ 727 .vtable = &.{ .readVec = readVec, .stream = stream }, 728 .buffer = buf, 729 .seek = 0, 730 .end = 0, 731 }, 732 .io = io, 733 .pipe = pipe, 734 }; 735 } 736 737 fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { 738 // tests don't drive any code path that calls stream, but the vtable 739 // requires a non-default impl. read into a small staging buffer and 740 // forward, so the implementation is correct if it ever fires. 741 const self: *PipeReader = @fieldParentPtr("interface", r); 742 var stage: [256]u8 = undefined; 743 const cap = limit.minInt(stage.len); 744 if (cap == 0) return 0; 745 const n = self.pipe.get(self.io, stage[0..cap], 1) catch |err| switch (err) { 746 error.Closed => return error.EndOfStream, 747 error.Canceled => return error.ReadFailed, 748 }; 749 return try w.write(stage[0..n]); 750 } 751 752 fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { 753 const self: *PipeReader = @fieldParentPtr("interface", r); 754 // vtable contract: if data[0] is empty, fill r.buffer[r.end..] instead. 755 const dest = if (data.len == 0 or data[0].len == 0) r.buffer[r.end..] else data[0]; 756 if (dest.len == 0) return 0; 757 const n = self.pipe.get(self.io, dest, 1) catch |err| switch (err) { 758 error.Closed => return error.EndOfStream, 759 error.Canceled => return error.ReadFailed, 760 }; 761 if (data.len == 0 or data[0].len == 0) { 762 r.end += n; 763 return 0; 764 } 765 return n; 766 } 767 }; 768 769 const PipeWriter = struct { 770 interface: std.Io.Writer, 771 io: std.Io, 772 pipe: *Pipe, 773 774 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeWriter { 775 return .{ 776 .interface = .{ 777 .vtable = &.{ .drain = drain }, 778 .buffer = buf, 779 }, 780 .io = io, 781 .pipe = pipe, 782 }; 783 } 784 785 fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 786 const self: *PipeWriter = @fieldParentPtr("interface", w); 787 const buffered = w.buffered(); 788 if (buffered.len > 0) self.pipe.putAll(self.io, buffered) catch return error.WriteFailed; 789 790 var extra: usize = 0; 791 if (data.len > 1) { 792 for (data[0 .. data.len - 1]) |chunk| { 793 if (chunk.len == 0) continue; 794 self.pipe.putAll(self.io, chunk) catch return error.WriteFailed; 795 extra += chunk.len; 796 } 797 } 798 if (data.len > 0) { 799 const pattern = data[data.len - 1]; 800 if (pattern.len > 0) { 801 var i: usize = 0; 802 while (i < splat) : (i += 1) { 803 self.pipe.putAll(self.io, pattern) catch return error.WriteFailed; 804 extra += pattern.len; 805 } 806 } 807 } 808 return w.consume(buffered.len + extra); 809 } 810 }; 811 812 // runs proto.handleConnection in a concurrent task (spawned via std.Io.concurrent). 813 // also acts as the session handler — captures the authenticated key's 814 // fingerprint and returns immediately, leaving runChannelLayer's wrap-up to 815 // send exit-status / EOF / CLOSE. records any error so the test can assert 816 // on it after awaiting the future. 817 const ServerTask = struct { 818 io: std.Io, 819 allocator: std.mem.Allocator, 820 host_key: *const proto.HostKey, 821 reader: *std.Io.Reader, 822 writer: *std.Io.Writer, 823 outgoing: *Pipe, // closed on exit so the client's reads return EOF 824 captured_fp: [proto.fingerprint_len]u8 = undefined, 825 captured_exec_buf: [256]u8 = undefined, 826 captured_exec_len: usize = 0, 827 result: ?anyerror = null, 828 829 pub fn handleSession(self: *ServerTask, sess: *proto.SessionCtx, request: proto.Request) !void { 830 @memcpy(&self.captured_fp, &sess.fingerprint); 831 switch (request) { 832 .exec => |cmd| { 833 // copy out of the protocol's stack-scoped buffer so the 834 // test can assert on it after the handler returns. 835 @memcpy(self.captured_exec_buf[0..cmd.len], cmd); 836 self.captured_exec_len = cmd.len; 837 }, 838 .shell => {}, 839 } 840 } 841 842 fn run(self: *ServerTask) void { 843 defer self.outgoing.close(self.io); 844 proto.handleConnection( 845 self.io, 846 self.allocator, 847 self.reader, 848 self.writer, 849 self.host_key, 850 null, 851 self, 852 ) catch |e| { 853 self.result = e; 854 }; 855 } 856 }; 857 858 // deterministic keys so failures don't depend on RNG state. 859 860 fn newHostKey() !proto.HostKey { 861 const seed = [_]u8{0xAA} ** Ed25519.KeyPair.seed_length; 862 return .{ .keypair = try Ed25519.KeyPair.generateDeterministic(seed) }; 863 } 864 865 fn newUserKeypair() !Ed25519.KeyPair { 866 const seed = [_]u8{0xBB} ** Ed25519.KeyPair.seed_length; 867 return try Ed25519.KeyPair.generateDeterministic(seed); 868 } 869 870 fn newClientEphemeral() !X25519.KeyPair { 871 const seed = [_]u8{0xCC} ** X25519.seed_length; 872 return try X25519.KeyPair.generateDeterministic(seed); 873 } 874