╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭ find file ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ ╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │ │ │http│ ssh │ │ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
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