diff options
Diffstat (limited to 'oxish/src')
| -rw-r--r-- | oxish/src/lib.rs | 20 | ||||
| -rw-r--r-- | oxish/src/server.rs | 4 | ||||
| -rw-r--r-- | oxish/src/session/connections.rs | 14 | ||||
| -rw-r--r-- | oxish/src/session/mod.rs | 140 | ||||
| -rw-r--r-- | oxish/src/tests.rs | 20 |
5 files changed, 191 insertions, 7 deletions
diff --git a/oxish/src/lib.rs b/oxish/src/lib.rs index 6f36bb6..7219e96 100644 --- a/oxish/src/lib.rs +++ b/oxish/src/lib.rs @@ -76,6 +76,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Connection<T> { Option<StrictKeyExchange>, Digest, KeySourceSet, + bool, )> { let (exchange, identities) = self.identify().await.context("identification failed")?; @@ -97,6 +98,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Connection<T> { let packet = receive(&mut self.stream, &mut self.read).await?; let ecdh_key_exchange_init = EcdhKeyExchangeInit::try_from(packet)?; + let post_quantum_kex = kx.negotiated.key_exchange.post_quantum_secure(); let (host_key, key_exchange_reply, session_id, keys) = kx .complete(ecdh_key_exchange_init, host_keys, provider) .context("key exchange failed")?; @@ -110,7 +112,14 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Connection<T> { } self.send(&Ignore::default()).await?; - Ok((identities, host_key, strict_kx, session_id, keys)) + Ok(( + identities, + host_key, + strict_kx, + session_id, + keys, + post_quantum_kex, + )) } /// Complete a client-initiated rekey after its `SSH_MSG_KEXINIT` has been parsed @@ -245,6 +254,7 @@ struct SessionState<H> { addr: SocketAddr, host_key: H, identities: Identities, + post_quantum_kx: bool, strict_kx: Option<StrictKeyExchange>, session_id: Digest, read: SideState, @@ -259,6 +269,7 @@ impl Encode for SessionState<ServerHostKey<'_>> { addr, host_key, identities, + post_quantum_kx, strict_kx, session_id, read, @@ -269,6 +280,7 @@ impl Encode for SessionState<ServerHostKey<'_>> { addr.to_string().as_bytes().encode(buf); host_key.encode(buf); identities.encode(buf); + post_quantum_kx.encode(buf); strict_kx.encode(buf); session_id.as_ref().encode(buf); read.encode(buf); @@ -302,6 +314,11 @@ impl SessionState<SessionHostKey> { } = Identities::decode(next)?; let Decoded { + value: post_quantum_kx, + next, + } = bool::decode(next)?; + + let Decoded { value: strict_kx, next, } = Option::<StrictKeyExchange>::decode(next)?; @@ -323,6 +340,7 @@ impl SessionState<SessionHostKey> { addr, host_key, identities, + post_quantum_kx, strict_kx, session_id: Digest::new(session_id), read, diff --git a/oxish/src/server.rs b/oxish/src/server.rs index 7f91705..1389640 100644 --- a/oxish/src/server.rs +++ b/oxish/src/server.rs @@ -126,7 +126,7 @@ impl Server { }; let future = conn.exchange_keys(&self.host_keys, self.provider); - let (identities, host_key, strict_kx, session_id, keys) = + let (identities, host_key, strict_kx, session_id, keys, post_quantum_kx) = match timeout(Duration::from_secs(30), future).await { Ok(result) => result.context("key exchange failed")?, Err(_) => return Err(anyhow::anyhow!("key exchange timed out")), @@ -142,6 +142,7 @@ impl Server { let session = Session::new( conn, Rekey::new(session_id, strict_kx, identities, host_key), + post_quantum_kx, ); return session.run(self.provider).await.context("session failed"); } @@ -169,6 +170,7 @@ impl Server { addr, host_key, identities, + post_quantum_kx, strict_kx, session_id, read: SideState { diff --git a/oxish/src/session/connections.rs b/oxish/src/session/connections.rs index 30013ce..e514a70 100644 --- a/oxish/src/session/connections.rs +++ b/oxish/src/session/connections.rs @@ -69,6 +69,7 @@ impl Channels { &mut self, request: ChannelRequest<'_>, encoder: &mut Encoder<'_>, + banner: Option<&str>, ) -> Result<(), Error> { let Some(channel) = self.channels.get_mut(&request.recipient_channel) else { return Err(ProtoError::InvalidPacket("channel request for unknown channel ID").into()); @@ -121,6 +122,19 @@ impl Channels { encoder.enqueue(&channel.success())?; } + let Some(banner) = banner else { + return Ok(()); + }; + + let Some(window) = channel.send_window.checked_sub(banner.len() as u32) else { + return Ok(()); + }; + + channel.send_window = window; + encoder.enqueue(&ChannelData { + recipient_channel: channel.remote_id, + data: Cow::Borrowed(banner.as_bytes()), + })?; Ok(()) } diff --git a/oxish/src/session/mod.rs b/oxish/src/session/mod.rs index 44b4f95..ae4a45c 100644 --- a/oxish/src/session/mod.rs +++ b/oxish/src/session/mod.rs @@ -1,4 +1,9 @@ -use core::{cmp::Ordering, future, mem::MaybeUninit}; +use core::{ + cmp::Ordering, + future, + mem::MaybeUninit, + str::{self, FromStr}, +}; use std::{ io::{self, IoSliceMut}, os::fd::AsFd, @@ -6,6 +11,7 @@ use std::{ use proto::{ Decoded, Disconnect, Encoder, MessageType, Pretty, ReadState, WriteState, + channels::{ChannelRequest, ChannelRequestType}, crypto::CryptoProvider, key_exchange::{Rekey, SessionHostKey}, }; @@ -30,6 +36,7 @@ pub struct Session<T> { conn: Connection<T>, rekey: Rekey, channels: Channels, + post_quantum_kx: bool, } impl Session<TcpStream> { @@ -120,6 +127,7 @@ impl Session<TcpStream> { addr, host_key, identities, + post_quantum_kx, strict_kx, session_id, read, @@ -152,16 +160,18 @@ impl Session<TcpStream> { }, rekey: Rekey::new(session_id, strict_kx, identities, host_key), channels: Channels::default(), + post_quantum_kx, }) } } impl<T: AsyncRead + AsyncWrite + Unpin> Session<T> { - pub(crate) fn new(conn: Connection<T>, rekey: Rekey) -> Self { + pub(crate) fn new(conn: Connection<T>, rekey: Rekey, post_quantum_kx: bool) -> Self { Self { conn, channels: Channels::default(), rekey, + post_quantum_kx, } } @@ -190,7 +200,9 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Session<T> { // key exchange init (RFC 4253 section 9). MessageType::KeyExchangeInit => { let kx = self.rekey.start(packet, provider)?; + let post_quantum_kx = kx.negotiated.key_exchange.post_quantum_secure(); self.conn.rekey(kx, &self.rekey, provider).await?; + self.post_quantum_kx = post_quantum_kx; continue; } _ => {} @@ -201,7 +213,10 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Session<T> { let mut encoder = Encoder::new(&mut self.conn.write); match channel_message { IncomingChannelMessage::Open(open) => self.channels.open(open, &mut encoder), - IncomingChannelMessage::Request(request) => self.channels.request(request, &mut encoder), + IncomingChannelMessage::Request(request) => { + let banner = banner(&request, self.rekey.client_identity(), self.post_quantum_kx); + self.channels.request(request, &mut encoder, banner.as_deref()) + } IncomingChannelMessage::Data(data) => match self.channels.data(&data, &mut encoder) { Ok(Some((session, data))) => match session.write(data).await { Ok(_) => Ok(()), @@ -232,3 +247,122 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Session<T> { } } } + +fn banner( + request: &ChannelRequest<'_>, + client_identity: &[u8], + post_quantum_kx: bool, +) -> Option<String> { + if post_quantum_kx { + return None; + } + + let width = match &request.r#type { + // A zero dimension means the client left it unspecified (RFC 4254 section 6.2) + ChannelRequestType::PtyReq(pty) => match pty.cols { + 0 => 80, + cols => Ord::max(40, cols as usize), + }, + _ => return None, + }; + + let mut banner = String::with_capacity(PREFIX.len() + NO_PQ_WARNING.len()); + banner.push_str(PREFIX); + let mut left = width.saturating_sub(PREFIX.len()); + for token in NO_PQ_WARNING.split(' ') { + if token.len() + 1 >= left { + banner.push_str("\r\n"); + banner.push_str(PREFIX); + left = width.saturating_sub(PREFIX.len()); + } + + banner.push_str(token); + banner.push(' '); + left = left.saturating_sub(token.len() + 1); + } + + banner.push_str("\r\n"); + let Some(version) = client_identity.strip_prefix(b"SSH-2.0-OpenSSH_") else { + return Some(banner); + }; + + let Ok(version) = str::from_utf8(version) else { + return Some(banner); + }; + + let Some((major, minor)) = version.split_once('.') else { + return Some(banner); + }; + + let minor = match minor.split_once(|c: char| !c.is_ascii_digit()) { + Some((minor, _)) => minor, + None => minor, + }; + + let (Ok(major), Ok(minor)) = (u8::from_str(major), u8::from_str(minor)) else { + return Some(banner); + }; + + if (major, minor) < (9, 9) { + banner.push_str(PREFIX); + banner.push_str(NO_PQ_WARNING_OPENSSH); + banner.push_str("\r\n"); + } + + Some(banner) +} + +const PREFIX: &str = "WARNING: "; +const NO_PQ_WARNING: &str = "the client negotiated a key exchange algorithm that is not post-quantum secure; your session may be decrypted by a cryptographically relevant quantum computer in the future"; +const NO_PQ_WARNING_OPENSSH: &str = + "consider upgrading your client version to OpenSSH 9.9 or newer"; + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + use std::collections::BTreeMap; + + use proto::channels::PtyReq; + + use super::*; + + #[test] + fn banner_at_80_columns() { + let banner = banner(&pty_request(80), b"SSH-2.0-OpenSSH_10.0", false).unwrap(); + assert_eq!( + banner, + "WARNING: the client negotiated a key exchange algorithm that is not \r\n\ + WARNING: post-quantum secure; your session may be decrypted by a \r\n\ + WARNING: cryptographically relevant quantum computer in the future \r\n" + ); + assert!(banner.lines().all(|line| line.len() <= 80)); + } + + #[test] + fn banner_at_80_columns_with_openssh_warning() { + let banner = banner(&pty_request(80), b"SSH-2.0-OpenSSH_9.8p1", false).unwrap(); + assert_eq!( + banner, + "WARNING: the client negotiated a key exchange algorithm that is not \r\n\ + WARNING: post-quantum secure; your session may be decrypted by a \r\n\ + WARNING: cryptographically relevant quantum computer in the future \r\n\ + WARNING: consider upgrading your client version to OpenSSH 9.9 or newer\r\n" + ); + assert!(banner.lines().all(|line| line.len() <= 80)); + } + + fn pty_request(cols: u32) -> ChannelRequest<'static> { + ChannelRequest { + recipient_channel: 0, + r#type: ChannelRequestType::PtyReq(PtyReq { + term: Cow::Borrowed("xterm-256color"), + cols, + rows: 24, + width_px: 0, + height_px: 0, + terminal_modes: BTreeMap::new(), + }), + want_reply: true, + } + } +} diff --git a/oxish/src/tests.rs b/oxish/src/tests.rs index 0f4abc7..4926b92 100644 --- a/oxish/src/tests.rs +++ b/oxish/src/tests.rs @@ -86,11 +86,17 @@ async fn handshake_x25519(provider: &'static dyn CryptoProvider) -> anyhow::Resu // server no longer supports it. client.cmd.args(["-o", "KexAlgorithms=curve25519-sha256"]); - let (_stdout, stderr) = client.run(COMMAND, Duration::from_secs(10), server).await?; + let (stdout, stderr) = client.run(COMMAND, Duration::from_secs(10), server).await?; anyhow::ensure!( stderr.contains("kex: algorithm: curve25519-sha256"), "client did not negotiate curve25519-sha256" ); + + // A non-post-quantum key exchange must surface a warning banner in the terminal + anyhow::ensure!( + stdout.contains(KX_WARNING_MARKER), + "expected key exchange warning banner in session output:\n{stdout}" + ); Ok(()) } @@ -172,7 +178,7 @@ async fn handshake( let (_key_dir, client, server) = setup(&algorithm, None, provider).await?; - let (_stdout, _stderr) = client + let (stdout, stderr) = client .run( COMMAND, // In the rekey scenario, keep the session open long enough for several rekeys before the @@ -182,6 +188,13 @@ async fn handshake( ) .await?; + if stderr.contains("kex: algorithm: mlkem768x25519-sha256") { + anyhow::ensure!( + !stdout.contains(KX_WARNING_MARKER), + "unexpected key exchange warning banner in session output:\n{stdout}" + ); + } + Ok(()) } @@ -337,6 +350,7 @@ fn session_state_round_trip() { client: b"client-identity".to_vec(), server: b"server-identity".to_vec(), }, + post_quantum_kx: false, strict_kx: None, host_key, session_id: Digest::new(b"session-id"), @@ -374,6 +388,7 @@ fn session_state_round_trip() { assert_eq!(decoded.identities.client, state.identities.client); assert_eq!(decoded.identities.server, state.identities.server); assert_eq!(decoded.strict_kx.is_some(), state.strict_kx.is_some()); + assert_eq!(decoded.post_quantum_kx, state.post_quantum_kx); assert_eq!(decoded.session_id.as_ref(), state.session_id.as_ref()); assert_eq!(decoded.read_buf, state.read_buf); assert_eq!( @@ -482,3 +497,4 @@ fn subscribe() { const USER: &str = "oxish-e2e"; const COMMAND: &[u8] = b"echo OXISH-$((6*7))\nexit\n"; const OUTPUT: &str = "OXISH-42"; +const KX_WARNING_MARKER: &str = "post-quantum secure"; |
