aboutsummaryrefslogtreecommitdiffhomepage
path: root/oxish
diff options
context:
space:
mode:
Diffstat (limited to 'oxish')
-rw-r--r--oxish/src/lib.rs20
-rw-r--r--oxish/src/server.rs4
-rw-r--r--oxish/src/session/connections.rs14
-rw-r--r--oxish/src/session/mod.rs140
-rw-r--r--oxish/src/tests.rs20
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";