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) is SwiftTerm's
built-in
TerminalAccessory, wrapped in a thin custom view (TerminalAccessoryView) to add a Shift+Tab button and fix the on-screen Ctrl key not combining with iOS keyboard input — see CLAUDE.md.
- 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. - Text size: pinch the terminal to zoom in or out, or drag the Terminal Text Size slider in Settings (with a live preview) -- both write the same app-wide setting, so they can't disagree. Whatever's chosen scales on top of the system text size from Settings > Accessibility > Display & Text Size, which the terminal follows out of the box, so the default needs no adjusting at all for most people.
- 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. Pinching to zoom takes over two-finger scrolling; one-finger drag-to-scroll is unaffected. 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 backgrounded (including the screen just locking), every connected session gets a keepalive nudge every 20 seconds, using iOS's limited background-execution allowance. Past that cap, or if it drops anyway, reconnect-on-foreground or Auto-Reconnect picks it back up.
- Auto-Reconnect (Settings, on by default): reconnects a session that
drops unexpectedly while the app is foregrounded, with exponential
backoff (1s up to 30s, no retry limit). Off, a dropped session is torn
down instead. Never fires for a clean shell exit (
exit/logout/Ctrl+D). - Verbose Connecting (Settings, on by default): narrates each
connection step (connecting, authenticating, requesting a pty) as
debug1:-style lines, similar tossh -v-- app-level lifecycle narration, not a tap into Citadel/NIOSSH's own handshake internals, which aren't logged at that level of detail. - Auto-Upgrade to Mosh (Settings, off by default): after
authenticating, checks whether the remote host has
Mosh installed, racing it against the plain SSH
shell so checking never adds delay. Confirms the actual UDP path works
end to end, not just that
mosh-serverstarted, before switching the live session over; falls back to plain SSH with no added delay if Mosh isn't installed or its UDP path is unreachable. - Predictive local echo and roaming, once running over Mosh: typed
characters render instantly, underlined, ahead of the server's own
echo -- mosh's signature responsiveness feature. A session also
survives a local network interruption or change without reconnecting
over SSH (verified against a real
mosh-serverthrough an induced ~15-second blackout). See CLAUDE.md's Mosh section for the full implementation story, including known simplifications.
- 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 raw-mode,
self-redrawing programs it can't keep up with (confirmed with Claude
Code's own CLI). See
MoshPredictionEngine's doc comment, or CLAUDE.md's Mosh section, for the full reasoning. - Roaming's local-network-change detection is real but only partially
provable in development. Verified against an induced ~15-second UDP
blackout and
NWPathMonitorpath changes; a genuine Wi-Fi-to-cellular handoff on a physical device hasn't been exercised. MoshTransportalso intentionally simplifies a few things real mosh does adaptively (no send pipelining, no SRTT-based retransmission timing) -- see 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
ssssh.xcodeproj is checked into git (only xcuserdata inside it is
ignored), unlike XcodeGen's usual recommendation -- Xcode Cloud needs a real
project file present at the repo root to discover a workflow at all. See
CLAUDE.md for this and other project-setup gotchas (e.g. a confirmed
XcodeGen bug around the top-level resources: key) before touching it.
- 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.