diff --git a/CHANGELOG.md b/CHANGELOG.md index e8065b8..b5435aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ ## 0.3.0.0 +**Breaking API Changes** +In this version, TermPair clients from previous versions cannot connect to this TermPair server + * Use new key sharing scheme: Different keys used in different directions; keys rotated; secret keys retrieved via RSA public key rather than embedding in the URL * [bugfix] Terminal dimensions in browser match upon initial connection, instead of after resizing +* Allow static site to route terminal traffic through other server. If static site is detected, user can enter the terminal id and server url in the browser UI. +* Allow Terminal ID to be entered on landing page + ## 0.2.0.0 * Add ability to copy+paste using keystrokes (copy with ctrl+shift+c or ctrl+shift+x, and paste with ctrl+shift+v) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00f5d4e..a0612c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,18 +33,28 @@ yarn install ``` to install dependencies. -You'll then be able to build the frontend app using: +You'll then be able to view and hot reload changes to the frontend app using: ```bash -yarn build +yarn start ``` The TermPair server does not need to be reloaded, so you can just refresh the webpage to view changes. -Changes that don't require an actively connected terminal can be tested much more easily with hot reloading by running: +If you want to connect a terminal to the frontend, in a new terminal run +``` +nox -s serve +``` +then share a terminal with +``` +nox -s broadcast +``` +open the browser at `http://localhost:3000` and enter the terminal id and url (`http://localhost:8000`). + +If you are testing something that needs a full build, you can build and statically serve the frontend with ```bash -yarn start +yarn build ``` ## Releasing new versions to PyPI diff --git a/README.md b/README.md index a38bea9..ee3d102 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ TermPair lets developers securely share and control terminals in real time. First start the TermPair server with `termpair serve`, or use the one already running at [https://chadsmith.dev/termpair](https://chadsmith.dev/termpair). -The server is used to route encrypted data between terminals and connected browsers. +The server is used to route encrypted data between terminals and connected browsers — it doesn't actually start sharing any terminals just by running it. ``` > termpair serve --port 8000 @@ -42,15 +42,15 @@ Then share your terminal by running `termpair share`. This connects your terminal to the server, and allows browsers to access the terminal. ``` -> termpair share --port 8000 --------------------------------------------------------------------------------- +> termpair share +--------------------------------------------------------------------------------------------------------------------------------- Connection established with end-to-end encryption 🔒 -Sharing '/bin/bash' at - -http://localhost:8000/?terminal_id=fd96c0f84adc6be776872950e19caecc - +Terminal ID: e8add1d61a63599b91c0f5ba8779319d +TermPair Server URL: http://localhost:8000/ +Sharable link (expires when this process ends): + http://localhost:8000/?terminal_id=e8add1d61a63599b91c0f5ba8779319d Type 'exit' or close terminal to stop sharing. --------------------------------------------------------------------------------- +--------------------------------------------------------------------------------------------------------------------------------- ``` The URL printed contains a unique terminal ID. You can share the URL with whoever you like. **Anyone who has it can access your terminal while the `termpair share` process is running.** @@ -76,13 +76,13 @@ TermPair consists of three pieces: 2. server 3. browser client(s) -First, the termpair server is started (`termpair serve`). The server acts as a router that blindly forwards encrypted data between TermPair terminal clients and connected browsers. The serve listens for termpair websocket connections from unix terminal clients, and maintains a mapping to any connected browsers. +First, the termpair server is started (`termpair serve`). The server acts as a router that blindly forwards encrypted data between TermPair terminal clients and connected browsers. The server listens for termpair websocket connections from unix terminal clients, and maintains a mapping to any connected browsers. -Before the TermPair client sends terminal output to the server, it creates two AES encryption keys and uses one of them to encrypt the output so the server cannot read it. The other is used by the browser to encrypt user input. +Before the TermPair client sends terminal output to the server, it creates two 128 bit AES encryption keys. One is used to encrypt the terminal's output to the browsers so the server cannot read it. The other is used by the browser when sending input from the browser to the terminal. The server then forwards that data to connected browsers. When the browsers receive the data, they use the secret key to decrypt and display the terminal output. -The browser obtains the secret AES keys by requesting them from the broadcasting terminal along with a public RSA key generated in the browser at runtime. The broadcasting terminal responds with AES keys encrypted with the public key. The AES keys are rotated periodically. +The browser obtains the secret AES keys without the server seeing them by using public key encryption. The browser generates an RSA key pair at runtime, then sends the public key to the broadcasting terminal. The broadcasting terminal responds with the AES keys encrypted with the public key. Both AES keys get rotated after either key has sent 2^20 (1048576) messages. The AES initialization vector (IV) values increment monotonically to ensure they are never reused. When a browser sends input to the terminal, it is encrypted in the browser, forwarded from the server to the terminal, then decrypted in the terminal by TermPair, and finally written to the terminal's input. @@ -149,6 +149,20 @@ server { } ``` +## Static Hosting +As an optional additional security measure, TermPair supports staticallly serving the JavaScript web app. In this arrangement, you can build the webapp yourself and host on your computer, or statically host on something like GitHub pages or Vercel. That way you can guarantee the server is not providing a malicious JavaScript web app. + +Then, you can connect to it and specify the Terminal ID and TermPair server that routes the encrypted data. + +To build the web app, see [CONTRIBUTING.md](https://github.com/cs01/termpair/blob/master/CONTRIBUTING.md). You can try the one being served at [https://cs01.github.io/termpair/site/connect/](https://cs01.github.io/termpair/site/connect/). + +Then you can deploy to GitHub pages, Vercel, etc. or self-serve with +```shell +$ cd termpair/termpair/frontend_build +$ python3 -m http.server 7999 --bind 127.0.0.1 +``` + + ## CLI API ``` diff --git a/docs/termpair_architecture.excalidraw b/docs/termpair_architecture.excalidraw index ce87fba..aa3de66 100644 --- a/docs/termpair_architecture.excalidraw +++ b/docs/termpair_architecture.excalidraw @@ -143,8 +143,8 @@ }, { "type": "text", - "version": 961, - "versionNonce": 1627041225, + "version": 966, + "versionNonce": 139749130, "isDeleted": false, "id": "e-Wl_LzVogxM43annJJMP", "fillStyle": "hachure", @@ -158,15 +158,15 @@ "strokeColor": "#000000", "backgroundColor": "transparent", "width": 560, - "height": 325, + "height": 300, "seed": 676973041, "groupIds": [], "strokeSharpness": "sharp", "boundElementIds": [], "fontSize": 20, "fontFamily": 1, - "text": "- Each browser creates a new RSA key pair, then\nrequests the AES keys from the broadcasting terminal\n- The terminal replies with AES keys that are encrypted\nwith the browser's public key\n- The browser decrypts with its private key and stores\nthe two AES keys used to encrypt/decrypt the terminal\ndata\n- Encrypted terminal data is received\nfrom server, decrypted, and rendered\n- User input to browser terminal is also\nencrypted, then\nsent to the TermPair server, which sends to the\nuser broadcasting", - "baseline": 318, + "text": "- Each browser creates a new RSA key pair, then\nrequests the AES keys from the broadcasting terminal\n- The terminal replies with AES keys that are encrypted\nwith the browser's public key\n- The browser decrypts with its private key and stores\nthe two AES keys used to encrypt/decrypt the terminal\ndata\n- Encrypted terminal data is received\nfrom server, decrypted, and rendered\n- User input to browser terminal is also\nencrypted, then sent to the TermPair server, \nwhich sends to the user broadcasting", + "baseline": 293, "textAlign": "left", "verticalAlign": "top" }, diff --git a/docs/termpair_architecture.png b/docs/termpair_architecture.png index b2c42e5..285c5db 100644 Binary files a/docs/termpair_architecture.png and b/docs/termpair_architecture.png differ diff --git a/noxfile.py b/noxfile.py index 86d3cfe..bc748c6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -38,12 +38,29 @@ def watch_docs(session): session.run("mkdocs", "serve") +@nox.session(python=python) +def build_frontend(session): + session.run("yarn", "--cwd", "termpair/frontend_src", "build", external=True) + + @nox.session(python=python) def publish_docs(session): session.install(*doc_deps) session.run("mkdocs", "gh-deploy") +@nox.session(python=python) +def publish_static_webapp(session): + build_frontend(session) + session.run("git", "checkout", "gh-pages", external=True) + session.run("rm", "-rf", "connect/", external=True) + session.run("mkdir", "connect", external=True) + session.run("cp", "-rT", "termpair/frontend_build/", "connect/", external=True) + session.run("git", "add", "connect", external=True) + session.run("git", "commit", "-m", "commit built frontend", external=True) + session.run("git", "push", "origin", "gh-pages", external=True) + + @nox.session(python=python) def publish(session): print("REMINDER: Has the changelog been updated?") @@ -54,6 +71,7 @@ def publish(session): session.run("python", "setup.py", "--quiet", "sdist", "bdist_wheel") session.run("python", "-m", "twine", "upload", "dist/*") publish_docs(session) + publish_static_webapp(session) @nox.session(python=python) diff --git a/termpair/frontend_src/package.json b/termpair/frontend_src/package.json index 4efa113..53a155c 100644 --- a/termpair/frontend_src/package.json +++ b/termpair/frontend_src/package.json @@ -30,7 +30,6 @@ "test": "craco test", "eject": "craco eject" }, - "proxy": "http://localhost:8000", "homepage": ".", "eslintConfig": { "extends": "react-app" diff --git a/termpair/frontend_src/src/App.tsx b/termpair/frontend_src/src/App.tsx index d6eb635..bc3e0c3 100644 --- a/termpair/frontend_src/src/App.tsx +++ b/termpair/frontend_src/src/App.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import React, { useEffect, useState, useLayoutEffect, useRef } from "react"; import "xterm/css/xterm.css"; import logo from "./logo.png"; // logomakr.com/4N54oK @@ -15,7 +14,7 @@ import { } from "./encryption"; import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; -import { atom, useRecoilState } from "recoil"; +// import { atom, useRecoilState } from "recoil"; import { debounce } from "debounce"; import { newBrowserConnected, @@ -35,40 +34,38 @@ const githubLogo = ( ); -const showSettings = atom({ - key: "showSettings", - default: false, -}); +// const showSettings = atom({ +// key: "showSettings", +// default: false, +// }); -function Settings(props: any) { - const [showSetting, setShowSettings] = useRecoilState(showSettings); - if (!showSetting) { - return null; - } - return ( -
-
-
TermPair Settings
-
Body
-
- -
-
-
- ); -} +// function Settings(props: any) { +// const [showSetting, setShowSettings] = useRecoilState(showSettings); +// if (!showSetting) { +// return null; +// } +// return ( +//
+//
+//
TermPair Settings
+//
Body
+//
+// +//
+//
+//
+// ); +// } function TopBar(props: any) { - // const [showSetting, setShowSettings] = useRecoilState(showSettings); - return (
@@ -148,10 +145,11 @@ function BottomBar(props: { {connectedClients} {startTime}
-
+
chadsmith.dev |{" "} - GitHub + GitHub |{" "} + Other Projects
@@ -172,12 +170,14 @@ class ErrorBoundary extends React.Component { componentDidCatch(error: any, errorInfo: any) { // You can also log the error to an error reporting service // logErrorToMyService(error, errorInfo); + console.error(error); + console.error(errorInfo); } render() { if (this.state.hasError) { // You can render any custom fallback UI - return

Something went wrong.

; + return

Something went wrong.

; } return this.props.children; @@ -206,7 +206,7 @@ function CopyCommand(props: { command: string }) {
{props.command} @@ -230,15 +230,130 @@ function CopyCommand(props: { command: string }) { ); } -function LandingPageContent() { - return ( -
-
-
Welcome to TermPair!
- Easily share terminals with end-to-end encryption 🔒. Terminal data is - always encrypted before being routed through the server.{" "} - Learn more. +function LandingPageContent(props: { + isStaticallyHosted: Nullable; + setCustomTermpairServer: (customServer: string) => void; + setTerminalId: (newTerminalId: string) => void; +}) { + const [terminalIdInput, setTerminalIdInput] = React.useState(""); + const [customHostInput, setCustomHostInput] = React.useState(""); + const connectToUserTerminal = () => { + if (!terminalIdInput) { + toast.dark("Terminal ID cannot be empty"); + return; + } + if (!props.isStaticallyHosted) { + props.setTerminalId(terminalIdInput); + } + if (!customHostInput) { + toast.dark("Host name cannot be empty"); + return; + } + try { + new URL(customHostInput); + } catch (e) { + toast.dark(`${customHostInput} is not a valid url`); + return; + } + props.setCustomTermpairServer(customHostInput); + props.setTerminalId(terminalIdInput); + }; + const inputClass = "text-black px-2 py-3 m-2 w-full font-mono"; + + const terminalIdInputEl = ( +
+ Terminal ID + { + setTerminalIdInput(event.target.value); + }} + value={terminalIdInput} + placeholder="abcdef123456789abcded123456789" + onKeyPress={(e) => { + if (e.key === "Enter") { + connectToUserTerminal(); + } + }} + /> +
+ ); + const terminalServerUrlEl = ( +
+ + TermPair Server URL + + { + setCustomHostInput(event.target.value); + }} + value={customHostInput} + onKeyPress={(e) => { + if (e.key === "Enter") { + connectToUserTerminal(); + } + }} + /> +
+ ); + + const canConnect = props.isStaticallyHosted + ? terminalIdInput.length !== 0 + : terminalIdInput.length !== 0 && customHostInput.length !== 0; + + const connectButton = ( +
+ +
+ ); + const staticLandingContent = ( +
+
This page is statically hosted
+
+ This is a static page serving the TermPair JavaScript app. It is + optional to use a statically served TermPair webapp, but it facilitates + easily building and self-serving to be certain the JavaScript app has + not been tampered with by an untrusted server. +
+
+ Connect to a broadcasting terminal by entering the fields below and + clicking Connect.
+
{ + e.preventDefault(); + connectToUserTerminal(); + }} + > + {terminalIdInputEl} + {terminalServerUrlEl} + {connectButton} +
+
+ ); + + const regularServerContent = ( + <>
Quick Start
@@ -256,13 +371,51 @@ function LandingPageContent() {
-
TermPair Demo
-
- Screencast of TermPair +
Connecting to a Terminal?
+ If a terminal is already broadcasting and you'd like to connect to it, + you don't need to install or run anything. Just enter the Terminal ID + below and click Connect. +
{ + e.preventDefault(); + connectToUserTerminal(); + }} + > + {terminalIdInputEl} + {connectButton} +
+
+ + ); + + const termpairDemoContent = ( +
+
TermPair Demo
+
+ Screencast of TermPair +
+
+ ); + + return ( +
+
+
+
Welcome to TermPair!
+ Easily share terminals with end-to-end encryption 🔒. Terminal data is + always encrypted before being routed through the server.{" "} + Learn more.
+ {props.isStaticallyHosted === null + ? null + : props.isStaticallyHosted === true + ? staticLandingContent + : regularServerContent} + + {termpairDemoContent}
); @@ -277,7 +430,9 @@ type Status = | "Browser is not running in a secure context" | "No Terminal provided" | "Failed to obtain encryption keys" - | "Developer Error: RSA key not ready"; + | "Ready for websocket connection" + | "Developer Error: RSA key not ready" + | "Failed to fetch terminal data"; type TerminalServerData = { terminal_id: string; @@ -306,7 +461,6 @@ function handleStatusChange( prevStatus: Status, setPrevStatus: (prevStatus: Status) => void ): void { - // console.log(`Terminal connection status: ${status}`); const noToast = ["No Terminal provided"]; if (status && noToast.indexOf(status) === -1) { toastStatus(
Terminal status: {status}
); @@ -389,6 +543,8 @@ function handleStatusChange( break; case "No Terminal provided": + case "Failed to fetch terminal data": + case "Ready for websocket connection": break; default: @@ -400,6 +556,8 @@ function handleStatusChange( } function App() { + const [isStaticallyHosted, setIsStaticallyHosted] = + useState>(null); const [terminalServerData, setTerminalServerData] = useState>(null); const [numClients, setNumClients] = useState(0); @@ -427,6 +585,45 @@ function App() { const [status, setStatus] = useState(null); const [prevStatus, setPrevStatus] = useState(null); + const defaultTermpairServer = new URL( + `${window.location.protocol}//${window.location.hostname}:${window.location.port}${window.location.pathname}` + ); + const [customTermpairServer, setCustomTermpairServer] = useState( + new URLSearchParams(window.location.search).get("termpair_server_url") + ); + const termpairHttpServer = + isStaticallyHosted === true ? customTermpairServer : defaultTermpairServer; + + useEffect(() => { + if (isStaticallyHosted === true && customTermpairServer) { + toast.dark( + `Terminal data is being routed through ${customTermpairServer.toString()}` + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [customTermpairServer]); + + useEffect(() => { + const fetchIsStaticallyHosted = async () => { + try { + const ret = await fetch(defaultTermpairServer.toString() + "ping", { + mode: "same-origin", + }); + const text = await ret.json(); + const pong = text === "pong"; + const isTermpairServer = ret.status === 200 && pong; + setIsStaticallyHosted(!isTermpairServer); + } catch (e) { + setIsStaticallyHosted(true); + } + }; + fetchIsStaticallyHosted(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const termpairWebsocketServer = termpairHttpServer + ? new URL(termpairHttpServer.toString().replace(/^http/, "ws")) + : null; const [xterm] = useState( new Xterm({ cursorBlink: true, @@ -434,7 +631,7 @@ function App() { scrollback: 1000, }) ); - const [terminalId] = useState( + const [terminalId, setTerminalId] = useState( new URLSearchParams(window.location.search).get("terminal_id") ); @@ -450,6 +647,7 @@ function App() { xterm.writeln(`Welcome to TermPair! https://github.com/cs01/termpair`); xterm.writeln(""); setXtermWasOpened(true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); const changeStatus = (newStatus: Status) => { @@ -468,18 +666,43 @@ function App() { changeStatus("Browser is not running in a secure context"); return; } - + if (isStaticallyHosted && !customTermpairServer) { + toast.dark( + "Page is statically hosted but no custom server was provided" + ); + return; + } + if (!termpairHttpServer) { + console.error("no termpair server"); + return; + } rsaKeyPair.current = await generateRSAKeyPair(); - const response = await fetch(`terminal/${terminalId}`); - if (response.status === 200) { - setTerminalServerData(await response.json()); - } else { - changeStatus("Terminal ID is invalid"); + try { + const response = await fetch( + new URL(`terminal/${terminalId}`, termpairHttpServer).toString() + ); + if (response.status === 200) { + setTerminalServerData(await response.json()); + setStatus("Ready for websocket connection"); + } else { + changeStatus("Terminal ID is invalid"); + setTerminalServerData(null); + } + } catch (e) { + changeStatus(`Failed to fetch terminal data`); + toast.dark( + `Error fetching terminal data from ${termpairHttpServer.toString()}. Is the URL correct? Error message: ${String( + e.message + )}`, + + { autoClose: false } + ); setTerminalServerData(null); } } getTerminalData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminalId]); useEffect(() => { @@ -491,11 +714,12 @@ function App() { xterm.resize(terminalSize.cols, terminalSize.rows); }, 500) ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminalSize, xterm]); useEffect(() => { function setupWebsocketConnection() { - if (status !== null) { + if (status !== "Ready for websocket connection") { return; } if ( @@ -503,12 +727,15 @@ function App() { ) { return; } + if (!termpairWebsocketServer) { + return; + } changeStatus("Connecting..."); - - const ws_protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const webSocket = new WebSocket( - `${ws_protocol}://${window.location.hostname}:${window.location.port}${window.location.pathname}connect_browser_to_terminal?terminal_id=${terminalServerData.terminal_id}` + const connectWebsocketUrl = new URL( + `connect_browser_to_terminal?terminal_id=${terminalId}`, + termpairWebsocketServer ); + const webSocket = new WebSocket(connectWebsocketUrl.toString()); xterm.attachCustomKeyEventHandler( getCustomKeyEventHandler( @@ -706,7 +933,9 @@ function App() { aesKeys.current.maxIvCount == null ) { console.error(e); + console.error(data); changeStatus("Failed to obtain encryption keys"); + return; } } } else if (data.event === "aes_key_rotation") { @@ -744,12 +973,22 @@ function App() { }); } setupWebsocketConnection(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminalServerData, status]); + const showLandingPage = + [null, "No Terminal provided", "Failed to fetch terminal data"].indexOf( + status + ) > -1 || isStaticallyHosted === null; + const content = (
- {[null, "No Terminal provided"].indexOf(status) > -1 ? ( - + {showLandingPage ? ( + ) : (
-
+
- {content} str: plaintext = encryption.aes_decrypt(self.secret_browser_key, ciphertext) return plaintext - def get_max_iv_count(self, start_iv_count: int) -> int: + def get_max_iv_for_browser(self, start_iv_count: int) -> int: + # each browser for this session encrypts using the same AES key. + # To avoid re-using an IV, we assign each a window to operate within. + # If the end of the window is hit, a new key is requested. max_iv_count = floor( start_iv_count + self.message_count_rotation_required @@ -148,8 +149,7 @@ async def register_broadcast_with_server(self) -> TerminalId: ) event, payload = await self.receive_data_from_websocket() if event == "start_broadcast": - terminal_id = payload - return terminal_id + return payload elif event == "fatal_error": raise TermPairError(fatal_server_error_msg(payload)) else: @@ -161,7 +161,6 @@ async def register_broadcast_with_server(self) -> TerminalId: async def run(self): self.terminal_id = await self.register_broadcast_with_server() - self.share_url = self.get_share_url(self.url, self.terminal_id) self.print_broadcast_init_message() @@ -269,7 +268,7 @@ async def task_receive_websocket_messages(self): "b64_pk_browser_aes_key": b64_pk_browser_aes_key, "encoding": "browser_public_key", "iv_count": iv_count, - "max_iv_count": self.aes_keys.get_max_iv_count( + "max_iv_count": self.aes_keys.get_max_iv_for_browser( iv_count ), "salt": base64.b64encode( @@ -301,28 +300,37 @@ async def receive_data_from_websocket(self): return parsed["event"], parsed.get("payload") def print_broadcast_init_message(self): - cmd_str = " ".join(shlex.quote(c) for c in self.cmd) _, cols = utils.get_terminal_size(sys.stdin) - dashes = "-" * cols - print( - textwrap.dedent( - f""" {dashes} - \033[1m\033[0;32mConnection established with end-to-end encryption\033[0m 🔒 - Sharing {cmd_str!r} at - {self.share_url} + msg = [ + "\033[1m\033[0;32mConnection established with end-to-end encryption\033[0m 🔒", + f"Terminal ID: {self.terminal_id}", + f"TermPair Server URL: {self.url}", + "Sharable link (expires when this process ends):", + " " + self.get_share_url(self.url, self.terminal_id), + "Type 'exit' or close terminal to stop sharing.", + ] - Type 'exit' or close terminal to stop sharing. - {dashes}""" - ) - ) + dashes = "-" * cols + print(dashes) + for m in msg: + print(m) + print(dashes) def get_share_url( - self, - url, - ws_id, + self, url: str, terminal_id: str, static_url: Optional[str] = None ): - return urljoin(url, f"?terminal_id={ws_id}") + if static_url: + qp = { + "terminal_id": terminal_id, + "termpair_server_url": url, + } + return urljoin(static_url, f"?{urlencode(qp)}") + else: + qp = { + "terminal_id": terminal_id, + } + return urljoin(url, f"?{urlencode(qp)}") def handle_new_pty_output(self, cleanup: Callable): """forwards pty's output to local stdout AND to websocket"""