Embeddable SSH terminal for the web

A real SSH terminal, inside your own page.

wsshasm runs SSH in WebAssembly, draws the screen with WebGPU, and keeps private keys in the browser. A small relay carries the encrypted bytes to your server and never sees inside them.

Illustration: a web app with a wsshasm terminal embedded in it. Keys stay in the browser, SSH runs there in WebAssembly, and the session stays encrypted all the way to sshd; the relay in between only forwards ciphertext.

Type into a real terminal.

This is the actual <wsshasm-terminal> and terminal core, running a small shell written in this page. Nothing you type leaves your browser.

Local playground Starting…
Click it and try help, colors, or top, or change its colours and font with Aa. From the keyboard, Enter starts typing and Ctrl+Alt lets go.

Three hops, one encrypted session.

The browser speaks SSH itself. The relay turns a WebSocket into a TCP connection, and the server sees an ordinary SSH client. Here is one keystroke on its way.

  1. Your page

    in the visitor's browser

    Draws the screen, runs SSH in WebAssembly, signs with WebCrypto.

  2. Relay

    one small Rust binary

    Copies bytes between WebSocket and TCP, for allowed targets only. Never parses SSH.

  3. sshd

    any OpenSSH server

    No changes, no agent. Authenticates the user as usual.

SSH encryption, from the browser to sshd

  1. The browser has

    keydown  l
    SSH_MSG_CHANNEL_DATA  "l"

    sealed with chacha20-poly1305

  2. The relay sees

    length  61 6b 79 b0
    payload e3 f5 43 6e 22 22 8a cc
            66 f2 6e fc 9a c1 c8 2e
    tag     45 6f 3e 76 72 f3 cb 5d
            86 c6 39 bf d3 43 b4 22

    36 bytes it cannot decrypt. Even the length is encrypted; the payload is the keystroke and its padding, and the tag lets sshd detect any change. The bytes are an example: every session's keys differ.

  3. sshd gets

    SSH_MSG_CHANNEL_DATA  "l"
    → the shell reads l

    opened with the session key only it and the browser hold

A keystroke is encrypted in the browser, crosses the relay as 36 bytes it cannot decrypt, and is decrypted at sshd.

Every step of a session, replayed in a terminal.

A terminal program draws wsshasm's parts and plays its real flows: signing in, one keystroke, a resize, a quiet connection, a new or changed host key, and a refusal. It runs in a second <wsshasm-terminal>, and each step is taken from the code.

How wsshasm works Starting…
Click it, then Space pauses, ← → pick a flow, and Tab or a click on a box tells what each part does. Ctrl+Alt lets go of the keyboard.
Read the flows as text

Built so the relay can't read your session.

Keys never leave the browser

Private keys are encrypted at rest and unlock as WebCrypto keys the page cannot export. The SSH core asks for signatures; the key never enters WebAssembly memory.

  1. key file
  2. PBKDF2-HMAC-SHA-256600,000 rounds or more
  3. AES-256-GCM envelopeSQLite in OPFS
  1. unlock
  2. non-extractable CryptoKeyWebCrypto
  3. signaturesfor the SSH core

The relay is a byte pipe

Session keys are agreed between the browser and sshd, so the relay never has them. Before it opens a connection, it checks:

Origin on --allowed-origins
Bearer token matches
Target on --allowed-targets; everything else is denied
Address every resolved address against the IANA special-purpose ranges
DNS resolved once, so a rebinding answer cannot swap it

Host keys are verified

Known hosts are kept per host, port, and key type. What happens on connect depends on the key the server shows.

ssh.example.com:22  ssh-ed25519
SHA256:U7DqCGkkn4Z69DvSJu7mD0OaW2HLWKCo7TrWAGheTlE
  • The same key connects.
  • An unknown key is shown with its SHA-256 fingerprint before you authenticate.
  • A changed key is a warning you confirm explicitly.

Current SSH, nothing legacy

Strict key exchange against Terrapin, rekeying, and keepalive. Type stack in the playground for every algorithm.

Key exchange curve25519-sha256and 6 more
Ciphers chacha20-poly1305@openssh.comaes128-gcm@openssh.comand 4 more
Host keys ssh-ed25519ecdsa-sha2-nistp256and 4 more
Strict kex kex-strict-c-v00@openssh.com

Take the whole stack, or one layer.

Each package does one job and works on its own. Here is where each one sits.

In the browser

Your page

You place

@wsshasm/terminal-element <wsshasm-terminal>: WebGPU screen, keyboard, IME, mouse, paste, scrollback
@wsshasm/identity-elements key import, key list, profile editor, host key dialog

You call

@wsshasm/client one SSH session: relay transport, states, prompts, reconnect

Underneath

@wsshasm/terminal terminal state and VT parser, Rust in WebAssembly, differential-tested against vte
@wsshasm/secure-shell sans-IO SSH 2.0 client, Rust in WebAssembly
@wsshasm/identity keys and known hosts in SQLite on OPFS, encrypted at rest, in a worker

WebSocket

On your server

wsshasm-relay WebSocket to TCP: allowlists, limits, metrics, graceful drain. A Rust binary.
sshd yours, unchanged
In the browser, your page places two element packages and calls the session package, and all of them stand on three cores. On your server, the relay sits next to sshd.

A terminal where your product needs one.

Three steps. The sample is type-checked against the packages every time this page is built.

  1. Put the element in your page

    It draws with WebGPU and falls back to text.

    index.html
    <wsshasm-terminal font-size="14"></wsshasm-terminal>
  2. Open a session

    Unlock a key kept in this browser, then connect through the relay.

    connect.ts
  3. Run the relay next to sshd

    Allow your page's origin and the one target it may reach.

    relay, next to sshd
    export WSSHASM_RELAY_BEARER_TOKEN="$(openssl rand -hex 32)"
    wsshasm-relay \
      --bind-address 0.0.0.0:22081 \
      --allowed-targets 127.0.0.1:22 \
      --allowed-origins https://ssh.example.com

See it connect to a real server.

The live console is the complete page: key import, profiles, host key checks, and a real shell through the relay. It needs a relay bearer token from whoever runs the relay.

Open the live console