Skip to content

Repository files navigation

ssssh app icon

Marketing site: ssssh.smerwin.com

ssssh

smerwin's simple ssh — a minimal, native SSH client for iPhone and iPad.

What it does

Four things, done well:

  1. Generate modern SSH keys on-device and keep them safe.
  2. Copy a key to a server the way ssh-copy-id does, without touching a desktop.
  3. Open a real terminal to that server — green phosphor text on black, full PTY support for curses apps like vim, tmux, and htop.
  4. 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.

Why does it do this

I had to sudo something for Claude the other day and I was away from my laptop.

Pricing

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.

License

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.

Platform

  • 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.

Core features

1. Key management

  • 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 via kSecAttrAccessControl, and never leave the Keychain; KeyStore only ever hands out a typed, reconstituted SSHPrivateKeyMaterial value 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."

2. Copy key to server

  • SSHCopyID.copyKey implements the guided ssh-copy-id flow for real: connects with password auth (Citadel), runs a single idempotent remote command to create ~/.ssh (0700) and append the public key to authorized_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.

3. Terminal

  • SSHConnection wraps a real Citadel SSHClient: pubkey auth (Ed25519 or ECDSA), PTY allocation (SSHChannelRequestEvent.PseudoTerminalRequest), and a bidirectional byte stream wired directly into SwiftTerm's TerminalView (TerminalSessionView) -- keystrokes out via TerminalViewDelegate.send, output in via TerminalView.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: SessionManager keeps one SSHConnection per 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 to ssh -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-server started, 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-server through an induced ~15-second blackout). See CLAUDE.md's Mosh section for the full implementation story, including known simplifications.

4. Hosts and connections

  • 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).

Non-goals (v1)

  • 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.

Known gaps

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 NWPathMonitor path changes; a genuine Wi-Fi-to-cellular handoff on a physical device hasn't been exercised.
  • MoshTransport also 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.

Building locally

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.

Architecture

  • 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 via CryptoKit, exported as standard OpenSSH wire-format authorized_keys lines.
  • 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: SSHConnection and HostKeyStore are @MainActor and @Observable for 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 publish state/onOutput. See the doc comments on SSHConnection and HostKeyStore for the reasoning.

Data model

  • SSHKey: id, label, algorithm, createdAt, publicKeyOpenSSH, deployedHostIDs
  • SSHHost (named to avoid colliding with Foundation's own Host class): id, nickname, hostname, port, username, keyID, startupCommand. Trusted host-key fingerprints live separately in HostKeyStore (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 by SessionManager

Security notes

  • 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-id flow'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.

About

smerwin's simple ssh

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages