Skip to content

Commit 3220a1d

Browse files
ChetanXproekzhang
andauthored
feat: Add read-only mode for terminal sessions (#104)
* feat: Add read-only mode for terminal sessions - Add support for read-only viewer access via --enable-readers flag - Implement write permissions check for terminal operations - New users default to read-only when write password not provided - Add visual indicator for read-only mode in UI - Disable terminal editing and controls for read-only users * fix: format Rust code * fix: resolve Clippy warnings * fix: resolve linting and formatting issues * fix: reformat Rust code * fix: address PR feedback for read-only users - Update read-only badge styles and poistion - Use subtle instead of constant_time_eq dependency - Add constant-time comparison for encrypted_zeros - Improve write password entropy - Optimize user scope updates to avoid double broadcast - Sort cargo dependencies - format cli display properly * fix: address review feedback and add test for read/write functionality * fix: resolve frontend formatting issues * Fix type error by casting * Refactor a bit of rust code * Remove duplicate fields * API nits --------- Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
1 parent c24c1c6 commit 3220a1d

File tree

21 files changed

+355
-95
lines changed

21 files changed

+355
-95
lines changed

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sshx-core/proto/sshx.proto

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ message TerminalSize {
3838

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

4647
// Details of a newly-created sshx session.
@@ -103,6 +104,7 @@ message SerializedSession {
103104
uint32 next_sid = 3;
104105
uint32 next_uid = 4;
105106
string name = 5;
107+
optional bytes write_password_hash = 6;
106108
}
107109

108110
message SerializedShell {

crates/sshx-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ redis = { version = "0.23.3", features = ["tokio-rustls-comp", "tls-rustls-webpk
3131
serde.workspace = true
3232
sha2 = "0.10.7"
3333
sshx-core.workspace = true
34+
subtle = "2.5.0"
3435
tokio.workspace = true
3536
tokio-stream.workspace = true
3637
tokio-tungstenite = "0.20.0"

crates/sshx-server/src/grpc.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ impl SshxService for GrpcServer {
5050
}
5151
let name = rand_alphanumeric(10);
5252
info!(%name, "creating new session");
53+
5354
match self.0.lookup(&name) {
5455
Some(_) => return Err(Status::already_exists("generated duplicate ID")),
5556
None => {
5657
let metadata = Metadata {
5758
encrypted_zeros: request.encrypted_zeros,
5859
name: request.name,
60+
write_password_hash: request.write_password_hash,
5961
};
6062
self.0.insert(&name, Arc::new(Session::new(metadata)));
6163
}

crates/sshx-server/src/session.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub struct Metadata {
3333

3434
/// Name of the session (human-readable).
3535
pub name: String,
36+
37+
/// Password for write access to the session.
38+
pub write_password_hash: Option<Bytes>,
3639
}
3740

3841
/// In-memory state for a single sshx session.
@@ -307,7 +310,7 @@ impl Session {
307310
}
308311

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

313316
#[must_use]
@@ -325,6 +328,7 @@ impl Session {
325328
name: format!("User {id}"),
326329
cursor: None,
327330
focus: None,
331+
can_write,
328332
};
329333
v.insert(user.clone());
330334
self.broadcast.send(WsServer::UserDiff(id, Some(user))).ok();
@@ -341,6 +345,16 @@ impl Session {
341345
self.broadcast.send(WsServer::UserDiff(id, None)).ok();
342346
}
343347

348+
/// Check if a user has write permission in the session.
349+
pub fn check_write_permission(&self, user_id: Uid) -> Result<()> {
350+
let users = self.users.read();
351+
let user = users.get(&user_id).context("user not found")?;
352+
if !user.can_write {
353+
bail!("No write permission");
354+
}
355+
Ok(())
356+
}
357+
344358
/// Send a chat message into the room.
345359
pub fn send_chat(&self, id: Uid, msg: &str) -> Result<()> {
346360
// Populate the message with the current name in case it's not known later.

crates/sshx-server/src/session/snapshot.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl Session {
6262
next_sid: ids.0 .0,
6363
next_uid: ids.1 .0,
6464
name: self.metadata().name.clone(),
65+
write_password_hash: self.metadata().write_password_hash.clone(),
6566
};
6667
let data = message.encode_to_vec();
6768
ensure!(data.len() < MAX_SNAPSHOT_SIZE, "snapshot too large");
@@ -72,9 +73,11 @@ impl Session {
7273
pub fn restore(data: &[u8]) -> Result<Self> {
7374
let data = zstd::bulk::decompress(data, MAX_SNAPSHOT_SIZE)?;
7475
let message = SerializedSession::decode(&*data)?;
76+
7577
let metadata = Metadata {
7678
encrypted_zeros: message.encrypted_zeros,
7779
name: message.name,
80+
write_password_hash: message.write_password_hash,
7881
};
7982

8083
let session = Self::new(metadata);

crates/sshx-server/src/web/protocol.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub struct WsUser {
3939
pub cursor: Option<(i32, i32)>,
4040
/// Currently focused terminal window ID.
4141
pub focus: Option<Sid>,
42+
/// Whether the user has write permissions in the session.
43+
pub can_write: bool,
4244
}
4345

4446
/// A real-time message sent from the server over WebSocket.
@@ -71,8 +73,9 @@ pub enum WsServer {
7173
#[derive(Serialize, Deserialize, Debug, Clone)]
7274
#[serde(rename_all = "camelCase")]
7375
pub enum WsClient {
74-
/// Authenticate the user's encryption key by zeros block.
75-
Authenticate(Bytes),
76+
/// Authenticate the user's encryption key by zeros block and write password
77+
/// (if provided).
78+
Authenticate(Bytes, Option<Bytes>),
7679
/// Set the name of the current user.
7780
SetName(String),
7881
/// Send real-time information about the user's cursor.

crates/sshx-server/src/web/socket.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use bytes::Bytes;
1111
use futures_util::SinkExt;
1212
use sshx_core::proto::{server_update::ServerMessage, NewShell, TerminalInput, TerminalSize};
1313
use sshx_core::Sid;
14+
use subtle::ConstantTimeEq;
1415
use tokio::sync::mpsc;
1516
use tokio_stream::StreamExt;
1617
use tracing::{error, info_span, warn, Instrument};
@@ -95,15 +96,38 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
9596
session.sync_now();
9697
send(socket, WsServer::Hello(user_id, metadata.name.clone())).await?;
9798

98-
match recv(socket).await? {
99-
Some(WsClient::Authenticate(bytes)) if bytes == metadata.encrypted_zeros => {}
99+
let can_write = match recv(socket).await? {
100+
Some(WsClient::Authenticate(bytes, write_password_bytes)) => {
101+
// Constant-time comparison of bytes, converting Choice to bool
102+
if !bool::from(bytes.ct_eq(metadata.encrypted_zeros.as_ref())) {
103+
send(socket, WsServer::InvalidAuth()).await?;
104+
return Ok(());
105+
}
106+
107+
match (write_password_bytes, &metadata.write_password_hash) {
108+
// No password needed, so all users can write (default).
109+
(_, None) => true,
110+
111+
// Password stored but not provided, user is read-only.
112+
(None, Some(_)) => false,
113+
114+
// Password stored and provided, compare them.
115+
(Some(provided), Some(stored)) => {
116+
if !bool::from(provided.ct_eq(stored)) {
117+
send(socket, WsServer::InvalidAuth()).await?;
118+
return Ok(());
119+
}
120+
true
121+
}
122+
}
123+
}
100124
_ => {
101125
send(socket, WsServer::InvalidAuth()).await?;
102126
return Ok(());
103127
}
104-
}
128+
};
105129

106-
let _user_guard = session.user_scope(user_id)?;
130+
let _user_guard = session.user_scope(user_id, can_write)?;
107131

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

140164
match msg {
141-
WsClient::Authenticate(_) => {}
165+
WsClient::Authenticate(_, _) => {}
142166
WsClient::SetName(name) => {
143167
if !name.is_empty() {
144168
session.update_user(user_id, |user| user.name = name)?;
@@ -151,6 +175,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
151175
session.update_user(user_id, |user| user.focus = id)?;
152176
}
153177
WsClient::Create(x, y) => {
178+
if let Err(e) = session.check_write_permission(user_id) {
179+
send(socket, WsServer::Error(e.to_string())).await?;
180+
continue;
181+
}
154182
let id = session.counter().next_sid();
155183
session.sync_now();
156184
let new_shell = NewShell { id: id.0, x, y };
@@ -159,9 +187,17 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
159187
.await?;
160188
}
161189
WsClient::Close(id) => {
190+
if let Err(e) = session.check_write_permission(user_id) {
191+
send(socket, WsServer::Error(e.to_string())).await?;
192+
continue;
193+
}
162194
update_tx.send(ServerMessage::CloseShell(id.0)).await?;
163195
}
164196
WsClient::Move(id, winsize) => {
197+
if let Err(e) = session.check_write_permission(user_id) {
198+
send(socket, WsServer::Error(e.to_string())).await?;
199+
continue;
200+
}
165201
if let Err(err) = session.move_shell(id, winsize) {
166202
send(socket, WsServer::Error(err.to_string())).await?;
167203
continue;
@@ -176,6 +212,10 @@ async fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<
176212
}
177213
}
178214
WsClient::Data(id, data, offset) => {
215+
if let Err(e) = session.check_write_permission(user_id) {
216+
send(socket, WsServer::Error(e.to_string())).await?;
217+
continue;
218+
}
179219
let input = TerminalInput {
180220
id: id.0,
181221
data,

crates/sshx-server/tests/common/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ impl Drop for TestServer {
8282
pub struct ClientSocket {
8383
inner: WebSocketStream<MaybeTlsStream<TcpStream>>,
8484
encrypt: Encrypt,
85+
write_encrypt: Option<Encrypt>,
8586

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

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

100101
let mut this = Self {
101102
inner: stream,
102103
encrypt: Encrypt::new(key),
104+
write_encrypt: write_password.map(Encrypt::new),
103105
user_id: Uid(0),
104106
users: BTreeMap::new(),
105107
shells: BTreeMap::new(),
@@ -113,7 +115,10 @@ impl ClientSocket {
113115

114116
async fn authenticate(&mut self) {
115117
let encrypted_zeros = self.encrypt.zeros().into();
116-
self.send(WsClient::Authenticate(encrypted_zeros)).await;
118+
let write_zeros = self.write_encrypt.as_ref().map(|e| e.zeros().into());
119+
120+
self.send(WsClient::Authenticate(encrypted_zeros, write_zeros))
121+
.await;
117122
}
118123

119124
pub async fn send(&mut self, msg: WsClient) {

crates/sshx-server/tests/simple.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ async fn test_rpc() -> Result<()> {
1515
origin: "sshx.io".into(),
1616
encrypted_zeros: Encrypt::new("").zeros().into(),
1717
name: String::new(),
18+
write_password_hash: None,
1819
};
1920
let resp = client.open(req).await?;
2021
assert!(!resp.into_inner().name.is_empty());

0 commit comments

Comments
 (0)