╭ serve_ssh_protocol.zig ───────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 //! In-process SSH server: full server side of SSH-2 for haxy's use case. 2 //! 3 //! handles, per connection: version exchange, KEXINIT, curve25519 key 4 //! exchange, NEWKEYS, key derivation, chacha20-poly1305 packet codec, 5 //! strict kex (Terrapin mitigation), client-initiated rekeying, 6 //! ssh-userauth service request, publickey authentication (ed25519 only; 7 //! captures the SHA256 fingerprint of the verified key), channel-layer 8 //! requests (pty-req, env, window-change, shell, exec), CHANNEL_DATA 9 //! flow with sender/receiver windows, and clean teardown 10 //! (exit-status / EOF / CLOSE). 11 //! 12 //! public surface for haxy code: 13 //! handleConnection — call from the listener with a session handler. 14 //! SessionCtx — handed to the handler; carries the channel + ciphers, 15 //! the verified key's fingerprint, and an event / 16 //! writeBytes / exit API. 17 //! SessionReader / SessionWriter — std.Io adapters so consumers (xit's 18 //! pack code, StreamTerminal, etc.) can use ordinary 19 //! Reader/Writer interfaces. 20 //! Request — what the client asked for: shell+pty or exec command. 21 //! HostKey — generated or loaded once at startup. 22 //! 23 //! hardcoded choices to keep the implementation small: 24 //! KEX: curve25519-sha256 (RFC 8731) 25 //! host key: ssh-ed25519 (RFC 8709) 26 //! cipher: chacha20-poly1305@openssh.com (RFC 4253 + openssh extension) 27 //! MAC: none (implicit in AEAD) 28 //! compress: none 29 //! user auth: publickey + ssh-ed25519 only (RSA/ECDSA deferred) 30 31 const std = @import("std"); 32 const builtin = @import("builtin"); 33 const Ed25519 = std.crypto.sign.Ed25519; 34 const X25519 = std.crypto.dh.X25519; 35 const Sha256 = std.crypto.hash.sha2.Sha256; 36 const ChaCha20 = std.crypto.stream.chacha.ChaCha20With64BitNonce; 37 const Poly1305 = std.crypto.onetimeauth.Poly1305; 38 39 pub const server_version = "SSH-2.0-haxy_0.0"; 40 pub const host_key_file_name = "ssh_host_ed25519_key"; 41 42 const max_packet_len: u32 = 35000; // RFC 4253 §6.1 — minimum implementations must support 43 pub const packet_buffer_size = 4 + max_packet_len + Poly1305.mac_length; 44 const max_name_list_len: u32 = 4096; 45 const max_auth_attempts: u32 = 20; 46 const max_banner_lines: u32 = 32; 47 // terminal grids can have several live copies during layout and rendering 48 const max_pty_width: u32 = 512; 49 const max_pty_height: u32 = 128; 50 51 // SSH message type bytes (RFC 4250 §4.1) 52 pub const SSH_MSG_DISCONNECT: u8 = 1; 53 pub const SSH_MSG_IGNORE: u8 = 2; 54 pub const SSH_MSG_UNIMPLEMENTED: u8 = 3; 55 pub const SSH_MSG_DEBUG: u8 = 4; 56 pub const SSH_MSG_SERVICE_REQUEST: u8 = 5; 57 pub const SSH_MSG_SERVICE_ACCEPT: u8 = 6; 58 pub const SSH_MSG_KEXINIT: u8 = 20; 59 pub const SSH_MSG_NEWKEYS: u8 = 21; 60 pub const SSH_MSG_KEX_ECDH_INIT: u8 = 30; 61 pub const SSH_MSG_KEX_ECDH_REPLY: u8 = 31; 62 pub const SSH_MSG_USERAUTH_REQUEST: u8 = 50; 63 pub const SSH_MSG_USERAUTH_FAILURE: u8 = 51; 64 pub const SSH_MSG_USERAUTH_SUCCESS: u8 = 52; 65 pub const SSH_MSG_USERAUTH_PK_OK: u8 = 60; // method-specific name for publickey 66 pub const SSH_MSG_GLOBAL_REQUEST: u8 = 80; 67 pub const SSH_MSG_REQUEST_SUCCESS: u8 = 81; 68 pub const SSH_MSG_REQUEST_FAILURE: u8 = 82; 69 pub const SSH_MSG_CHANNEL_OPEN: u8 = 90; 70 pub const SSH_MSG_CHANNEL_OPEN_CONFIRMATION: u8 = 91; 71 pub const SSH_MSG_CHANNEL_OPEN_FAILURE: u8 = 92; 72 pub const SSH_MSG_CHANNEL_WINDOW_ADJUST: u8 = 93; 73 pub const SSH_MSG_CHANNEL_DATA: u8 = 94; 74 pub const SSH_MSG_CHANNEL_EXTENDED_DATA: u8 = 95; 75 pub const SSH_MSG_CHANNEL_EOF: u8 = 96; 76 pub const SSH_MSG_CHANNEL_CLOSE: u8 = 97; 77 pub const SSH_MSG_CHANNEL_REQUEST: u8 = 98; 78 pub const SSH_MSG_CHANNEL_SUCCESS: u8 = 99; 79 pub const SSH_MSG_CHANNEL_FAILURE: u8 = 100; 80 81 pub const SSH_DISCONNECT_PROTOCOL_ERROR: u32 = 2; 82 pub const SSH_DISCONNECT_MAC_ERROR: u32 = 5; 83 pub const SSH_DISCONNECT_BY_APPLICATION: u32 = 11; 84 85 // SSH_OPEN_* reason codes for CHANNEL_OPEN_FAILURE 86 pub const SSH_OPEN_RESOURCE_SHORTAGE: u32 = 4; 87 pub const SSH_OPEN_UNKNOWN_CHANNEL_TYPE: u32 = 3; 88 89 // data type code for CHANNEL_EXTENDED_DATA (stderr) 90 pub const SSH_EXTENDED_DATA_STDERR: u32 = 1; 91 92 // initial flow-control window we advertise. typical openssh setting; large 93 // enough that small interactive sessions never need a WINDOW_ADJUST. 94 const initial_recv_window: u32 = 1 << 20; 95 const max_packet_size: u32 = 32768; 96 // ceiling on unread CHANNEL_DATA held for the consumer. window credit is only 97 // ever restored up to what's left of this, so the peer can't send more than 98 // the buffer will take. 99 const max_incoming_buffered: u32 = initial_recv_window; 100 101 /// shared with the host's watchdog. `activity` is bumped for non-ignored 102 /// session packets and for packets we send, `waiting` is set while we're 103 /// blocked on the peer, `session_started` releases the hard pre-session 104 /// deadline, and `closing` starts the bounded close drain. `exempt` is set 105 /// once an interactive session starts, since users idle in a TUI legitimately. 106 pub const IdleState = struct { 107 activity: std.atomic.Value(u64) = .init(0), 108 waiting: std.atomic.Value(bool) = .init(false), 109 session_started: std.atomic.Value(bool) = .init(false), 110 exempt: std.atomic.Value(bool) = .init(false), 111 closing: std.atomic.Value(bool) = .init(false), 112 }; 113 114 // --------------------------------------------------------------------------- 115 // host key 116 // --------------------------------------------------------------------------- 117 118 pub const HostKey = struct { 119 keypair: Ed25519.KeyPair, 120 121 pub fn loadOrGenerate(io: std.Io, allocator: std.mem.Allocator, data_dir_path: []const u8) !HostKey { 122 const path = try std.fs.path.join(allocator, &.{ data_dir_path, host_key_file_name }); 123 defer allocator.free(path); 124 125 const cwd = std.Io.Dir.cwd(); 126 if (cwd.openFile(io, path, .{ .mode = .read_only })) |file| { 127 defer file.close(io); 128 var buf: [Ed25519.SecretKey.encoded_length]u8 = undefined; 129 var read_storage: [128]u8 = undefined; 130 var file_reader = file.reader(io, &read_storage); 131 try file_reader.interface.readSliceAll(&buf); 132 const sk = try Ed25519.SecretKey.fromBytes(buf); 133 const keypair = try Ed25519.KeyPair.fromSecretKey(sk); 134 return .{ .keypair = keypair }; 135 } else |err| switch (err) { 136 error.FileNotFound => {}, 137 else => return err, 138 } 139 140 const keypair = Ed25519.KeyPair.generate(io); 141 // owner-only from the start — never a window where the key is readable 142 const permissions: std.Io.File.Permissions = if (builtin.os.tag == .windows) .default_file else @enumFromInt(0o600); 143 const file = try cwd.createFile(io, path, .{ .permissions = permissions }); 144 defer file.close(io); 145 try file.writeStreamingAll(io, &keypair.secret_key.bytes); 146 return .{ .keypair = keypair }; 147 } 148 149 /// SSH wire-format ed25519 public key blob (used as K_S in the hash and 150 /// returned in KEX_ECDH_REPLY): string "ssh-ed25519" || string pubkey. 151 pub fn appendPublicBlob(self: HostKey, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void { 152 try writeStringField(buf, allocator, "ssh-ed25519"); 153 try writeStringField(buf, allocator, &self.keypair.public_key.bytes); 154 } 155 156 /// SSH wire-format signature blob: string "ssh-ed25519" || string sig. 157 fn appendSignatureBlob(self: HostKey, buf: *std.ArrayList(u8), allocator: std.mem.Allocator, message: []const u8) !void { 158 const sig = try self.keypair.sign(message, null); 159 const sig_bytes = sig.toBytes(); 160 try writeStringField(buf, allocator, "ssh-ed25519"); 161 try writeStringField(buf, allocator, &sig_bytes); 162 } 163 }; 164 165 // --------------------------------------------------------------------------- 166 // public session API 167 // --------------------------------------------------------------------------- 168 169 /// what the client wants to do on this channel. 170 pub const Request = union(enum) { 171 shell: ?PtySize, // pty info if a pty-req arrived before the shell 172 exec: Exec, 173 174 pub const Exec = struct { 175 command: []const u8, 176 /// the GIT_PROTOCOL env value, if one arrived before the exec 177 git_protocol: ?[]const u8, 178 }; 179 }; 180 181 /// next thing that arrived from the client. 182 pub const Event = union(enum) { 183 data: []u8, // CHANNEL_DATA payload — caller owns and must free via sess.conn.allocator 184 resize: PtySize, // window-change request 185 eof, // peer sent CHANNEL_EOF: no more input, but we may still write 186 close, // peer sent CHANNEL_CLOSE, or the transport hit EOF 187 }; 188 189 /// per-connection transport state: the byte stream, both ciphers, and what a 190 /// rekey needs. built once after the initial KEX and threaded through the 191 /// auth and channel layers. 192 pub const Conn = struct { 193 io: std.Io, 194 allocator: std.mem.Allocator, 195 reader: *std.Io.Reader, 196 writer: *std.Io.Writer, 197 cs_cipher: Cipher, 198 sc_cipher: Cipher, 199 rekey: RekeyState, 200 idle: ?*IdleState = null, 201 // set only while the TUI waits to resolve a lone escape key 202 read_timeout: std.Io.Timeout = .none, 203 // teardown ran to the peer's EOF, so nothing more can be said 204 drained: bool = false, 205 206 fn setWaiting(self: *Conn, waiting: bool) void { 207 if (self.idle) |idle| idle.waiting.store(waiting, .release); 208 } 209 210 fn writePacket(self: *Conn, payload: []const u8) !void { 211 try self.writePacketVec(&.{payload}); 212 } 213 214 fn writePacketVec(self: *Conn, parts: []const []const u8) !void { 215 self.setWaiting(true); 216 defer self.setWaiting(false); 217 try self.sc_cipher.writePacketVec(self.io, self.writer, parts); 218 // a peer that keeps reading is alive 219 recordActivity(self); 220 } 221 222 fn readPacket(self: *Conn) ![]u8 { 223 self.setWaiting(true); 224 defer self.setWaiting(false); 225 if (self.read_timeout != .none and !try self.cs_cipher.hasBufferedPacket(self.reader)) { 226 // peek keeps partial packets in the reader if the timer wins. 227 // join both tasks before consuming bytes or changing cipher state. 228 const Result = union(enum) { ready: anyerror!void, timeout: std.Io.Cancelable!void }; 229 var results: [2]Result = undefined; 230 var select = std.Io.Select(Result).init(self.io, &results); 231 defer select.cancelDiscard(); 232 try select.concurrent(.ready, Cipher.peekPacket, .{ &self.cs_cipher, self.reader }); 233 try select.concurrent(.timeout, std.Io.Timeout.sleep, .{ self.read_timeout, self.io }); 234 switch (try select.await()) { 235 .ready => |result| try result, 236 .timeout => |result| { 237 try result; 238 return error.Timeout; 239 }, 240 } 241 } 242 return self.cs_cipher.readPacket(self.allocator, self.reader); 243 } 244 }; 245 246 /// session bridge handed to the consumer's handleSession callback. exposes a 247 /// pumped event API plus a byte-write API; the byte stream goes out as 248 /// CHANNEL_DATA packets respecting the channel's flow-control window. 249 pub const SessionCtx = struct { 250 conn: *Conn, 251 channel: *Channel, 252 /// SHA256 fingerprint of the pubkey this session authenticated with, 253 /// formatted as "SHA256:<base64-no-padding>" — same value the openssh 254 /// client logs as `Offering public key: ED25519 SHA256:…`. consumers 255 /// can use it as a stable per-key identity. 256 fingerprint: [fingerprint_len]u8, 257 closed: bool = false, 258 // what a SessionReader or SessionWriter actually hit. kept apart so a 259 // cleanup write can't replace the cause of a failed read. 260 read_err: ?anyerror = null, 261 write_err: ?anyerror = null, 262 263 // shared state for the packet pump. incoming CHANNEL_DATA bytes 264 // accumulate in incoming_buffer (already-consumed prefix tracked by 265 // incoming_start) until drained via SessionReader or nextEvent. 266 incoming_buffer: std.ArrayList(u8) = .empty, 267 incoming_start: usize = 0, 268 // peer sent CHANNEL_EOF — no more input arrives, but the channel stays 269 // open and we may keep writing (RFC 4254 §5.3) 270 incoming_eof: bool = false, 271 // peer sent CHANNEL_CLOSE, or the transport died — the channel is gone 272 remote_closed: bool = false, 273 // .eof is handed to the caller once; later pumps wait for the close 274 eof_reported: bool = false, 275 // resize seen by the background pump, returned by the next nextEvent call 276 pending_resize: ?PtySize = null, 277 278 pub fn deinit(self: *SessionCtx) void { 279 self.incoming_buffer.deinit(self.conn.allocator); 280 } 281 282 /// the std.Io adapters can only report ReadFailed or WriteFailed. recover 283 /// what the session hit. 284 pub fn underlyingError(self: *const SessionCtx, err: anyerror) anyerror { 285 return switch (err) { 286 error.ReadFailed => self.read_err orelse err, 287 error.WriteFailed => self.write_err orelse err, 288 else => err, 289 }; 290 } 291 292 fn failRead(self: *SessionCtx, err: anyerror) error{ReadFailed} { 293 self.read_err = err; 294 return error.ReadFailed; 295 } 296 297 fn failWrite(self: *SessionCtx, err: anyerror) error{WriteFailed} { 298 self.write_err = err; 299 return error.WriteFailed; 300 } 301 302 fn incomingBytes(self: *const SessionCtx) []const u8 { 303 return self.incoming_buffer.items[self.incoming_start..]; 304 } 305 306 // mark n incoming bytes as consumed; the buffer is recycled once empty 307 fn consumeIncoming(self: *SessionCtx, n: usize) void { 308 self.incoming_start += n; 309 if (self.incoming_start == self.incoming_buffer.items.len) { 310 self.incoming_buffer.clearRetainingCapacity(); 311 self.incoming_start = 0; 312 } 313 } 314 315 // append to the incoming buffer, first compacting away the consumed 316 // prefix so the buffer only ever holds unread bytes. bounded by the 317 // receive window, which is never credited past max_incoming_buffered. 318 fn appendIncoming(self: *SessionCtx, payload: []const u8) !void { 319 if (self.incoming_start > 0) { 320 const remaining = self.incoming_buffer.items.len - self.incoming_start; 321 std.mem.copyForwards(u8, self.incoming_buffer.items[0..remaining], self.incoming_buffer.items[self.incoming_start..]); 322 self.incoming_buffer.shrinkRetainingCapacity(remaining); 323 self.incoming_start = 0; 324 } 325 try self.incoming_buffer.appendSlice(self.conn.allocator, payload); 326 } 327 328 /// interactive sessions idle legitimately; tell the host's idle 329 /// watchdog to stand down for the rest of this connection. 330 pub fn exemptFromIdleTimeout(self: *SessionCtx) void { 331 if (self.conn.idle) |idle| idle.exempt.store(true, .release); 332 } 333 334 /// pump SSH packets until something interesting (data / resize / close) 335 /// arrives. background traffic (window adjusts, env requests, etc.) is 336 /// handled silently. drains the same incoming state as SessionReader, 337 /// so a session should consume through one or the other, not both. 338 pub fn nextEvent(self: *SessionCtx) !Event { 339 while (true) { 340 if (self.pending_resize) |sz| { 341 self.pending_resize = null; 342 return .{ .resize = sz }; 343 } 344 if (self.incomingBytes().len > 0) { 345 // hand the buffered bytes to the caller (caller owns) 346 const payload = try self.conn.allocator.dupe(u8, self.incomingBytes()); 347 errdefer self.conn.allocator.free(payload); 348 self.consumeIncoming(payload.len); 349 try self.maybeRefillRecvWindowForBufferedInput(); 350 return .{ .data = payload }; 351 } 352 if (self.remote_closed) return .close; 353 if (self.incoming_eof and !self.eof_reported) { 354 self.eof_reported = true; 355 return .eof; 356 } 357 try self.processOneBackgroundPacket(); 358 } 359 } 360 361 /// send bytes to the client's stdout (CHANNEL_DATA). 362 pub fn writeBytes(self: *SessionCtx, bytes: []const u8) !void { 363 try self.writeChannel(bytes, false); 364 } 365 366 /// send bytes to the client's stderr (CHANNEL_EXTENDED_DATA). use this for 367 /// server messages on a git exec session, where stdout carries the pack 368 /// protocol and stray text breaks the client's pkt-line parser. 369 pub fn writeStderr(self: *SessionCtx, bytes: []const u8) !void { 370 try self.writeChannel(bytes, true); 371 } 372 373 /// ship bytes as one or more stdout CHANNEL_DATA (or stderr 374 /// CHANNEL_EXTENDED_DATA) packets, respecting the peer's max-packet size. 375 /// if the remote window is exhausted, pump incoming SSH packets until a 376 /// WINDOW_ADJUST arrives (background packets are processed for their side 377 /// effects — incoming CHANNEL_DATA goes into incoming_buffer for a future 378 /// SessionReader). 379 fn writeChannel(self: *SessionCtx, bytes: []const u8, extended: bool) !void { 380 const conn = self.conn; 381 382 // byte type, uint32 recipient_channel, [if extended: uint32 type_code], 383 // uint32 data length 384 var header: [13]u8 = undefined; 385 header[0] = if (extended) SSH_MSG_CHANNEL_EXTENDED_DATA else SSH_MSG_CHANNEL_DATA; 386 std.mem.writeInt(u32, header[1..5], self.channel.remote_id, .big); 387 if (extended) std.mem.writeInt(u32, header[5..9], SSH_EXTENDED_DATA_STDERR, .big); 388 const len_start: usize = if (extended) 9 else 5; 389 390 var rest = bytes; 391 while (rest.len > 0) { 392 if (self.closed or self.remote_closed) return error.RemoteClosed; 393 while (self.channel.remote_window == 0) { 394 if (self.remote_closed) return error.RemoteClosed; 395 try self.processOneBackgroundPacket(); 396 } 397 // also clamp to our own max so the chunk fits writePacket's buffer 398 const cap = @min(rest.len, self.channel.max_packet, self.channel.remote_window, max_packet_size); 399 const chunk_len: u32 = @intCast(cap); 400 401 std.mem.writeInt(u32, header[len_start..][0..4], chunk_len, .big); 402 try conn.writePacketVec(&.{ header[0 .. len_start + 4], rest[0..chunk_len] }); 403 404 self.channel.remote_window -= chunk_len; 405 rest = rest[chunk_len..]; 406 } 407 } 408 409 /// the session's packet pump: process exactly one SSH packet for side 410 /// effects only. CHANNEL_DATA payloads accumulate in incoming_buffer; 411 /// CHANNEL_EOF/CLOSE flips incoming_eof; WINDOW_ADJUST updates the 412 /// channel state; window-change is stashed in pending_resize; other 413 /// channel/global requests get a polite failure. driven by nextEvent, 414 /// SessionReader, and writeBytes (waiting for send-window). 415 fn processOneBackgroundPacket(self: *SessionCtx) !void { 416 const conn = self.conn; 417 const packet = readSessionPacket(conn) catch |err| switch (err) { 418 error.EndOfStream => { 419 self.incoming_eof = true; 420 self.remote_closed = true; 421 return; 422 }, 423 else => return err, 424 }; 425 defer conn.allocator.free(packet); 426 if (packet.len < 1) return; 427 428 switch (packet[0]) { 429 SSH_MSG_CHANNEL_DATA => { 430 const payload = try parseChannelData(packet, false, self.channel.local_id); 431 const data_len: u32 = @intCast(payload.len); 432 if (data_len > self.channel.local_window) return error.WindowExceeded; 433 self.channel.local_window -= data_len; 434 try self.appendIncoming(payload); 435 try self.maybeRefillRecvWindowForBufferedInput(); 436 }, 437 SSH_MSG_CHANNEL_EXTENDED_DATA => { 438 // discarded, but the window is still credited against what the 439 // incoming buffer can take, since CHANNEL_DATA may be sitting 440 // in it unread 441 try discardChannelData(self.channel, packet, true); 442 try self.maybeRefillRecvWindowForBufferedInput(); 443 }, 444 SSH_MSG_CHANNEL_WINDOW_ADJUST => try handleWindowAdjust(self.channel, packet), 445 SSH_MSG_CHANNEL_REQUEST => { 446 var r = std.Io.Reader.fixed(packet[1..]); 447 const recipient_channel = try r.takeInt(u32, .big); 448 if (recipient_channel != self.channel.local_id) return error.UnknownChannel; 449 const req_type = try takeString(&r, 64); 450 const want_reply = (try r.takeByte()) != 0; 451 if (std.mem.eql(u8, req_type, "window-change")) { 452 const sz = try takePtySize(&r); 453 if (self.channel.pty != null) self.channel.pty = sz; 454 self.pending_resize = sz; 455 try replyChannelRequest(conn, self.channel, want_reply, true); 456 } else { 457 // other mid-session channel requests aren't honored — fail them 458 try replyChannelRequest(conn, self.channel, want_reply, false); 459 } 460 }, 461 SSH_MSG_CHANNEL_EOF => { 462 try parseChannelId(packet, self.channel.local_id); 463 self.incoming_eof = true; 464 }, 465 SSH_MSG_CHANNEL_CLOSE => { 466 try parseChannelId(packet, self.channel.local_id); 467 self.incoming_eof = true; 468 self.remote_closed = true; 469 try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_CLOSE); 470 }, 471 SSH_MSG_GLOBAL_REQUEST => try handleGlobalRequest(conn, packet), 472 // RFC 4252 §5.1: silently ignored once authenticated 473 SSH_MSG_USERAUTH_REQUEST => {}, 474 else => try sendUnimplemented(conn), 475 } 476 } 477 478 fn maybeRefillRecvWindowForBufferedInput(self: *SessionCtx) !void { 479 if (self.remote_closed or self.closed) return; 480 // credit only what the incoming buffer can still take 481 const buffered: u32 = @intCast(self.incomingBytes().len); 482 try maybeRefillRecvWindow(self.conn, self.channel, max_incoming_buffered - buffered); 483 } 484 485 /// signal the consumer's exit status and tear the channel down. 486 pub fn exit(self: *SessionCtx, status: u32) !void { 487 if (self.closed) return; 488 self.closed = true; 489 const conn = self.conn; 490 if (conn.idle) |idle| idle.closing.store(true, .release); 491 492 // a peer CLOSE is acknowledged by the packet pump. don't send data, 493 // exit-status, or EOF after that acknowledgement. 494 if (!self.remote_closed) { 495 // exit-status request (informational; client uses it as the 496 // command's exit code) 497 { 498 var req: std.ArrayList(u8) = .empty; 499 defer req.deinit(conn.allocator); 500 try req.append(conn.allocator, SSH_MSG_CHANNEL_REQUEST); 501 try writeU32(&req, conn.allocator, self.channel.remote_id); 502 try writeStringField(&req, conn.allocator, "exit-status"); 503 try req.append(conn.allocator, 0); // want_reply MUST be false 504 try writeU32(&req, conn.allocator, status); 505 try conn.writePacket(req.items); 506 } 507 508 // EOF then CLOSE 509 try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_EOF); 510 try sendChannelMessage(conn, self.channel, SSH_MSG_CHANNEL_CLOSE); 511 } 512 drainConnection(conn); 513 } 514 }; 515 516 fn drainConnection(conn: *Conn) void { 517 if (conn.idle) |idle| idle.closing.store(true, .release); 518 // read and discard until the peer closes (TCP FIN) before letting the 519 // caller close the socket. a git client, after our CHANNEL_CLOSE, still 520 // sends its own CHANNEL_CLOSE and SSH_MSG_DISCONNECT before closing; if 521 // we close with those unread, Linux sends RST instead of FIN, which the 522 // client reports as "Connection reset by peer" and treats as a failed 523 // push even though the ref updated. draining to EOF leaves nothing 524 // unread, so we send a clean FIN. the bytes don't matter, and the host's 525 // watchdog bounds how long a peer can keep this going. 526 _ = conn.reader.discardRemaining() catch {}; 527 conn.drained = true; 528 } 529 530 /// std.Io.Writer adapter that ships bytes to a SessionCtx as CHANNEL_DATA. 531 /// useful for plugging the session into anything that wants a *std.Io.Writer 532 /// (e.g. xitui.StreamTerminal). 533 pub const SessionWriter = struct { 534 interface: std.Io.Writer, 535 sess: *SessionCtx, 536 537 pub fn init(sess: *SessionCtx, buffer: []u8) SessionWriter { 538 return .{ 539 .interface = .{ 540 .vtable = &.{ .drain = drain }, 541 .buffer = buffer, 542 }, 543 .sess = sess, 544 }; 545 } 546 547 fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 548 const self: *SessionWriter = @fieldParentPtr("interface", w); 549 550 // first ship anything already accumulated in the Writer's buffer 551 const buffered = w.buffered(); 552 if (buffered.len > 0) { 553 self.sess.writeBytes(buffered) catch |err| return self.sess.failWrite(err); 554 } 555 556 // then ship the bytes passed in directly. data[0..len-1] are written 557 // once each; data[len-1] is written `splat` times (this is how the 558 // Writer interface represents fan-out / repeated patterns). 559 var extra: usize = 0; 560 if (data.len > 1) { 561 for (data[0 .. data.len - 1]) |chunk| { 562 if (chunk.len == 0) continue; 563 self.sess.writeBytes(chunk) catch |err| return self.sess.failWrite(err); 564 extra += chunk.len; 565 } 566 } 567 if (data.len > 0) { 568 const pattern = data[data.len - 1]; 569 if (pattern.len > 0) { 570 var i: usize = 0; 571 while (i < splat) : (i += 1) { 572 self.sess.writeBytes(pattern) catch |err| return self.sess.failWrite(err); 573 extra += pattern.len; 574 } 575 } 576 } 577 578 // consume tells the Writer how many bytes (buffered + data) were 579 // processed and clears w.end. it returns the count attributed to 580 // data, which is what `write` propagates back to the caller. 581 return w.consume(buffered.len + extra); 582 } 583 }; 584 585 /// std.Io.Reader adapter that drains incoming CHANNEL_DATA from a SessionCtx. 586 /// when the consumer reads beyond what's buffered, runs the SSH packet loop 587 /// (via processOneBackgroundPacket) until more bytes arrive or the peer 588 /// closes the channel. 589 pub const SessionReader = struct { 590 interface: std.Io.Reader, 591 sess: *SessionCtx, 592 593 pub fn init(sess: *SessionCtx, buffer: []u8) SessionReader { 594 return .{ 595 .interface = .{ 596 .vtable = &.{ 597 .readVec = readVec, 598 .stream = stream, 599 }, 600 .buffer = buffer, 601 .seek = 0, 602 .end = 0, 603 }, 604 .sess = sess, 605 }; 606 } 607 608 fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { 609 const self: *SessionReader = @fieldParentPtr("interface", r); 610 611 // wait for data to arrive (or EOF) 612 while (self.sess.incomingBytes().len == 0) { 613 if (self.sess.incoming_eof) return error.EndOfStream; 614 self.sess.processOneBackgroundPacket() catch |err| return self.sess.failRead(err); 615 } 616 617 // drain as much buffered input as the limit allows, in one go 618 const chunk = limit.sliceConst(self.sess.incomingBytes()); 619 const written = try w.write(chunk); 620 621 self.sess.consumeIncoming(written); 622 self.sess.maybeRefillRecvWindowForBufferedInput() catch |err| return self.sess.failRead(err); 623 624 return written; 625 } 626 627 fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { 628 const self: *SessionReader = @fieldParentPtr("interface", r); 629 630 // wait until some input is buffered (or EOF) 631 while (self.sess.incomingBytes().len == 0) { 632 if (self.sess.incoming_eof) return error.EndOfStream; 633 self.sess.processOneBackgroundPacket() catch |err| return self.sess.failRead(err); 634 } 635 636 const src = self.sess.incomingBytes(); 637 638 // vtable.readVec contract: if data[0] is empty, write into r.buffer 639 // instead and return 0. without this branch the caller spins, 640 // calling readVec repeatedly with the same empty buffer. 641 if (data.len == 0 or data[0].len == 0) { 642 const dest = r.buffer[r.end..]; 643 if (dest.len == 0) return 0; 644 const copy = @min(dest.len, src.len); 645 @memcpy(dest[0..copy], src[0..copy]); 646 r.end += copy; 647 self.sess.consumeIncoming(copy); 648 self.sess.maybeRefillRecvWindowForBufferedInput() catch |err| return self.sess.failRead(err); 649 return 0; 650 } 651 652 const dest = data[0]; 653 const copy = @min(dest.len, src.len); 654 @memcpy(dest[0..copy], src[0..copy]); 655 self.sess.consumeIncoming(copy); 656 self.sess.maybeRefillRecvWindowForBufferedInput() catch |err| return self.sess.failRead(err); 657 return copy; 658 } 659 }; 660 661 // --------------------------------------------------------------------------- 662 // public entry point 663 // --------------------------------------------------------------------------- 664 665 /// `handler` must be a pointer to a struct with a method: 666 /// pub fn handleSession(self, sess: *SessionCtx, request: Request) anyerror!void 667 /// invoked once the channel is open and a shell/exec request has arrived. 668 /// 669 /// `reader` and `writer` are the bidirectional byte stream. `idle`, if 670 /// given, is kept current for the host's watchdog. 671 pub fn handleConnection( 672 io: std.Io, 673 allocator: std.mem.Allocator, 674 reader: *std.Io.Reader, 675 writer: *std.Io.Writer, 676 host_key: *const HostKey, 677 idle: ?*IdleState, 678 handler: anytype, 679 ) !void { 680 const client_version = try exchangeVersions(allocator, reader, writer); 681 defer allocator.free(client_version); 682 683 var kex = try runKex(io, allocator, reader, writer, host_key, client_version); 684 defer std.crypto.secureZero(u8, std.mem.asBytes(&kex)); 685 686 // post-KEX everything is encrypted with chacha20-poly1305@openssh.com 687 var conn = Conn{ 688 .io = io, 689 .allocator = allocator, 690 .reader = reader, 691 .writer = writer, 692 .cs_cipher = Cipher.init(&kex.cs_key, kex.next_cs_seq), 693 .sc_cipher = Cipher.init(&kex.sc_key, kex.next_sc_seq), 694 .rekey = .{ 695 .host_key = host_key, 696 .client_version = client_version, 697 .session_id = kex.session_id, 698 .strict = kex.strict, 699 }, 700 .idle = idle, 701 }; 702 defer std.crypto.secureZero(u8, std.mem.asBytes(&conn.cs_cipher)); 703 defer std.crypto.secureZero(u8, std.mem.asBytes(&conn.sc_cipher)); 704 705 const fingerprint = runAuth(&conn) catch |err| { 706 disconnectOnError(&conn, err); 707 return err; 708 }; 709 710 runChannelLayer(&conn, &fingerprint, handler) catch |err| { 711 disconnectOnError(&conn, err); 712 return err; 713 }; 714 } 715 716 /// tell the peer why we're hanging up (RFC 4253 §11.1), best effort. protocol 717 /// errors are named for whoever is debugging the client. 718 fn disconnectOnError(conn: *Conn, err: anyerror) void { 719 if (conn.drained) return; 720 const reason: u32, const description: []const u8 = switch (err) { 721 // the peer hung up first, or the transport is broken 722 error.EndOfStream, error.ReadFailed, error.WriteFailed => return, 723 error.MacVerificationFailed => .{ SSH_DISCONNECT_MAC_ERROR, @errorName(err) }, 724 error.OutOfMemory, error.Canceled => .{ SSH_DISCONNECT_BY_APPLICATION, "server error" }, 725 else => .{ SSH_DISCONNECT_PROTOCOL_ERROR, @errorName(err) }, 726 }; 727 728 disconnect(conn, reason, description) catch {}; 729 } 730 731 fn disconnect(conn: *Conn, reason: u32, description: []const u8) !void { 732 var buf: std.ArrayList(u8) = .empty; 733 defer buf.deinit(conn.allocator); 734 try buf.append(conn.allocator, SSH_MSG_DISCONNECT); 735 try writeU32(&buf, conn.allocator, reason); 736 try writeStringField(&buf, conn.allocator, description); 737 try writeStringField(&buf, conn.allocator, ""); // language tag 738 try conn.writePacket(buf.items); 739 } 740 741 // --------------------------------------------------------------------------- 742 // version exchange (RFC 4253 §4.2) 743 // --------------------------------------------------------------------------- 744 745 fn exchangeVersions( 746 allocator: std.mem.Allocator, 747 reader: *std.Io.Reader, 748 writer: *std.Io.Writer, 749 ) ![]u8 { 750 try writer.writeAll(server_version ++ "\r\n"); 751 try writer.flush(); 752 753 // the spec allows the client to send comment lines before its banner; 754 // skip anything that isn't an "SSH-2.0-…" / "SSH-1.99-…" line, up to a cap 755 // so a garbage-spewing peer can't hold the connection open forever. 756 var lines: u32 = 0; 757 while (lines < max_banner_lines) : (lines += 1) { 758 const line = (try reader.takeDelimiter('\n')) orelse return error.UnexpectedEof; 759 const trimmed = if (line.len > 0 and line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; 760 if (std.mem.startsWith(u8, trimmed, "SSH-2.0-") or std.mem.startsWith(u8, trimmed, "SSH-1.99-")) { 761 return try allocator.dupe(u8, trimmed); 762 } 763 } 764 return error.TooManyBannerLines; 765 } 766 767 // --------------------------------------------------------------------------- 768 // binary packet codec, unencrypted form (RFC 4253 §6) 769 // --------------------------------------------------------------------------- 770 771 pub fn writePlainPacket(io: std.Io, writer: *std.Io.Writer, payload: []const u8) !void { 772 const block: usize = 8; 773 const initial_pad = block - ((5 + payload.len) % block); 774 const padding_len: u8 = @intCast(if (initial_pad < 4) initial_pad + block else initial_pad); 775 const packet_len: u32 = @intCast(1 + payload.len + padding_len); 776 777 try writer.writeInt(u32, packet_len, .big); 778 try writer.writeByte(padding_len); 779 try writer.writeAll(payload); 780 781 var padding: [255]u8 = undefined; 782 io.random(padding[0..padding_len]); 783 try writer.writeAll(padding[0..padding_len]); 784 try writer.flush(); 785 } 786 787 pub fn readPlainPacket(allocator: std.mem.Allocator, reader: *std.Io.Reader) ![]u8 { 788 const packet_len = try reader.takeInt(u32, .big); 789 if (packet_len < 8 or packet_len > max_packet_len) return error.InvalidPacketLength; 790 const padding_len = try reader.takeByte(); 791 if (padding_len < 4 or @as(u32, padding_len) + 1 > packet_len) return error.InvalidPadding; 792 const payload_len = packet_len - 1 - @as(u32, padding_len); 793 794 const payload = try reader.readAlloc(allocator, payload_len); 795 errdefer allocator.free(payload); 796 try reader.discardAll(padding_len); 797 return payload; 798 } 799 800 // IGNORE/DEBUG/UNIMPLEMENTED may arrive at any time (RFC 4253 §11); skip 801 // them wherever a specific message is expected 802 fn isIgnorableMsg(msg_type: u8) bool { 803 return msg_type == SSH_MSG_IGNORE or msg_type == SSH_MSG_UNIMPLEMENTED or msg_type == SSH_MSG_DEBUG; 804 } 805 806 /// packets handled during the plaintext phase — each direction's cipher picks 807 /// up its sequence number where that direction left off. 808 pub const PlainSeqs = struct { read: u64 = 0, written: u64 = 0 }; 809 810 // transport for KEX packets: the initial exchange runs in plaintext, a rekey 811 // runs under the current session ciphers. 812 pub const KexTransport = union(enum) { 813 plain: *PlainSeqs, 814 encrypted: struct { cs: *Cipher, sc: *Cipher }, 815 816 // read one packet, skipping ignorable messages — unless strict kex is in 817 // force, which bans them mid-handshake 818 pub fn readPacket(self: KexTransport, allocator: std.mem.Allocator, reader: *std.Io.Reader, strict: bool) ![]u8 { 819 while (true) { 820 const packet = switch (self) { 821 .plain => |seqs| packet: { 822 const packet = try readPlainPacket(allocator, reader); 823 seqs.read += 1; 824 break :packet packet; 825 }, 826 .encrypted => |ciphers| try ciphers.cs.readPacket(allocator, reader), 827 }; 828 if (packet.len >= 1 and isIgnorableMsg(packet[0])) { 829 allocator.free(packet); 830 if (strict) return error.StrictKexViolation; 831 continue; 832 } 833 return packet; 834 } 835 } 836 837 fn writePacket(self: KexTransport, io: std.Io, writer: *std.Io.Writer, payload: []const u8) !void { 838 switch (self) { 839 .plain => |seqs| { 840 try writePlainPacket(io, writer, payload); 841 seqs.written += 1; 842 }, 843 .encrypted => |ciphers| try ciphers.sc.writePacket(io, writer, payload), 844 } 845 } 846 }; 847 848 // read one encrypted packet for the auth and channel layers, transparently 849 // skipping ignorable messages and servicing client-initiated rekeys. 850 pub fn readSessionPacket(conn: *Conn) ![]u8 { 851 while (true) { 852 const packet = try conn.readPacket(); 853 if (packet.len >= 1 and isIgnorableMsg(packet[0])) { 854 conn.allocator.free(packet); 855 continue; 856 } 857 // the peer is hanging up (RFC 4253 §11.1). same end of the packet 858 // stream as a transport close, so callers handle both the same way. 859 if (packet.len >= 1 and packet[0] == SSH_MSG_DISCONNECT) { 860 conn.allocator.free(packet); 861 return error.EndOfStream; 862 } 863 if (packet.len >= 1 and packet[0] == SSH_MSG_KEXINIT) { 864 defer conn.allocator.free(packet); 865 recordActivity(conn); 866 try runRekey(conn, packet); 867 continue; 868 } 869 recordActivity(conn); 870 return packet; 871 } 872 } 873 874 /// RFC 4253 §11.4 — an unrecognized message must be answered with 875 /// SSH_MSG_UNIMPLEMENTED carrying the offending packet's sequence number. 876 fn sendUnimplemented(conn: *Conn) !void { 877 var buf: std.ArrayList(u8) = .empty; 878 defer buf.deinit(conn.allocator); 879 try buf.append(conn.allocator, SSH_MSG_UNIMPLEMENTED); 880 // the read already advanced past the packet we're rejecting 881 try writeU32(&buf, conn.allocator, @truncate(conn.cs_cipher.seq - 1)); 882 try conn.writePacket(buf.items); 883 } 884 885 fn recordActivity(conn: *Conn) void { 886 if (conn.idle) |idle| _ = idle.activity.fetchAdd(1, .monotonic); 887 } 888 889 // --------------------------------------------------------------------------- 890 // SSH field encoders / decoders (RFC 4251 §5) 891 // --------------------------------------------------------------------------- 892 893 pub fn writeStringField(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, bytes: []const u8) !void { 894 var len_bytes: [4]u8 = undefined; 895 std.mem.writeInt(u32, &len_bytes, @intCast(bytes.len), .big); 896 try buf.appendSlice(allocator, &len_bytes); 897 try buf.appendSlice(allocator, bytes); 898 } 899 900 pub fn writeNameList(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, names: []const []const u8) !void { 901 var total: usize = 0; 902 for (names, 0..) |name, i| { 903 if (i > 0) total += 1; // comma separator 904 total += name.len; 905 } 906 var len_bytes: [4]u8 = undefined; 907 std.mem.writeInt(u32, &len_bytes, @intCast(total), .big); 908 try buf.appendSlice(allocator, &len_bytes); 909 for (names, 0..) |name, i| { 910 if (i > 0) try buf.append(allocator, ','); 911 try buf.appendSlice(allocator, name); 912 } 913 } 914 915 /// SSH "mpint" — big-endian two's complement multi-precision integer. for 916 /// our use (X25519 shared secret) the value is always non-negative, so we 917 /// strip leading zero bytes, then prepend a single 0x00 if the high bit of 918 /// the most-significant byte is set (to keep it parsed as positive). 919 pub fn writeMpint(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, bytes: []const u8) !void { 920 var start: usize = 0; 921 while (start < bytes.len and bytes[start] == 0) start += 1; 922 const trimmed = bytes[start..]; 923 if (trimmed.len == 0) { 924 try buf.appendSlice(allocator, &[_]u8{ 0, 0, 0, 0 }); 925 return; 926 } 927 const prepend = (trimmed[0] & 0x80) != 0; 928 const out_len: u32 = @intCast(trimmed.len + @as(usize, @intFromBool(prepend))); 929 var len_bytes: [4]u8 = undefined; 930 std.mem.writeInt(u32, &len_bytes, out_len, .big); 931 try buf.appendSlice(allocator, &len_bytes); 932 if (prepend) try buf.append(allocator, 0); 933 try buf.appendSlice(allocator, trimmed); 934 } 935 936 /// a string field's bytes, borrowed from the packet behind a fixed reader 937 pub fn takeString(reader: *std.Io.Reader, max_len: u32) ![]const u8 { 938 const len = try reader.takeInt(u32, .big); 939 if (len > max_len) return error.FieldTooLarge; 940 return reader.take(len); 941 } 942 943 fn nameListContainsAny(haystack: []const u8, our_options: []const []const u8) bool { 944 var iter = std.mem.splitScalar(u8, haystack, ','); 945 while (iter.next()) |name| { 946 for (our_options) |opt| { 947 if (std.mem.eql(u8, name, opt)) return true; 948 } 949 } 950 return false; 951 } 952 953 // --------------------------------------------------------------------------- 954 // KEXINIT (RFC 4253 §7.1) 955 // --------------------------------------------------------------------------- 956 957 pub const our_kex_algos = [_][]const u8{ "mlkem768x25519-sha256", "curve25519-sha256", "curve25519-sha256@libssh.org" }; 958 959 // strict kex markers (Terrapin / CVE-2023-48795 mitigation). pseudo-algorithms 960 // carried in the initial KEXINIT's kex list: we advertise the -s form, the 961 // client advertises the -c form. they are never selected as an actual kex 962 // algorithm because the matcher only picks from our_kex_algos. 963 pub const kex_strict_server = "kex-strict-s-v00@openssh.com"; 964 pub const kex_strict_client = "kex-strict-c-v00@openssh.com"; 965 966 const MLKem768 = std.crypto.kem.ml_kem.MLKem768; 967 const hybrid_client_blob_len = MLKem768.PublicKey.encoded_length + X25519.public_length; // 1216 968 const hybrid_server_blob_len = MLKem768.ciphertext_length + X25519.public_length; // 1120 969 pub const our_host_key_algos = [_][]const u8{"ssh-ed25519"}; 970 pub const our_ciphers = [_][]const u8{"chacha20-poly1305@openssh.com"}; 971 const our_macs = [_][]const u8{}; // none — implicit in the AEAD cipher 972 const our_compression = [_][]const u8{"none"}; 973 974 /// build a KEXINIT payload with the given algorithm name-lists 975 pub fn buildKexInit( 976 io: std.Io, 977 allocator: std.mem.Allocator, 978 kex_algos: []const []const u8, 979 host_key_algos: []const []const u8, 980 ciphers: []const []const u8, 981 ) ![]u8 { 982 var buf: std.ArrayList(u8) = .empty; 983 errdefer buf.deinit(allocator); 984 985 try buf.append(allocator, SSH_MSG_KEXINIT); 986 987 var cookie: [16]u8 = undefined; 988 io.random(&cookie); 989 try buf.appendSlice(allocator, &cookie); 990 991 try writeNameList(&buf, allocator, kex_algos); 992 try writeNameList(&buf, allocator, host_key_algos); 993 try writeNameList(&buf, allocator, ciphers); // c->s 994 try writeNameList(&buf, allocator, ciphers); // s->c 995 try writeNameList(&buf, allocator, &our_macs); 996 try writeNameList(&buf, allocator, &our_macs); 997 try writeNameList(&buf, allocator, &our_compression); 998 try writeNameList(&buf, allocator, &our_compression); 999 try writeNameList(&buf, allocator, &.{}); // langs c->s 1000 try writeNameList(&buf, allocator, &.{}); // langs s->c 1001 try buf.append(allocator, 0); // first_kex_packet_follows = false 1002 try buf.appendSlice(allocator, &[_]u8{ 0, 0, 0, 0 }); // reserved 1003 1004 return try buf.toOwnedSlice(allocator); 1005 } 1006 1007 fn buildServerKexInit(io: std.Io, allocator: std.mem.Allocator) ![]u8 { 1008 return buildKexInit(io, allocator, &(our_kex_algos ++ [_][]const u8{kex_strict_server}), &our_host_key_algos, &our_ciphers); 1009 } 1010 1011 // the name-lists borrow from the KEXINIT payload 1012 const ParsedKexInit = struct { 1013 kex_algos: []const u8, 1014 host_key_algos: []const u8, 1015 cs_cipher: []const u8, 1016 sc_cipher: []const u8, 1017 first_kex_packet_follows: bool, 1018 }; 1019 1020 fn parseClientKexInit(payload: []const u8) !ParsedKexInit { 1021 if (payload.len < 1 + 16) return error.KexInitTruncated; 1022 if (payload[0] != SSH_MSG_KEXINIT) return error.UnexpectedMessage; 1023 1024 var reader = std.Io.Reader.fixed(payload[1 + 16 ..]); // skip type + cookie 1025 1026 const kex_algos = try takeString(&reader, max_name_list_len); 1027 const host_key_algos = try takeString(&reader, max_name_list_len); 1028 const cs_cipher = try takeString(&reader, max_name_list_len); 1029 const sc_cipher = try takeString(&reader, max_name_list_len); 1030 1031 // skip c->s mac, s->c mac, c->s compress, s->c compress, lang c->s, lang s->c 1032 for (0..6) |_| _ = try takeString(&reader, max_name_list_len); 1033 const first_kex_packet_follows = (try reader.takeByte()) != 0; 1034 _ = try reader.takeInt(u32, .big); // reserved 1035 1036 return .{ 1037 .kex_algos = kex_algos, 1038 .host_key_algos = host_key_algos, 1039 .cs_cipher = cs_cipher, 1040 .sc_cipher = sc_cipher, 1041 .first_kex_packet_follows = first_kex_packet_follows, 1042 }; 1043 } 1044 1045 // --------------------------------------------------------------------------- 1046 // KEX orchestration: KEXINIT → ECDH → NEWKEYS → key derivation 1047 // --------------------------------------------------------------------------- 1048 1049 const KexResult = struct { 1050 cs_key: [64]u8, // client → server cipher key material (K_2 || K_1) 1051 sc_key: [64]u8, // server → client cipher key material 1052 session_id: [Sha256.digest_length]u8, 1053 next_cs_seq: u64, 1054 next_sc_seq: u64, 1055 strict: bool, 1056 }; 1057 1058 fn runKex( 1059 io: std.Io, 1060 allocator: std.mem.Allocator, 1061 reader: *std.Io.Reader, 1062 writer: *std.Io.Writer, 1063 host_key: *const HostKey, 1064 client_version: []const u8, 1065 ) !KexResult { 1066 var seqs: PlainSeqs = .{}; 1067 const transport = KexTransport{ .plain = &seqs }; 1068 1069 // exchange KEXINITs 1070 const server_kex_init = try buildServerKexInit(io, allocator); 1071 defer allocator.free(server_kex_init); 1072 try transport.writePacket(io, writer, server_kex_init); 1073 1074 const client_kex_init = try transport.readPacket(allocator, reader, false); 1075 defer allocator.free(client_kex_init); 1076 1077 const parsed = try parseClientKexInit(client_kex_init); 1078 1079 // strict kex: when the client advertises it, its KEXINIT must be the 1080 // first packet, ignorable messages are banned until NEWKEYS, and both 1081 // seqnos reset to 0 after every NEWKEYS. 1082 const strict = nameListContainsAny(parsed.kex_algos, &.{kex_strict_client}); 1083 if (strict and seqs.read != 1) return error.StrictKexViolation; 1084 1085 var negotiated = try exchangeKeys( 1086 io, 1087 allocator, 1088 reader, 1089 writer, 1090 transport, 1091 strict, 1092 host_key, 1093 client_version, 1094 client_kex_init, 1095 server_kex_init, 1096 &parsed, 1097 null, 1098 ); 1099 defer std.crypto.secureZero(u8, std.mem.asBytes(&negotiated.keys)); 1100 1101 return .{ 1102 .cs_key = negotiated.keys.cs_enc, 1103 .sc_key = negotiated.keys.sc_enc, 1104 .session_id = negotiated.exchange_hash, 1105 // each cipher continues its own direction's count; the read side 1106 // includes any skipped IGNORE/DEBUG packets. 1107 .next_cs_seq = if (strict) 0 else seqs.read, 1108 .next_sc_seq = if (strict) 0 else seqs.written, 1109 .strict = strict, 1110 }; 1111 } 1112 1113 /// everything needed to service a client-initiated rekey mid-session. 1114 const RekeyState = struct { 1115 host_key: *const HostKey, 1116 client_version: []const u8, 1117 session_id: [Sha256.digest_length]u8, 1118 strict: bool, 1119 }; 1120 1121 // service a rekey whose triggering KEXINIT payload has just been read: run 1122 // the whole exchange under the current keys, then swap the new keys into 1123 // both ciphers in place. 1124 fn runRekey(conn: *Conn, client_kex_init: []const u8) !void { 1125 const parsed = try parseClientKexInit(client_kex_init); 1126 1127 // the exchange reads and writes through the ciphers directly 1128 conn.setWaiting(true); 1129 defer conn.setWaiting(false); 1130 1131 const server_kex_init = try buildServerKexInit(conn.io, conn.allocator); 1132 defer conn.allocator.free(server_kex_init); 1133 try conn.sc_cipher.writePacket(conn.io, conn.writer, server_kex_init); 1134 1135 var negotiated = try exchangeKeys( 1136 conn.io, 1137 conn.allocator, 1138 conn.reader, 1139 conn.writer, 1140 .{ .encrypted = .{ .cs = &conn.cs_cipher, .sc = &conn.sc_cipher } }, 1141 false, // strict kex message rules apply to the initial exchange only 1142 conn.rekey.host_key, 1143 conn.rekey.client_version, 1144 client_kex_init, 1145 server_kex_init, 1146 &parsed, 1147 &conn.rekey.session_id, 1148 ); 1149 defer std.crypto.secureZero(u8, std.mem.asBytes(&negotiated.keys)); 1150 1151 // seqnos continue across a rekey unless strict kex was negotiated, 1152 // which resets them after every NEWKEYS 1153 conn.cs_cipher.setKeys(&negotiated.keys.cs_enc, if (conn.rekey.strict) 0 else conn.cs_cipher.seq); 1154 conn.sc_cipher.setKeys(&negotiated.keys.sc_enc, if (conn.rekey.strict) 0 else conn.sc_cipher.seq); 1155 } 1156 1157 const NegotiatedKeys = struct { 1158 keys: SessionKeys, 1159 exchange_hash: [Sha256.digest_length]u8, 1160 }; 1161 1162 // the KEX core shared by the initial exchange and rekeys: pick algorithms, 1163 // run the (hybrid) ECDH round trip, exchange NEWKEYS, derive keys. both 1164 // KEXINIT payloads have already been exchanged by the caller, which also owns 1165 // `parsed`; the client's raw payload comes along too because the exchange hash 1166 // covers it verbatim. session_id is null on the initial exchange (where it 1167 // becomes the exchange hash) and the original session id on a rekey. 1168 fn exchangeKeys( 1169 io: std.Io, 1170 allocator: std.mem.Allocator, 1171 reader: *std.Io.Reader, 1172 writer: *std.Io.Writer, 1173 transport: KexTransport, 1174 strict: bool, 1175 host_key: *const HostKey, 1176 client_version: []const u8, 1177 client_kex_init: []const u8, 1178 server_kex_init: []const u8, 1179 parsed: *const ParsedKexInit, 1180 session_id: ?*const [Sha256.digest_length]u8, 1181 ) !NegotiatedKeys { 1182 // pick the first kex algo in the client's preference list that we also 1183 // support (RFC 4253 §7.1.3). 1184 const kex_algo = blk: { 1185 var iter = std.mem.splitScalar(u8, parsed.kex_algos, ','); 1186 while (iter.next()) |name| { 1187 for (our_kex_algos) |opt| { 1188 if (std.mem.eql(u8, name, opt)) break :blk opt; 1189 } 1190 } 1191 return error.NoCommonKexAlgorithm; 1192 }; 1193 if (!nameListContainsAny(parsed.host_key_algos, &our_host_key_algos)) return error.NoCommonHostKeyAlgorithm; 1194 if (!nameListContainsAny(parsed.cs_cipher, &our_ciphers)) return error.NoCommonCipher; 1195 if (!nameListContainsAny(parsed.sc_cipher, &our_ciphers)) return error.NoCommonCipher; 1196 const hybrid = std.mem.eql(u8, kex_algo, "mlkem768x25519-sha256"); 1197 1198 // a client may follow its KEXINIT with a kex packet for its guess. the 1199 // guess is right only when both sides' first kex and host key algorithms 1200 // match (RFC 4253 §7.1) — not when the client's first choice merely 1201 // happens to be kex_algo, so this compares against our list. a client 1202 // whose guess was wrong re-sends an init for the negotiated method. 1203 if (parsed.first_kex_packet_follows) { 1204 var kex_iter = std.mem.splitScalar(u8, parsed.kex_algos, ','); 1205 var host_key_iter = std.mem.splitScalar(u8, parsed.host_key_algos, ','); 1206 const guessed_right = std.mem.eql(u8, kex_iter.first(), our_kex_algos[0]) and 1207 std.mem.eql(u8, host_key_iter.first(), our_host_key_algos[0]); 1208 if (!guessed_right) { 1209 const wrong_guess = try transport.readPacket(allocator, reader, strict); 1210 allocator.free(wrong_guess); 1211 } 1212 } 1213 1214 // receive KEX_ECDH_INIT (mlkem768x25519 reuses message code 30 — same 1215 // wire shape, only the string size differs). 1216 const ecdh_init = try transport.readPacket(allocator, reader, strict); 1217 defer allocator.free(ecdh_init); 1218 if (ecdh_init.len < 1 or ecdh_init[0] != SSH_MSG_KEX_ECDH_INIT) return error.UnexpectedMessage; 1219 1220 var ecdh_init_reader = std.Io.Reader.fixed(ecdh_init[1..]); 1221 const expected_client_len: u32 = if (hybrid) hybrid_client_blob_len else X25519.public_length; 1222 const client_blob = try takeString(&ecdh_init_reader, expected_client_len); 1223 if (client_blob.len != expected_client_len) return error.InvalidEphemeralKey; 1224 1225 // derive server reply blob (S_REPLY) + shared secret K. 1226 // ECDH: server_blob = X25519 server pub (32B); K = X25519(s, c_pub). 1227 // hybrid: server_blob = MLKEM ciphertext (1088B) || X25519 server pub (32B); 1228 // K = SHA-256(K_MLKEM || K_X25519). 1229 var server_blob_buf: [hybrid_server_blob_len]u8 = undefined; 1230 var k: [Sha256.digest_length]u8 = undefined; 1231 defer std.crypto.secureZero(u8, &k); 1232 const server_blob: []const u8 = blk: { 1233 const x25519_offset: usize = if (hybrid) MLKem768.PublicKey.encoded_length else 0; 1234 var x25519_client_pub: [X25519.public_length]u8 = undefined; 1235 @memcpy(&x25519_client_pub, client_blob[x25519_offset..]); 1236 var server_kp = X25519.KeyPair.generate(io); 1237 defer std.crypto.secureZero(u8, &server_kp.secret_key); 1238 var x25519_shared = try X25519.scalarmult(server_kp.secret_key, x25519_client_pub); 1239 defer std.crypto.secureZero(u8, &x25519_shared); 1240 1241 if (!hybrid) { 1242 server_blob_buf[0..X25519.public_length].* = server_kp.public_key; 1243 k = x25519_shared; 1244 break :blk server_blob_buf[0..X25519.public_length]; 1245 } 1246 1247 var pq_pub_bytes: [MLKem768.PublicKey.encoded_length]u8 = undefined; 1248 @memcpy(&pq_pub_bytes, client_blob[0..MLKem768.PublicKey.encoded_length]); 1249 const pq_pub = try MLKem768.PublicKey.fromBytes(&pq_pub_bytes); 1250 var encap = pq_pub.encaps(io); 1251 defer std.crypto.secureZero(u8, &encap.shared_secret); 1252 1253 server_blob_buf[0..MLKem768.ciphertext_length].* = encap.ciphertext; 1254 server_blob_buf[MLKem768.ciphertext_length..].* = server_kp.public_key; 1255 1256 var h = Sha256.init(.{}); 1257 defer std.crypto.secureZero(u8, std.mem.asBytes(&h)); 1258 h.update(&encap.shared_secret); 1259 h.update(&x25519_shared); 1260 h.final(&k); 1261 break :blk &server_blob_buf; 1262 }; 1263 1264 // build host key blob (K_S) 1265 var host_key_blob: std.ArrayList(u8) = .empty; 1266 defer host_key_blob.deinit(allocator); 1267 try host_key.appendPublicBlob(&host_key_blob, allocator); 1268 1269 // build exchange hash input and compute H 1270 const exchange_hash = try computeExchangeHash( 1271 allocator, 1272 client_version, 1273 server_version, 1274 client_kex_init, 1275 server_kex_init, 1276 host_key_blob.items, 1277 client_blob, 1278 server_blob, 1279 &k, 1280 hybrid, 1281 ); 1282 1283 // sign H with the host key, format as ssh signature blob 1284 var signature_blob: std.ArrayList(u8) = .empty; 1285 defer signature_blob.deinit(allocator); 1286 try host_key.appendSignatureBlob(&signature_blob, allocator, &exchange_hash); 1287 1288 // build & send KEX_ECDH_REPLY (msg code 31 reused as KEX_HYBRID_REPLY) 1289 var reply: std.ArrayList(u8) = .empty; 1290 defer reply.deinit(allocator); 1291 try reply.append(allocator, SSH_MSG_KEX_ECDH_REPLY); 1292 try writeStringField(&reply, allocator, host_key_blob.items); 1293 try writeStringField(&reply, allocator, server_blob); 1294 try writeStringField(&reply, allocator, signature_blob.items); 1295 try transport.writePacket(io, writer, reply.items); 1296 1297 // NEWKEYS — both sides switch to the new keys after this is sent and 1298 // the peer's NEWKEYS is received. 1299 try transport.writePacket(io, writer, &[_]u8{SSH_MSG_NEWKEYS}); 1300 1301 const peer_newkeys = try transport.readPacket(allocator, reader, strict); 1302 defer allocator.free(peer_newkeys); 1303 if (peer_newkeys.len < 1 or peer_newkeys[0] != SSH_MSG_NEWKEYS) return error.UnexpectedMessage; 1304 1305 // derive session keys per RFC 4253 §7.2. only the encrypt keys are 1306 // needed for chacha20-poly1305 (no separate MAC/IV). 1307 var keys: SessionKeys = undefined; 1308 defer std.crypto.secureZero(u8, std.mem.asBytes(&keys)); 1309 try deriveSessionKeys(allocator, &k, &exchange_hash, if (session_id) |sid| sid else &exchange_hash, &keys, hybrid); 1310 1311 return .{ .keys = keys, .exchange_hash = exchange_hash }; 1312 } 1313 1314 // --------------------------------------------------------------------------- 1315 // exchange hash (RFC 4253 §8, RFC 5656 §4) 1316 // --------------------------------------------------------------------------- 1317 1318 pub fn computeExchangeHash( 1319 allocator: std.mem.Allocator, 1320 client_version: []const u8, 1321 server_version_str: []const u8, 1322 client_kex_init: []const u8, 1323 server_kex_init: []const u8, 1324 host_key_blob: []const u8, 1325 client_ephemeral: []const u8, 1326 server_ephemeral: []const u8, 1327 shared_secret: []const u8, 1328 /// curve25519-sha256 encodes K as mpint; mlkem768x25519-sha256 encodes it 1329 /// as a plain SSH string (length-prefixed bytes). 1330 k_is_string: bool, 1331 ) ![Sha256.digest_length]u8 { 1332 var buf: std.ArrayList(u8) = .empty; 1333 defer buf.deinit(allocator); 1334 defer std.crypto.secureZero(u8, buf.items); 1335 // reserved up front so a reallocation can't leave K behind 1336 try buf.ensureTotalCapacityPrecise(allocator, 8 * 4 + 1 + client_version.len + server_version_str.len + 1337 client_kex_init.len + server_kex_init.len + host_key_blob.len + client_ephemeral.len + 1338 server_ephemeral.len + shared_secret.len); 1339 1340 try writeStringField(&buf, allocator, client_version); 1341 try writeStringField(&buf, allocator, server_version_str); 1342 try writeStringField(&buf, allocator, client_kex_init); 1343 try writeStringField(&buf, allocator, server_kex_init); 1344 try writeStringField(&buf, allocator, host_key_blob); 1345 try writeStringField(&buf, allocator, client_ephemeral); 1346 try writeStringField(&buf, allocator, server_ephemeral); 1347 if (k_is_string) { 1348 try writeStringField(&buf, allocator, shared_secret); 1349 } else { 1350 try writeMpint(&buf, allocator, shared_secret); 1351 } 1352 1353 var hasher = Sha256.init(.{}); 1354 defer std.crypto.secureZero(u8, std.mem.asBytes(&hasher)); 1355 hasher.update(buf.items); 1356 return hasher.finalResult(); 1357 } 1358 1359 // --------------------------------------------------------------------------- 1360 // key derivation (RFC 4253 §7.2) 1361 // --------------------------------------------------------------------------- 1362 1363 // chacha20-poly1305@openssh.com wants 64 bytes per direction (32-byte main key 1364 // + 32-byte header key); SHA256 produces 32, so each encrypt key is two hash 1365 // blocks chained per RFC 4253. 1366 pub const SessionKeys = struct { 1367 cs_enc: [64]u8, 1368 sc_enc: [64]u8, 1369 }; 1370 1371 pub fn deriveSessionKeys( 1372 allocator: std.mem.Allocator, 1373 shared_secret: []const u8, 1374 exchange_hash: []const u8, 1375 session_id: []const u8, 1376 out: *SessionKeys, 1377 /// see computeExchangeHash — must match the KEX algorithm's K encoding. 1378 k_is_string: bool, 1379 ) !void { 1380 // pre-build the K || H prefix once; reused for every derivation 1381 var prefix: std.ArrayList(u8) = .empty; 1382 defer prefix.deinit(allocator); 1383 defer std.crypto.secureZero(u8, prefix.items); 1384 // reserved up front so a reallocation can't leave K behind 1385 try prefix.ensureTotalCapacityPrecise(allocator, 4 + 1 + shared_secret.len + exchange_hash.len); 1386 if (k_is_string) { 1387 try writeStringField(&prefix, allocator, shared_secret); 1388 } else { 1389 try writeMpint(&prefix, allocator, shared_secret); 1390 } 1391 try prefix.appendSlice(allocator, exchange_hash); 1392 1393 deriveKey(prefix.items, 'C', session_id, &out.cs_enc); 1394 deriveKey(prefix.items, 'D', session_id, &out.sc_enc); 1395 } 1396 1397 fn deriveKey( 1398 prefix: []const u8, // K || H, already encoded 1399 letter: u8, 1400 session_id: []const u8, 1401 out: *[2 * Sha256.digest_length]u8, 1402 ) void { 1403 // a finished context still holds its digest and last input block 1404 // K_1 = HASH(K || H || letter || session_id) 1405 var first = Sha256.init(.{}); 1406 defer std.crypto.secureZero(u8, std.mem.asBytes(&first)); 1407 first.update(prefix); 1408 first.update(&.{letter}); 1409 first.update(session_id); 1410 first.final(out[0..Sha256.digest_length]); 1411 1412 // K_2 = HASH(K || H || K_1) 1413 var second = Sha256.init(.{}); 1414 defer std.crypto.secureZero(u8, std.mem.asBytes(&second)); 1415 second.update(prefix); 1416 second.update(out[0..Sha256.digest_length]); 1417 second.final(out[Sha256.digest_length..]); 1418 } 1419 1420 // --------------------------------------------------------------------------- 1421 // chacha20-poly1305@openssh.com packet codec 1422 // --------------------------------------------------------------------------- 1423 // 1424 // per openssh's PROTOCOL.chacha20poly1305: 1425 // K_2 = first 32 bytes of key material — encrypts the packet body 1426 // K_1 = next 32 bytes — encrypts the 4-byte length field 1427 // nonce = 64-bit big-endian sequence number 1428 // body = chacha20(K_2, nonce, counter=1) XOR plaintext_body 1429 // length = chacha20(K_1, nonce, counter=0) XOR length_uint32_be 1430 // mac = poly1305(poly_key, encrypted_length || encrypted_body) 1431 // where poly_key = chacha20(K_2, nonce, counter=0)[0..32] 1432 // 1433 // padding is computed on the body alone (padding_length byte + payload + 1434 // padding) — the 4-byte length field is excluded from alignment. body must 1435 // be a multiple of 8 bytes; minimum padding 4 bytes. 1436 1437 pub const Cipher = struct { 1438 main_key: [32]u8, // K_2 1439 header_key: [32]u8, // K_1 1440 // wider than the 32-bit wire seqno, so a peer that never rekeys fails the 1441 // mac at 2^32 packets instead of wrapping into nonce reuse 1442 seq: u64, 1443 1444 pub fn init(key_material: *const [64]u8, initial_seq: u64) Cipher { 1445 return .{ 1446 .main_key = key_material[0..32].*, 1447 .header_key = key_material[32..64].*, 1448 .seq = initial_seq, 1449 }; 1450 } 1451 1452 /// swap in new key material after a rekey, leaving the rest of the 1453 /// cipher state alone 1454 fn setKeys(self: *Cipher, key_material: *const [64]u8, seq: u64) void { 1455 self.main_key = key_material[0..32].*; 1456 self.header_key = key_material[32..64].*; 1457 self.seq = seq; 1458 } 1459 1460 fn makeNonce(seq: u64) [8]u8 { 1461 var nonce: [8]u8 = undefined; 1462 std.mem.writeInt(u64, &nonce, seq, .big); 1463 return nonce; 1464 } 1465 1466 fn bodyLength(self: *const Cipher, enc_length: *const [4]u8) !u32 { 1467 var plain: [4]u8 = undefined; 1468 ChaCha20.xor(&plain, enc_length, 0, self.header_key, makeNonce(self.seq)); 1469 const len = std.mem.readInt(u32, &plain, .big); 1470 if (len < 8 or len > max_packet_len or len % 8 != 0) return error.InvalidPacketLength; 1471 return len; 1472 } 1473 1474 fn peekPacket(self: *const Cipher, reader: *std.Io.Reader) !void { 1475 const len = 4 + (try self.bodyLength(try reader.peekArray(4))) + Poly1305.mac_length; 1476 if (len > reader.buffer.len) return error.ReadBufferTooSmall; 1477 _ = try reader.peek(len); 1478 } 1479 1480 fn hasBufferedPacket(self: *const Cipher, reader: *std.Io.Reader) !bool { 1481 const bytes = reader.buffered(); 1482 if (bytes.len < 4) return false; 1483 return bytes.len >= 4 + (try self.bodyLength(bytes[0..4])) + Poly1305.mac_length; 1484 } 1485 1486 pub fn writePacket( 1487 self: *Cipher, 1488 io: std.Io, 1489 writer: *std.Io.Writer, 1490 payload: []const u8, 1491 ) !void { 1492 try self.writePacketVec(io, writer, &.{payload}); 1493 } 1494 1495 /// the payload is the concatenation of `parts` 1496 pub fn writePacketVec( 1497 self: *Cipher, 1498 io: std.Io, 1499 writer: *std.Io.Writer, 1500 parts: []const []const u8, 1501 ) !void { 1502 var payload_len: usize = 0; 1503 for (parts) |part| payload_len += part.len; 1504 1505 const block: usize = 8; 1506 const initial_pad = block - ((1 + payload_len) % block); 1507 const padding_len: u8 = @intCast(if (initial_pad < 4) initial_pad + block else initial_pad); 1508 const body_len = 1 + payload_len + padding_len; 1509 if (body_len > max_packet_len) return error.PacketTooLarge; 1510 1511 const nonce = makeNonce(self.seq); 1512 1513 // encrypted length || body || mac, contiguous so the packet goes out 1514 // in one write. stack-allocated to avoid a heap alloc on every packet. 1515 var packet_buf: [packet_buffer_size]u8 = undefined; 1516 const packet = packet_buf[0 .. 4 + body_len + Poly1305.mac_length]; 1517 const enc_length = packet[0..4]; 1518 const body = packet[4..][0..body_len]; 1519 1520 // plaintext body = padding_length || payload || random padding 1521 body[0] = padding_len; 1522 var end: usize = 1; 1523 for (parts) |part| { 1524 @memcpy(body[end..][0..part.len], part); 1525 end += part.len; 1526 } 1527 io.random(body[end..]); 1528 1529 // encrypt body in place with counter=1 1530 ChaCha20.xor(body, body, 1, self.main_key, nonce); 1531 1532 // encrypt length field with header key, counter=0 1533 std.mem.writeInt(u32, enc_length, @intCast(body_len), .big); 1534 ChaCha20.xor(enc_length, enc_length, 0, self.header_key, nonce); 1535 1536 // poly1305 key = first 32 bytes of chacha20(K_2, nonce, counter=0) 1537 var poly_key: [Poly1305.key_length]u8 = undefined; 1538 ChaCha20.stream(&poly_key, 0, self.main_key, nonce); 1539 1540 // mac over encrypted_length || encrypted_body 1541 var poly = Poly1305.init(&poly_key); 1542 poly.update(packet[0 .. 4 + body_len]); 1543 poly.final(packet[4 + body_len ..][0..Poly1305.mac_length]); 1544 1545 try writer.writeAll(packet); 1546 try writer.flush(); 1547 1548 self.seq += 1; 1549 } 1550 1551 pub fn readPacket( 1552 self: *Cipher, 1553 allocator: std.mem.Allocator, 1554 reader: *std.Io.Reader, 1555 ) ![]u8 { 1556 const nonce = makeNonce(self.seq); 1557 1558 var enc_length: [4]u8 = undefined; 1559 try reader.readSliceAll(&enc_length); 1560 1561 const body_len = try self.bodyLength(&enc_length); 1562 1563 // the body allocation becomes the returned payload, so a packet only 1564 // ever costs one allocation 1565 const body = try allocator.alloc(u8, body_len); 1566 errdefer allocator.free(body); 1567 try reader.readSliceAll(body); 1568 1569 var tag_recv: [Poly1305.mac_length]u8 = undefined; 1570 try reader.readSliceAll(&tag_recv); 1571 1572 var poly_key: [Poly1305.key_length]u8 = undefined; 1573 ChaCha20.stream(&poly_key, 0, self.main_key, nonce); 1574 1575 var poly = Poly1305.init(&poly_key); 1576 poly.update(&enc_length); 1577 poly.update(body); 1578 var tag_computed: [Poly1305.mac_length]u8 = undefined; 1579 poly.final(&tag_computed); 1580 if (!std.crypto.timing_safe.eql([Poly1305.mac_length]u8, tag_recv, tag_computed)) { 1581 return error.MacVerificationFailed; 1582 } 1583 1584 // decrypt the body in place 1585 ChaCha20.xor(body, body, 1, self.main_key, nonce); 1586 1587 const padding_len = body[0]; 1588 if (padding_len < 4 or 1 + @as(u32, padding_len) > body_len) return error.InvalidPadding; 1589 const payload_len = body_len - 1 - @as(u32, padding_len); 1590 1591 // shift the payload over the padding_length byte, then release the 1592 // trailing padding (caller owns the result) 1593 std.mem.copyForwards(u8, body[0..payload_len], body[1 .. 1 + payload_len]); 1594 const payload = try allocator.realloc(body, payload_len); 1595 1596 self.seq += 1; 1597 return payload; 1598 } 1599 }; 1600 1601 // --------------------------------------------------------------------------- 1602 // post-KEX: service request + user authentication (RFC 4252) 1603 // --------------------------------------------------------------------------- 1604 1605 /// "SHA256:" (7 bytes) + base64-no-padding of a 32-byte SHA256 digest 1606 /// (43 bytes) = 50 bytes. matches what openssh's client logs as 1607 /// `Offering public key: … SHA256:…`. 1608 pub const fingerprint_len = 50; 1609 1610 /// hash the SSH wire-format pubkey blob (`string algo` + `string raw_key`) 1611 /// the same way openssh does for `ssh-keygen -lf` / authorized-key logs. 1612 pub fn formatFingerprint(pubkey_blob: []const u8) [fingerprint_len]u8 { 1613 var digest: [Sha256.digest_length]u8 = undefined; 1614 Sha256.hash(pubkey_blob, &digest, .{}); 1615 1616 var out: [fingerprint_len]u8 = undefined; 1617 @memcpy(out[0..7], "SHA256:"); 1618 const encoded = std.base64.standard_no_pad.Encoder.encode(out[7..], &digest); 1619 std.debug.assert(encoded.len == fingerprint_len - 7); 1620 return out; 1621 } 1622 1623 fn runAuth(conn: *Conn) ![fingerprint_len]u8 { 1624 const allocator = conn.allocator; 1625 1626 // step 1: service request for ssh-userauth → service accept 1627 { 1628 const req = try readSessionPacket(conn); 1629 defer allocator.free(req); 1630 if (req.len < 1 or req[0] != SSH_MSG_SERVICE_REQUEST) return error.UnexpectedMessage; 1631 1632 var req_reader = std.Io.Reader.fixed(req[1..]); 1633 const service = try takeString(&req_reader, 64); 1634 if (!std.mem.eql(u8, service, "ssh-userauth")) return error.UnsupportedService; 1635 1636 var accept: std.ArrayList(u8) = .empty; 1637 defer accept.deinit(allocator); 1638 try accept.append(allocator, SSH_MSG_SERVICE_ACCEPT); 1639 try writeStringField(&accept, allocator, "ssh-userauth"); 1640 try conn.writePacket(accept.items); 1641 } 1642 1643 // step 2: USERAUTH loop until SUCCESS. accept any ed25519 key whose 1644 // signature verifies against the offered pubkey. 1645 var auth_attempts: u32 = 0; 1646 while (true) { 1647 const req = try readSessionPacket(conn); 1648 defer allocator.free(req); 1649 if (req.len < 1 or req[0] != SSH_MSG_USERAUTH_REQUEST) return error.UnexpectedMessage; 1650 1651 // every request counts, probes included, so a client can't loop here 1652 // forever 1653 if (auth_attempts >= max_auth_attempts) return error.TooManyAuthAttempts; 1654 auth_attempts += 1; 1655 1656 var req_reader = std.Io.Reader.fixed(req[1..]); 1657 const user_name = try takeString(&req_reader, 256); 1658 const service_name = try takeString(&req_reader, 64); 1659 const method = try takeString(&req_reader, 64); 1660 1661 // RFC 4252 §5: the server must verify the requested service 1662 if (!std.mem.eql(u8, service_name, "ssh-connection")) { 1663 try sendUserauthFailure(conn); 1664 continue; 1665 } 1666 1667 if (!std.mem.eql(u8, method, "publickey")) { 1668 try sendUserauthFailure(conn); 1669 continue; 1670 } 1671 1672 const has_signature = (try req_reader.takeByte()) != 0; 1673 const algo = try takeString(&req_reader, 64); 1674 const pubkey_blob = try takeString(&req_reader, 4096); 1675 1676 if (!std.mem.eql(u8, algo, "ssh-ed25519")) { 1677 try sendUserauthFailure(conn); 1678 continue; 1679 } 1680 1681 if (!has_signature) { 1682 // probe — tell the client this key is acceptable so it'll send 1683 // the signed version next. 1684 var pk_ok: std.ArrayList(u8) = .empty; 1685 defer pk_ok.deinit(allocator); 1686 try pk_ok.append(allocator, SSH_MSG_USERAUTH_PK_OK); 1687 try writeStringField(&pk_ok, allocator, algo); 1688 try writeStringField(&pk_ok, allocator, pubkey_blob); 1689 try conn.writePacket(pk_ok.items); 1690 continue; 1691 } 1692 1693 // has signature — verify it 1694 const signature_blob = try takeString(&req_reader, 1024); 1695 1696 const ok = verifyUserauthSignature( 1697 allocator, 1698 &conn.rekey.session_id, 1699 user_name, 1700 service_name, 1701 algo, 1702 pubkey_blob, 1703 signature_blob, 1704 ) catch |err| switch (err) { 1705 error.OutOfMemory => return err, 1706 // a malformed key or signature blob 1707 else => false, 1708 }; 1709 1710 if (!ok) { 1711 try sendUserauthFailure(conn); 1712 continue; 1713 } 1714 1715 try conn.writePacket(&[_]u8{SSH_MSG_USERAUTH_SUCCESS}); 1716 return formatFingerprint(pubkey_blob); 1717 } 1718 } 1719 1720 fn sendUserauthFailure(conn: *Conn) !void { 1721 var buf: std.ArrayList(u8) = .empty; 1722 defer buf.deinit(conn.allocator); 1723 try buf.append(conn.allocator, SSH_MSG_USERAUTH_FAILURE); 1724 try writeNameList(&buf, conn.allocator, &.{"publickey"}); // allowed methods 1725 try buf.append(conn.allocator, 0); // partial_success = false 1726 try conn.writePacket(buf.items); 1727 } 1728 1729 /// append the canonical publickey-signed-data bytes (RFC 4252 §7) to `buf`. 1730 /// used by the server to recompute the signed input for verification 1731 pub fn appendPublickeySignedData( 1732 buf: *std.ArrayList(u8), 1733 allocator: std.mem.Allocator, 1734 session_id: []const u8, 1735 user_name: []const u8, 1736 service_name: []const u8, 1737 algo: []const u8, 1738 pubkey_blob: []const u8, 1739 ) !void { 1740 try writeStringField(buf, allocator, session_id); 1741 try buf.append(allocator, SSH_MSG_USERAUTH_REQUEST); 1742 try writeStringField(buf, allocator, user_name); 1743 try writeStringField(buf, allocator, service_name); 1744 try writeStringField(buf, allocator, "publickey"); 1745 try buf.append(allocator, 1); 1746 try writeStringField(buf, allocator, algo); 1747 try writeStringField(buf, allocator, pubkey_blob); 1748 } 1749 1750 // build the bytes a publickey signature is computed over (RFC 4252 §7) and 1751 // verify the supplied signature against them. 1752 fn verifyUserauthSignature( 1753 allocator: std.mem.Allocator, 1754 session_id: *const [Sha256.digest_length]u8, 1755 user_name: []const u8, 1756 service_name: []const u8, 1757 algo: []const u8, 1758 pubkey_blob: []const u8, 1759 signature_blob: []const u8, 1760 ) !bool { 1761 var signed: std.ArrayList(u8) = .empty; 1762 defer signed.deinit(allocator); 1763 try appendPublickeySignedData(&signed, allocator, session_id, user_name, service_name, algo, pubkey_blob); 1764 1765 // parse pubkey_blob: string "ssh-ed25519" || string raw_pubkey 1766 var pubkey_reader = std.Io.Reader.fixed(pubkey_blob); 1767 const pk_algo = try takeString(&pubkey_reader, 64); 1768 if (!std.mem.eql(u8, pk_algo, "ssh-ed25519")) return false; 1769 const raw_pubkey = try takeString(&pubkey_reader, 64); 1770 if (raw_pubkey.len != Ed25519.PublicKey.encoded_length) return false; 1771 1772 // parse signature_blob: string "ssh-ed25519" || string raw_signature 1773 var sig_reader = std.Io.Reader.fixed(signature_blob); 1774 const sig_algo = try takeString(&sig_reader, 64); 1775 if (!std.mem.eql(u8, sig_algo, "ssh-ed25519")) return false; 1776 const raw_sig = try takeString(&sig_reader, Ed25519.Signature.encoded_length); 1777 if (raw_sig.len != Ed25519.Signature.encoded_length) return false; 1778 1779 var pubkey_bytes: [Ed25519.PublicKey.encoded_length]u8 = undefined; 1780 @memcpy(&pubkey_bytes, raw_pubkey); 1781 var sig_bytes: [Ed25519.Signature.encoded_length]u8 = undefined; 1782 @memcpy(&sig_bytes, raw_sig); 1783 1784 const pk = Ed25519.PublicKey.fromBytes(pubkey_bytes) catch return false; 1785 const sig = Ed25519.Signature.fromBytes(sig_bytes); 1786 sig.verify(signed.items, pk) catch return false; 1787 return true; 1788 } 1789 1790 // --------------------------------------------------------------------------- 1791 // connection layer (RFC 4254): channels, requests, data, flow control 1792 // --------------------------------------------------------------------------- 1793 1794 const Channel = struct { 1795 local_id: u32, 1796 remote_id: u32, 1797 local_window: u32, // bytes peer may still send us before we adjust 1798 remote_window: u32, // bytes we may still send to peer before they adjust 1799 max_packet: u32, // max CHANNEL_DATA payload the peer accepts in one packet 1800 pty: ?PtySize = null, 1801 // value of GIT_PROTOCOL, the one env variable we keep 1802 git_protocol_buf: [64]u8 = undefined, 1803 git_protocol_len: ?usize = null, 1804 }; 1805 1806 pub const PtySize = struct { width_cells: u16, height_cells: u16 }; 1807 1808 // take the uint32 width/height cell counts of a pty-req or window-change, 1809 // bounded before either initial rendering or a resize can allocate grids. 1810 // keep zero dimensions intact (unspecified at startup, minimized on resize). 1811 fn takePtySize(r: *std.Io.Reader) !PtySize { 1812 const width = @min(try r.takeInt(u32, .big), max_pty_width); 1813 const height = @min(try r.takeInt(u32, .big), max_pty_height); 1814 return .{ 1815 .width_cells = @intCast(width), 1816 .height_cells = @intCast(height), 1817 }; 1818 } 1819 1820 fn runChannelLayer( 1821 conn: *Conn, 1822 fingerprint: *const [fingerprint_len]u8, 1823 handler: anytype, 1824 ) !void { 1825 const allocator = conn.allocator; 1826 var channel: ?Channel = null; 1827 1828 while (true) { 1829 const packet = try readSessionPacket(conn); 1830 defer allocator.free(packet); 1831 if (packet.len < 1) return error.EmptyPacket; 1832 const msg_type = packet[0]; 1833 1834 switch (msg_type) { 1835 SSH_MSG_GLOBAL_REQUEST => try handleGlobalRequest(conn, packet), 1836 1837 SSH_MSG_CHANNEL_OPEN => { 1838 if (channel != null) { 1839 try sendChannelOpenFailure(conn, packet, SSH_OPEN_RESOURCE_SHORTAGE, "only one channel per connection"); 1840 continue; 1841 } 1842 channel = try handleChannelOpen(conn, packet) orelse continue; 1843 }, 1844 1845 SSH_MSG_CHANNEL_REQUEST => { 1846 const ch = if (channel) |*c| c else continue; 1847 const start_request = try handleChannelRequest(conn, ch, packet); 1848 // the request borrows from the packet, which outlives the session 1849 if (start_request) |request| { 1850 // any key authenticates, so the deadline holds until here 1851 if (conn.idle) |idle| idle.session_started.store(true, .release); 1852 var sess = SessionCtx{ 1853 .conn = conn, 1854 .channel = ch, 1855 .fingerprint = fingerprint.*, 1856 }; 1857 defer sess.deinit(); 1858 handler.handleSession(&sess, request) catch |session_err| { 1859 // try to send a non-zero exit status before closing 1860 sess.exit(1) catch {}; 1861 return sess.underlyingError(session_err); 1862 }; 1863 if (!sess.closed) try sess.exit(0); 1864 return; 1865 } 1866 }, 1867 1868 SSH_MSG_CHANNEL_WINDOW_ADJUST => { 1869 const ch = if (channel) |*c| c else continue; 1870 try handleWindowAdjust(ch, packet); 1871 }, 1872 1873 SSH_MSG_CHANNEL_DATA, SSH_MSG_CHANNEL_EXTENDED_DATA => { 1874 const ch = if (channel) |*c| c else continue; 1875 // before shell/exec the client shouldn't be sending data, but 1876 // some clients are chatty; discard and refill window. nothing 1877 // is buffered yet, so the whole window is available to credit. 1878 try discardChannelData(ch, packet, msg_type == SSH_MSG_CHANNEL_EXTENDED_DATA); 1879 try maybeRefillRecvWindow(conn, ch, initial_recv_window); 1880 }, 1881 1882 SSH_MSG_CHANNEL_EOF => { 1883 if (channel) |*c| try parseChannelId(packet, c.local_id); 1884 }, 1885 1886 SSH_MSG_CHANNEL_CLOSE => { 1887 if (channel) |*c| { 1888 try parseChannelId(packet, c.local_id); 1889 try sendChannelMessage(conn, c, SSH_MSG_CHANNEL_CLOSE); 1890 drainConnection(conn); 1891 } 1892 return; 1893 }, 1894 1895 // RFC 4252 §5.1: silently ignored once authenticated 1896 SSH_MSG_USERAUTH_REQUEST => {}, 1897 else => try sendUnimplemented(conn), 1898 } 1899 } 1900 } 1901 1902 fn handleGlobalRequest(conn: *Conn, packet: []const u8) !void { 1903 // byte SSH_MSG_GLOBAL_REQUEST 1904 // string request_name 1905 // boolean want_reply 1906 // …request-specific data we ignore 1907 var r = std.Io.Reader.fixed(packet[1..]); 1908 _ = try takeString(&r, 256); 1909 const want_reply = (try r.takeByte()) != 0; 1910 if (want_reply) { 1911 try conn.writePacket(&[_]u8{SSH_MSG_REQUEST_FAILURE}); 1912 } 1913 } 1914 1915 /// parse a CHANNEL_OPEN. on a session channel, send CHANNEL_OPEN_CONFIRMATION 1916 /// and return the new Channel. on other types, send CHANNEL_OPEN_FAILURE and 1917 /// return null. 1918 fn handleChannelOpen(conn: *Conn, packet: []const u8) !?Channel { 1919 const allocator = conn.allocator; 1920 1921 // byte SSH_MSG_CHANNEL_OPEN 1922 // string channel_type 1923 // uint32 sender_channel (peer's id) 1924 // uint32 initial_window 1925 // uint32 max_packet 1926 var r = std.Io.Reader.fixed(packet[1..]); 1927 const ch_type = try takeString(&r, 64); 1928 const remote_id = try r.takeInt(u32, .big); 1929 const initial_window = try r.takeInt(u32, .big); 1930 const max_packet = try r.takeInt(u32, .big); 1931 1932 if (!std.mem.eql(u8, ch_type, "session")) { 1933 try sendChannelOpenFailure(conn, packet, SSH_OPEN_UNKNOWN_CHANNEL_TYPE, "only session channels are supported"); 1934 return null; 1935 } 1936 1937 // a zero max_packet would leave writeChannel looping without progress 1938 if (max_packet == 0) { 1939 try sendChannelOpenFailure(conn, packet, SSH_OPEN_RESOURCE_SHORTAGE, "max packet size must be non-zero"); 1940 return null; 1941 } 1942 1943 const local_id: u32 = 0; // single-channel connection — id is always 0 from our side 1944 var reply: std.ArrayList(u8) = .empty; 1945 defer reply.deinit(allocator); 1946 try reply.append(allocator, SSH_MSG_CHANNEL_OPEN_CONFIRMATION); 1947 try writeU32(&reply, allocator, remote_id); 1948 try writeU32(&reply, allocator, local_id); 1949 try writeU32(&reply, allocator, initial_recv_window); 1950 try writeU32(&reply, allocator, max_packet_size); 1951 try conn.writePacket(reply.items); 1952 1953 return .{ 1954 .local_id = local_id, 1955 .remote_id = remote_id, 1956 .local_window = initial_recv_window, 1957 .remote_window = initial_window, 1958 .max_packet = max_packet, 1959 }; 1960 } 1961 1962 fn sendChannelOpenFailure(conn: *Conn, open_packet: []const u8, reason: u32, description: []const u8) !void { 1963 const allocator = conn.allocator; 1964 1965 // recover the sender_channel field from the CHANNEL_OPEN packet so we 1966 // address the failure to the correct id 1967 var r = std.Io.Reader.fixed(open_packet[1..]); 1968 const ch_type_len = try r.takeInt(u32, .big); 1969 try r.discardAll(ch_type_len); 1970 const remote_id = try r.takeInt(u32, .big); 1971 1972 var reply: std.ArrayList(u8) = .empty; 1973 defer reply.deinit(allocator); 1974 try reply.append(allocator, SSH_MSG_CHANNEL_OPEN_FAILURE); 1975 try writeU32(&reply, allocator, remote_id); 1976 try writeU32(&reply, allocator, reason); 1977 try writeStringField(&reply, allocator, description); 1978 try writeStringField(&reply, allocator, ""); // language tag 1979 try conn.writePacket(reply.items); 1980 } 1981 1982 /// returns the session request if this one triggered it, else null. an exec 1983 /// command borrows from `packet`. 1984 fn handleChannelRequest(conn: *Conn, ch: *Channel, packet: []const u8) !?Request { 1985 // byte SSH_MSG_CHANNEL_REQUEST 1986 // uint32 recipient_channel (== our local_id) 1987 // string request_type 1988 // boolean want_reply 1989 // …request-specific data 1990 var r = std.Io.Reader.fixed(packet[1..]); 1991 const recipient_channel = try r.takeInt(u32, .big); 1992 if (recipient_channel != ch.local_id) return error.UnknownChannel; 1993 const req_type = try takeString(&r, 64); 1994 const want_reply = (try r.takeByte()) != 0; 1995 1996 if (std.mem.eql(u8, req_type, "pty-req")) { 1997 // string TERM, uint32 width chars, uint32 height rows, uint32 width px, 1998 // uint32 height px, string modes 1999 _ = try takeString(&r, 64); 2000 ch.pty = try takePtySize(&r); 2001 try replyChannelRequest(conn, ch, want_reply, true); 2002 return null; 2003 } 2004 2005 if (std.mem.eql(u8, req_type, "env")) { 2006 // string name, string value. the rest are ignored silently. 2007 const name = try takeString(&r, max_packet_len); 2008 const value = try takeString(&r, max_packet_len); 2009 if (std.mem.eql(u8, name, "GIT_PROTOCOL") and value.len <= ch.git_protocol_buf.len) { 2010 @memcpy(ch.git_protocol_buf[0..value.len], value); 2011 ch.git_protocol_len = value.len; 2012 } 2013 try replyChannelRequest(conn, ch, want_reply, true); 2014 return null; 2015 } 2016 2017 if (std.mem.eql(u8, req_type, "window-change")) { 2018 // uint32 width cells, uint32 height rows, uint32 width px, uint32 height px 2019 const sz = try takePtySize(&r); 2020 if (ch.pty != null) ch.pty = sz; 2021 // spec: window-change MUST NOT request a reply, but accept either 2022 try replyChannelRequest(conn, ch, want_reply, true); 2023 return null; 2024 } 2025 2026 if (std.mem.eql(u8, req_type, "shell")) { 2027 try replyChannelRequest(conn, ch, want_reply, true); 2028 return .{ .shell = ch.pty }; 2029 } 2030 2031 if (std.mem.eql(u8, req_type, "exec")) { 2032 // capture the command string for the handler to inspect 2033 const cmd = try takeString(&r, 4096); 2034 try replyChannelRequest(conn, ch, want_reply, true); 2035 return .{ .exec = .{ 2036 .command = cmd, 2037 .git_protocol = if (ch.git_protocol_len) |len| ch.git_protocol_buf[0..len] else null, 2038 } }; 2039 } 2040 2041 // unknown request kind — fail it 2042 try replyChannelRequest(conn, ch, want_reply, false); 2043 return null; 2044 } 2045 2046 fn replyChannelRequest(conn: *Conn, ch: *Channel, want_reply: bool, success: bool) !void { 2047 if (!want_reply) return; 2048 try sendChannelMessage(conn, ch, if (success) SSH_MSG_CHANNEL_SUCCESS else SSH_MSG_CHANNEL_FAILURE); 2049 } 2050 2051 fn sendChannelMessage(conn: *Conn, ch: *Channel, msg_type: u8) !void { 2052 var packet: [5]u8 = undefined; 2053 packet[0] = msg_type; 2054 std.mem.writeInt(u32, packet[1..5], ch.remote_id, .big); 2055 try conn.writePacket(&packet); 2056 } 2057 2058 fn handleWindowAdjust(ch: *Channel, packet: []const u8) !void { 2059 // byte SSH_MSG_CHANNEL_WINDOW_ADJUST 2060 // uint32 recipient_channel 2061 // uint32 bytes_to_add 2062 var r = std.Io.Reader.fixed(packet[1..]); 2063 const recipient_channel = try r.takeInt(u32, .big); 2064 if (recipient_channel != ch.local_id) return error.UnknownChannel; 2065 const add = try r.takeInt(u32, .big); 2066 ch.remote_window +|= add; 2067 } 2068 2069 // charge a payload's bytes against the receive window without keeping them. 2070 // the caller credits the window back, choosing the budget that suits it. 2071 fn discardChannelData(ch: *Channel, packet: []const u8, extended: bool) !void { 2072 // byte SSH_MSG_CHANNEL_DATA/EXTENDED_DATA 2073 // uint32 recipient_channel 2074 // [if extended: uint32 type_code] 2075 // string data 2076 const payload = try parseChannelData(packet, extended, ch.local_id); 2077 const data_len: u32 = @intCast(payload.len); 2078 2079 if (data_len > ch.local_window) return error.WindowExceeded; 2080 ch.local_window -= data_len; 2081 } 2082 2083 pub fn parseChannelData(packet: []const u8, extended: bool, expected_channel: u32) ![]const u8 { 2084 if (packet.len < 1) return error.EmptyPacket; 2085 var r = std.Io.Reader.fixed(packet[1..]); 2086 const recipient_channel = try r.takeInt(u32, .big); 2087 if (recipient_channel != expected_channel) return error.UnknownChannel; 2088 if (extended) { 2089 const data_type = try r.takeInt(u32, .big); 2090 if (data_type != SSH_EXTENDED_DATA_STDERR) return error.UnsupportedExtendedData; 2091 } 2092 const data_len = try r.takeInt(u32, .big); 2093 // catches both data_len > remaining (would otherwise be a bounds-check 2094 // panic on the slice) and trailing junk after the declared payload. 2095 if (data_len != r.buffered().len) return error.InvalidChannelData; 2096 return r.buffered(); 2097 } 2098 2099 fn parseChannelId(packet: []const u8, expected_channel: u32) !void { 2100 if (packet.len < 1) return error.EmptyPacket; 2101 var r = std.Io.Reader.fixed(packet[1..]); 2102 const recipient_channel = try r.takeInt(u32, .big); 2103 if (recipient_channel != expected_channel) return error.UnknownChannel; 2104 if (r.buffered().len != 0) return error.InvalidChannelMessage; 2105 } 2106 2107 /// send a CHANNEL_WINDOW_ADJUST if our receive window has dipped below half 2108 /// of `budget`, the credit we're willing to have outstanding. used both for 2109 /// the pre-session discard path and by SessionCtx after it consumes a 2110 /// CHANNEL_DATA payload. 2111 fn maybeRefillRecvWindow(conn: *Conn, ch: *Channel, budget: u32) !void { 2112 if (ch.local_window >= budget / 2) return; 2113 const add = budget - ch.local_window; 2114 ch.local_window += add; 2115 var adj: std.ArrayList(u8) = .empty; 2116 defer adj.deinit(conn.allocator); 2117 try adj.append(conn.allocator, SSH_MSG_CHANNEL_WINDOW_ADJUST); 2118 try writeU32(&adj, conn.allocator, ch.remote_id); 2119 try writeU32(&adj, conn.allocator, add); 2120 try conn.writePacket(adj.items); 2121 } 2122 2123 pub fn writeU32(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, value: u32) !void { 2124 var bytes: [4]u8 = undefined; 2125 std.mem.writeInt(u32, &bytes, value, .big); 2126 try buf.appendSlice(allocator, &bytes); 2127 } 2128 2129 test "pty dimensions have bounded axes and area, preserving normal and zero sizes" { 2130 for ([_]struct { width: u32, height: u32, expected: PtySize }{ 2131 .{ .width = 80, .height = 24, .expected = .{ .width_cells = 80, .height_cells = 24 } }, 2132 .{ .width = 0, .height = 0, .expected = .{ .width_cells = 0, .height_cells = 0 } }, 2133 .{ .width = 0xffffffff, .height = 0xffffffff, .expected = .{ .width_cells = 512, .height_cells = 128 } }, 2134 }) |case| { 2135 var bytes: [8]u8 = undefined; 2136 std.mem.writeInt(u32, bytes[0..4], case.width, .big); 2137 std.mem.writeInt(u32, bytes[4..8], case.height, .big); 2138 var reader = std.Io.Reader.fixed(&bytes); 2139 try std.testing.expectEqual(case.expected, try takePtySize(&reader)); 2140 } 2141 } 2142 2143 test "channel close before shell or exec is acknowledged" { 2144 const io = std.testing.io; 2145 const allocator = std.testing.allocator; 2146 const key = [_]u8{0x5a} ** 64; 2147 var encoded: [256]u8 = undefined; 2148 var encoded_writer = std.Io.Writer.fixed(&encoded); 2149 var sender = Cipher.init(&key, 0); 2150 var open: std.ArrayList(u8) = .empty; 2151 defer open.deinit(allocator); 2152 try open.append(allocator, SSH_MSG_CHANNEL_OPEN); 2153 try writeStringField(&open, allocator, "session"); 2154 try writeU32(&open, allocator, 7); 2155 try writeU32(&open, allocator, 32768); 2156 try writeU32(&open, allocator, 32768); 2157 try sender.writePacket(io, &encoded_writer, open.items); 2158 try sender.writePacket(io, &encoded_writer, &.{ SSH_MSG_CHANNEL_CLOSE, 0, 0, 0, 0 }); 2159 var reader = std.Io.Reader.fixed(encoded_writer.buffered()); 2160 var output: [256]u8 = undefined; 2161 var writer = std.Io.Writer.fixed(&output); 2162 var conn = Conn{ 2163 .io = io, 2164 .allocator = allocator, 2165 .reader = &reader, 2166 .writer = &writer, 2167 .cs_cipher = Cipher.init(&key, 0), 2168 .sc_cipher = Cipher.init(&key, 0), 2169 .rekey = undefined, 2170 }; 2171 const Handler = struct { 2172 pub fn handleSession(_: *@This(), _: *SessionCtx, _: Request) !void { 2173 return error.UnexpectedSession; 2174 } 2175 }; 2176 var handler = Handler{}; 2177 try runChannelLayer(&conn, &([_]u8{0} ** fingerprint_len), &handler); 2178 var output_reader = std.Io.Reader.fixed(writer.buffered()); 2179 var receiver = Cipher.init(&key, 0); 2180 const confirmation = try receiver.readPacket(allocator, &output_reader); 2181 defer allocator.free(confirmation); 2182 try std.testing.expectEqual(SSH_MSG_CHANNEL_OPEN_CONFIRMATION, confirmation[0]); 2183 const close = try receiver.readPacket(allocator, &output_reader); 2184 defer allocator.free(close); 2185 try std.testing.expectEqualSlices(u8, &.{ SSH_MSG_CHANNEL_CLOSE, 0, 0, 0, 7 }, close); 2186 try std.testing.expectEqual(0, output_reader.buffered().len); 2187 } 2188