╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
425 // failure modes against a single live session, in increasing severity: 426 // - publickey signed-data binding (session_id and user_name) — server 427 // replies USERAUTH_FAILURE, connection continues 428 // - second CHANNEL_OPEN on the same connection — server replies 429 // CHANNEL_OPEN_FAILURE, connection continues 430 // - MAC-tampered encrypted packet — server bails out of handleConnection 431 // with MacVerificationFailed (terminal) 432 // running all of these against one negotiated session avoids paying the 433 // KEX cost per failure case and keeps the failure tests visually together. 434 test "auth + channel layer failure modes" { 435 const io = std.testing.io; 436 const allocator = std.testing.allocator; 437 438 const host_key = try newHostKey(); 439 440 var c2s_buf: [4096]u8 = undefined; 441 var c2s = Pipe.init(&c2s_buf); 442 var s2c_buf: [4096]u8 = undefined; 443 var s2c = Pipe.init(&s2c_buf); 444 445 var server_rd_buf: [4096]u8 = undefined; 446 var server_wr_buf: [4096]u8 = undefined; 447 var client_rd_buf: [4096]u8 = undefined; 448 var client_wr_buf: [4096]u8 = undefined; 449 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 450 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 451 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 452 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 453 const cr = &client_pipe_reader.interface; 454 const cw = &client_pipe_writer.interface; 455 456 var server: ServerTask = .{ 457 .io = io, 458 .allocator = allocator, 459 .host_key = &host_key, 460 .reader = &server_pipe_reader.interface, 461 .writer = &server_pipe_writer.interface, 462 .outgoing = &s2c, 463 }; 464 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 465 defer server_future.await(io); 466 defer c2s.close(io); 467 468 // bring the connection up to a post-NEWKEYS, ready-to-encrypt state: 469 // banner exchange, KEXINIT exchange, ECDH, NEWKEYS, key derivation. 470 var cs: proto.Cipher = undefined; 471 var sc: proto.Cipher = undefined; 472 var session_id: [Sha256.digest_length]u8 = undefined; 473 { 474 try cw.writeAll(client_version ++ "\r\n"); 475 try cw.flush(); 476 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 477 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; 478 const v_s = try allocator.dupe(u8, v_s_trimmed); 479 defer allocator.free(v_s); 480 481 const ckex = try proto.buildKexInit(io, allocator, &.{"curve25519-sha256"}, &proto.our_host_key_algos, &proto.our_ciphers); 482 defer allocator.free(ckex); 483 try proto.writePlainPacket(io, cw, ckex); 484 const skex = try proto.readPlainPacket(allocator, cr); 485 defer allocator.free(skex); 486 487 const eph = try newClientEphemeral(); 488 { 489 var pkt: std.ArrayList(u8) = .empty; 490 defer pkt.deinit(allocator); 491 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 492 try proto.writeStringField(&pkt, allocator, &eph.public_key); 493 try proto.writePlainPacket(io, cw, pkt.items); 494 } 495 const reply = try proto.readPlainPacket(allocator, cr); 496 defer allocator.free(reply); 497 var rr = std.Io.Reader.fixed(reply[1..]); 498 const k_s = try proto.takeStringField(allocator, &rr, 4096); 499 defer allocator.free(k_s); 500 const q_s = try proto.takeStringField(allocator, &rr, X25519.public_length); 501 defer allocator.free(q_s); 502 var server_pub: [X25519.public_length]u8 = undefined; 503 @memcpy(&server_pub, q_s); 504 const sig_blob = try proto.takeStringField(allocator, &rr, 1024); 505 defer allocator.free(sig_blob); 506 const k = try X25519.scalarmult(eph.secret_key, server_pub); 507 const exchange = try proto.computeExchangeHash(allocator, client_version, v_s, ckex, skex, k_s, &eph.public_key, &server_pub, &k, false); 508 509 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 510 { 511 const server_newkeys = try proto.readPlainPacket(allocator, cr); 512 defer allocator.free(server_newkeys); 513 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 514 } 515 516 var keys: proto.SessionKeys = undefined; 517 try proto.deriveSessionKeys(allocator, &k, &exchange, &exchange, &keys, false); 518 cs = proto.Cipher.init(&keys.cs_enc, 3); 519 sc = proto.Cipher.init(&keys.sc_enc, 3); 520 session_id = exchange; 521 } 522 523 // SERVICE_REQUEST → SERVICE_ACCEPT, then USERAUTH_REQUEST publickey 524 // probe → PK_OK, then three signed USERAUTH_REQUEST attempts that 525 // exercise the cryptographic-binding properties of publickey auth: 526 // 1. signed with a different session_id → USERAUTH_FAILURE 527 // (replay-across-sessions defense — H is bound into the signature) 528 // 2. signed with one user_name but the request carries another → 529 // USERAUTH_FAILURE (user_name is bound into the signature) 530 // 3. legit (correct session_id and user_name) → USERAUTH_SUCCESS 531 // each USERAUTH_FAILURE is non-terminal: the server's auth loop just 532 // increments attempts and waits for the next request. 533 const user_kp = try newUserKeypair(); 534 { 535 { 536 var req: std.ArrayList(u8) = .empty; 537 defer req.deinit(allocator); 538 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 539 try proto.writeStringField(&req, allocator, "ssh-userauth"); 540 try cs.writePacket(io, allocator, cw, req.items); 541 } 542 { 543 const accept = try sc.readPacket(allocator, cr); 544 defer allocator.free(accept); 545 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 546 var r = std.Io.Reader.fixed(accept[1..]); 547 const service = try proto.takeStringField(allocator, &r, 64); 548 defer allocator.free(service); 549 try std.testing.expectEqualStrings("ssh-userauth", service); 550 } 551 552 var pubkey_blob: std.ArrayList(u8) = .empty; 553 defer pubkey_blob.deinit(allocator); 554 try proto.writeStringField(&pubkey_blob, allocator, "ssh-ed25519"); 555 try proto.writeStringField(&pubkey_blob, allocator, &user_kp.public_key.bytes); 556 557 { 558 var req: std.ArrayList(u8) = .empty; 559 defer req.deinit(allocator); 560 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 561 try proto.writeStringField(&req, allocator, "testuser"); 562 try proto.writeStringField(&req, allocator, "ssh-connection"); 563 try proto.writeStringField(&req, allocator, "publickey"); 564 try req.append(allocator, 0); 565 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 566 try proto.writeStringField(&req, allocator, pubkey_blob.items); 567 try cs.writePacket(io, allocator, cw, req.items); 568 } 569 { 570 const pk_ok = try sc.readPacket(allocator, cr); 571 defer allocator.free(pk_ok); 572 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 573 var r = std.Io.Reader.fixed(pk_ok[1..]); 574 const algo = try proto.takeStringField(allocator, &r, 64); 575 defer allocator.free(algo); 576 try std.testing.expectEqualStrings("ssh-ed25519", algo); 577 const accepted_blob = try proto.takeStringField(allocator, &r, 4096); 578 defer allocator.free(accepted_blob); 579 try std.testing.expectEqualSlices(u8, pubkey_blob.items, accepted_blob); 580 } 581 582 const wrong_session = [_]u8{0xFF} ** Sha256.digest_length; 583 for ([_]struct { 584 sign_session: []const u8, 585 sign_user: []const u8, 586 send_user: []const u8, 587 expect: u8, 588 }{ 589 .{ .sign_session = &wrong_session, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 590 .{ .sign_session = &session_id, .sign_user = "alice", .send_user = "bob", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 591 .{ .sign_session = &session_id, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_SUCCESS }, 592 }) |attempt| { 593 var signed: std.ArrayList(u8) = .empty; 594 defer signed.deinit(allocator); 595 try proto.appendPublickeySignedData(&signed, allocator, attempt.sign_session, attempt.sign_user, "ssh-connection", "ssh-ed25519", pubkey_blob.items); 596 const sig = try user_kp.sign(signed.items, null); 597 const sig_bytes = sig.toBytes(); 598 var sig_wire: std.ArrayList(u8) = .empty; 599 defer sig_wire.deinit(allocator); 600 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 601 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 602 603 var req: std.ArrayList(u8) = .empty; 604 defer req.deinit(allocator); 605 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 606 try proto.writeStringField(&req, allocator, attempt.send_user); 607 try proto.writeStringField(&req, allocator, "ssh-connection"); 608 try proto.writeStringField(&req, allocator, "publickey"); 609 try req.append(allocator, 1); 610 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 611 try proto.writeStringField(&req, allocator, pubkey_blob.items); 612 try proto.writeStringField(&req, allocator, sig_wire.items); 613 try cs.writePacket(io, allocator, cw, req.items); 614 615 const reply = try sc.readPacket(allocator, cr); 616 defer allocator.free(reply); 617 try std.testing.expectEqual(attempt.expect, reply[0]); 618 } 619 } 620 621 // open two channels back to back: server confirms the first, then 622 // refuses the second with OPEN_FAILURE and stays running. 623 for ([_]struct { id: u32, expect: u8 }{ 624 .{ .id = 7, .expect = proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION }, 625 .{ .id = 8, .expect = proto.SSH_MSG_CHANNEL_OPEN_FAILURE }, 626 }) |case| { 627 var req: std.ArrayList(u8) = .empty; 628 defer req.deinit(allocator); 629 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 630 try proto.writeStringField(&req, allocator, "session"); 631 try proto.writeU32(&req, allocator, case.id); 632 try proto.writeU32(&req, allocator, 1 << 20); 633 try proto.writeU32(&req, allocator, 32768); 634 try cs.writePacket(io, allocator, cw, req.items); 635 636 const reply = try sc.readPacket(allocator, cr); 637 defer allocator.free(reply); 638 try std.testing.expectEqual(case.expect, reply[0]); 639 } 640 641 // now break the connection: build any encrypted packet, flip one bit of 642 // its trailing Poly1305 tag, and ship the corrupted bytes. server must 643 // surface MacVerificationFailed out of handleConnection. 644 var req: std.ArrayList(u8) = .empty; 645 defer req.deinit(allocator); 646 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 647 try proto.writeU32(&req, allocator, 0); 648 try proto.writeStringField(&req, allocator, "shell"); 649 try req.append(allocator, 0); 650 651 var tmp_buf: [512]u8 = undefined; 652 var sink = std.Io.Writer.fixed(&tmp_buf); 653 try cs.writePacket(io, allocator, &sink, req.items); 654 const written = sink.buffered(); 655 tmp_buf[written.len - 1] ^= 0x01; 656 try cw.writeAll(tmp_buf[0..written.len]); 657 try cw.flush(); 658 659 server_future.await(io); 660 try std.testing.expectEqual(@as(?anyerror, error.MacVerificationFailed), server.result); 661 } 662 663 const client_version = "SSH-2.0-haxy_test"; 664 665 // in-memory bidirectional pipe — drives the server side of handleConnection 666 // without a socket. one Pipe carries bytes in one direction. 667 668 const Pipe = std.Io.Queue(u8); 669 670 const PipeReader = struct { 671 interface: std.Io.Reader, 672 io: std.Io, 673 pipe: *Pipe, 674 675 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeReader { 676 return .{ 677 .interface = .{ 678 .vtable = &.{ .readVec = readVec, .stream = stream }, 679 .buffer = buf, 680 .seek = 0, 681 .end = 0, 682 }, 683 .io = io, 684 .pipe = pipe, 685 }; 686 } 687 688 fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { 689 // tests don't drive any code path that calls stream, but the vtable 690 // requires a non-default impl. read into a small staging buffer and 691 // forward, so the implementation is correct if it ever fires. 692 const self: *PipeReader = @fieldParentPtr("interface", r); 693 var stage: [256]u8 = undefined; 694 const cap = limit.minInt(stage.len); 695 if (cap == 0) return 0; 696 const n = self.pipe.get(self.io, stage[0..cap], 1) catch |err| switch (err) { 697 error.Closed => return error.EndOfStream, 698 error.Canceled => return error.ReadFailed, 699 }; 700 return try w.write(stage[0..n]); 701 } 702 703 fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { 704 const self: *PipeReader = @fieldParentPtr("interface", r); 705 // vtable contract: if data[0] is empty, fill r.buffer[r.end..] instead. 706 const dest = if (data.len == 0 or data[0].len == 0) r.buffer[r.end..] else data[0]; 707 if (dest.len == 0) return 0; 708 const n = self.pipe.get(self.io, dest, 1) catch |err| switch (err) { 709 error.Closed => return error.EndOfStream, 710 error.Canceled => return error.ReadFailed, 711 }; 712 if (data.len == 0 or data[0].len == 0) { 713 r.end += n; 714 return 0; 715 } 716 return n; 717 } 718 }; 719 720 const PipeWriter = struct { 721 interface: std.Io.Writer, 722 io: std.Io, 723 pipe: *Pipe, 724 725 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeWriter { 726 return .{ 727 .interface = .{ 728 .vtable = &.{ .drain = drain }, 729 .buffer = buf, 730 }, 731 .io = io, 732 .pipe = pipe, 733 }; 734 } 735 736 fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 737 const self: *PipeWriter = @fieldParentPtr("interface", w); 738 const buffered = w.buffered(); 739 if (buffered.len > 0) self.pipe.putAll(self.io, buffered) catch return error.WriteFailed; 740 741 var extra: usize = 0; 742 if (data.len > 1) { 743 for (data[0 .. data.len - 1]) |chunk| { 744 if (chunk.len == 0) continue; 745 self.pipe.putAll(self.io, chunk) catch return error.WriteFailed; 746 extra += chunk.len; 747 } 748 } 749 if (data.len > 0) { 750 const pattern = data[data.len - 1]; 751 if (pattern.len > 0) { 752 var i: usize = 0; 753 while (i < splat) : (i += 1) { 754 self.pipe.putAll(self.io, pattern) catch return error.WriteFailed; 755 extra += pattern.len; 756 } 757 } 758 } 759 return w.consume(buffered.len + extra); 760 } 761 }; 762 763 // runs proto.handleConnection in a concurrent task (spawned via std.Io.concurrent). 764 // also acts as the session handler — captures the authenticated key's 765 // fingerprint and returns immediately, leaving runChannelLayer's wrap-up to 766 // send exit-status / EOF / CLOSE. records any error so the test can assert 767 // on it after awaiting the future. 768 const ServerTask = struct { 769 io: std.Io, 770 allocator: std.mem.Allocator, 771 host_key: *const proto.HostKey, 772 reader: *std.Io.Reader, 773 writer: *std.Io.Writer, 774 outgoing: *Pipe, // closed on exit so the client's reads return EOF 775 captured_fp: [proto.fingerprint_len]u8 = undefined, 776 captured_exec_buf: [256]u8 = undefined, 777 captured_exec_len: usize = 0, 778 result: ?anyerror = null, 779 780 pub fn handleSession(self: *ServerTask, sess: *proto.SessionCtx, request: proto.Request) !void { 781 @memcpy(&self.captured_fp, &sess.fingerprint); 782 switch (request) { 783 .exec => |cmd| { 784 // copy out of the protocol's stack-scoped buffer so the 785 // test can assert on it after the handler returns. 786 @memcpy(self.captured_exec_buf[0..cmd.len], cmd); 787 self.captured_exec_len = cmd.len; 788 }, 789 .shell => {}, 790 } 791 } 792 793 fn run(self: *ServerTask) void { 794 defer self.outgoing.close(self.io); 795 proto.handleConnection( 796 self.io, 797 self.allocator, 798 self.reader, 799 self.writer, 800 self.host_key, 801 self, 802 ) catch |e| { 803 self.result = e; 804 }; 805 } 806 }; 807 808 // deterministic keys so failures don't depend on RNG state. 809 810 fn newHostKey() !proto.HostKey { 811 const seed = [_]u8{0xAA} ** Ed25519.KeyPair.seed_length; 812 return .{ .keypair = try Ed25519.KeyPair.generateDeterministic(seed) }; 813 } 814 815 fn newUserKeypair() !Ed25519.KeyPair { 816 const seed = [_]u8{0xBB} ** Ed25519.KeyPair.seed_length; 817 return try Ed25519.KeyPair.generateDeterministic(seed); 818 } 819 820 fn newClientEphemeral() !X25519.KeyPair { 821 const seed = [_]u8{0xCC} ** X25519.seed_length; 822 return try X25519.KeyPair.generateDeterministic(seed); 823 } 824