Add WASI SSH CLI and in-process Git SSH transport
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user