Skip to content

Commit

Permalink
Add end-to-end encryption for inputs
Browse files Browse the repository at this point in the history
I'm not using AE for this as before since it's complex, but the server has been changed so that it refuses to listen to any input before the user first sends the encrypted zero block for verification that they hold the right key. So there's no risk of someone just sending / repeating random data without knowing the key.

It's a minor thing anyway since we only truly care about confidentiality from a correctly-running server here and otherwise trust them to not tamper! A "zero-trust sshx" would only be relevant if you're paranoid.

Resolves #13.
  • Loading branch information
ekzhang committed Sep 9, 2023
1 parent 9530c7d commit 1000348
Show file tree
Hide file tree
Showing 13 changed files with 111 additions and 68 deletions.
10 changes: 0 additions & 10 deletions Cargo.lock

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

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

// Details of bytes input to the terminal (not necessarily valid UTF-8).
message TerminalInput {
uint32 id = 1; // ID of the shell.
bytes data = 2; // Binary sequence of terminal data.
uint32 id = 1; // ID of the shell.
bytes data = 2; // Encrypted binary sequence of terminal data.
uint64 offset = 3; // Offset of the first byte for encryption.
}

// Pair of a terminal ID and its associated size.
Expand Down
1 change: 0 additions & 1 deletion crates/sshx-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ hyper = { version = "0.14.18", features = ["full"] }
parking_lot = "0.12.0"
rand.workspace = true
serde.workspace = true
serde_bytes = "0.11.6"
sha2 = "0.10.2"
sshx-core = { path = "../sshx-core" }
tokio.workspace = true
Expand Down
5 changes: 2 additions & 3 deletions crates/sshx-server/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ use tokio_stream::{wrappers::ReceiverStream, StreamExt};
use tonic::{Request, Response, Status, Streaming};
use tracing::{error, info, warn};

use crate::session::Session;
use crate::web::protocol::WsMetadata;
use crate::session::{Metadata, Session};
use crate::ServerState;

