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