SwiftiCloudShellby

Credentials Can't Touch the Cloud: Shellby's Dual-Track Sync

A Shellby postmortem: syncing an SSH client across devices, the hardest constraint is that passwords and private keys must never reach the cloud in plaintext.

Shellby is a multi-platform SSH client. The hosts, groups, and tunnel rules you set up on your Mac, you want to just work on your iPhone and iPad too. That means sync.

But syncing an SSH client has one line you can’t cross: passwords and private keys must never enter any cloud storage in plaintext. If a security tool uploads a user’s server credentials to the cloud in the clear, the tool itself becomes the biggest hole. That constraint means sync can’t use a single channel — it has to split into two tracks.

Why two tracks, not one

Look at the two storage options iCloud offers:

  • CloudKit private database: large capacity, holds structured data, integrates seamlessly with SwiftData. But it’s Apple-managed — data is encrypted in transit and at rest, but Apple holds the keys, so Apple can decrypt it in principle. Fine for hostnames, ports, groups; not fine for passwords and private keys.
  • iCloud Keychain: end-to-end encrypted, unreadable even to Apple. But it’s designed for “small secrets,” not structured config.

So the design naturally splits into two tracks:

  • Config track: metadata for hosts, groups, and identities (name, type, public key, keychain reference keys), plus port-forward rules → SwiftData + CloudKit private DB.
  • Credential track: passwords and private keys themselves → iCloud Keychain, end-to-end encrypted.

The key invariant: a CloudKit record only ever holds reference-key strings and public keys — not one byte of plaintext credential.

How two tracks reassemble into a usable host

The SSHHost on the config track stores not a password but a reference key, like host.<uuid>.password; an Identity’s private-key field is a key too, identity.<uuid>.privkey. These reference keys sync over on the config track.

On the credential track, the matching keychain entry (the actual ciphertext) syncs over via iCloud Keychain.

On a new device, both arrive: config says “this host’s password is at host.<uuid>.password,” and the keychain has exactly that key’s ciphertext — put them together and it connects, no re-entering credentials. This “config stores the reference, credential stores the body, matched by key name” indirection is a natural extension of the security layering (config in SwiftData, credentials only in Keychain); sync just carries it across devices.

Two tracks arrive asynchronously: config first, credential not yet

Splitting into two tracks creates a new edge case: the tracks propagate at different speeds. Often the config track arrives first — you can already see the host on your new iPhone, but the credential track hasn’t synced yet.

If you don’t handle this, the user taps connect, the code takes a reference key to look up the password in the keychain, finds nothing, then authenticates with an empty password, and the server returns a vague “auth failed.” The user is baffled: the host is right there, why won’t it connect?

The fix is an explicit error: when the reference key is non-empty but the password can’t be found locally, throw “Credentials haven’t synced to this device yet: make sure iCloud Keychain is on for both devices,” instead of silently authenticating with an empty password into an obscure failure. Translate an opaque low-level error into something the user can understand and fix themselves.

Migration can’t use a one-time flag

When you enable sync, the “this-device-only” credentials already on the device need migrating to “syncable” — rewriting keychain entries from …ThisDeviceOnly to items with kSecAttrSynchronizable.

The first-version intuition is: migrate once, set a flag in UserDefaults, never migrate again. That approach was rejected, and the reason is worth recording.

The problem: at the moment of migration, iCloud Keychain may not be ready. If a migration runs while the keychain isn’t initialized yet — so it doesn’t actually get to the cloud — but you’ve already dropped a “migrated” flag, then this device will never retry, and the credentials never sync out. A one-time flag equates “migration succeeded” with “migration ran,” and those aren’t the same thing.

The chosen approach: with sync on, do an idempotent best-effort migration on every launch, scanning only “local, not-yet-syncable” entries and rewriting each into a syncable item. After the first migration, subsequent launches scan an empty set — a cheap no-op. No flag, so no “flag set wrong, never retries” trap.

CloudKit reshapes the data model in reverse

Mirroring SwiftData over CloudKit means satisfying a pile of its constraints, and those constraints directly change how you model:

  • Every property must have a default value — so every enum is stored as xxxRaw: String plus a computed-property bridge (authMethodRaw, keyTypeRaw, storageRaw), satisfying the default-value requirement while staying stable and migratable;
  • Every relationship must be optional and have an inverseHostGroup’s children / hosts are built as optional arrays [HostGroup]? = [];
  • No @Attribute(.unique);
  • Some references aren’t modeled as SwiftData relationships at all — PortForwardRule.hostID uses a flat UUID reference instead of a model relationship, avoiding relationship explosion and CloudKit compatibility headaches.

Cloud sync isn’t a layer you bolt on top of your data model — it seeps downward and reshapes the model definition. Decide whether you’ll sync before you design your tables, not after.

Sync can’t hot-switch, but failure degrades gracefully

CloudKit’s binding is fixed at container-creation time — you can’t flip sync from off to on while the app runs. So the whole toggle is “takes effect on restart”: read UserDefaults at launch to decide the container config.

More important is failure handling. The CloudKit container can fail to load: the user isn’t signed into iCloud, the container isn’t set up, the network is down. The app must never crash here. The approach is to catch the failure and, if the user enabled sync, fall back to a local store and launch normally, marking the state localFallback and recording the reason. The settings screen shows the state — “Fell back to local (CloudKit unavailable)” with an orange cloud icon, a green check when available. Using CloudKit’s own error text (container not found / not authorized / not signed in) is far clearer than SwiftData’s errors.

The principle: offline-capable is the floor. Sync is a bonus; whatever goes wrong in the cloud, local features have to keep running.

An aside: the hidden third track

There’s actually a third track. The AI provider config list (excluding the API key) goes over NSUbiquitousKeyValueStore (iCloud KVS), while the API key still goes over the syncable Keychain.

Why yet another mechanism? Because this data has different characteristics: it’s small, non-sensitive, and doesn’t need a restart to take effect (KVS has no CloudKit-style container binding). In the same app, host config goes over CloudKit (needs restart), AI config over KVS (instant) — not inconsistency, but choosing the most fitting sync channel per data characteristic: large and structured → CloudKit, small secrets → E2E Keychain, small and non-sensitive → KVS.

Takeaways

Building multi-device sync for a security tool:

  • Set the red line first, then pick the channel: “credentials can’t reach the cloud in plaintext” is a hard constraint that immediately rules out a single-channel design and forces two tracks;
  • Indirection is sync’s friend: config stores reference keys, credentials store bodies, matched by name — that layer lets “structured config” and “E2E secrets” take separate channels and reassemble;
  • Don’t migrate with a one-time flag: distinguish “ran” from “succeeded”; use an idempotent every-launch retry instead of a one-shot flag;
  • Cloud sync reshapes your data model: default values, optional relationships, no unique constraints, flat references — think it through before you model;
  • Offline is the floor: degrade gracefully to local on cloud failure, and show the user the real state.

In one line: the hard part of sync is never “how do I get the data across” — it’s “which data can cross, in what form, and what happens when it can’t.”

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.