/// Interval for synchronizing sequence numbers with the client.
Expand Down Expand Up @@ -56,7 +55,7 @@ impl SshxService for GrpcServer {
match self.0.store.entry(name.clone()) {
Occupied(_) => return Err(Status::already_exists("generated duplicate ID")),
Vacant(v) => {
let metadata = WsMetadata {
let metadata = Metadata {
encrypted_zeros: request.encrypted_zeros.into(),
};
v.insert(Session::new(metadata).into());
Expand Down
17 changes: 12 additions & 5 deletions crates/sshx-server/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,23 @@ use tokio_stream::Stream;
use tracing::{debug, warn};

use crate::utils::Shutdown;
use crate::web::protocol::{WsMetadata, WsServer, WsUser, WsWinsize};
use crate::web::protocol::{WsServer, WsUser, WsWinsize};

/// Store a rolling buffer with at most this quantity of output, per shell.
const SHELL_STORED_BYTES: u64 = 4 << 20;

/// Metadata sent to clients on connection.
#[derive(Debug, Clone)]
pub struct Metadata {
/// Used to validate that clients have the correct encryption key.
pub encrypted_zeros: Bytes,
}

/// In-memory state for a single sshx session.
#[derive(Debug)]
pub struct Session {
/// Metadata sent to clients on connection.
metadata: WsMetadata,
/// Static metadata for this session.
metadata: Metadata,

/// In-memory state for the session.
shells: RwLock<HashMap<Sid, State>>,
Expand Down Expand Up @@ -85,7 +92,7 @@ struct State {

impl Session {
/// Construct a new session.
pub fn new(metadata: WsMetadata) -> Self {
pub fn new(metadata: Metadata) -> Self {
let now = Instant::now();
let (update_tx, update_rx) = async_channel::bounded(256);
Session {
Expand All @@ -103,7 +110,7 @@ impl Session {
}

/// Returns the metadata for this session.
pub fn metadata(&self) -> &WsMetadata {
pub fn metadata(&self) -> &Metadata {
&self.metadata
}

Expand Down
16 changes: 6 additions & 10 deletions crates/sshx-server/src/web/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use sshx_core::{Sid, Uid};

/// Metadata sent to clients on connection.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WsMetadata {
/// Used by clients to validate their encryption key.
pub encrypted_zeros: Bytes,
}

/// Real-time message conveying the position and size of a terminal.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -68,7 +60,9 @@ pub struct WsUser {
#[serde(rename_all = "camelCase")]
pub enum WsServer {
/// Initial server message, with the user's ID and session metadata.
Hello(Uid, WsMetadata),
Hello(Uid),
/// The user's authentication was invalid.
InvalidAuth(),
/// A snapshot of all current users in the session.
Users(Vec<(Uid, WsUser)>),
/// Info about a single user in the session: joined, left, or changed.
Expand All @@ -89,6 +83,8 @@ 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),
/// Set the name of the current user.
SetName(String),
/// Send real-time information about the user's cursor.
Expand All @@ -102,7 +98,7 @@ pub enum WsClient {
/// Move a shell window to a new position and focus it.
Move(Sid, Option<WsWinsize>),
/// Add user data to a given shell.
Data(Sid, #[serde(with = "serde_bytes")] Vec<u8>),
Data(Sid, Bytes, u64),
/// Subscribe to a shell, starting at a given chunk index.
Subscribe(Sid, u64),
/// Send a a chat message to the room.
Expand Down
23 changes: 18 additions & 5 deletions crates/sshx-server/src/web/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,16 @@ async fn handle_socket(mut socket: WebSocket, session: Arc<Session>) -> Result<(
}

let user_id = session.counter().next_uid();
let metadata = session.metadata().clone();
send(&mut socket, WsServer::Hello(user_id, metadata)).await?;
send(&mut socket, WsServer::Hello(user_id)).await?;

match recv(&mut socket).await? {
Some(WsClient::Authenticate(bytes)) if bytes == session.metadata().encrypted_zeros => {}
_ => {
send(&mut socket, WsServer::InvalidAuth()).await?;
socket.close().await?;
return Ok(());
}
}

let _user_guard = session.user_scope(user_id)?;

Expand Down Expand Up @@ -109,6 +117,7 @@ async fn handle_socket(mut socket: WebSocket, session: Arc<Session>) -> Result<(
};

match msg {
WsClient::Authenticate(_) => {}
WsClient::SetName(name) => {
session.update_user(user_id, |user| user.name = name)?;
}
Expand Down Expand Up @@ -139,9 +148,13 @@ async fn handle_socket(mut socket: WebSocket, session: Arc<Session>) -> Result<(
session.update_tx().send(msg).await?;
}
}
WsClient::Data(id, data) => {
let data = TerminalInput { id: id.0, data };
update_tx.send(ServerMessage::Input(data)).await?;
WsClient::Data(id, data, offset) => {
let input = TerminalInput {
id: id.0,
data: data.into(),
offset,
};
update_tx.send(ServerMessage::Input(input)).await?;
}
WsClient::Subscribe(id, chunknum) => {
if subscribed.contains(&id) {
Expand Down
21 changes: 18 additions & 3 deletions crates/sshx-server/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ impl ClientSocket {
pub async fn connect(uri: &str, key: &str) -> Result<Self> {
let (stream, resp) = tokio_tungstenite::connect_async(uri).await?;
ensure!(resp.status() == StatusCode::SWITCHING_PROTOCOLS);
Ok(Self {

let mut this = Self {
inner: stream,
encrypt: Encrypt::new(key),
user_id: Uid(0),
Expand All @@ -112,7 +113,14 @@ impl ClientSocket {
messages: Vec::new(),
errors: Vec::new(),
terminated: false,
})
};
this.authenticate().await;
Ok(this)
}

async fn authenticate(&mut self) {
let encrypted_zeros = self.encrypt.zeros().into();
self.send(WsClient::Authenticate(encrypted_zeros)).await;
}

pub async fn send(&mut self, msg: WsClient) {
Expand All @@ -121,6 +129,12 @@ impl ClientSocket {
self.inner.send(Message::Binary(buf)).await.unwrap();
}

pub async fn send_input(&mut self, id: Sid, data: &[u8]) {
let offset = 42; // arbitrary, don't reuse the offset in real code though
let data = self.encrypt.segment(0x200000000, offset, data);
self.send(WsClient::Data(id, data.into(), offset)).await;
}

async fn recv(&mut self) -> Option<WsServer> {
loop {
match self.inner.next().await.transpose().unwrap() {
Expand All @@ -147,7 +161,8 @@ impl ClientSocket {
let flush_task = async {
while let Some(msg) = self.recv().await {
match msg {
WsServer::Hello(user_id, _) => self.user_id = user_id,
WsServer::Hello(user_id) => self.user_id = user_id,
WsServer::InvalidAuth() => panic!("invalid authentication"),
WsServer::Users(users) => self.users = BTreeMap::from_iter(users),
WsServer::UserDiff(id, maybe_user) => {
self.users.remove(&id);
Expand Down
12 changes: 8 additions & 4 deletions crates/sshx-server/tests/with_client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
use sshx::{controller::Controller, runner::Runner};
use sshx::{controller::Controller, encrypt::Encrypt, runner::Runner};
use sshx_core::{
proto::{server_update::ServerMessage, TerminalInput},
Sid, Uid,
Expand Down Expand Up @@ -32,9 +32,13 @@ async fn test_command() -> Result<()> {
let updates = session.update_tx();
updates.send(ServerMessage::CreateShell(1)).await?;

let key = controller.encryption_key();
let encrypt = Encrypt::new(key);
let offset = 4242;
let data = TerminalInput {
id: 1,
data: "ls\r\n".into(),
data: encrypt.segment(0x200000000, offset, b"ls\r\n"),
offset,
};
updates.send(ServerMessage::Input(data)).await?;

Expand Down Expand Up @@ -80,11 +84,11 @@ async fn test_ws_basic() -> Result<()> {
s.send(WsClient::Subscribe(Sid(1), 0)).await;
assert_eq!(s.read(Sid(1)), "");

s.send(WsClient::Data(Sid(1), b"hello!".to_vec())).await;
s.send_input(Sid(1), b"hello!").await;
s.flush().await;
assert_eq!(s.read(Sid(1)), "hello!");

s.send(WsClient::Data(Sid(1), b" 123".to_vec())).await;
s.send_input(Sid(1), b" 123").await;
s.flush().await;
assert_eq!(s.read(Sid(1)), "hello! 123");

Expand Down
4 changes: 2 additions & 2 deletions crates/sshx/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,10 @@ impl Controller {

match message {
ServerMessage::Input(input) => {
// We ignore `data.seq` because it should be unused here.
let data = self.encrypt.segment(0x200000000, input.offset, &input.data);
if let Some(sender) = self.shells_tx.get(&Sid(input.id)) {
// This line applies backpressure if the shell task is overloaded.
sender.send(ShellData::Data(input.data)).await.ok();
sender.send(ShellData::Data(data)).await.ok();
} else {
warn!(%input.id, "received data for non-existing shell");
}
Expand Down
51 changes: 34 additions & 17 deletions src/lib/Session.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
];
}
let encrypt: Encrypt;
let srocket: Srocket<WsServer, WsClient> | null = null;
let connected = false;
Expand Down Expand Up @@ -107,32 +108,33 @@
onMount(async () => {
// The page hash sets the end-to-end encryption key.
const key = window.location.hash?.slice(1) ?? "";
const encrypt = await Encrypt.new(key);
const clientEncryptedZeros = await encrypt.zeros();
encrypt = await Encrypt.new(key);
const encryptedZeros = await encrypt.zeros();
srocket = new Srocket<WsServer, WsClient>(`/api/s/${id}`, {
onMessage(message) {
if (message.hello) {
userId = message.hello[0];
const { encryptedZeros } = message.hello[1];
if (!isEqual(encryptedZeros, clientEncryptedZeros)) {
exitReason =
"The URL is not correct, invalid end-to-end encryption key.";
srocket?.dispose();
} else {
makeToast({
kind: "success",
message: `Connected to the server.`,
});
}
userId = message.hello;
srocket?.send({ authenticate: encryptedZeros });
makeToast({
kind: "success",
message: `Connected to the server.`,
});
} else if (message.invalidAuth) {
exitReason =
"The URL is not correct, invalid end-to-end encryption key.";
srocket?.dispose();
} else if (message.chunks) {
let [id, seqnum, chunks] = message.chunks;
locks[id](async () => {
await tick();
chunknums[id] += chunks.length;
for (const data of chunks) {
const streamNum = 0x100000000n | BigInt(id);
const buf = await encrypt.segment(streamNum, seqnum, data);
const buf = await encrypt.segment(
0x100000000n | BigInt(id),
BigInt(seqnum),
data,
);
seqnum += data.length;
writers[id](new TextDecoder().decode(buf));
}
Expand Down Expand Up @@ -191,6 +193,21 @@
onDestroy(() => srocket?.dispose());
let counter = 0n;
async function handleInput(id: number, data: Uint8Array) {
if (counter === 0n) {
// On the first call, initialize the counter to a random 64-bit integer.
const array = new Uint8Array(8);
crypto.getRandomValues(array);
counter = new DataView(array.buffer).getBigUint64(0);
}
const offset = counter;
counter += BigInt(data.length); // Must increment before the `await`.
const encrypted = await encrypt.segment(0x200000000n, offset, data);
srocket?.send({ data: [id, encrypted, offset] });
}
// Stupid hack to preserve input focus when terminals are reordered.
// See: https://github.com/sveltejs/svelte/issues/3973
let activeElement: Element | null = null;
Expand Down Expand Up @@ -348,7 +365,7 @@
cols={ws.cols}
bind:write={writers[id]}
bind:termEl={termElements[id]}
on:data={({ detail: data }) => srocket?.send({ data: [id, data] })}
on:data={({ detail: data }) => handleInput(id, data)}
on:close={() => srocket?.send({ close: id })}
on:shrink={() => {
const rows = Math.max(ws.rows - 4, TERM_MIN_ROWS);
Expand Down
Loading

0 comments on commit 1000348

Please sign in to comment.