╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────╮ │src/test/ssh.zig│ ╰────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 86 + │ │ 87 +test "SSH-2 negotiation: walk through every step (banner -> KEX -> auth -> channel -> exec)" { │ │ 88 + const io = std.testing.io; │ │ 89 + const allocator = std.testing.allocator; │ │ 90 + │ │ 91 + const host_key = try newHostKey(); │ │ 92 + const user_kp = try newUserKeypair(); │ │ 93 + │ │ 94 + // c2s = bytes the client sends, server reads. s2c = the other direction. │ │ 95 + var c2s_buf: [4096]u8 = undefined; │ │ 96 + var c2s = Pipe.init(&c2s_buf); │ │ 97 + var s2c_buf: [4096]u8 = undefined; │ │ 98 + var s2c = Pipe.init(&s2c_buf); │ │ 99 + │ │100 + var server_rd_buf: [4096]u8 = undefined; │ │101 + var server_wr_buf: [4096]u8 = undefined; │ │102 + var client_rd_buf: [4096]u8 = undefined; │ │103 + var client_wr_buf: [4096]u8 = undefined; │ │104 + var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); │ │105 + var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); │ │106 + var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); │ │107 + var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); │ │108 + const cr = &client_pipe_reader.interface; │ │109 + const cw = &client_pipe_writer.interface; │ │110 + │ │111 + var server: ServerTask = .{ │ │112 + .io = io, │ │113 + .allocator = allocator, │ │114 + .host_key = &host_key, │ │115 + .reader = &server_pipe_reader.interface, │ │116 + .writer = &server_pipe_writer.interface, │ │117 + .outgoing = &s2c, │ │118 + }; │ │119 + var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); │ │120 + defer server_future.await(io); │ │121 + defer c2s.close(io); // unblocks the server even on early test failure │ │122 + │ │123 + // STEP 1: VERSION EXCHANGE (RFC 4253 §4.2) │ │124 + // Each side writes one "SSH-protoversion-softwareversion\r\n" line. │ │125 + // The exact strings get hashed into the exchange hash later, so they │ │126 + // bind the session's identity. │ │127 + try cw.writeAll(client_version ++ "\r\n"); │ │128 + try cw.flush(); │ │129 + const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; │ │130 + 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; │ │131 + try std.testing.expect(std.mem.startsWith(u8, v_s, "SSH-2.0-haxy_")); │ │132 + const v_c = client_version; │ │133 + │ │134 + // STEP 2: ALGORITHM NEGOTIATION (RFC 4253 §7.1) │ │135 + // Both sides send SSH_MSG_KEXINIT listing supported algorithms, in │ │136 + // preference order, for: key exchange, host-key signature, c->s and │ │137 + // s->c ciphers, c->s and s->c MACs (none with chacha20-poly1305 since │ │138 + // it's an AEAD), c->s and s->c compression, plus languages. The first │ │139 + // algorithm in each slot that the *client* prefers and the server also │ │140 + // offers is selected. Both raw KEXINITs are hashed into the exchange │ │141 + // hash so tampering is caught at signature verification time. │ │142 + const client_kex_init = try proto.buildKexInit(io, allocator, &proto.our_kex_algos, &proto.our_host_key_algos, &proto.our_ciphers); │ │143 + defer allocator.free(client_kex_init); │ │144 + try proto.writePlainPacket(io, cw, client_kex_init); │ │145 + │ │146 + const server_kex_init = try proto.readPlainPacket(allocator, cr); │ │147 + defer allocator.free(server_kex_init); │ │148 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEXINIT), server_kex_init[0]); │ │149 + │ │150 + // STEP 3: ECDH KEY EXCHANGE (RFC 8731) │ │151 + // Client generates a fresh X25519 keypair, sends its public ephemeral │ │152 + // q_c in SSH_MSG_KEX_ECDH_INIT. Server: │ │153 + // 1. generates its own ephemeral │ │154 + // 2. computes K = X25519(server_priv, q_c) — shared secret │ │155 + // 3. computes H = SHA256(V_C || V_S || I_C || I_S || K_S || q_c || q_s || K) │ │156 + // (each "||" element is length-prefixed; K is an mpint) │ │157 + // 4. signs H with its long-term host key │ │158 + // 5. replies KEX_ECDH_REPLY with K_S (host key blob), q_s, signature │ │159 + // │ │160 + // Client verifies the signature against K_S — MITM defense. │ │161 + const client_eph = try newClientEphemeral(); │ │162 + { │ │163 + var pkt: std.ArrayList(u8) = .empty; │ │164 + defer pkt.deinit(allocator); │ │165 + try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); │ │166 + try proto.writeStringField(&pkt, allocator, &client_eph.public_key); │ │167 + try proto.writePlainPacket(io, cw, pkt.items); │ │168 + } │ │169 + const ecdh_reply = try proto.readPlainPacket(allocator, cr); │ │170 + defer allocator.free(ecdh_reply); │ │171 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEX_ECDH_REPLY), ecdh_reply[0]); │ │172 + │ │173 + var server_eph_pub: [X25519.public_length]u8 = undefined; │ │174 + var k_s: []u8 = undefined; │ │175 + var sig_blob: []u8 = undefined; │ │176 + { │ │177 + var r = std.Io.Reader.fixed(ecdh_reply[1..]); │ │178 + k_s = try proto.takeStringField(allocator, &r, 4096); │ │179 + errdefer allocator.free(k_s); │ │180 + const q_s = try proto.takeStringField(allocator, &r, X25519.public_length); │ │181 + defer allocator.free(q_s); │ │182 + try std.testing.expectEqual(X25519.public_length, q_s.len); │ │183 + @memcpy(&server_eph_pub, q_s); │ │184 + sig_blob = try proto.takeStringField(allocator, &r, 1024); │ │185 + } │ │186 + defer allocator.free(k_s); │ │187 + defer allocator.free(sig_blob); │ │188 + │ │189 + // K_S must be the host's ed25519 pubkey we configured. │ │190 + var expected_k_s: std.ArrayList(u8) = .empty; │ │191 + defer expected_k_s.deinit(allocator); │ │192 + try host_key.appendPublicBlob(&expected_k_s, allocator); │ │193 + try std.testing.expectEqualSlices(u8, expected_k_s.items, k_s); │ │194 + │ │195 + // K = X25519(client_priv, q_s) — same scalarmult on the other side. │ │196 + const k = try X25519.scalarmult(client_eph.secret_key, server_eph_pub); │ │197 + │ │198 + // Recompute H independently. If the server lied about any input the │ │199 + // signature won't verify against the trusted host key. │ │200 + 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); │ │201 + │ │202 + // Verify the host signature on H. │ │203 + { │ │204 + var r = std.Io.Reader.fixed(sig_blob); │ │205 + const algo = try proto.takeStringField(allocator, &r, 64); │ │206 + defer allocator.free(algo); │ │207 + try std.testing.expectEqualStrings("ssh-ed25519", algo); │ │208 + const raw_sig = try proto.takeStringField(allocator, &r, Ed25519.Signature.encoded_length); │ │209 + defer allocator.free(raw_sig); │ │210 + var sig_bytes: [Ed25519.Signature.encoded_length]u8 = undefined; │ │211 + @memcpy(&sig_bytes, raw_sig); │ │212 + const sig = Ed25519.Signature.fromBytes(sig_bytes); │ │213 + try sig.verify(&h, host_key.keypair.public_key); │ │214 + } │ │215 + │ │216 + // STEP 4: NEWKEYS — switch to encrypted mode (RFC 4253 §7.3) │ │217 + // Each side sends a one-byte SSH_MSG_NEWKEYS and starts encrypting │ │218 + // from the next packet on. chacha20-poly1305@openssh.com needs 64 │ │219 + // bytes per direction (32 for the body cipher, 32 for the length │ │220 + // cipher); only the 'C' (c->s) and 'D' (s->c) RFC 4253 §7.2 outputs │ │221 + // are used since the AEAD covers MAC. │ │222 + try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); │ │223 + { │ │224 + const server_newkeys = try proto.readPlainPacket(allocator, cr); │ │225 + defer allocator.free(server_newkeys); │ │226 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); │ │227 + } │ │228 + │ │229 + var keys: proto.SessionKeys = undefined; │ │230 + try proto.deriveSessionKeys(allocator, &k, &h, &h, &keys); │ │231 + // KEXINIT/KEX_ECDH_INIT/NEWKEYS were seqno 0/1/2; first encrypted is 3. │ │232 + var cs_cipher = proto.Cipher.init(&keys.cs_enc, 3); │ │233 + var sc_cipher = proto.Cipher.init(&keys.sc_enc, 3); │ │234 + │ │235 + // STEP 5: SERVICE REQUEST (RFC 4253 §10) │ │236 + // First encrypted message: client asks for the "ssh-userauth" service. │ │237 + // Server replies SERVICE_ACCEPT. ("ssh-connection" comes after auth.) │ │238 + { │ │239 + var req: std.ArrayList(u8) = .empty; │ │240 + defer req.deinit(allocator); │ │241 + try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); │ │242 + try proto.writeStringField(&req, allocator, "ssh-userauth"); │ │243 + try cs_cipher.writePacket(io, allocator, cw, req.items); │ │244 + } │ │245 + { │ │246 + const accept = try sc_cipher.readPacket(allocator, cr); │ │247 + defer allocator.free(accept); │ │248 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); │ │249 + } │ │250 + │ │251 + // STEP 6: PUBLIC-KEY USERAUTH — PROBE (RFC 4252 §7) │ │252 + // Two-trip dance. Round 1: USERAUTH_REQUEST with method=publickey and │ │253 + // has_signature=false, carrying just the algo + public key blob. The │ │254 + // server says USERAUTH_PK_OK if it would accept that key (haxy │ │255 + // accepts any ed25519) or USERAUTH_FAILURE if not — so the client │ │256 + // doesn't waste a signature on a rejected key. │ │257 + var user_pubkey_blob: std.ArrayList(u8) = .empty; │ │258 + defer user_pubkey_blob.deinit(allocator); │ │259 + try proto.writeStringField(&user_pubkey_blob, allocator, "ssh-ed25519"); │ │260 + try proto.writeStringField(&user_pubkey_blob, allocator, &user_kp.public_key.bytes); │ │261 │ │162 -fn appendU32(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), value: u32) !void { │ │163 - var bytes: [4]u8 = undefined; │ │164 - std.mem.writeInt(u32, &bytes, value, .big); │ │165 - try buf.appendSlice(allocator, &bytes); │ │262 + { │ │263 + var req: std.ArrayList(u8) = .empty; │ │264 + defer req.deinit(allocator); │ │265 + try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); │ │266 + try proto.writeStringField(&req, allocator, "testuser"); │ │267 + try proto.writeStringField(&req, allocator, "ssh-connection"); │ │268 + try proto.writeStringField(&req, allocator, "publickey"); │ │269 + try req.append(allocator, 0); // has_signature = false │ │270 + try proto.writeStringField(&req, allocator, "ssh-ed25519"); │ │271 + try proto.writeStringField(&req, allocator, user_pubkey_blob.items); │ │272 + try cs_cipher.writePacket(io, allocator, cw, req.items); │ │273 + } │ │274 + { │ │275 + const pk_ok = try sc_cipher.readPacket(allocator, cr); │ │276 + defer allocator.free(pk_ok); │ │277 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); │ │278 + } │ │279 + │ │280 + // STEP 7: PUBLIC-KEY USERAUTH — SIGNED REQUEST │ │281 + // Round 2: same USERAUTH_REQUEST, now with has_signature=true and an │ │282 + // ed25519 signature over the canonical signed-data block — see │ │283 + // proto.appendPublickeySignedData for the exact byte layout. The │ │284 + // block includes session_id (== H of the first KEX), so signatures │ │285 + // can't be replayed across sessions. │ │286 + var signed: std.ArrayList(u8) = .empty; │ │287 + defer signed.deinit(allocator); │ │288 + try proto.appendPublickeySignedData(&signed, allocator, &h, "testuser", "ssh-connection", "ssh-ed25519", user_pubkey_blob.items); │ │289 + const sig = try user_kp.sign(signed.items, null); │ │290 + const sig_bytes = sig.toBytes(); │ │291 + │ │292 + var sig_wire: std.ArrayList(u8) = .empty; │ │293 + defer sig_wire.deinit(allocator); │ │294 + try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); │ │295 + try proto.writeStringField(&sig_wire, allocator, &sig_bytes); │ │296 + │ │297 + { │ │298 + var req: std.ArrayList(u8) = .empty; │ │299 + defer req.deinit(allocator); │ │300 + try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); │ │301 + try proto.writeStringField(&req, allocator, "testuser"); │ │302 + try proto.writeStringField(&req, allocator, "ssh-connection"); │ │303 + try proto.writeStringField(&req, allocator, "publickey"); │ │304 + try req.append(allocator, 1); │ │305 + try proto.writeStringField(&req, allocator, "ssh-ed25519"); │ │306 + try proto.writeStringField(&req, allocator, user_pubkey_blob.items); │ │307 + try proto.writeStringField(&req, allocator, sig_wire.items); │ │308 + try cs_cipher.writePacket(io, allocator, cw, req.items); │ │309 + } │ │310 + { │ │311 + const success = try sc_cipher.readPacket(allocator, cr); │ │312 + defer allocator.free(success); │ │313 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_SUCCESS), success[0]); │ │314 + } │ │315 + │ │316 + // STEP 8: CHANNEL_OPEN (RFC 4254 §5.1) │ │317 + // Open a "session" channel — SSH's container for shell/exec/subsystem. │ │318 + // Client picks its own channel id and announces an initial receive │ │319 + // window + max-packet size; server replies CHANNEL_OPEN_CONFIRMATION │ │320 + // with its own channel id and c->s limits. Each side identifies the │ │321 + // channel by *the other's* id when sending. │ │322 + // │ │323 + // haxy supports exactly one channel per connection — a second │ │324 + // CHANNEL_OPEN here gets CHANNEL_OPEN_FAILURE (see the failure tests). │ │325 + const client_channel_id: u32 = 42; │ │326 + { │ │327 + var req: std.ArrayList(u8) = .empty; │ │328 + defer req.deinit(allocator); │ │329 + try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); │ │330 + try proto.writeStringField(&req, allocator, "session"); │ │331 + try proto.writeU32(&req, allocator, client_channel_id); │ │332 + try proto.writeU32(&req, allocator, 1 << 20); // initial window │ │333 + try proto.writeU32(&req, allocator, 32768); // max packet │ │334 + try cs_cipher.writePacket(io, allocator, cw, req.items); │ │335 + } │ │336 + var server_channel_id: u32 = undefined; │ │337 + { │ │338 + const conf = try sc_cipher.readPacket(allocator, cr); │ │339 + defer allocator.free(conf); │ │340 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION), conf[0]); │ │341 + var r = std.Io.Reader.fixed(conf[1..]); │ │342 + const recipient = try r.takeInt(u32, .big); │ │343 + try std.testing.expectEqual(client_channel_id, recipient); │ │344 + server_channel_id = try r.takeInt(u32, .big); │ │345 + } │ │346 + │ │347 + // STEP 9: CHANNEL_REQUEST exec (RFC 4254 §6.5) │ │348 + // Ask the server to run a command on this channel. Packet is │ │349 + // addressed to the *server's* channel id. With want_reply=true the │ │350 + // server must respond CHANNEL_SUCCESS or CHANNEL_FAILURE before any │ │351 + // CHANNEL_DATA flows. │ │352 + const exec_command = "git-upload-pack 'demo-repo'"; │ │353 + { │ │354 + var req: std.ArrayList(u8) = .empty; │ │355 + defer req.deinit(allocator); │ │356 + try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); │ │357 + try proto.writeU32(&req, allocator, server_channel_id); │ │358 + try proto.writeStringField(&req, allocator, "exec"); │ │359 + try req.append(allocator, 1); // want_reply │ │360 + try proto.writeStringField(&req, allocator, exec_command); │ │361 + try cs_cipher.writePacket(io, allocator, cw, req.items); │ │362 + } │ │363 + { │ │364 + const success = try sc_cipher.readPacket(allocator, cr); │ │365 + defer allocator.free(success); │ │366 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_SUCCESS), success[0]); │ │367 + } │ │368 + │ │369 + // STEP 10: HANDLER RUNS, CHANNEL TEARDOWN │ │370 + // The server is now in handleSession. Our recording handler returns │ │371 + // immediately; runChannelLayer's wrap-up then sends: │ │372 + // - CHANNEL_REQUEST "exit-status" (status=0, want_reply=false) │ │373 + // - CHANNEL_EOF │ │374 + // - CHANNEL_CLOSE │ │375 + // …and waits for our CHANNEL_CLOSE in reply (so the underlying socket │ │376 + // isn't closed-with-unread-data, which would surface as TCP RST). │ │377 + var saw_exit_status = false; │ │378 + var saw_server_close = false; │ │379 + while (!saw_server_close) { │ │380 + const pkt = try sc_cipher.readPacket(allocator, cr); │ │381 + defer allocator.free(pkt); │ │382 + switch (pkt[0]) { │ │383 + proto.SSH_MSG_CHANNEL_REQUEST => { │ │384 + var r = std.Io.Reader.fixed(pkt[1..]); │ │385 + _ = try r.takeInt(u32, .big); │ │386 + const req_type = try proto.takeStringField(allocator, &r, 64); │ │387 + defer allocator.free(req_type); │ │388 + if (std.mem.eql(u8, req_type, "exit-status")) { │ │389 + _ = try r.takeByte(); │ │390 + const status = try r.takeInt(u32, .big); │ │391 + try std.testing.expectEqual(@as(u32, 0), status); │ │392 + saw_exit_status = true; │ │393 + } │ │394 + }, │ │395 + proto.SSH_MSG_CHANNEL_EOF => {}, │ │396 + proto.SSH_MSG_CHANNEL_CLOSE => saw_server_close = true, │ │397 + proto.SSH_MSG_CHANNEL_WINDOW_ADJUST => {}, │ │398 + else => {}, │ │399 + } │ │400 + } │ │401 + try std.testing.expect(saw_exit_status); │ │402 + │ │403 + { │ │404 + var pkt: std.ArrayList(u8) = .empty; │ │405 + defer pkt.deinit(allocator); │ │406 + try pkt.append(allocator, proto.SSH_MSG_CHANNEL_CLOSE); │ │407 + try proto.writeU32(&pkt, allocator, server_channel_id); │ │408 + try cs_cipher.writePacket(io, allocator, cw, pkt.items); │ │409 + } │ │410 + │ │411 + server_future.await(io); │ │412 + try std.testing.expect(server.result == null); │ │413 + │ │414 + // FINAL ASSERTIONS │ │415 + // the exec command we sent on the wire should have reached the handler │ │416 + // verbatim, and the server should have recorded our pubkey's SHA256 │ │417 + // fingerprint as the authenticated identity for this session. │ │418 + try std.testing.expectEqualStrings(exec_command, server.captured_exec_buf[0..server.captured_exec_len]); │ │419 + const expected_fp = proto.formatFingerprint(user_pubkey_blob.items); │ │420 + try std.testing.expectEqualSlices(u8, &expected_fp, &server.captured_fp); │ │421 +} │ │422 + │ │423 +// failure modes against a single live session, in increasing severity: │ │424 +// - publickey signed-data binding (session_id and user_name) — server │ │425 +// replies USERAUTH_FAILURE, connection continues │ │426 +// - second CHANNEL_OPEN on the same connection — server replies │ │427 +// CHANNEL_OPEN_FAILURE, connection continues │ │428 +// - MAC-tampered encrypted packet — server bails out of handleConnection │ │429 +// with MacVerificationFailed (terminal) │ │430 +// running all of these against one negotiated session avoids paying the │ │431 +// KEX cost per failure case and keeps the failure tests visually together. │ │432 +test "auth + channel layer failure modes" { │ │433 + const io = std.testing.io; │ │434 + const allocator = std.testing.allocator; │ │435 + │ │436 + const host_key = try newHostKey(); │ │437 + │ │438 + var c2s_buf: [4096]u8 = undefined; │ │439 + var c2s = Pipe.init(&c2s_buf); │ │440 + var s2c_buf: [4096]u8 = undefined; │ │441 + var s2c = Pipe.init(&s2c_buf); │ │442 + │ │443 + var server_rd_buf: [4096]u8 = undefined; │ │444 + var server_wr_buf: [4096]u8 = undefined; │ │445 + var client_rd_buf: [4096]u8 = undefined; │ │446 + var client_wr_buf: [4096]u8 = undefined; │ │447 + var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); │ │448 + var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); │ │449 + var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); │ │450 + var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); │ │451 + const cr = &client_pipe_reader.interface; │ │452 + const cw = &client_pipe_writer.interface; │ │453 + │ │454 + var server: ServerTask = .{ │ │455 + .io = io, │ │456 + .allocator = allocator, │ │457 + .host_key = &host_key, │ │458 + .reader = &server_pipe_reader.interface, │ │459 + .writer = &server_pipe_writer.interface, │ │460 + .outgoing = &s2c, │ │461 + }; │ │462 + var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); │ │463 + defer server_future.await(io); │ │464 + defer c2s.close(io); │ │465 + │ │466 + // bring the connection up to a post-NEWKEYS, ready-to-encrypt state: │ │467 + // banner exchange, KEXINIT exchange, ECDH, NEWKEYS, key derivation. │ │468 + var cs: proto.Cipher = undefined; │ │469 + var sc: proto.Cipher = undefined; │ │470 + var session_id: [Sha256.digest_length]u8 = undefined; │ │471 + { │ │472 + try cw.writeAll(client_version ++ "\r\n"); │ │473 + try cw.flush(); │ │474 + const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; │ │475 + 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; │ │476 + const v_s = try allocator.dupe(u8, v_s_trimmed); │ │477 + defer allocator.free(v_s); │ │478 + │ │479 + const ckex = try proto.buildKexInit(io, allocator, &proto.our_kex_algos, &proto.our_host_key_algos, &proto.our_ciphers); │ │480 + defer allocator.free(ckex); │ │481 + try proto.writePlainPacket(io, cw, ckex); │ │482 + const skex = try proto.readPlainPacket(allocator, cr); │ │483 + defer allocator.free(skex); │ │484 + │ │485 + const eph = try newClientEphemeral(); │ │486 + { │ │487 + var pkt: std.ArrayList(u8) = .empty; │ │488 + defer pkt.deinit(allocator); │ │489 + try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); │ │490 + try proto.writeStringField(&pkt, allocator, &eph.public_key); │ │491 + try proto.writePlainPacket(io, cw, pkt.items); │ │492 + } │ │493 + const reply = try proto.readPlainPacket(allocator, cr); │ │494 + defer allocator.free(reply); │ │495 + var rr = std.Io.Reader.fixed(reply[1..]); │ │496 + const k_s = try proto.takeStringField(allocator, &rr, 4096); │ │497 + defer allocator.free(k_s); │ │498 + const q_s = try proto.takeStringField(allocator, &rr, X25519.public_length); │ │499 + defer allocator.free(q_s); │ │500 + var server_pub: [X25519.public_length]u8 = undefined; │ │501 + @memcpy(&server_pub, q_s); │ │502 + const sig_blob = try proto.takeStringField(allocator, &rr, 1024); │ │503 + defer allocator.free(sig_blob); │ │504 + const k = try X25519.scalarmult(eph.secret_key, server_pub); │ │505 + const exchange = try proto.computeExchangeHash(allocator, client_version, v_s, ckex, skex, k_s, &eph.public_key, &server_pub, &k); │ │506 + │ │507 + try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); │ │508 + { │ │509 + const server_newkeys = try proto.readPlainPacket(allocator, cr); │ │510 + defer allocator.free(server_newkeys); │ │511 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); │ │512 + } │ │513 + │ │514 + var keys: proto.SessionKeys = undefined; │ │515 + try proto.deriveSessionKeys(allocator, &k, &exchange, &exchange, &keys); │ │516 + cs = proto.Cipher.init(&keys.cs_enc, 3); │ │517 + sc = proto.Cipher.init(&keys.sc_enc, 3); │ │518 + session_id = exchange; │ │519 + } │ │520 + │ │521 + // SERVICE_REQUEST → SERVICE_ACCEPT, then USERAUTH_REQUEST publickey │ │522 + // probe → PK_OK, then three signed USERAUTH_REQUEST attempts that │ │523 + // exercise the cryptographic-binding properties of publickey auth: │ │524 + // 1. signed with a different session_id → USERAUTH_FAILURE │ │525 + // (replay-across-sessions defense — H is bound into the signature) │ │526 + // 2. signed with one user_name but the request carries another → │ │527 + // USERAUTH_FAILURE (user_name is bound into the signature) │ │528 + // 3. legit (correct session_id and user_name) → USERAUTH_SUCCESS │ │529 + // each USERAUTH_FAILURE is non-terminal: the server's auth loop just │ │530 + // increments attempts and waits for the next request. │ │531 + const user_kp = try newUserKeypair(); │ │532 + { │ │533 + { │ │534 + var req: std.ArrayList(u8) = .empty; │ │535 + defer req.deinit(allocator); │ │536 + try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); │ │537 + try proto.writeStringField(&req, allocator, "ssh-userauth"); │ │538 + try cs.writePacket(io, allocator, cw, req.items); │ │539 + } │ │540 + { │ │541 + const accept = try sc.readPacket(allocator, cr); │ │542 + defer allocator.free(accept); │ │543 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); │ │544 + var r = std.Io.Reader.fixed(accept[1..]); │ │545 + const service = try proto.takeStringField(allocator, &r, 64); │ │546 + defer allocator.free(service); │ │547 + try std.testing.expectEqualStrings("ssh-userauth", service); │ │548 + } │ │549 + │ │550 + var pubkey_blob: std.ArrayList(u8) = .empty; │ │551 + defer pubkey_blob.deinit(allocator); │ │552 + try proto.writeStringField(&pubkey_blob, allocator, "ssh-ed25519"); │ │553 + try proto.writeStringField(&pubkey_blob, allocator, &user_kp.public_key.bytes); │ │554 + │ │555 + { │ │556 + var req: std.ArrayList(u8) = .empty; │ │557 + defer req.deinit(allocator); │ │558 + try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); │ │559 + try proto.writeStringField(&req, allocator, "testuser"); │ │560 + try proto.writeStringField(&req, allocator, "ssh-connection"); │ │561 + try proto.writeStringField(&req, allocator, "publickey"); │ │562 + try req.append(allocator, 0); │ │563 + try proto.writeStringField(&req, allocator, "ssh-ed25519"); │ │564 + try proto.writeStringField(&req, allocator, pubkey_blob.items); │ │565 + try cs.writePacket(io, allocator, cw, req.items); │ │566 + } │ │567 + { │ │568 + const pk_ok = try sc.readPacket(allocator, cr); │ │569 + defer allocator.free(pk_ok); │ │570 + try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); │ │571 + var r = std.Io.Reader.fixed(pk_ok[1..]); │ │572 + const algo = try proto.takeStringField(allocator, &r, 64); │ │573 + defer allocator.free(algo); │ │574 + try std.testing.expectEqualStrings("ssh-ed25519", algo); │ │575 + const accepted_blob = try proto.takeStringField(allocator, &r, 4096); │ │576 + defer allocator.free(accepted_blob); │ │577 + try std.testing.expectEqualSlices(u8, pubkey_blob.items, accepted_blob); │ │578 + } │ │579 + │ │580 + const wrong_session = [_]u8{0xFF} ** Sha256.digest_length; │ │581 + for ([_]struct { │ │582 + sign_session: []const u8, │ │583 + sign_user: []const u8, │ │584 + send_user: []const u8, │ │585 + expect: u8, │ │586 + }{ │ │587 + .{ .sign_session = &wrong_session, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, │ │588 + .{ .sign_session = &session_id, .sign_user = "alice", .send_user = "bob", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, │ │589 + .{ .sign_session = &session_id, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_SUCCESS }, │ │590 + }) |attempt| { │ │591 + var signed: std.ArrayList(u8) = .empty; │ │592 + defer signed.deinit(allocator); │ │593 + try proto.appendPublickeySignedData(&signed, allocator, attempt.sign_session, attempt.sign_user, "ssh-connection", "ssh-ed25519", pubkey_blob.items);│ │594 + const sig = try user_kp.sign(signed.items, null); │ │595 + const sig_bytes = sig.toBytes(); │ │596 + var sig_wire: std.ArrayList(u8) = .empty; │ │597 + defer sig_wire.deinit(allocator); │ │598 + try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); │ │599 + try proto.writeStringField(&sig_wire, allocator, &sig_bytes); │ │600 + │ │601 + var req: std.ArrayList(u8) = .empty; │ │602 + defer req.deinit(allocator); │ │603 + try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); │ │604 + try proto.writeStringField(&req, allocator, attempt.send_user); │ │605 + try proto.writeStringField(&req, allocator, "ssh-connection"); │ │606 + try proto.writeStringField(&req, allocator, "publickey"); │ │607 + try req.append(allocator, 1); │ │608 + try proto.writeStringField(&req, allocator, "ssh-ed25519"); │ │609 + try proto.writeStringField(&req, allocator, pubkey_blob.items); │ │610 + try proto.writeStringField(&req, allocator, sig_wire.items); │ │611 + try cs.writePacket(io, allocator, cw, req.items); │ │612 + │ │613 + const reply = try sc.readPacket(allocator, cr); │ │614 + defer allocator.free(reply); │ │615 + try std.testing.expectEqual(attempt.expect, reply[0]); │ │616 + } │ │617 + } │ │618 + │ │619 + // open two channels back to back: server confirms the first, then │ │620 + // refuses the second with OPEN_FAILURE and stays running. │ │621 + for ([_]struct { id: u32, expect: u8 }{ │ │622 + .{ .id = 7, .expect = proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION }, │ │623 + .{ .id = 8, .expect = proto.SSH_MSG_CHANNEL_OPEN_FAILURE }, │ │624 + }) |case| { │ │625 + var req: std.ArrayList(u8) = .empty; │ │626 + defer req.deinit(allocator); │ │627 + try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); │ │628 + try proto.writeStringField(&req, allocator, "session"); │ │629 + try proto.writeU32(&req, allocator, case.id); │ │630 + try proto.writeU32(&req, allocator, 1 << 20); │ │631 + try proto.writeU32(&req, allocator, 32768); │ │632 + try cs.writePacket(io, allocator, cw, req.items); │ │633 + │ │634 + const reply = try sc.readPacket(allocator, cr); │ │635 + defer allocator.free(reply); │ │636 + try std.testing.expectEqual(case.expect, reply[0]); │ │637 + } │ │638 + │ │639 + // now break the connection: build any encrypted packet, flip one bit of │ │640 + // its trailing Poly1305 tag, and ship the corrupted bytes. server must │ │641 + // surface MacVerificationFailed out of handleConnection. │ │642 + var req: std.ArrayList(u8) = .empty; │ │643 + defer req.deinit(allocator); │ │644 + try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); │ │645 + try proto.writeU32(&req, allocator, 0); │ │646 + try proto.writeStringField(&req, allocator, "shell"); │ │647 + try req.append(allocator, 0); │ │648 + │ │649 + var tmp_buf: [512]u8 = undefined; │ │650 + var sink = std.Io.Writer.fixed(&tmp_buf); │ │651 + try cs.writePacket(io, allocator, &sink, req.items); │ │652 + const written = sink.buffered(); │ │653 + tmp_buf[written.len - 1] ^= 0x01; │ │654 + try cw.writeAll(tmp_buf[0..written.len]); │ │655 + try cw.flush(); │ │656 + │ │657 + server_future.await(io); │ │658 + try std.testing.expectEqual(@as(?anyerror, error.MacVerificationFailed), server.result); │ │659 +} │ │660 + │ │661 +const client_version = "SSH-2.0-haxy_test"; │ │662 + │ │663 +// in-memory bidirectional pipe — drives the server side of handleConnection │ │664 +// without a socket. one Pipe carries bytes in one direction. │ │665 + │ │666 +const Pipe = std.Io.Queue(u8); │ │667 + │ │668 +const PipeReader = struct { │ │669 + interface: std.Io.Reader, │ │670 + io: std.Io, │ │671 + pipe: *Pipe, │ │672 + │ │673 + fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeReader { │ │674 + return .{ │ │675 + .interface = .{ │ │676 + .vtable = &.{ .readVec = readVec, .stream = stream }, │ │677 + .buffer = buf, │ │678 + .seek = 0, │ │679 + .end = 0, │ │680 + }, │ │681 + .io = io, │ │682 + .pipe = pipe, │ │683 + }; │ │684 + } │ │685 + │ │686 + fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { │ │687 + // tests don't drive any code path that calls stream, but the vtable │ │688 + // requires a non-default impl. read into a small staging buffer and │ │689 + // forward, so the implementation is correct if it ever fires. │ │690 + const self: *PipeReader = @fieldParentPtr("interface", r); │ │691 + var stage: [256]u8 = undefined; │ │692 + const cap = limit.minInt(stage.len); │ │693 + if (cap == 0) return 0; │ │694 + const n = self.pipe.get(self.io, stage[0..cap], 1) catch |err| switch (err) { │ │695 + error.Closed => return error.EndOfStream, │ │696 + error.Canceled => return error.ReadFailed, │ │697 + }; │ │698 + return try w.write(stage[0..n]); │ │699 + } │ │700 + │ │701 + fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { │ │702 + const self: *PipeReader = @fieldParentPtr("interface", r); │ │703 + // vtable contract: if data[0] is empty, fill r.buffer[r.end..] instead. │ │704 + const dest = if (data.len == 0 or data[0].len == 0) r.buffer[r.end..] else data[0]; │ │705 + if (dest.len == 0) return 0; │ │706 + const n = self.pipe.get(self.io, dest, 1) catch |err| switch (err) { │ │707 + error.Closed => return error.EndOfStream, │ │708 + error.Canceled => return error.ReadFailed, │ │709 + }; │ │710 + if (data.len == 0 or data[0].len == 0) { │ │711 + r.end += n; │ │712 + return 0; │ │713 + } │ │714 + return n; │ │715 + } │ │716 +}; │ │717 + │ │718 +const PipeWriter = struct { │ │719 + interface: std.Io.Writer, │ │720 + io: std.Io, │ │721 + pipe: *Pipe, │ │722 + │ │723 + fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeWriter { │ │724 + return .{ │ │725 + .interface = .{ │ │726 + .vtable = &.{ .drain = drain }, │ │727 + .buffer = buf, │ │728 + }, │ │729 + .io = io, │ │730 + .pipe = pipe, │ │731 + }; │ │732 + } │ │733 + │ │734 + fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { │ │735 + const self: *PipeWriter = @fieldParentPtr("interface", w); │ │736 + const buffered = w.buffered(); │ │737 + if (buffered.len > 0) self.pipe.putAll(self.io, buffered) catch return error.WriteFailed; │ │738 + │ │739 + var extra: usize = 0; │ │740 + if (data.len > 1) { │ │741 + for (data[0 .. data.len - 1]) |chunk| { │ │742 + if (chunk.len == 0) continue; │ │743 + self.pipe.putAll(self.io, chunk) catch return error.WriteFailed; │ │744 + extra += chunk.len; │ │745 + } │ │746 + } │ │747 + if (data.len > 0) { │ │748 + const pattern = data[data.len - 1]; │ │749 + if (pattern.len > 0) { │ │750 + var i: usize = 0; │ │751 + while (i < splat) : (i += 1) { │ │752 + self.pipe.putAll(self.io, pattern) catch return error.WriteFailed; │ │753 + extra += pattern.len; │ │754 + } │ │755 + } │ │756 + } │ │757 + return w.consume(buffered.len + extra); │ │758 + } │ │759 +}; │ │760 + │ │761 +// runs proto.handleConnection in a concurrent task (spawned via std.Io.concurrent). │ │762 +// also acts as the session handler — captures the authenticated key's │ │763 +// fingerprint and returns immediately, leaving runChannelLayer's wrap-up to │ │764 +// send exit-status / EOF / CLOSE. records any error so the test can assert │ │765 +// on it after awaiting the future. │ │766 +const ServerTask = struct { │ │767 + io: std.Io, │ │768 + allocator: std.mem.Allocator, │ │769 + host_key: *const proto.HostKey, │ │770 + reader: *std.Io.Reader, │ │771 + writer: *std.Io.Writer, │ │772 + outgoing: *Pipe, // closed on exit so the client's reads return EOF │ │773 + captured_fp: [proto.fingerprint_len]u8 = undefined, │ │774 + captured_exec_buf: [256]u8 = undefined, │ │775 + captured_exec_len: usize = 0, │ │776 + result: ?anyerror = null, │ │777 + │ │778 + pub fn handleSession(self: *ServerTask, sess: *proto.SessionCtx, request: proto.Request) !void { │ │779 + @memcpy(&self.captured_fp, &sess.fingerprint); │ │780 + switch (request) { │ │781 + .exec => |cmd| { │ │782 + // copy out of the protocol's stack-scoped buffer so the │ │783 + // test can assert on it after the handler returns. │ │784 + @memcpy(self.captured_exec_buf[0..cmd.len], cmd); │ │785 + self.captured_exec_len = cmd.len; │ │786 + }, │ │787 + .shell => {}, │ │788 + } │ │789 + } │ │790 + │ │791 + fn run(self: *ServerTask) void { │ │792 + defer self.outgoing.close(self.io); │ │793 + proto.handleConnection( │ │794 + self.io, │ │795 + self.allocator, │ │796 + self.reader, │ │797 + self.writer, │ │798 + self.host_key, │ │799 + self, │ │800 + ) catch |e| { │ │801 + self.result = e; │ │802 + }; │ │803 + } │ │804 +}; │ │805 + │ │806 +// deterministic keys so failures don't depend on RNG state. │ │807 + │ │808 +fn newHostKey() !proto.HostKey { │ │809 + const seed = [_]u8{0xAA} ** Ed25519.KeyPair.seed_length; │ │810 + return .{ .keypair = try Ed25519.KeyPair.generateDeterministic(seed) }; │ │811 } │ │812 │ │813 +fn newUserKeypair() !Ed25519.KeyPair { │ │814 + const seed = [_]u8{0xBB} ** Ed25519.KeyPair.seed_length; │ │815 + return try Ed25519.KeyPair.generateDeterministic(seed); │ │816 +} │ │817 + │ │818 +fn newClientEphemeral() !X25519.KeyPair { │ │819 + const seed = [_]u8{0xCC} ** X25519.seed_length; │ │820 + return try X25519.KeyPair.generateDeterministic(seed); │ │821 +} │ │822 + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