Marketing site: ssssh.smerwin.com
smerwin's simple ssh — a minimal, native SSH client for iPhone and iPad.
Four things, done well:
- Generate modern SSH keys on-device and keep them safe.
- Copy a key to a server the way
ssh-copy-iddoes, without touching a desktop. - Open a real terminal to that server — green phosphor text on black,
full PTY support for curses apps like
vim,tmux, andhtop. - Optionally upgrade that session to Mosh — survives a dropped Wi-Fi connection or switching networks entirely without reconnecting, with predictive local echo for snappy typing over high-latency links.
Everything else (SFTP browsing, port forwarding, config sync) is explicitly out of scope for v1. This is a terminal, not an IDE.
I had to sudo something for Claude the other day and I was away from my laptop.
Free: one host, one key. A one-time $9.99 in-app purchase (or a $0.99/month
subscription, if you'd rather support ongoing development) unlocks
unlimited hosts and keys. Either purchase grants the same entitlement --
see Sources/Purchases/PurchaseManager.swift.
Yes, you can just download and compile this yourself and skip the pricing. I charge money at all just to try and recoup my apple developer account spend. You could even just knock your own together in a day with Claude like I did, or you can clone this repo and save a few tokens on the boring work.
This source is available under the PolyForm Noncommercial License 1.0.0: you're free to clone it, read it, compile it, run it, and modify it for any noncommercial purpose (personal use, learning, contributing back) at no cost. Any commercial use -- reselling it, distributing your own build, running it as part of a paid product or service -- isn't covered by this license and needs permission from the author. The official build on the App Store is sold separately by the author, who as copyright holder isn't bound by the license granted to everyone else.
SwiftTerm and Citadel, the two dependencies this app is built on, are both MIT-licensed; their required notices are preserved in NOTICE.md.
- iOS 17 / iPadOS 17+, SwiftUI, Swift 6 language mode.
- iPhone and iPad in one universal target. No Mac Catalyst, no watch/TV targets.
- Keyboard accessory row (Esc/Tab/Ctrl/arrows/function keys) comes from
SwiftTerm's built-in
TerminalAccessory, not custom-built — it was already exactly what the spec asked for, so there was no reason to reinvent it.
- Ed25519 (via
CryptoKit.Curve25519.Signing.PrivateKey), ECDSA P-256, and ECDSA P-384 generation, all on-device. Private key bytes are stored in the Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly), gated behind a Face ID/Touch ID (or device passcode) prompt on every use viakSecAttrAccessControl, and never leave the Keychain;KeyStoreonly ever hands out a typed, reconstitutedSSHPrivateKeyMaterialvalue to the SSH layer, never raw key bytes. - Multiple keys, each with a label, algorithm, creation date, and a list of
host IDs it's been deployed to (
KeyStore,SSHKey). - Public key export via
KeyDetailView: QR code (CoreImage), copy to clipboard, and the system share sheet. - RSA exists as an enum case (
SSHKeyAlgorithm.rsa) reserved for future import support, but there's no import UI yet -- see "Known gaps."
SSHCopyID.copyKeyimplements the guidedssh-copy-idflow for real: connects with password auth (Citadel), runs a single idempotent remote command to create~/.ssh(0700) and append the public key toauthorized_keys(0600) if it isn't already there, then reconnects using the new key to confirm it actually works before reporting success.- The key line is shipped base64-encoded and decoded remotely into a shell variable, so its contents never need shell-escaping.
- The password is a local variable for the duration of the call; it's never written to disk or logged.
SSHConnectionwraps a real CitadelSSHClient: pubkey auth (Ed25519 or ECDSA), PTY allocation (SSHChannelRequestEvent.PseudoTerminalRequest), and a bidirectional byte stream wired directly into SwiftTerm'sTerminalView(TerminalSessionView) -- keystrokes out viaTerminalViewDelegate.send, output in viaTerminalView.feed, resize events forwarded both ways.- Verified against a real OpenSSH server (a throwaway Docker container, not just unit tests): password auth, key deployment, pubkey auth, and an interactive PTY echo all round-tripped correctly end to end.
- Visual theme: green or amber phosphor CRT looks (both with the subtle
ScanlineOverlay) plus a plain high-contrast alternative, toggled from the Settings tab and persisted via@AppStorage. - Swipe down on the terminal to page up through scrollback (or send a real
page-up keystroke to full-screen apps like
vim/less); swipe up to page back down toward the live output. The keyboard's own show/hide is a toolbar button instead, next to the terminal's title. - Copy/paste and OSC 52 clipboard support come from SwiftTerm's built-in defaults, not custom code; rectangular selection isn't implemented.
- Sessions persist independent of navigation:
SessionManagerkeeps oneSSHConnectionper host alive regardless of which view is on screen, and reconnects any dropped session when the app returns to the foreground (scenePhase->.active) regardless of the Auto-Reconnect setting below -- the user foregrounding the app is itself a signal they want back in. - Backgrounding keeps sessions alive for up to five minutes: while the app is backgrounded (including the screen simply locking), every connected session gets a harmless keepalive nudge every 20 seconds, using iOS's limited background-execution allowance, so a connection has a chance to survive that long instead of going stale the moment the screen turns off. Past that five-minute cap, or if it drops anyway, the reconnect-on-foreground behavior above (or Auto-Reconnect below) picks it back up.
- Auto-Reconnect (Settings, on by default): when a session drops
unexpectedly (network blip, remote hangup, auth failure) while the app is
already in the foreground, it's automatically reconnected with
exponential backoff (1s, 2s, 4s, ... capped at 30s, reset after a
successful connect) if this is on; if it's off, the dropped session is
torn down and removed from the Sessions list instead of lingering in a
disconnected state. There's no hard retry limit -- a persistently
broken host (revoked key, permanently unreachable) will keep retrying
forever, just slowly, rather than eventually giving up. Never fires for
a clean end to the shell (typing
exit/logout, or Ctrl+D) -- reconnecting the instant someone deliberately logs out would be exactly the wrong behavior. - Verbose Connecting (Settings, on by default): narrates each
connection lifecycle step (connecting, authenticating, requesting a
pty) as
debug1:-style lines in the terminal itself while a session connects, similar tossh -v. Citadel/NIOSSH don't log the actual handshake internals (key exchange, algorithm negotiation) at all, so this can't show true protocol-level detail -- it narrates the lifecycle steps the app itself controls, not a tap into lower-level logging. - Auto-Upgrade to Mosh (Settings, off by default): after authenticating,
checks whether the remote host has Mosh installed by
running
mosh-server newover the same SSH connection, at the same time a plain SSH shell is being requested -- whichever one actually confirms first is the one that goes live, so checking for Mosh never adds delay to a normal SSH connection. Confirming Mosh means proof its UDP path actually works end to end, not just thatmosh-serverstarted (mosh-servernever speaks first, so silence for 2 seconds usually means a firewall is blocking UDP); once confirmed, the SSH connection is closed and the rest of the session -- keystrokes, terminal output, resizing -- runs over Mosh instead. Every step is reported as adebug1:-style line when Verbose Connecting is also on: detection, the upgrade attempt, confirmation or the UDP timeout, and any error a live Mosh session hits later (which drops the session the same way an SSH drop does, so Auto-Reconnect can retry it). If Mosh isn't installed, or its UDP path is unreachable, the session just continues over SSH as if the toggle were off, with no added delay from having tried. - Predictive local echo and roaming, once a session is running over
Mosh: typed characters render instantly, underlined, before the
server's own echo confirms them -- mosh's signature responsiveness
feature, verified against a real terminal emulator to render correctly
and reconcile without duplicating anything once the real echo arrives.
A session also survives its local network interruption or changing out
from under it (confirmed against a real
mosh-serverthrough an induced ~15-second network blackout) without needing to reconnect over SSH -- the same encrypted session just keeps going once a path is available again.
- Add/edit sheets (
HostEditView) for nickname, hostname, port, username, which key to use, and an optional startup command. - Trust-on-first-use host key verification (
HostKeyStore,TOFUHostKeyValidator): a new host's SHA256 fingerprint is shown in a real confirmation dialog before it's trusted and persisted; a host whose key has since changed fails the connection outright with no in-the-moment override -- the only way back in is explicitly "forgetting" the known host key from its context menu. - A "Sessions" tab lists every host with a live or recent connection and
lets you jump back into any of them -- the practical equivalent of
browser-style tabs, implemented as a list/switcher rather than a
dynamic
TabView. - No cloud sync of host profiles or keys -- everything is local
(
Application Support/hosts.json,keys.json,known_hosts.json).
- SFTP/file browser
- Port forwarding / tunneling UI
- Snippet libraries, scriptable automation, or a command palette
- Team/shared host or key management
These are reasonable follow-ups once the core loop (generate → deploy → use) is solid, but they add real surface area and aren't needed to be useful.
Things the original spec described that aren't actually implemented yet -- worth knowing before relying on them:
- No passphrase support on generated private keys, on top of Keychain protection.
- No RSA or ECDSA import, only Ed25519 (Keys tab > New Key > Import Key, file-picker only -- no paste). See CLAUDE.md for why RSA/ECDSA import isn't a small addition.
- No rectangular text selection in the terminal.
- Predictive local echo is deliberately simplified, not frame-based
like real mosh, and backs off automatically against programs it
doesn't work well with.
MoshPredictionEngine(Sources/Mosh/) predicts by drawing an underlined preview character and moving the cursor back, rather than maintaining mosh's own mirrored terminal framebuffer with epoch/glitch tracking. Raw-mode, self-redrawing programs (confirmed with Claude Code's own CLI running over a Mosh session) don't echo keystrokes sequentially, so most predictions against them get abandoned rather than confirmed -- any abandoned prediction is now explicitly erased rather than left on screen, and after a few genuine mismatches in a row this engine stops predicting entirely for the rest of that session rather than continuing to flicker against a program it's already shown it can't keep up with. SeeMoshPredictionEngine's doc comment for the full reasoning. - Roaming's local-network-change detection is real but only
partially provable in development.
MoshTransportrecovers from an actual, induced network blackout (verified against a realmosh-server, blocking and restoring UDP traffic for ~15 seconds) and fromNWPathMonitor-reported local path changes -- but a genuine Wi-Fi-to-cellular handoff on a physical device hasn't been exercised, only reasoned through and code-reviewed. MoshTransportalso intentionally simplifies a few things real mosh does adaptively (no send pipelining, no SRTT-based retransmission timing) -- see its doc comment for specifics, and CLAUDE.md's Mosh section for the full picture.
Prerequisites: Xcode 16+, and XcodeGen
(brew install xcodegen).
git clone git@github.com:smerwin/ssssh.git
cd ssssh
xcodegen generate # produces ssssh.xcodeproj from project.yml
open ssssh.xcodeproj
Then build/run the ssssh scheme like any other Xcode project. Swift
package dependencies (SwiftTerm, Citadel) resolve automatically on first
build -- no extra setup.
project.yml is the source of truth, not ssssh.xcodeproj. Don't hand-edit
the .xcodeproj. After changing project.yml:
xcodegen generate
git add ssssh.xcodeproj # the regenerated project IS committed -- see below
Two things worth knowing before touching the project setup:
ssssh.xcodeprojis checked into git (onlyxcuserdatainside it is ignored), unlike XcodeGen's usual recommendation to gitignore it. This is required for Xcode Cloud, which needs a real project file present in the repo to discover a workflow at all -- if it's missing, Cloud fails with "Project ssssh.xcodeproj does not exist at the root of the repository."- XcodeGen 2.45.4's top-level
resources:target key silently produces no Resources build phase on this project (a real, confirmed bug, not a config mistake) -- the app icon and accent color are wired in via asourcesentry with an explicitbuildPhase: resourcesoverride instead. Don't switch this back to a plainresources:list without re-verifying the built app actually containsAssets.carandCFBundleIconName.
- UI: SwiftUI throughout. Four tabs: Hosts, Sessions, Keys, Settings.
- SSH transport: Citadel (built on SwiftNIO/NIOSSH) for async/await-native Ed25519/ECDSA auth, PTY shells, and password auth for the copy-id flow.
- Terminal emulation: SwiftTerm for VT100/xterm rendering, input handling, and the keyboard accessory row.
- Key storage: Keychain Services directly (
Common/Keychain.swift); key generation viaCryptoKit, exported as standard OpenSSH wire-formatauthorized_keyslines. - Persistence: host profiles, key metadata, and trusted host-key fingerprints as plain Codable JSON files under Application Support -- no SwiftData, no server, no account system.
- Concurrency:
SSHConnectionandHostKeyStoreare@MainActorand@Observablefor UI binding, but Citadel's own types (SSHClient,SSHAuthenticationMethod,TTYStdinWriter,TTYOutput) aren't Sendable-audited, so the actual connect/PTY-read work runs in a detached, non-isolated task that hops back to the main actor only to publishstate/onOutput. See the doc comments onSSHConnectionandHostKeyStorefor the reasoning.
SSHKey: id, label, algorithm, createdAt, publicKeyOpenSSH, deployedHostIDsSSHHost(named to avoid colliding with Foundation's ownHostclass): id, nickname, hostname, port, username, keyID, startupCommand. Trusted host-key fingerprints live separately inHostKeyStore(known_hosts.json), keyed by host ID -- not on this struct.SSHConnection(runtime only, not persisted): host, connection state, output callback -- one instance per host, owned bySessionManager
- Private keys never leave the device and are never transmitted, backed up unencrypted, or logged.
- Host key verification is mandatory; there is no "always trust" bypass -- see "Hosts and connections" above.
- The
ssh-copy-idflow's password path is the single highest-risk piece of code in the app (handles a plaintext credential, however briefly) and deserves the most scrutiny/testing. - See "Known gaps" for the passphrase protection the original spec called for but that isn't implemented yet.