A key that lives in your Mac’s Secure Enclave can’t be copied, period. The private half gets created inside a separate little processor and never comes out again, not to the app, not to macOS, and not even to you (which is exactly the point). That makes it just about the best place there is for an SSH key, and also the most annoying one to actually use, because OpenSSH wants to read a key before it can sign anything with it… and reading it is the one thing the Secure Enclave won’t let anybody do.
So most people go the agent route. Tools like Secretive keep the key in the Secure Enclave and run an ssh-agent that OpenSSH asks for signatures, and if you live in Terminal all day, that works really well.
My app, LogRaker, doesn’t run OpenSSH at all, though. It speaks SSH on its own through swift-nio-ssh, right inside the app, so it doesn’t need an agent in the first place and can simply hand the Secure Enclave key straight to the SSH library. Getting there was mostly easy, with a few gotchas along the way that bit me the first time around (more on those below). If you just want to know where LogRaker keeps your keys and how to set one up, Where LogRaker Keeps Your SSH Keys covers that.
The key is really just a handle
Creating the key is a job for CryptoKit:
var error: Unmanaged<CFError>?
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
.privateKeyUsage, &error)!
let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
What you get back isn’t a key you could save somewhere, though. key.dataRepresentation is an opaque blob that only this particular Mac’s Secure Enclave can turn back into a working key, and on any other machine it’s just useless bytes.
That blob still needs looking after, because on this Mac it’s the only form the key has. Lose it and the key is gone for good, since the Secure Enclave doesn’t keep a list of the keys it made. So LogRaker keeps it in the keychain on this Mac only, never synced and never backed up anywhere else, and loading it again later is a one-liner:
let key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: handle)
The part I expected to be hard
Signing the SSH login was the part I was dreading, and it turned out to be the easy one, because swift-nio-ssh already takes a Secure Enclave key directly:
let sshKey = NIOSSHPrivateKey(secureEnclaveP256Key: key)
let openSSHLine = String(openSSHPublicKey: sshKey.publicKey)
// "ecdsa-sha2-nistp256 AAAAE2VjZHNh..."
The library asks the Secure Enclave for every signature, and the server just sees a plain old ecdsa-sha2-nistp256 key. CryptoKit’s Secure Enclave keys are P-256 only, so that’s the one key type you get, but OpenSSH has accepted it since 2011, and so does dropbear, the tiny SSH server on lots of small devices. Of course I had to try both on my boat (OpenSSH on the NVIDIA Jetson, dropbear on the Victron Cerbo GX), and both were perfectly happy with it.
The public line then goes into the server’s authorized_keys like any other key. LogRaker does that the way ssh-copy-id would, signing in with whatever already works, and then it connects again with the new key before it calls the job done, to make sure it’s working.
Decisions, decisions…
The first one was Touch ID, or rather no Touch ID for every single signature. The access control above has .privateKeyUsage and nothing else, so the key signs without asking, and that’s on purpose. LogRaker reconnects its log streams on its own, at 3 a.m. if a server happens to restart, and a key that wanted a fingertip for every signature would just leave them hanging until I wake up. If you do want a prompt, there’s one gate in front of all the stored credentials instead, and one Touch ID covers you until the Mac locks or goes to sleep.
Then there’s syncing. A key that can’t leave a Mac can’t sync either, but the rest of LogRaker’s setup syncs through iCloud. So what syncs is the choice (“sign in with a Secure Enclave key”), and every Mac keeps its own little map from Source to its own key. A second Mac sees that setting, notices it has no key of its own yet, and tells you so, instead of failing with some cryptic authentication error.
And then the scary one: never delete a key just because something seems to be missing. The key list syncs through iCloud, while the handles live in each Mac’s own keychain, and it’s really tempting to clean up handles whose key has vanished from the list. But the list can come up short for perfectly harmless reasons (iCloud hasn’t caught up yet, or someone restored an older configuration), and deleting a handle destroys the key forever. So a handle only goes away after you actually delete that key, and only when the app quits, so Undo can still bring it back until then.
What I got wrong first
My first version created the key, put its public line on the server, and only then saved the handle. If saving failed, the server trusted a key that didn’t exist anymore, and there was no record anywhere to remove it by.
Oops.
Now the handle gets saved first, and thrown away again if the deploy gets cancelled or fails.
Then there are Macs without a Secure Enclave at all, like some of the older Intel Macs that still run macOS 15. On those, the editor quietly fell back to “Private Key”, and saving the Source, even just to rename it, synced that choice to all the other Macs and broke their sign-in! Now such a Mac keeps the setting and explains why it can’t use it.
And then capitalization, of all things. When you remove a key from a server, the Sources that used it switch back to how they signed in before. My lookup compared host names exactly, so a Source saved as JETSON.LAN didn’t match a key deployed to jetson.lan, and that Source would have kept trying a key the server no longer accepted. Host names don’t care about case, and now the lookup doesn’t either.
One more that’s easy to miss: once a Source switches keys, drop any pooled SSH connection to that server. The open connection signed in with the old key, and reusing it hides whether the new one actually works.
What it doesn’t protect you from
The Secure Enclave makes sure the key can’t be copied, but it doesn’t decide who gets to use it. While your Mac is unlocked, anyone (or anything) using LogRaker can sign in with that key, and that’s why the optional Touch ID gate is there.
Also, a key that can’t be copied can’t be recovered either. A new Mac or a wiped one means a new key, so keep some other way into every server that matters (a second Mac’s key, a synced key, or a good old password).
None of this needs private API or any special entitlements, by the way, and it works just the same in the sandboxed Mac App Store build. Long story short: if your app already speaks SSH through swift-nio-ssh, the cryptography is the easy part, and the real work is deciding what should happen on the Mac that doesn’t have the key!
Thanks for reading!