A cross-platform SSH client with end-to-end-encrypted vaults — local-first, and optionally synced through a server you host.
Your keys, your hosts, your machine. The client is complete on its own — no account, no cloud, no server required. Run one only when you want the same vault on a second device or in a team, and even then it can't read a single byte of your data.
⬇ Download the client — macOS · Windows · Linux · Android · iOS
Quick Start · Do I need the server?
- Quick Start — get the client · self-host a server (optional) · admin panel
- Why UniSSH?
- Features
- Screenshots
- How it works · Identity model
- Components
- Installing unsigned builds — macOS · Windows · Linux · Android · iOS
- Configuration
- Security & Privacy
- Changelog — what changed, and whether it breaks your vault
- Build from source
- Contributing
- Community — supporting the project
- FAQ / Troubleshooting
- License
(A) is the whole install. Download the client and open it: the first run creates a local vault, and your hosts and keys are encrypted on that device. No account to create, no backend to stand up, and nothing of ours between you and the machines you connect to. (Do write down the Secret Key it shows you — it is what recovers the vault.)
(B) and (C) exist only if you want the same vault on a second device or shared with a team. Everything the client does over SSH — sessions, SFTP, tunnels, fleet commands — behaves identically either way: SSH traffic goes straight from your device to your hosts and never through the sync server.
Download (recommended). Grab the latest build for your OS from the Releases page:
| OS | Architecture | Artifact |
|---|---|---|
| macOS | Apple Silicon and Intel | UniSSH_<version>_universal.dmg (drag to Applications) |
| Windows | x86-64 | UniSSH_<version>_x64-setup.exe or .msi |
| Windows | ARM64 | UniSSH_<version>_arm64-setup.exe or _arm64_en-US.msi |
| Linux | x86-64 | UniSSH_<version>_amd64.AppImage (portable) or .deb / .rpm |
| Linux | ARM64 | UniSSH_<version>_aarch64.AppImage or _arm64.deb / .rpm |
| Android | any (universal) | UniSSH_<version>_universal.apk — sideload |
| Android | pick your own | _arm64 / _armv7 / _x86_64 / _x86 .apk — smaller |
| iOS | Apple Silicon | UniSSH_<version>_ios-sideload.ipa — unsigned, sideload |
Every desktop platform here covers both architectures, so take the ARM file on an ARM machine instead of letting it emulate the x86-64 one — Windows on ARM will happily run the x64 build, and the emulation tax lands squarely on vault crypto and terminal I/O. What is not built, so you know before you look: nothing here ships 32-bit for desktop; build from source if you need one that is missing. The ARM builds exist because that is where an SSH client actually gets used on ARM: Asahi, ARM laptops, Snapdragon Windows machines, a Raspberry Pi on a desk. ARM servers are served by the container images, which are multi-arch.
Release builds are unsigned. On desktop that means a one-time warning and a ten-second "open anyway" — on Android it means a permanent Play Protect warning, and on iOS it means re-signing the .ipa with your own Apple ID before it will run at all. The steps for all five platforms are in Installing unsigned builds. Always verify the download against the published checksum first.
Build the client from source (also your strongest trust check)
Desktop (Tauri — macOS / Windows / Linux):
cd client
npm install
npm run tauri dev # dev run (Vite + the Rust app) — or: just dev-client
npm run tauri build # production bundle: .app/.dmg/.deb/.AppImage/.msiMobile (run init once):
npm run tauri ios init && npm run tauri ios dev
npm run tauri android init && npm run tauri android devPrerequisites (clients): Node 20.19+ / 22.12+ and Rust 1.94+. Linux desktop also needs the WebKitGTK stack: libwebkit2gtk-4.1-dev libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev libayatana-appindicator3-dev. iOS needs Xcode + CocoaPods; Android needs Android Studio + SDK + NDK.
On first launch you pick a Local or Cloud vault. Local needs nothing else — you're done. For Cloud sync across devices or a team, stand up a server next.
Stop here if one device is all you need — a local vault is fully functional without any of this, and standing a server up later takes nothing away: the client keeps working exactly as it does now, and a portable encrypted backup carries the vault to another machine.
Be aware of what that migration is and isn't, though: a restored backup lands as a local vault wherever it goes. Putting existing hosts behind sync today means creating a Cloud vault and moving them into it — there is no in-place local→cloud switch.
Running one buys you exactly four things: the same vault unlocked on your laptop, phone and tablet; hosts and secrets shared with a team, with membership, roles and revocation; a lost device cut off from the vault without re-keying everything; and a server-side audit trail. What it does not buy the server is the ability to read any of it — it holds ciphertext and routing metadata, and the keys never leave your devices.
Docker (recommended). The top-level compose.yml brings up the server (plain HTTP, internal-only) behind a bundled Caddy reverse proxy that does TLS 1.3 + automatic HTTPS for you. The Docker build context is the repo root (so the server image can reach rust-core/ for its byte-compatibility tests).
# from the repository root
cp deploy/.env.example .env # then edit: set UNISSH_DOMAIN (+ a TLS directive)
docker compose up -d --buildEdit .env before first boot — the only required value is:
UNISSH_DOMAIN— your public domain (→ automatic Let's Encrypt ACME), or a*.local/IP host together withUNISSH_TLS_DIRECTIVE="tls internal"for a Caddy-issued self-signed cert on a LAN.
There is no bootstrap token. On first boot, while the instance is still unclaimed, the server prints a one-time SETUP CODE to its log. Grab it:
docker compose logs server 2>&1 | grep -i "setup code"Then open the client (or the admin panel), point it at your instance URL, and claim the instance with that code — the first user to claim becomes the owner. (For IaC/automation you can pin a deterministic code with UNISSH__SETUP__CODE=… instead of the random one.) After that, teammates join via a space-scoped invite link or SSO — no code needed.
Lost that line? The code is printed once and only its hash is stored, so a restart with the scrollback gone leaves nothing to read — but nothing is broken either. Issue a fresh one, no data touched, effective immediately:
docker compose exec server /usr/local/bin/unissh-server setup-code --rotate --config /app/config.tomlWithout --rotate the same command only reports where the code stands. If the container will not stay up — which is how this usually happens, since a bad port or a bad certificate is what sent you looking — run it on the stopped stack instead:
docker compose run --rm server setup-code --rotate --config /app/config.tomlDropping the volumes to get a new code is never necessary.
Caddy publishes :80 / :443 (and :443/udp for HTTP/3); the server (:8443) and metrics (:9090) stay internal to the compose network. SQLite is the default (data persisted in a named Docker volume); the server runs as a non-root user on a read-only rootfs.
Check it's alive (through Caddy):
curl -k https://localhost/healthz # liveness; /readyz also checks the DBSwitching to Postgres is a small .env change — enable the postgres compose profile and set POSTGRES_PASSWORD, UNISSH__DB__BACKEND=postgres, and UNISSH__DB__URL. The full deployment guide (TLS modes, profiles, backups) is in deploy/README.md.
Server without Docker (build from source)
# from the repository root
just build-server # → target/release/unissh-server
cp server/config.example.toml server/config.toml # then edit
./target/release/unissh-server migrate --config server/config.toml # also auto-applied on serve
./target/release/unissh-server --config server/config.tomlRequires Rust 1.94+ (rust-toolchain.toml) and a C toolchain (for bundled SQLCipher in the dev/test path). Without the bundled Caddy you must terminate TLS yourself — set tls_cert/tls_key for in-process rustls, or put your own reverse proxy in front and set trust_proxy=true.
The admin panel (server-ui/) is a zero-knowledge SPA that talks to your live server. It needs the wasm crypto bundle built once:
cd server-ui
rustup target add wasm32-unknown-unknown # one-time
cargo install wasm-pack # one-time
npm run build:wasm # → crypto-wasm/pkg/
npm install
npm run dev # http://localhost:5180(Or from the repo root: just build-ui then just dev-ui.) On the login screen, point it at your instance URL and sign in as the owner (or a space admin) — the same account you claimed the instance with:
- Escrow sign-in — handle + password + Secret Key. The keyset is recovered and unlocked in-browser (it never leaves the page, and never reaches the server); Lock wipes it. There is no
.keysetfile to import and no ops-token to enter first. A brand-new browser that isn't linked yet is onboarded by QR-approve from an already-trusted device. - SSO — if the instance has
[oidc]enabled, "Sign in with SSO" runs the browser OIDC flow instead.
If the instance is still unclaimed, the login screen offers to claim it with the setup code from the server log (see above).
The panel's screens include the instance Overview, Spaces, and the member Directory, plus devices/sessions/invites, vaults/grants, objects, and audit. An optional server-trusted ops break-glass token ([ops] token) unlocks only the infrastructure surface (overview / instance / seq-bump) and grants no decryption.
For production, build it (npm run build) and serve dist/ behind your reverse proxy.
Most polished SSH clients with sync (think Termius) make a trade you can't undo: your hosts, keys, and connection metadata live on someone else's servers, under their account system, readable under their terms.
And "can't undo" is often literal. These products compete on how much they can import — SSH config, PuTTY, a rival's export, a dozen paths in — while the way back out goes undocumented or unbuilt. Check before you commit a fleet to one: if there is no export, the data is only nominally yours, and leaving means retyping every host by hand. UniSSH's answer is a portable encrypted backup of the whole vault, restorable into any instance, including one that isn't ours.
UniSSH flips that. It's an open-source SSH client whose sync backend is a small server you run yourself, and the data it syncs is end-to-end encrypted on your devices before it ever leaves them. The server is deliberately "honest-but-curious": it stores ciphertext and a bit of routing metadata, coordinates your devices and team, and never holds the keys to decrypt anything.
For developers, sysadmins, and small teams who want a modern multi-host SSH client without renting their secrets to a SaaS vendor.
And it's built to be one of the best-looking SSH clients around — a considered, modern interface with a unified light/dark Theme picker, hand-tuned accent palettes and the pink Barbie theme, crisp terminals with linked color schemes, and a drag-first SFTP experience. Self-hosted and zero-knowledge doesn't have to mean spartan.
| UniSSH | Typical SaaS SSH client | Plain ssh + dotfiles |
|
|---|---|---|---|
| Vault encryption | Zero-knowledge, E2E | Provider-managed | Up to you |
| Sync backend | Self-hosted (Docker, SQLite/PG) | Vendor cloud | None / DIY |
| Account/lock-in | None — you own the server | Vendor account | None |
| Multi-device & team | Yes (membership, roles, revocation) | Yes | Manual |
| Fleet ops, tunnels, SFTP | Yes | Varies | Manual |
| Design & polish | First-class — themeable modern UI | Varies | Terminal-only |
| Source | Open (MIT OR Apache-2.0) | Closed | Open |
Unsigned release builds. UniSSH release builds currently ship without a paid Developer-ID / code-signing certificate, so your OS will show a one-time warning on first launch — see Installing unsigned builds for the 10-second "open anyway" steps per OS, and Build from source if you'd rather trust nothing but your own compiler.
Everything below is implemented in the shared Rust core and exposed to the clients. The crypto and SSH stack are not re-implemented per platform — every client calls the same core, so there is one implementation to review rather than four. That core has not been audited by a third party; see Independent review status for exactly what is and isn't verified.
Vaults & secrets (zero-knowledge)
- Encrypted vaults backed by SQLCipher, per-item keys under a per-vault key.
- Item types: SSH keys (generate or import) and user certificates, host/connection profiles, server passwords, encrypted notes, and nested host groups.
- Version history for passwords/notes — past versions archived per item, reveal any version; history is purged on delete.
- Type-gated reveal: passwords/notes can be revealed. A private key leaves the core only through an explicit, user-initiated export of that key — never as a side effect of any other call.
Connectivity
- Auth by key (via a built-in in-memory agent — the private key never leaves the core), password (inline or from the vault), or certificate. Two-factor logins work:
keyboard-interactiveprompts that no stored secret can answer — a one-time code, a push confirmation — are put to you. - Hardware keys via the system ssh-agent, per host: FIDO/U2F tokens, PKCS#11 smart cards, Secure Enclave keys, 1Password and gpg-agent all sign through the OS agent, and UniSSH stores only which identity to ask for. The trade is explicit — for such a host the key is outside the vault, by definition, since that is what makes a token usable.
- Agent forwarding, per host and off by default, because while the session lives anything running as you on that machine can ask your agent to sign. When it is on, every signature is confirmed individually, naming the host that asked — the behaviour of
ssh-add -c, rather than the silentForwardAgent yesthat made the feature notorious. - Interactive PTY sessions with resize; streaming
execwith separate stdout/stderr; auto-reconnect with backoff (and a hard stop on MITM/host-key change). - TOFU host-key pinning; a
HostKeyMismatchis always surfaced to you, never trusted silently. - Post-quantum key exchange by default: the transport negotiates the hybrid
mlkem768x25519-sha256(ML-KEM-768 + X25519, NIST FIPS 203) ahead of classical curve25519, and falls back cleanly on servers that don't offer it. Hybrid means a future quantum adversary must break both halves — and it defeats "harvest now, decrypt later" against traffic recorded today. - An optional modern-only algorithm policy: post-quantum key exchange required with no classical fallback, Ed25519 host keys, AEAD ciphers only. Off by default, because a server without ML-KEM then stops connecting — which is the point when the requirement is "prove the weak suites cannot be negotiated".
Terminal & workflow
- Local terminal (desktop): a shell on your own machine in a tab, living by the same rules as an SSH session — splits, zoom, search, themes, copy/paste, snippets, recording. You already live in a terminal; switching to a separate emulator for
git statusshould not be what breaks your context. A local pane is always marked as local — icon, machine name, and the word — because running the right command on the wrong machine is the failure mode this exists to avoid. Not on iOS (the sandbox forbidsfork/execand there is no shell in the bundle) or Android. - Snippets — a command library that is vault content, so it is encrypted at rest and syncs like everything else. Reachable from the command palette, where picking one types it into the active pane without running it; linkable per host as startup commands, where they do run, in the order you chose.
- Session recording as asciicast v2, per host, encrypted in the vault rather than dropped on disk as a plaintext log. Exportable to a file that plays in
asciinema, because a recording only its own tool can read is not evidence anyone else can check. Capped at 8 MB per session, and a recording that reaches the cap says so instead of ending quietly. - Shell integration (OSC 133): jump between prompts, and a gutter mark on a prompt whose command failed. Nothing is inferred — a shell that emits no marks produces no marks.
- GPU terminal rendering on desktop (opt-in; phones already had it), and a scrollback limit you set, with no ceiling imposed.
Fleet operations
- Multi-host
execwith a concurrency limit and per-host timeout; target by group, by tags, by hand-picked hosts (an in-grid multi-select target picker), or dry-run first. - Broadcast (one keystroke stream → N live PTYs, cluster-ssh style).
- Fleet-push a file to many hosts over SFTP in one shot.
SFTP & tunnels
- Full SFTP, including resumable upload/download with live progress and cancel, and parallel multi-file transfers over a pooled set of channels.
- Tunnels: local, remote, and dynamic (SOCKS5); ProxyJump chains.
Interop & portability
- Import/export
~/.ssh/config— applyingHostName,Port,User,IdentityFile,ProxyJump, all three forward directives,SetEnv,ServerAliveInterval,ConnectTimeoutandCompression, followingInclude, and naming every directive it could not apply, with its line number. A real config is mostly directives UniSSH does not implement (MatchandProxyCommandamong them); importing in silence left people believing otherwise. - Import
~/.ssh/known_hosts; import PuTTY sessions (.reg). - Portable encrypted vault backup (export/import under a passphrase + Argon2id), re-encrypted to the target instance's keys on import.
Integrity & audit (local)
verify_chain(checks signatures across all versions, including history and tombstones) andcheck_consistency(structural DB check) — neither leaks secrets into its report.
Sync & team (server-backed)
- Device + team sync of encrypted blobs over a self-hosted server, with signed monotonic versions and last-writer-wins conflict resolution (verify-before-apply).
- Membership, roles, sharing, and revocation with cryptographic vault roles (viewer/editor/admin) distinct from the server-trusted owner / space-admin roles.
- Server-side audit hash-chain and Prometheus metrics for operators.
The host library is shown at the top — dark mode, with the operations rail (terminals, SFTP, run) and the vault & network group (secrets, tunnels, known hosts) on the left, and the selected host's connection, auth, and host-key status on the right.
SFTP, two panes. Any pane can hold any location — a different host in each, or your own machine alongside a remote one. Permissions and modification times are shown for what they are: read-only facts about the file, not fields to edit here.
Broadcast. One synchronized input mirrored to every live host at once, each tile showing that host's own output. Destructive commands re-confirm before they fan out.
Fleet exec. The same command run across a selection, with each host's exit code and timing reported separately — bounded concurrency, and a stop that actually skips hosts that have not started.
The rest of the client — terminal, secrets, tunnels, known hosts, recordings, snippets — and the
server's admin panel are captured in docs/screenshots/, viewable in the browser
without cloning anything.
UniSSH is split into a shared core, thin clients, and an optional self-hosted server. SSH traffic always goes straight from your device to your hosts — it never tunnels through the sync server. The server only ever sees encrypted blobs and open routing metadata.
flowchart LR
subgraph yours["Your devices"]
D1["Desktop client<br/>(macOS · Windows · Linux)"]
D2["Mobile client<br/>(iOS · Android)"]
CLI["CLI"]
end
core["unissh-core (shared Rust)<br/>crypto · vault (SQLCipher) · SSH · agent"]
server[("Self-hosted server<br/>ciphertext store + delta sync<br/>membership · roles · audit")]
admin["Admin web panel<br/>(zero-knowledge SPA)"]
hosts[["Your SSH hosts / fleet"]]
D1 --- core
D2 --- core
CLI --- core
core -- "encrypted blobs + signed versions<br/>(TLS 1.3)" --> server
server -- "delta sync" --> core
admin -. "escrow/SSO sign-in · admin" .-> server
core == "direct SSH — never via the server" ==> hosts
The trust boundary in one paragraph. Your devices derive keys from a Secret Key (your Emergency Kit) plus a password (Argon2id). Vault contents are encrypted client-side; the server stores those ciphertext blobs keyed by signed, monotonically-versioned records. Authentication and registration use Ed25519 signatures the server verifies, but the server performs no payload crypto — it cannot decrypt vaults, mint access, or forge records. A malicious server can withhold or delay data, but it cannot read it. (Full threat model and the metadata that is visible by design are in Security & Privacy.)
One account, many spaces. An instance is a single server that hosts many spaces (teams — Backend, Security, …). A person has one account across every space they belong to; there are no tenants and no separate logins per team.
- An account = one keyset identity; its Ed25519 public key is the canonical member-id that vault grants are keyed on. The private keyset never leaves the device.
- Devices share an account's keyset — grant a teammate once and it works on all their devices. Each device has its own id for sessions/revocation. A fresh device recovers the keyset by escrow sign-in (handle + password + Secret Key) — no key file to copy around.
- Server-trusted roles are distinct from the cryptographic vault roles. The first user to claim the instance is its owner (creates spaces, appoints space admins, r



0 comments
log in to comment.