Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions crates/sshx-core/proto/sshx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ message TerminalSize {

// Request to open an sshx session.
message OpenRequest {
string origin = 1; // Web origin of the server.
bytes encrypted_zeros = 2; // Encrypted zero block, for client verification.
string name = 3; // Name of the session (user@hostname).
string origin = 1; // Web origin of the server.
bytes encrypted_zeros = 2; // Encrypted zero block, for client verification.
string name = 3; // Name of the session (user@hostname).
optional bytes write_password_hash = 4; // Hashed write password, if read-only mode is enabled.
}

// Details of a newly-created sshx session.
Expand Down Expand Up @@ -103,6 +104,7 @@ message SerializedSession {
uint32 next_sid = 3;
uint32 next_uid = 4;
string name = 5;
optional bytes write_password_hash = 6;
}

message SerializedShell {
Expand Down
1 change: 1 addition & 0 deletions crates/sshx-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ redis = { version = "0.23.3", features = ["tokio-rustls-comp", "tls-rustls-webpk
serde.workspace = true
sha2 = "0.10.7"
sshx-core.workspace = true
subtle = "2.5.0"
tokio.workspace = true
tokio-stream.workspace = true
tokio-tungstenite = "0.20.0"
Expand Down
2 changes: 2 additions & 0 deletions crates/sshx-server/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ impl SshxService for GrpcServer {
}
let name = rand_alphanumeric(10);
info!(%name, "creating new session");

match self.0.lookup(&name) {
Some(_) => return Err(Status::already_exists("generated duplicate ID")),
None => {
let metadata = Metadata {
encrypted_zeros: request.encrypted_zeros,
name: request.name,
write_password_hash: request.write_password_hash,
};
self.0.insert(&name, Arc::new(Session::new(metadata)));
}
Expand Down
16 changes: 15 additions & 1 deletion crates/sshx-server/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub struct Metadata {

/// Name of the session (human-readable).
pub name: String,

/// Password for write access to the session.
pub write_password_hash: Option<Bytes>,
}

/// In-memory state for a single sshx session.
Expand Down Expand Up @@ -307,7 +310,7 @@ impl Session {
}

/// Add a new user, and return a guard that removes the user when dropped.
pub fn user_scope(&self, id: Uid) -> Result<impl Drop + '_> {
pub fn user_scope(&self, id: Uid, can_write: bool) -> Result<impl Drop + '_> {
use std::collections::hash_map::Entry::*;

#[must_use]
Expand All @@ -325,6 +328,7 @@ impl Session {
name: format!("User {id}"),
cursor: None,
focus: None,
can_write,
};
v.insert(user.clone());
self.broadcast.send(WsServer::UserDiff(id, Some(user))).ok();
Expand All @@ -341,6 +345,16 @@ impl Session {
self.broadcast.send(WsServer::UserDiff(id, None)).ok();
}

/// Check if a user has write permission in the session.
pub fn check_write_permission(&self, user_id: Uid) -> Result<()> {
let users = self.users.read();
let user = users.get(&user_id).context("user not found")?;
if !user.can_write {
bail!("No write permission");
}
Ok(())
}

/// Send a chat message into the room.
pub fn send_chat(&self, id: Uid, msg: &str) -> Result<()> {
// Populate the message with the current name in case it's not known later.
Expand Down
3 changes: 3 additions & 0 deletions crates/sshx-server/src/session/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl Session {
next_sid: ids.0 .0,
next_uid: ids.1 .0,
name: self.metadata().name.clone(),
write_password_hash: self.metadata().write_password_hash.clone(),
};
let data = message.encode_to_vec();
ensure!(data.len() < MAX_SNAPSHOT_SIZE, "snapshot too large");
Expand All @@ -72,9 +73,11 @@ impl Session {
pub fn restore(data: &[u8]) -> Result<Self> {
let data = zstd::bulk::decompress(data, MAX_SNAPSHOT_SIZE)?;
let message = SerializedSession::decode(&*data)?;

let metadata = Metadata {
encrypted_zeros: message.encrypted_zeros,
name: message.name,
write_password_hash: message.write_password_hash,
};

let session = Self::new(metadata);
Expand Down
7 changes: 5 additions & 2 deletions crates/sshx-server/src/web/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub struct WsUser {
pub cursor: Option<(i32, i32)>,
/// Currently focused terminal window ID.
pub focus: Option<Sid>,
/// Whether the user has write permissions in the session.
pub can_write: bool,
}

/// A real-time message sent from the server over WebSocket.
Expand Down Expand Up @@ -71,8 +73,9 @@ pub enum WsServer {
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub enum WsClient {
/// Authenticate the user's encryption key by zeros block.
Authenticate(Bytes),
/// Authenticate the user's encryption key by zeros block and write password
/// (if provided).
Authenticate(Bytes, Option<Bytes>),
/// Set the name of the current user.
SetName(String),
/// Send real-time information about the user's cursor.
Expand Down
50 changes: 45 additions & 5 deletions crates/sshx-server/src/web/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use bytes::Bytes;
use futures_util::SinkExt;
use sshx_core::proto::{server_update::ServerMessage, NewShell, TerminalInput, TerminalSize};
use sshx_core::Sid;
use subtle::ConstantTimeEq;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tracing::{error, info_span, warn, Instrument};
Expand Down Expand Up @@ -95,15 +96,38 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
session.sync_now();
send(socket, WsServer::Hello(user_id, metadata.name.clone())).await?;

match recv(socket).await? {
Some(WsClient::Authenticate(bytes)) if bytes == metadata.encrypted_zeros => {}
let can_write = match recv(socket).await? {
Some(WsClient::Authenticate(bytes, write_password_bytes)) => {
// Constant-time comparison of bytes, converting Choice to bool
if !bool::from(bytes.ct_eq(metadata.encrypted_zeros.as_ref())) {
send(socket, WsServer::InvalidAuth()).await?;
return Ok(());
}

match (write_password_bytes, &metadata.write_password_hash) {
// No password needed, so all users can write (default).
(_, None) => true,

// Password stored but not provided, user is read-only.
(None, Some(_)) => false,

// Password stored and provided, compare them.
(Some(provided), Some(stored)) => {
if !bool::from(provided.ct_eq(stored)) {
send(socket, WsServer::InvalidAuth()).await?;
return Ok(());
}
true
}
}
}
_ => {
send(socket, WsServer::InvalidAuth()).await?;
return Ok(());
}
}
};

let _user_guard = session.user_scope(user_id)?;
let _user_guard = session.user_scope(user_id, can_write)?;

let update_tx = session.update_tx(); // start listening for updates before any state reads
let mut broadcast_stream = session.subscribe_broadcast();
Expand Down Expand Up @@ -138,7 +162,7 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
};

match msg {
WsClient::Authenticate(_) => {}
WsClient::Authenticate(_, _) => {}
WsClient::SetName(name) => {
if !name.is_empty() {
session.update_user(user_id, |user| user.name = name)?;
Expand All @@ -151,6 +175,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
session.update_user(user_id, |user| user.focus = id)?;
}
WsClient::Create(x, y) => {
if let Err(e) = session.check_write_permission(user_id) {
send(socket, WsServer::Error(e.to_string())).await?;
continue;
}
let id = session.counter().next_sid();
session.sync_now();
let new_shell = NewShell { id: id.0, x, y };
Expand All @@ -159,9 +187,17 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
.await?;
}
WsClient::Close(id) => {
if let Err(e) = session.check_write_permission(user_id) {
send(socket, WsServer::Error(e.to_string())).await?;
continue;
}
update_tx.send(ServerMessage::CloseShell(id.0)).await?;
}
WsClient::Move(id, winsize) => {
if let Err(e) = session.check_write_permission(user_id) {
send(socket, WsServer::Error(e.to_string())).await?;
continue;
}
if let Err(err) = session.move_shell(id, winsize) {
send(socket, WsServer::Error(err.to_string())).await?;
continue;
Expand All @@ -176,6 +212,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
}
}
WsClient::Data(id, data, offset) => {
if let Err(e) = session.check_write_permission(user_id) {
send(socket, WsServer::Error(e.to_string())).await?;
continue;
}
let input = TerminalInput {
id: id.0,
data,
Expand Down
9 changes: 7 additions & 2 deletions crates/sshx-server/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ impl Drop for TestServer {
pub struct ClientSocket {
inner: WebSocketStream<MaybeTlsStream<TcpStream>>,
encrypt: Encrypt,
write_encrypt: Option<Encrypt>,

pub user_id: Uid,
pub users: BTreeMap<Uid, WsUser>,
Expand All @@ -93,13 +94,14 @@ pub struct ClientSocket {

impl ClientSocket {
/// Connect to a WebSocket endpoint.
pub async fn connect(uri: &str, key: &str) -> Result<Self> {
pub async fn connect(uri: &str, key: &str, write_password: Option<&str>) -> Result<Self> {
let (stream, resp) = tokio_tungstenite::connect_async(uri).await?;
ensure!(resp.status() == StatusCode::SWITCHING_PROTOCOLS);

let mut this = Self {
inner: stream,
encrypt: Encrypt::new(key),
write_encrypt: write_password.map(Encrypt::new),
user_id: Uid(0),
users: BTreeMap::new(),
shells: BTreeMap::new(),
Expand All @@ -113,7 +115,10 @@ impl ClientSocket {

async fn authenticate(&mut self) {
let encrypted_zeros = self.encrypt.zeros().into();
self.send(WsClient::Authenticate(encrypted_zeros)).await;
let write_zeros = self.write_encrypt.as_ref().map(|e| e.zeros().into());

self.send(WsClient::Authenticate(encrypted_zeros, write_zeros))
.await;
}

pub async fn send(&mut self, msg: WsClient) {
Expand Down
1 change: 1 addition & 0 deletions crates/sshx-server/tests/simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async fn test_rpc() -> Result<()> {
origin: "sshx.io".into(),
encrypted_zeros: Encrypt::new("").zeros().into(),
name: String::new(),
write_password_hash: None,
};
let resp = client.open(req).await?;
assert!(!resp.into_inner().name.is_empty());
Expand Down
6 changes: 3 additions & 3 deletions crates/sshx-server/tests/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ pub mod common;
async fn test_basic_restore() -> Result<()> {
let server = TestServer::new().await;

let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo).await?;
let mut controller = Controller::new(&server.endpoint(), "", Runner::Echo, false).await?;
let name = controller.name().to_owned();
let key = controller.encryption_key().to_owned();
tokio::spawn(async move { controller.run().await });

let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key).await?;
let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;
s.flush().await;
assert_eq!(s.user_id, Uid(1));

Expand All @@ -47,7 +47,7 @@ async fn test_basic_restore() -> Result<()> {
.state()
.insert(&name, Arc::new(Session::restore(&data)?));

let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key).await?;
let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;
s.send(WsClient::Subscribe(Sid(1), 0)).await;
s.flush().await;

Expand Down
Loading
Loading