Add WASI SSH CLI and in-process Git SSH transport

This commit is contained in:
2026-11-06 19:42:00 +00:00
parent 608d0c3fc1
commit 99a8e3a8be
29 changed files with 3546 additions and 30 deletions
+118
View File
@@ -0,0 +1,118 @@
//! Absolute-deadline blocking TCP adapter for single-command clients.
#[cfg(not(target_os = "wasi"))]
use std::net::ToSocketAddrs;
use std::{
io::{self, Read, Write},
net::{SocketAddr, TcpStream},
time::Instant,
};
#[cfg(not(target_os = "wasi"))]
fn resolve(host: &str, port: u16, _deadline: Instant) -> io::Result<Vec<SocketAddr>> {
Ok((host, port).to_socket_addrs()?.take(8).collect())
}
#[cfg(target_os = "wasi")]
fn resolve(host: &str, port: u16, deadline: Instant) -> io::Result<Vec<SocketAddr>> {
use wasi::sockets::{ip_name_lookup, network::IpAddress};
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(vec![SocketAddr::new(ip, port)]);
}
let network = wasi::sockets::instance_network::instance_network();
let addresses = ip_name_lookup::resolve_addresses(&network, host)
.map_err(|_| io::Error::other("DNS resolution failed"))?;
let ready = addresses.subscribe();
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(timeout)?;
let timer = wasi::clocks::monotonic_clock::subscribe_duration(
remaining.as_nanos().min(u64::MAX as u128) as u64,
);
let mut result = Vec::new();
loop {
if Instant::now() >= deadline {
return Err(timeout());
}
match addresses.resolve_next_address() {
Ok(Some(ip)) => {
let ip = match ip {
IpAddress::Ipv4((a, b, c, d)) => {
std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d))
}
IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => {
std::net::IpAddr::V6(std::net::Ipv6Addr::new(a, b, c, d, e, f, g, h))
}
};
result.push(SocketAddr::new(ip, port));
if result.len() == 8 {
break;
}
}
Ok(None) => break,
Err(wasi::sockets::network::ErrorCode::WouldBlock) => {
if wasi::io::poll::poll(&[&ready, &timer]).contains(&1) {
return Err(timeout());
}
}
Err(_) => return Err(io::Error::other("DNS resolution failed")),
}
}
Ok(result)
}
pub(crate) fn timeout() -> io::Error {
io::Error::new(io::ErrorKind::TimedOut, "timeout")
}
/// Captured raw streams, sharing a single byte budget. None means no exit-status (e.g. signal).
#[derive(Default, Debug)]
pub struct CommandOutput {
pub exit_code: Option<u32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub truncated: bool,
}
/// Each underlying operation checks time, including peers sending tiny packets indefinitely.
pub struct DeadlineTcp {
stream: TcpStream,
deadline: Instant,
}
impl DeadlineTcp {
pub fn connect(host: &str, port: u16, deadline: Instant) -> io::Result<Self> {
let addresses = resolve(host, port, deadline)?;
let mut last = io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused");
for address in addresses {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(timeout)?;
match TcpStream::connect_timeout(&address, remaining) {
Ok(stream) => return Ok(Self { stream, deadline }),
Err(e) => last = e,
}
}
Err(last)
}
fn prepare(&self) -> io::Result<()> {
let remaining = self
.deadline
.checked_duration_since(Instant::now())
.filter(|d| !d.is_zero())
.ok_or_else(timeout)?;
self.stream.set_read_timeout(Some(remaining))?;
self.stream.set_write_timeout(Some(remaining))
}
}
impl Read for DeadlineTcp {
fn read(&mut self, b: &mut [u8]) -> io::Result<usize> {
self.prepare()?;
self.stream.read(b)
}
}
impl Write for DeadlineTcp {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.prepare()?;
self.stream.write(b)
}
fn flush(&mut self) -> io::Result<()> {
self.prepare()?;
self.stream.flush()
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Public SSH identities and signing through the host's standard HTTP API.
use crate::{Session, Stream, connect_tcp, ssh_wire::fail};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde::Deserialize;
use serde_json::{Value, json};
use std::io;
use wasi::http::{outgoing_handler, types::*};
#[derive(Deserialize)]
pub struct Identity {
pub item: String,
pub public_key: String,
pub user: Option<String>,
}
fn post(route: &str, payload: Value) -> io::Result<Value> {
let base = std::env::var("SAUSAGE_HOST_API").unwrap_or_else(|_| "http://host.sausage".into());
let url = url::Url::parse(&format!("{}{route}", base.trim_end_matches('/')))
.map_err(|_| fail("invalid host API URL"))?;
let bytes = serde_json::to_vec(&payload).map_err(|e| fail(&e.to_string()))?;
let headers = Fields::new();
headers
.set("content-type", &[b"application/json".to_vec()])
.map_err(|_| fail("HTTP headers"))?;
headers
.set("content-length", &[bytes.len().to_string().into_bytes()])
.map_err(|_| fail("HTTP headers"))?;
let request = OutgoingRequest::new(headers);
request
.set_method(&Method::Post)
.map_err(|_| fail("HTTP method"))?;
request
.set_scheme(Some(&Scheme::Http))
.map_err(|_| fail("HTTP scheme"))?;
request
.set_authority(Some(
&url[url::Position::BeforeHost..url::Position::AfterPort],
))
.map_err(|_| fail("HTTP authority"))?;
request
.set_path_with_query(Some(url.path()))
.map_err(|_| fail("HTTP path"))?;
let body = request.body().map_err(|_| fail("HTTP body"))?;
let stream = body.write().map_err(|_| fail("HTTP stream"))?;
let response =
outgoing_handler::handle(request, None).map_err(|e| fail(&format!("host API: {e:?}")))?;
stream
.blocking_write_and_flush(&bytes)
.map_err(|_| fail("host API upload"))?;
drop(stream);
OutgoingBody::finish(body, None).map_err(|_| fail("host API upload finish"))?;
response.subscribe().block();
let response = response
.get()
.ok_or_else(|| fail("host API response missing"))?
.map_err(|_| fail("host API response consumed"))?
.map_err(|e| fail(&format!("host API: {e:?}")))?;
let status = response.status();
let body = response
.consume()
.map_err(|_| fail("host API response body"))?;
let stream = body
.stream()
.map_err(|_| fail("host API response stream"))?;
let mut bytes = Vec::new();
loop {
match stream.blocking_read(65536) {
Ok(data) if data.is_empty() => stream.subscribe().block(),
Ok(data) => bytes.extend(data),
Err(wasi::io::streams::StreamError::Closed) => break,
Err(_) => return Err(fail("host API response read")),
}
}
drop(stream);
let trailers = IncomingBody::finish(body);
trailers.subscribe().block();
if !matches!(trailers.get(), Some(Ok(Ok(_)))) {
return Err(fail("host API response finish"));
}
if status != 200 {
return Err(fail(&format!(
"host API HTTP {status}: {}",
String::from_utf8_lossy(&bytes)
)));
}
serde_json::from_slice(&bytes).map_err(|_| fail("invalid host API JSON"))
}
impl Identity {
pub fn load(host: &str) -> io::Result<Self> {
serde_json::from_value(post("/ssh/identity", json!({"host": host}))?)
.map_err(|_| fail("invalid SSH identity"))
}
pub fn sign(&self, host: &str, payload: Vec<u8>) -> io::Result<Vec<u8>> {
let result = post(
"/ssh/sign",
json!({"host":host,"item":self.item,"payload":STANDARD.encode(payload)}),
)?;
STANDARD
.decode(
result["signature"]
.as_str()
.ok_or_else(|| fail("missing signature"))?,
)
.map_err(|_| fail("invalid signature"))
}
}
pub fn connect(
host: &str,
port: u16,
user: Option<&str>,
accept_new: bool,
) -> io::Result<Session<Box<dyn Stream>>> {
let identity = Identity::load(host)?;
let user = user
.or(identity.user.as_deref())
.filter(|u| !u.is_empty())
.ok_or_else(|| fail("specify an SSH user"))?;
let public = ssh_key::PublicKey::from_bytes(
&STANDARD
.decode(&identity.public_key)
.map_err(|_| fail("invalid public key"))?,
)
.map_err(|_| fail("invalid public key"))?;
let mut session = Session::start(connect_tcp(host, port)?, host.into(), port, accept_new)?;
futures_lite::future::block_on(session.authenticate_with(user, &public, |payload| {
std::future::ready(identity.sign(host, payload))
}))?;
Ok(session)
}
+9
View File
@@ -0,0 +1,9 @@
//! SSH2 sessions, world-owned host trust, and host-mediated signing for WASI programs.
mod command;
pub mod identity;
pub mod ssh_hosts;
pub mod ssh_kex;
pub mod ssh_wire;
pub use command::{CommandOutput, DeadlineTcp};
mod session;
pub use session::*;
+463
View File
@@ -0,0 +1,463 @@
//! Blocking SSH2 exec transport. No shell/process on the WASI host.
//! Host trust uses the existing lock-free known_hosts check/append policy.
use super::command::{CommandOutput, timeout};
use super::{ssh_hosts, ssh_kex, ssh_wire::*};
use std::{
cell::RefCell,
collections::VecDeque,
io::{self, Read, Write},
net::{TcpStream, ToSocketAddrs},
rc::Rc,
time::{Duration, Instant},
};
const WINDOW: u32 = 1024 * 1024;
include!("ssh_channel_tests.inc");
pub trait Stream: Read + Write {}
impl<T: Read + Write> Stream for T {}
/// TCP connection and per-operation I/O budgets. The overall budget starts after DNS.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TcpTimeouts {
pub overall: Duration,
pub connect: Duration,
pub read: Duration,
pub write: Duration,
}
impl Default for TcpTimeouts {
fn default() -> Self {
Self {
overall: Duration::from_secs(5),
connect: Duration::from_secs(2),
read: Duration::from_secs(15),
write: Duration::from_secs(15),
}
}
}
pub fn connect_tcp(host: &str, port: u16) -> io::Result<Box<dyn Stream>> {
connect_tcp_with_timeouts(host, port, TcpTimeouts::default())
}
pub fn connect_tcp_with_timeouts(
host: &str,
port: u16,
timeouts: TcpTimeouts,
) -> io::Result<Box<dyn Stream>> {
Ok(Box::new(tcp_stream(host, port, timeouts)?))
}
fn tcp_stream(host: &str, port: u16, timeouts: TcpTimeouts) -> io::Result<TcpStream> {
let addresses = (host, port)
.to_socket_addrs()
.map_err(|_| fail("DNS resolution failed"))?;
let deadline = Instant::now() + timeouts.overall;
for address in addresses.take(8) {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
break;
};
if let Ok(stream) = TcpStream::connect_timeout(&address, remaining.min(timeouts.connect)) {
stream.set_read_timeout(Some(timeouts.read))?;
stream.set_write_timeout(Some(timeouts.write))?;
return Ok(stream);
}
}
Err(fail(&format!(
"TCP connection failed or timed out ({}s)",
timeouts.overall.as_secs()
)))
}
#[cfg(test)]
mod timeout_tests {
use super::*;
#[test]
fn tcp_socket_uses_default_and_overridden_io_timeouts() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
for timeouts in [
TcpTimeouts::default(),
TcpTimeouts {
overall: Duration::from_secs(7),
connect: Duration::from_secs(3),
read: Duration::from_secs(11),
write: Duration::from_secs(13),
},
] {
let stream = tcp_stream("127.0.0.1", port, timeouts).unwrap();
let (_peer, _) = listener.accept().unwrap();
assert_eq!(stream.read_timeout().unwrap(), Some(timeouts.read));
assert_eq!(stream.write_timeout().unwrap(), Some(timeouts.write));
}
}
}
pub struct Session<S> {
pub(crate) packets: Packets<S>,
version: Vec<u8>,
id: Vec<u8>,
host: String,
port: u16,
accept_new: bool,
remote: u32,
send_window: u32,
send_max: u32,
receive_window: u32,
data: VecDeque<u8>,
eof: bool,
closed: bool,
exit: Option<u32>,
capture: Option<CommandOutput>,
capture_limit: usize,
deadline: Option<Instant>,
}
impl<S: Read + Write> Session<S> {
pub fn start(mut stream: S, host: String, port: u16, accept_new: bool) -> io::Result<Self> {
let version = ssh_kex::banner(&mut stream)?;
let mut packets = Packets::new(stream);
let mut id = Vec::new();
ssh_kex::exchange(&mut packets, &version, &mut id, None, |key| {
ssh_hosts::verify(&host, port, key, accept_new)
})?;
Ok(Self {
packets,
version,
id,
host,
port,
accept_new,
remote: 0,
send_window: 0,
send_max: 0,
receive_window: WINDOW,
data: VecDeque::new(),
eof: false,
closed: false,
exit: None,
capture: None,
capture_limit: 0,
deadline: None,
})
}
fn next(&mut self) -> io::Result<Vec<u8>> {
for _ in 0..256 {
if self.deadline.is_some_and(|d| Instant::now() >= d) {
return Err(timeout());
}
let p = self.packets.meaningful()?;
match p.first() {
Some(20) => {
let (host, port, accept_new) = (&self.host, self.port, self.accept_new);
ssh_kex::exchange(
&mut self.packets,
&self.version,
&mut self.id,
Some(p),
|key| ssh_hosts::verify(host, port, key, accept_new),
)?;
}
Some(80) => {
let mut d = Decoder(&p[1..]);
d.string()?;
if d.byte()? != 0 {
self.packets.write(&[82])?;
}
}
Some(_) => return Ok(p),
None => return Err(fail("empty packet")),
}
}
Err(fail("too many non-progress transport messages"))
}
/// Authenticate without holding a private key. The callback returns an SSH-encoded signature.
/// Async permits a vault endpoint round trip; no retries or key bytes cross this API.
pub async fn authenticate_with<F, Fut>(
&mut self,
user: &str,
public: &ssh_key::PublicKey,
sign: F,
) -> io::Result<()>
where
F: FnOnce(Vec<u8>) -> Fut,
Fut: std::future::Future<Output = io::Result<Vec<u8>>>,
{
self.packets.write(&message(5, &[b"ssh-userauth"]))?;
let accept = self.next()?;
if accept != message(6, &[b"ssh-userauth"]) {
return Err(fail("userauth service rejected"));
}
if public.algorithm() != ssh_key::Algorithm::Ed25519 {
return Err(fail("only ed25519 authentication supported"));
}
let public = public
.to_bytes()
.map_err(|_| fail("could not encode public key"))?;
let mut request = message(50, &[user.as_bytes(), b"ssh-connection", b"publickey"]);
request.push(1);
string(&mut request, b"ssh-ed25519");
string(&mut request, &public);
let mut signed = Vec::new();
string(&mut signed, &self.id);
signed.extend_from_slice(&request);
let signature = sign(signed).await?;
string(&mut request, &signature);
self.packets.write(&request)?;
for _ in 0..16 {
match self.next()?.first() {
Some(52) => return Ok(()),
Some(53) => continue, // banner, never logged
Some(51) => return Err(fail("auth failed: publickey auth rejected")),
_ => return Err(fail("unexpected authentication reply")),
}
}
Err(fail("too many authentication banners"))
}
pub fn open_command(&mut self, command: &[u8]) -> io::Result<()> {
if command.is_empty() || command.contains(&0) {
return Err(fail("invalid exec command"));
}
let mut p = message(90, &[b"session"]);
uint(&mut p, 0);
uint(&mut p, WINDOW);
uint(&mut p, 32768);
self.packets.write(&p)?;
let reply = self.next()?;
let mut d = Decoder(&reply);
if d.byte()? != 91 || d.uint()? != 0 {
return Err(fail("exec channel open rejected"));
}
self.remote = d.uint()?;
self.send_window = d.uint()?;
self.send_max = d.uint()?.min(32768);
if self.send_max == 0 {
return Err(fail("invalid channel max packet size"));
}
let mut p = vec![98];
uint(&mut p, self.remote);
string(&mut p, b"exec");
p.push(1);
string(&mut p, command);
self.packets.write(&p)?;
for _ in 0..256 {
let p = self.next()?;
if p.first() == Some(&99) {
let mut d = Decoder(&p[1..]);
if d.uint()? != 0 {
return Err(fail("wrong channel in exec reply"));
}
return Ok(());
}
if p.first() == Some(&100) {
return Err(fail("remote exec request rejected"));
}
self.handle(p)?;
if self.closed {
return Err(fail("channel closed before exec confirmation"));
}
}
Err(fail("exec confirmation missing"))
}
/// Run exactly one exec channel, send stdin EOF, drain both streams through CLOSE.
/// The stream must enforce the same absolute deadline for blocking reads/writes.
pub fn execute(
self,
command: &[u8],
max_bytes: usize,
deadline: Instant,
) -> io::Result<CommandOutput> {
self.execute_input(command, &[], max_bytes, deadline)
}
/// Execute a command with buffered stdin and capture both output streams.
pub fn execute_input(
mut self,
command: &[u8],
mut input: &[u8],
max_bytes: usize,
deadline: Instant,
) -> io::Result<CommandOutput> {
self.deadline = Some(deadline);
self.capture_limit = max_bytes;
self.capture = Some(CommandOutput::default());
self.open_command(command)?;
while !input.is_empty() {
let sent = self.write_data(input)?;
input = &input[sent..];
}
let mut eof = vec![96];
uint(&mut eof, self.remote);
self.packets.write(&eof)?;
while !self.closed {
let p = self.next()?;
self.handle(p)?;
}
let mut output = self
.capture
.take()
.ok_or_else(|| fail("missing command result"))?;
output.exit_code = self.exit;
Ok(output)
}
fn replenish(&mut self, count: u32) -> io::Result<()> {
if count == 0 || self.closed {
return Ok(());
}
self.receive_window = self
.receive_window
.checked_add(count)
.filter(|w| *w <= WINDOW)
.ok_or_else(|| fail("channel receive window overflow"))?;
let mut p = vec![93];
uint(&mut p, self.remote);
uint(&mut p, count);
self.packets.write(&p)
}
fn handle(&mut self, p: Vec<u8>) -> io::Result<()> {
let mut d = Decoder(&p);
let kind = d.byte()?;
if !(93..=100).contains(&kind) {
return Err(fail("unexpected SSH channel message"));
}
if d.uint()? != 0 {
return Err(fail("wrong SSH channel recipient"));
}
match kind {
93 => {
self.send_window = self
.send_window
.checked_add(d.uint()?)
.ok_or_else(|| fail("channel send window overflow"))?;
}
94 | 95 => {
if self.eof {
return Err(fail("data after channel EOF"));
}
if kind == 95 {
d.uint()?;
}
let data = d.string()?;
if data.len() > 32768 || data.len() > self.receive_window as usize {
return Err(fail("channel receive window exceeded"));
}
self.receive_window -= data.len() as u32;
if let Some(output) = &mut self.capture {
let remaining = self
.capture_limit
.saturating_sub(output.stdout.len() + output.stderr.len());
let n = remaining.min(data.len());
output.truncated |= n < data.len();
if kind == 94 {
output.stdout.extend_from_slice(&data[..n]);
} else {
output.stderr.extend_from_slice(&data[..n]);
}
self.replenish(data.len() as u32)?;
} else if kind == 94 {
self.data.extend(data);
} else {
self.replenish(data.len() as u32)?;
} // Git discards stderr
}
96 => {
self.eof = true;
}
97 => {
if !self.closed {
let mut p = vec![97];
uint(&mut p, self.remote);
self.packets.write(&p)?;
}
self.closed = true;
self.eof = true;
}
98 => {
let request = d.string()?;
let want_reply = d.byte()? != 0;
match request {
b"exit-status" => self.exit = Some(d.uint()?),
b"exit-signal" if self.capture.is_some() => {}
b"exit-signal" => return Err(fail("remote git terminated by signal")),
_ => {}
}
if want_reply {
let mut p = vec![100];
uint(&mut p, self.remote);
self.packets.write(&p)?;
}
}
_ => return Err(fail("unsolicited channel request result")),
}
Ok(())
}
fn read_data(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
for _ in 0..1024 {
if !self.data.is_empty() {
let n = buf.len().min(self.data.len());
for b in &mut buf[..n] {
*b = self
.data
.pop_front()
.ok_or_else(|| fail("channel buffer underflow"))?;
}
self.replenish(n as u32)?;
return Ok(n);
}
if self.exit.is_some_and(|n| n != 0) {
return Err(fail("remote git exited unsuccessfully"));
}
if self.eof {
return Ok(0);
}
let p = self.next()?;
self.handle(p)?;
}
Err(fail("too many non-progress channel messages"))
}
fn write_data(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
if self.closed {
return Err(fail("channel closed"));
}
for _ in 0..1024 {
if self.send_window != 0 {
let n = buf
.len()
.min(self.send_window as usize)
.min(self.send_max as usize);
let mut p = vec![94];
uint(&mut p, self.remote);
string(&mut p, &buf[..n]);
self.packets.write(&p)?;
self.send_window -= n as u32;
return Ok(n);
}
let p = self.next()?;
self.handle(p)?;
if self.closed {
return Err(fail("channel closed"));
}
}
Err(fail("channel send window stalled"))
}
}
pub struct Reader(Rc<RefCell<Session<Box<dyn Stream>>>>);
pub struct Writer(Rc<RefCell<Session<Box<dyn Stream>>>>);
impl Read for Reader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.borrow_mut().read_data(buf)
}
}
impl Write for Writer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().write_data(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.borrow_mut().packets.stream.flush()
}
}
impl Session<Box<dyn Stream>> {
/// Split one channel into blocking reader/writer halves, as used by gitoxide.
pub fn split(self) -> (Reader, Writer) {
let shared = Rc::new(RefCell::new(self));
(Reader(shared.clone()), Writer(shared))
}
}
@@ -0,0 +1,43 @@
#[cfg(test)]
mod channel_tests {
use super::*;
use std::io::Cursor;
struct Wire { input: Cursor<Vec<u8>>, output: Vec<u8> }
impl Read for Wire { fn read(&mut self, b: &mut [u8]) -> io::Result<usize> { self.input.read(b) } }
impl Write for Wire {
fn write(&mut self, b: &[u8]) -> io::Result<usize> { self.output.extend(b); Ok(b.len()) }
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
fn session() -> Session<Wire> {
Session { packets: Packets::new(Wire { input: Cursor::new(vec![]), output: vec![] }), version: vec![], id: vec![],
host: "fake.example".into(), port: 22, accept_new: false, remote: 42, send_window: 10, send_max: 4, receive_window: WINDOW,
data: VecDeque::new(), eof: false, closed: false, exit: None,
capture: None, capture_limit: 0, deadline: None }
}
fn channel(kind: u8, data: &[u8]) -> Vec<u8> {
let mut p = vec![kind]; uint(&mut p, 0); p.extend(data); p
}
#[test]
fn channel_data_stderr_window_eof_close() {
let mut s = session();
let mut data = vec![]; string(&mut data, b"hello"); s.handle(channel(94, &data)).unwrap();
assert_eq!(s.receive_window, WINDOW-5);
let mut stderr = vec![]; uint(&mut stderr, 1); string(&mut stderr, b"discarded stderr"); s.handle(channel(95, &stderr)).unwrap();
assert_eq!(s.receive_window, WINDOW-5);
s.handle(channel(96, &[])).unwrap();
let mut buffer = [0;10]; assert_eq!(s.read_data(&mut buffer).unwrap(),5); assert_eq!(&buffer[..5], b"hello");
assert_eq!(s.receive_window, WINDOW); assert_eq!(s.read_data(&mut buffer).unwrap(), 0);
assert!(s.handle(channel(94, &data)).is_err());
s.handle(channel(97, &[])).unwrap(); assert!(s.closed); assert!(s.write_data(b"after close").is_err());
}
#[test]
fn channel_send_chunks_reject_overflow_and_failed_exit() {
let mut s = session(); assert_eq!(s.write_data(b"0123456789").unwrap(), 4); assert_eq!(s.send_window, 6);
let mut wrong = vec![93]; uint(&mut wrong, 99); uint(&mut wrong, 1); assert!(s.handle(wrong).is_err());
let mut adjustment = vec![]; uint(&mut adjustment, u32::MAX); assert!(s.handle(channel(93,&adjustment)).is_err());
let mut s = session(); s.receive_window = 1;
let mut data = vec![]; string(&mut data, b"over window"); assert!(s.handle(channel(94,&data)).is_err());
let mut status = vec![]; string(&mut status, b"exit-status"); status.push(0); uint(&mut status, 1);
s.handle(channel(98,&status)).unwrap(); assert!(s.read_data(&mut [0]).is_err());
}
}
+239
View File
@@ -0,0 +1,239 @@
//! OpenSSH known_hosts policy: explicit keys, hashed names, patterns and revocations.
use super::ssh_wire::fail;
use base64::{Engine, engine::general_purpose::STANDARD};
use hmac::{Hmac, Mac};
use std::io::{self, Write};
fn pattern(pattern: &[u8], name: &[u8]) -> bool {
// Linear glob matcher with a single backtracking '*' position, no recursion.
let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0);
while n < name.len() {
if p < pattern.len() && (pattern[p] == b'?' || pattern[p].eq_ignore_ascii_case(&name[n])) {
p += 1;
n += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
p += 1;
retry = n;
} else if let Some(s) = star {
retry += 1;
n = retry;
p = s + 1;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
fn matches(names: &str, host: &str) -> bool {
if names.starts_with('|') {
let parts: Vec<_> = names.split('|').collect();
if parts.len() != 4 || parts[1] != "1" {
return false;
}
let (Ok(salt), Ok(hash)) = (STANDARD.decode(parts[2]), STANDARD.decode(parts[3])) else {
return false;
};
let Ok(mut mac) = <Hmac<sha1::Sha1> as Mac>::new_from_slice(&salt) else {
return false;
};
mac.update(host.as_bytes());
return mac.verify_slice(&hash).is_ok();
}
let mut found = false;
for part in names.split(',') {
if let Some(negated) = part.strip_prefix('!') {
if pattern(negated.as_bytes(), host.as_bytes()) {
return false;
}
} else if pattern(part.as_bytes(), host.as_bytes()) {
found = true;
}
}
found
}
/// Returns false for an unknown host; mismatched/revoked keys are always errors.
pub fn check(text: &str, host: &str, key: &[u8]) -> io::Result<bool> {
let mut seen = false;
let mut matched = false;
for line in text.lines() {
let mut fields = line.split_whitespace();
let Some(first) = fields.next() else {
continue;
};
if first.starts_with('#') {
continue;
}
let (marker, names) = if first.starts_with('@') {
(Some(first), fields.next().unwrap_or(""))
} else {
(None, first)
};
if !matches(names, host) {
continue;
}
let algorithm = fields
.next()
.ok_or_else(|| fail("malformed known_hosts entry"))?;
let encoded = fields
.next()
.ok_or_else(|| fail("malformed known_hosts entry"))?;
let bytes = STANDARD
.decode(encoded)
.map_err(|_| fail("malformed known_hosts key"))?;
if marker == Some("@cert-authority") {
continue;
} // certificates not negotiated
if marker.is_some() && marker != Some("@revoked") {
return Err(fail("unsupported known_hosts marker"));
}
if marker == Some("@revoked") {
if bytes == key {
return Err(fail(
"Host key verification failed: revoked key in ~/.ssh/known_hosts",
));
}
continue;
}
// Other algorithms for this host are not a changed ed25519 key.
if algorithm == "ssh-ed25519" {
seen = true;
if bytes == key {
matched = true;
}
}
}
if matched {
Ok(true)
} else if seen {
Err(fail(
"WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! Host key verification failed.",
))
} else {
Ok(false)
}
}
/// No global lock: network operations in unrelated repositories must stay independent.
/// Accept-new uses append mode, never read/modify/replace. Racing checks may append
/// identical entries; these are harmless. External trust-file edits must still be
/// coordinated by the person (as with any concurrent known_hosts editor).
pub fn verify(host: &str, port: u16, key: &[u8], accept_new: bool) -> io::Result<()> {
let host = if port == 22 {
host.to_owned()
} else {
format!("[{host}]:{port}")
};
let folder = std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_else(|| "/".into()))
.join(".ssh");
std::fs::create_dir_all(&folder)?;
let path = folder.join("known_hosts");
let text = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(_) => return Err(fail("could not read ~/.ssh/known_hosts")),
};
if check(&text, &host, key)? {
return Ok(());
}
if !accept_new {
use sha2::Digest;
let fingerprint =
base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(key));
return Err(fail(&format!(
"No ED25519 host key is known for {host} and you have requested strict checking. Host key verification failed. SHA256:{fingerprint}"
)));
}
let prefix = if text.is_empty() || text.ends_with('\n') {
""
} else {
"\n"
};
let line = format!("{prefix}{host} ssh-ed25519 {}\n", STANDARD.encode(key));
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_plain_hashed_changed_revoked() {
let key = b"synthetic-host-key";
let encoded = STANDARD.encode(key);
assert!(
check(
&format!("*.example,!bad.example ssh-ed25519 {encoded}"),
"git.example",
key
)
.unwrap()
);
assert!(
!check(
&format!("*.example,!bad.example ssh-ed25519 {encoded}"),
"bad.example",
key
)
.unwrap()
);
assert!(
check(
&format!("git.example ssh-ed25519 {encoded}"),
"git.example",
b"changed"
)
.is_err()
);
assert!(
check(
&format!(
"git.example ssh-ed25519 {encoded}\n@revoked git.example ssh-ed25519 {encoded}"
),
"git.example",
key
)
.is_err()
);
let salt = b"01234567890123456789";
let mut mac = <Hmac<sha1::Sha1> as Mac>::new_from_slice(salt).unwrap();
mac.update(b"[git.example]:2222");
let names = format!(
"|1|{}|{}",
STANDARD.encode(salt),
STANDARD.encode(mac.finalize().into_bytes())
);
assert!(
check(
&format!("{names} ssh-ed25519 {encoded}"),
"[git.example]:2222",
key
)
.unwrap()
);
assert!(
!check(
&format!("{names} ssh-ed25519 {encoded}"),
"git.example",
key
)
.unwrap()
);
}
#[test]
fn duplicate_appends_preserve_trust_policy() {
// Model two accept-new callers that both read an unknown host before appending.
let key = b"synthetic-host-key";
let line = format!("git.example ssh-ed25519 {}\n", STANDARD.encode(key));
let duplicated = line.repeat(2);
assert!(check(&duplicated, "git.example", key).unwrap());
assert!(check(&duplicated, "git.example", b"changed").is_err());
let revoked = format!("{duplicated}@revoked {line}");
assert!(check(&revoked, "git.example", key).is_err());
}
}
+223
View File
@@ -0,0 +1,223 @@
//! RFC8731 curve25519-sha256, RFC4253 key derivation, ed25519 host signatures.
include!("ssh_negotiation_tests.inc");
use super::ssh_wire::*;
use sha2::{Digest, Sha256};
use signature::Verifier;
use std::io::{self, Read, Write};
use x25519_dalek::{PublicKey, StaticSecret};
pub const VERSION: &[u8] = b"SSH-2.0-SausageGit";
const KEX: &[u8] = b"curve25519-sha256";
const HOST: &[u8] = b"ssh-ed25519";
const CIPHER: &[u8] = b"aes128-ctr";
const MAC: &[u8] = b"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256";
pub fn banner<S: Read + Write>(stream: &mut S) -> io::Result<Vec<u8>> {
stream.write_all(VERSION)?;
stream.write_all(b"\r\n")?;
stream.flush()?;
// RFC4253 permits pre-banner lines, bounded here to 8KiB.
let mut line = Vec::new();
for _ in 0..8192 {
let mut b = [0];
stream.read_exact(&mut b)?;
if b[0] == b'\n' {
if line.last() == Some(&b'\r') {
line.pop();
}
if line.starts_with(b"SSH-") {
if !line.starts_with(b"SSH-2.0-") || line.len() > 253 {
return Err(fail("unsupported server version"));
}
return Ok(line);
}
line.clear();
} else {
line.push(b[0]);
if line.len() > 1024 {
return Err(fail("server banner too long"));
}
}
}
Err(fail("server banner missing"))
}
fn init() -> io::Result<Vec<u8>> {
let mut p = vec![20];
let mut cookie = [0; 16];
random(&mut cookie)?;
p.extend(cookie);
for list in [
KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none", b"", b"",
] {
string(&mut p, list);
}
p.push(0);
uint(&mut p, 0);
Ok(p)
}
fn contains(list: &[u8], name: &[u8]) -> bool {
list.split(|b| *b == b',').any(|n| n == name)
}
fn select<'a>(kind: &str, ours: &'a [u8], theirs: &[u8]) -> io::Result<&'a [u8]> {
ours.split(|b| *b == b',')
.find(|name| contains(theirs, name))
.ok_or_else(|| {
fail(&format!(
"no matching {kind}; client offered {:?}; server offered {:?}",
String::from_utf8_lossy(ours),
String::from_utf8_lossy(theirs)
))
})
}
#[derive(Debug)]
struct Negotiated {
send: MacAlgorithm,
recv: MacAlgorithm,
wrong_guess: bool,
}
fn negotiate(server: &[u8]) -> io::Result<Negotiated> {
let mut d = Decoder(server);
if d.byte()? != 20 {
return Err(fail("expected KEXINIT"));
}
d.take(16)?;
let kex = d.string()?;
let host = d.string()?;
select("kex", KEX, kex)?;
select("host-key", HOST, host)?;
select("cipher client-to-server", CIPHER, d.string()?)?;
select("cipher server-to-client", CIPHER, d.string()?)?;
let send = MacAlgorithm::from_name(select("MAC client-to-server", MAC, d.string()?)?)?;
let recv = MacAlgorithm::from_name(select("MAC server-to-client", MAC, d.string()?)?)?;
select("compression client-to-server", b"none", d.string()?)?;
select("compression server-to-client", b"none", d.string()?)?;
d.string()?;
d.string()?;
let follows = d.byte()? != 0;
d.uint()?;
d.end()?;
Ok(Negotiated {
send,
recv,
wrong_guess: follows
&& (kex.split(|b| *b == b',').next() != Some(KEX)
|| host.split(|b| *b == b',').next() != Some(HOST)),
})
}
/// RFC8731 §3.1 reinterprets the raw X25519 output as network-order unsigned K.
pub fn mpint(shared: &[u8; 32]) -> Vec<u8> {
let mut value: Vec<_> = shared.iter().copied().skip_while(|b| *b == 0).collect();
if value.first().is_some_and(|b| b & 128 != 0) {
value.insert(0, 0);
}
let mut out = Vec::new();
string(&mut out, &value);
out
}
pub fn derive(k: &[u8], hash: &[u8], letter: u8, session: &[u8]) -> Vec<u8> {
let mut h = Sha256::new();
h.update(k);
h.update(hash);
h.update([letter]);
h.update(session);
h.finalize().to_vec()
}
/// RFC4253 §7.2 expansion: SHA-512 MAC needs 64 bytes even with SHA-256 KEX.
pub fn derive_len(k: &[u8], hash: &[u8], letter: u8, session: &[u8], len: usize) -> Vec<u8> {
let mut key = derive(k, hash, letter, session);
while key.len() < len {
let mut h = Sha256::new();
h.update(k);
h.update(hash);
h.update(&key);
key.extend_from_slice(&h.finalize());
}
key.truncate(len);
key
}
/// Authenticate a key exchange and switch protection in each direction at NEWKEYS.
pub fn exchange<S: Read + Write>(
packets: &mut Packets<S>,
server_version: &[u8],
session: &mut Vec<u8>,
server_init: Option<Vec<u8>>,
mut verify: impl FnMut(&[u8]) -> io::Result<()>,
) -> io::Result<()> {
let ours = init()?;
packets.write(&ours)?;
let theirs = match server_init {
Some(p) => p,
None => packets.meaningful()?,
};
let negotiated = negotiate(&theirs)?;
let mut secret = [0; 32];
random(&mut secret)?;
let secret = StaticSecret::from(secret);
let public = PublicKey::from(&secret);
packets.write(&message(30, &[public.as_bytes()]))?;
if negotiated.wrong_guess {
packets.meaningful()?;
}
let reply = packets.meaningful()?;
let mut d = Decoder(&reply);
if d.byte()? != 31 {
return Err(fail("expected curve25519 reply"));
}
let host = d.string()?;
let remote = d.string()?;
let signature = d.string()?;
d.end()?;
let remote: [u8; 32] = remote
.try_into()
.map_err(|_| fail("invalid curve25519 public key"))?;
let shared = secret.diffie_hellman(&PublicKey::from(remote));
if !shared.was_contributory() {
return Err(fail("invalid all-zero shared secret"));
}
let k = mpint(shared.as_bytes());
let mut transcript = Vec::new();
for item in [
VERSION,
server_version,
&ours,
&theirs,
host,
public.as_bytes(),
&remote,
] {
string(&mut transcript, item);
}
transcript.extend_from_slice(&k);
let hash = Sha256::digest(&transcript);
let key = ssh_key::PublicKey::from_bytes(host).map_err(|_| fail("invalid host public key"))?;
if key.algorithm() != ssh_key::Algorithm::Ed25519 {
return Err(fail("unsupported host key"));
}
let signature =
ssh_key::Signature::try_from(signature).map_err(|_| fail("invalid host signature"))?;
Verifier::verify(&key, &hash, &signature)
.map_err(|_| fail("host key exchange signature mismatch"))?;
verify(host)?;
if session.is_empty() {
session.extend_from_slice(&hash);
}
let send = Protection::with_mac(
&derive(&k, &hash, b'C', session)[..16],
&derive(&k, &hash, b'A', session)[..16],
derive_len(&k, &hash, b'E', session, negotiated.send.key_len()),
negotiated.send,
)?;
let recv = Protection::with_mac(
&derive(&k, &hash, b'D', session)[..16],
&derive(&k, &hash, b'B', session)[..16],
derive_len(&k, &hash, b'F', session, negotiated.recv.key_len()),
negotiated.recv,
)?;
packets.write(&[21])?;
packets.send = Some(send);
if packets.meaningful()? != [21] {
return Err(fail("expected NEWKEYS"));
}
packets.recv = Some(recv);
Ok(())
}
@@ -0,0 +1,48 @@
#[cfg(test)]
mod negotiation_tests {
use super::*;
fn server(lists: &[&[u8];8], follows: bool) -> Vec<u8> {
let mut p = vec![20]; p.extend([7;16]);
for list in lists { string(&mut p, list); }
string(&mut p, b""); string(&mut p, b""); p.push(u8::from(follows)); uint(&mut p, 0); p
}
const NIXOS: &[u8] = b"hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com";
#[test]
fn nixos_selection_directionality_and_wrong_guess() {
let mut lists = [KEX, HOST, CIPHER, CIPHER, NIXOS, NIXOS, b"none", b"none"];
let n = negotiate(&server(&lists, false)).unwrap();
// Client order wins over server preference for SHA-512.
assert_eq!(n.send, MacAlgorithm::Sha256Etm); assert_eq!(n.recv, n.send); assert!(!n.wrong_guess);
lists[4] = b"hmac-sha2-512-etm@openssh.com"; lists[5] = b"hmac-sha2-256";
let n = negotiate(&server(&lists, true)).unwrap();
assert_eq!(n.send, MacAlgorithm::Sha512Etm); assert_eq!(n.recv, MacAlgorithm::Sha256); assert!(!n.wrong_guess);
lists[0] = b"unknown-kex,curve25519-sha256";
assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess);
lists[0] = KEX; lists[1] = b"unknown-host,ssh-ed25519";
assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess);
}
#[test]
fn mismatch_names_both_offers_and_direction() {
let lists = [KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none"];
let categories = ["kex", "host-key", "cipher client-to-server", "cipher server-to-client",
"MAC client-to-server", "MAC server-to-client", "compression client-to-server", "compression server-to-client"];
for (i, category) in categories.into_iter().enumerate() {
let mut bad = lists; bad[i] = b"unsupported-one,unsupported-two";
let error = negotiate(&server(&bad, false)).unwrap_err().to_string();
assert!(error.contains(category), "{error}");
assert!(error.contains(&format!("client offered {:?}", String::from_utf8_lossy(lists[i]))), "{error}");
assert!(error.contains("server offered \"unsupported-one,unsupported-two\""), "{error}");
}
let valid = server(&lists, false);
for end in 0..valid.len() { assert!(negotiate(&valid[..end]).is_err()); }
}
#[test]
fn sha512_mac_key_uses_rfc4253_sha256_expansion() {
// Independent OpenSSL/Node fixture, including an original session id != H.
let key = derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 64);
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "2a9e79189e2902184e372b346241650ef74c7c339fd779556f7c09c1c0cc646e1c94097a9ede9b16f621e18b672f1a126c6530502a27032c85018b243cbde381");
assert_eq!(&key[..32], derive(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32]));
assert_eq!(derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 16), key[..16]);
}
}
@@ -0,0 +1,89 @@
#[cfg(test)]
mod protection_tests {
use super::*;
use std::io::Cursor;
struct Memory { input: Cursor<Vec<u8>>, output: Vec<u8> }
impl Read for Memory { fn read(&mut self, b: &mut [u8]) -> io::Result<usize> { self.input.read(b) } }
impl Write for Memory {
fn write(&mut self, b: &[u8]) -> io::Result<usize> { self.output.extend_from_slice(b); Ok(b.len()) }
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
fn protection(algorithm: MacAlgorithm) -> Protection {
Protection::with_mac(&[3;16], &[4;16], vec![5;algorithm.key_len()], algorithm).unwrap()
}
fn reader(bytes: Vec<u8>, algorithm: MacAlgorithm) -> Packets<Memory> {
let mut packets = Packets::new(Memory { input: Cursor::new(bytes), output: vec![] });
packets.recv = Some(protection(algorithm)); packets
}
fn hex(s: &str) -> Vec<u8> {
s.as_bytes().chunks_exact(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect()
}
#[test]
fn etm_openssl_fixtures_and_authenticate_before_decrypt() {
// Generated independently using Node/OpenSSL; generator and output recorded in
// /docs/symphony/artifacts/SAU-415/vectors.mjs (no shared packet/KDF code).
for (algorithm, vector) in [
(MacAlgorithm::Sha256Etm, "00000010e0682f200ab506a20a4da74db3b6ff408f978288292d6b1f9a194fcf108ee3d8dd20edd265854e0683c7545d89a98533000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9d96ec28c65f3d7a23da2197da11b30ea7f2625d4179ece3c724ccbe8f78da4463"),
(MacAlgorithm::Sha512Etm, "00000010e0682f200ab506a20a4da74db3b6ff406d1fd80c98bae4318b01500b79fe77b71ec0ab3ca69841dd51116b6d3277d7e51405c3c76ac95b4964988851f12e55d9430c8e227931fffdd5b95a98859586e5000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9da235ac50894cf397f912da5296a848b5d240de3e78fadf4ac278430a377ee6448ddaa491aa9007041e83a6fcd9d77aec0855f0cfff765924d1e060796ca2b09c"),
] {
let bytes = hex(vector);
let mut packets = reader(bytes.clone(), algorithm);
assert_eq!(packets.read().unwrap(), [5]);
assert_eq!(packets.read().unwrap(), b"independent fixture");
for offset in [4, 19, 20] { // encrypted padding length, ciphertext, tag
let mut bad = bytes.clone(); bad[offset] ^= 1;
let mut packets = reader(bad, algorithm);
assert!(packets.read().unwrap_err().to_string().contains("MAC mismatch"));
// Failed authentication did not consume cipher state or sequence number.
packets.stream.input = Cursor::new(bytes.clone());
assert_eq!(packets.read().unwrap(), [5]);
}
let first_len = 4 + 16 + algorithm.key_len();
assert!(reader(bytes[first_len..].to_vec(), algorithm).read().unwrap_err().to_string().contains("MAC mismatch"));
for end in 0..first_len { assert!(reader(bytes[..end].to_vec(), algorithm).read().is_err()); }
for len in [0u32, 5, 17, 262145, u32::MAX] {
let mut bad = bytes.clone(); bad[..4].copy_from_slice(&len.to_be_bytes());
assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("invalid packet length"));
}
let mut bad = bytes.clone(); bad[..4].copy_from_slice(&32u32.to_be_bytes());
assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("MAC mismatch"));
}
}
#[test]
fn all_mac_packet_boundaries_and_independent_outbound_check() {
for algorithm in [MacAlgorithm::Sha256, MacAlgorithm::Sha256Etm, MacAlgorithm::Sha512Etm] {
let mut packets = Packets::new(Memory { input: Cursor::new(vec![]), output: vec![] });
packets.send = Some(protection(algorithm));
let mut cipher = Aes::new_from_slices(&[3;16], &[4;16]).unwrap();
let mut incoming = reader(vec![], algorithm);
for (seq, size) in [1, 7, 8, 11, 12, 15, 16, 31, 32768, 65536].into_iter().enumerate() {
let payload = vec![42;size]; packets.write(&payload).unwrap();
let encoded = std::mem::take(&mut packets.stream.output);
incoming.stream.input = Cursor::new(encoded.clone());
assert_eq!(incoming.read().unwrap(), payload);
// Check sender without using Protection or Packets for decryption/MAC.
let split = encoded.len() - algorithm.key_len();
let mut plain = encoded[..split].to_vec();
if algorithm.etm() {
assert_eq!(Decoder(&plain).uint().unwrap() as usize, split - 4);
assert_eq!((split - 4) % 16, 0);
}
let start = if algorithm.etm() { 4 } else { 0 };
cipher.apply_keystream(&mut plain[start..]);
let signed = if algorithm.etm() { &encoded[..split] } else { &plain };
let key = vec![5;algorithm.key_len()];
let seq = (seq as u32).to_be_bytes();
if algorithm == MacAlgorithm::Sha512Etm {
let mut h = <Hmac<Sha512> as Mac>::new_from_slice(&key).unwrap();
h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap();
} else {
let mut h = <Hmac<Sha256> as Mac>::new_from_slice(&key).unwrap();
h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap();
}
assert_eq!(&plain[5..5+size], payload);
assert!(plain[4] >= 4);
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
//! Small SSH binary codec and RFC4253 / OpenSSH EtM packet protection.
include!("ssh_protection_tests.inc");
use aes::cipher::{KeyIvInit, StreamCipher};
use hmac::{Hmac, Mac};
use rand_core::{OsRng, RngCore};
use sha2::{Sha256, Sha512};
use std::io::{self, Read, Write};
pub type Aes = ctr::Ctr128BE<aes::Aes128>;
pub fn fail(message: &str) -> io::Error {
io::Error::other(format!("ssh: {message}"))
}
pub fn random(bytes: &mut [u8]) -> io::Result<()> {
OsRng
.try_fill_bytes(bytes)
.map_err(|_| fail("random source unavailable"))
}
pub fn uint(out: &mut Vec<u8>, n: u32) {
out.extend_from_slice(&n.to_be_bytes());
}
pub fn string(out: &mut Vec<u8>, bytes: &[u8]) {
uint(out, bytes.len() as u32);
out.extend_from_slice(bytes);
}
pub fn message(kind: u8, strings: &[&[u8]]) -> Vec<u8> {
let mut out = vec![kind];
for s in strings {
string(&mut out, s);
}
out
}
pub struct Decoder<'a>(pub &'a [u8]);
impl<'a> Decoder<'a> {
pub fn take(&mut self, len: usize) -> io::Result<&'a [u8]> {
if len > self.0.len() {
return Err(fail("truncated message"));
}
let (a, b) = self.0.split_at(len);
self.0 = b;
Ok(a)
}
pub fn byte(&mut self) -> io::Result<u8> {
Ok(self.take(1)?[0])
}
pub fn uint(&mut self) -> io::Result<u32> {
let b = self.take(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
pub fn string(&mut self) -> io::Result<&'a [u8]> {
let len = self.uint()? as usize;
self.take(len)
}
pub fn end(&self) -> io::Result<()> {
if self.0.is_empty() {
Ok(())
} else {
Err(fail("trailing message bytes"))
}
}
}
/// MAC key/tag sizes match OpenSSH mac.c; EtM framing follows PROTOCOL §1.5.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MacAlgorithm {
Sha256,
Sha256Etm,
Sha512Etm,
}
impl MacAlgorithm {
pub fn from_name(name: &[u8]) -> io::Result<Self> {
match name {
b"hmac-sha2-256" => Ok(Self::Sha256),
b"hmac-sha2-256-etm@openssh.com" => Ok(Self::Sha256Etm),
b"hmac-sha2-512-etm@openssh.com" => Ok(Self::Sha512Etm),
_ => Err(fail("unsupported MAC")),
}
}
pub fn key_len(self) -> usize {
if self == Self::Sha512Etm { 64 } else { 32 }
}
fn etm(self) -> bool {
self != Self::Sha256
}
}
enum Tag {
Sha256(Hmac<Sha256>),
Sha512(Hmac<Sha512>),
}
impl Tag {
fn bytes(self) -> Vec<u8> {
match self {
Self::Sha256(h) => h.finalize().into_bytes().to_vec(),
Self::Sha512(h) => h.finalize().into_bytes().to_vec(),
}
}
fn verify(self, tag: &[u8]) -> io::Result<()> {
match self {
Self::Sha256(h) => h.verify_slice(tag),
Self::Sha512(h) => h.verify_slice(tag),
}
.map_err(|_| fail("packet MAC mismatch"))
}
}
pub struct Protection {
cipher: Aes,
mac: Vec<u8>,
algorithm: MacAlgorithm,
}
impl Protection {
/// Historical non-EtM constructor, retained for existing callers.
pub fn new(key: &[u8], iv: &[u8], mac: Vec<u8>) -> io::Result<Self> {
Self::with_mac(key, iv, mac, MacAlgorithm::Sha256)
}
pub fn with_mac(
key: &[u8],
iv: &[u8],
mac: Vec<u8>,
algorithm: MacAlgorithm,
) -> io::Result<Self> {
if mac.len() != algorithm.key_len() {
return Err(fail("invalid MAC key size"));
}
Ok(Self {
cipher: Aes::new_from_slices(key, iv).map_err(|_| fail("invalid key size"))?,
mac,
algorithm,
})
}
fn tag(&self, seq: u32, bytes: &[u8]) -> io::Result<Tag> {
if self.algorithm == MacAlgorithm::Sha512Etm {
let mut h = <Hmac<Sha512> as Mac>::new_from_slice(&self.mac)
.map_err(|_| fail("invalid MAC key"))?;
h.update(&seq.to_be_bytes());
h.update(bytes);
Ok(Tag::Sha512(h))
} else {
let mut h = <Hmac<Sha256> as Mac>::new_from_slice(&self.mac)
.map_err(|_| fail("invalid MAC key"))?;
h.update(&seq.to_be_bytes());
h.update(bytes);
Ok(Tag::Sha256(h))
}
}
}
pub struct Packets<S> {
pub stream: S,
pub send: Option<Protection>,
pub recv: Option<Protection>,
tx: u32,
rx: u32,
}
impl<S: Read + Write> Packets<S> {
pub fn new(stream: S) -> Self {
Self {
stream,
send: None,
recv: None,
tx: 0,
rx: 0,
}
}
pub fn write(&mut self, payload: &[u8]) -> io::Result<()> {
if payload.is_empty() || payload.len() > 65536 {
return Err(fail("outgoing packet size"));
}
let block = if self.send.is_some() { 16 } else { 8 };
let etm = self.send.as_ref().is_some_and(|p| p.algorithm.etm());
let mut padding = block - (payload.len() + if etm { 1 } else { 5 }) % block;
if padding < 4 {
padding += block;
}
let mut packet = Vec::with_capacity(payload.len() + padding + 5);
uint(&mut packet, (payload.len() + padding + 1) as u32);
packet.push(padding as u8);
packet.extend_from_slice(payload);
let start = packet.len();
packet.resize(start + padding, 0);
random(&mut packet[start..])?;
// Legacy MAC authenticates plaintext; EtM authenticates length + ciphertext.
let mut tag = None;
if let Some(p) = &mut self.send {
if !etm {
tag = Some(p.tag(self.tx, &packet)?.bytes());
}
p.cipher
.apply_keystream(&mut packet[if etm { 4 } else { 0 }..]);
if etm {
tag = Some(p.tag(self.tx, &packet)?.bytes());
}
}
self.stream.write_all(&packet)?;
if let Some(tag) = tag {
self.stream.write_all(&tag)?;
}
self.stream.flush()?;
self.tx = self.tx.wrapping_add(1);
Ok(())
}
pub fn read(&mut self) -> io::Result<Vec<u8>> {
let block = if self.recv.is_some() { 16 } else { 8 };
let etm = self.recv.as_ref().is_some_and(|p| p.algorithm.etm());
let prefix = if etm { 4 } else { block };
let mut packet = vec![0; prefix];
self.stream.read_exact(&mut packet)?;
if !etm {
if let Some(p) = &mut self.recv {
p.cipher.apply_keystream(&mut packet);
}
}
let len = Decoder(&packet).uint()? as usize;
if !(5..=262144).contains(&len) {
return Err(fail("invalid packet length"));
}
let aligned = len + if etm { 0 } else { 4 };
if aligned % block != 0 || len + 4 < prefix {
return Err(fail("invalid packet length"));
}
packet.resize(len + 4, 0);
self.stream.read_exact(&mut packet[prefix..])?;
if let Some(p) = &mut self.recv {
let mut tag = vec![0; p.algorithm.key_len()];
if etm {
self.stream.read_exact(&mut tag)?;
p.tag(self.rx, &packet)?.verify(&tag)?;
// Never advance the cipher or inspect padding before authenticating EtM.
p.cipher.apply_keystream(&mut packet[4..]);
} else {
p.cipher.apply_keystream(&mut packet[prefix..]);
self.stream.read_exact(&mut tag)?;
p.tag(self.rx, &packet)?.verify(&tag)?;
}
}
self.rx = self.rx.wrapping_add(1);
let padding = packet[4] as usize;
if padding < 4 || padding + 1 >= len {
return Err(fail("invalid packet padding"));
}
Ok(packet[5..len + 4 - padding].to_vec())
}
pub fn meaningful(&mut self) -> io::Result<Vec<u8>> {
for _ in 0..256 {
let p = self.read()?;
match p.first() {
Some(1) => return Err(fail("server disconnected")),
Some(2 | 4) => continue, // IGNORE, DEBUG (never log remote text)
Some(_) => return Ok(p),
None => return Err(fail("empty packet")),
}
}
Err(fail("too many non-progress messages"))
}
}