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