Sicherheits-Whitepaper
Das Whitepaper gibt es nur auf Englisch. Derselbe Text steht im Repository als docs/whitepaper.md.
Version 1.0 draft · BYTEPOTATO UG (haftungsbeschränkt) · Keyhatch has not been audited by a third party.
This document describes what Keyhatch protects, how, and where its limits are. It is written so that you can check every claim against the source code in this repository. Where something is a design decision rather than a measured fact, it says so.
1. What Keyhatch is
Keyhatch is an SSH agent for macOS. SSH and Git ask it to sign; it answers only after a decision: a rule you set, Touch ID on the Mac, or a signed approval from one of your paired devices (iPhone, Apple Watch, another Mac). Before every decision it shows who is asking (the process chain up to the app, for example Claude Code → git → ssh), for what (sign in, Git commit signature, other) and where (the server, cryptographically bound where the server supports it).
| Part | Runs as | Notes |
|---|---|---|
| Keyhatch.app | normal Mac app (Developer ID, Hardened Runtime, not sandboxed) | keys, rules, devices, activity log, setup |
| Keyhatch Agent | login item (SMAppService), menu bar | owns no socket: launchd holds ~/.ssh/keyhatch/agent.sock and starts the agent on demand |
keyhatch CLI | command line | status, keys, log, lock, doctor; cannot approve |
| Keyhatch for iPhone / Apple Watch | App Store apps, free | approval devices; they unlock nothing and sell nothing |
| Relay | Go service at relay.keyhatch.app | stores end-to-end encrypted messages for at most two minutes; cannot approve |
2. Keys and protection classes
All keys created by Keyhatch are ECDSA P-256 keys generated inside the Secure Enclave. The private key never exists outside it. What Keyhatch stores is an encrypted handle ("blob") that only this Mac's Secure Enclave can use. Keyhatch keeps that handle in the data-protection Keychain under its own access group (N3WYDR67GB.com.bytepotato.keyhatch, WhenUnlockedThisDeviceOnly, never synchronised). We measured that a blob is usable by any process that can read it (spike S-8) and that the access group keeps other processes out (spike S-14); this is why the blob is never written to a file.
| Class | Name in the app | Enforced by | Holds against malware without root? | Holds against root? |
|---|---|---|---|---|
| K1 | Touch ID on this Mac only | Secure Enclave (.biometryCurrentSet) | yes: no signature without a finger on this Mac | yes |
| K3 | Remote approval allowed | Keyhatch (agent), key .privateKeyUsage only | yes, as long as malware can't read the Keychain item or inject code into the agent (Hardened Runtime, no get-task-allow) | no: root can use the key (not copy it) |
| K4 | Imported key (Ed25519, RSA ≥ 2048, ECDSA) | Keyhatch | as K3 | no, and the key may exist elsewhere |
K2 ("this Mac or a nearby Apple Watch", .companion) is implemented in the key store but not offered until it is measured on real hardware (spike S-13).
The protection of a Secure Enclave key is fixed when it is created. "Change protection" in the app therefore creates a new key, shows where the old one was used, and removes the old one only after you confirm.
There is no way to export a private key, in the app, the CLI or the agent protocol.
3. Threat model
3.1 Attackers
| ID | Attacker | In scope |
|---|---|---|
| A1 | Malicious process running as you (malware, a poisoned package, a prompt-injected coding agent) | yes, the main adversary |
| A2 | An over-eager but honest coding agent | yes |
| A3 | Root malware, kernel exploit, SIP disabled | damage limitation only |
| A4 / A5 | Thief with your Mac / your phone or watch | yes |
| A6 | Network attacker | yes |
| A7 | Compromised relay, hosting provider or operator (including us) | yes |
| A8 | Malicious server or jump host during agent forwarding | yes |
| A9 | Supply chain (build, update channel) | partly (see 9) |
| A10 | Approval fatigue ("MFA bombing") | yes |
3.2 What rules can and cannot do
Keyhatch separates security rules from guardrails, and the app marks them differently.
Security rules use facts Keyhatch can verify cryptographically:
- Server. OpenSSH (8.9 and later) sends
[email protected]to the agent: the server's host key, the session identifier and the server's signature over it. Keyhatch verifies that signature. A sign-in counts as verified only when the signed data uses[email protected]and names the same host key. With plainpublickey, a process on your Mac could bind a session of its own choosing (review pass 1, finding H1); such sign-ins are shown as "host not verified", always ask, and never get an approval window. Measured on 2026-10-08: github.com and codeberg.org support host-bound authentication, gitlab.com does not. - Purpose. Sign-in,
SSHSIGsignatures (with namespace, e.g.git) and everything else are told apart from the bytes to be signed. They cannot be confused (an SSH sign-in starts with a length,SSHSIGwith the letter S). - Forwarding. Whether a request came through a forwarded agent is part of the session binding.
Guardrails use process details: the app at the top of the process chain, its code signature, the working directory, and whether ssh was started for git-upload-pack (fetch) or git-receive-pack (push). Malware running as you can fake all of these for its own processes. Guardrails stop mistakes and over-eager agents (A2); they are not a barrier against A1, and the app says so.
Against A1, what ultimately helps is a human who sees the verified server and purpose before the signature. The default for anything not covered by a rule is therefore "ask". An approval window is chosen per request (5, 15 or 30 minutes, 1, 12 or 24 hours, never open-ended), and every window ends when the screen locks or the Mac sleeps. The dialog starts at "this time only" unless you chose a different default yourself (Settings, or "Remember as default" in the dialog, confirmed with Touch ID). The default sits in the Keychain group of app and agent, so a program can neither set it nor preselect a duration of its own choosing.
3.3 Out of scope
- Root or kernel compromise (A3): K3 and K4 keys can be used; K1 keys still need your finger.
- A person who approves everything.
- What happens on a server after a sign-in you approved.
- Side channels of the Secure Enclave; Apple as an attacker.
4. The agent
- Socket. launchd owns the socket (
SecureSocketWithKey, mode 0600). The agent reads the caller's audit token (LOCAL_PEERTOKEN) and rejects other users. If the agent crashes, launchd keeps accepting connections and restarts it; we measured 0 failed requests in 30 across two crashes (spike S-4). - Process context is read once per connection, from the audit token (PID plus PID version, so PID reuse can't swap the process), and frozen.
- Parsing. Every byte from the socket is treated as hostile. All parsers are bounds-checked Swift and run in CI against 20,000 random and mutated inputs per parser. Messages are limited to 256 KiB.
- Refused operations. Adding or removing keys over the socket, smartcard keys and
UNLOCKalways fail.LOCKworks (it only makes Keyhatch stricter); unlocking needs Touch ID or your password in Keyhatch, and the locked state survives an agent restart. - Approval at the Mac. The approval window never approves with a bare click. "Approve" starts LocalAuthentication (Touch ID, Apple Watch or your password), so a program that can click on your screen can't approve. The button becomes active only after 0.7 seconds. Only one window is visible at a time. The window starts at "this time only" unless you chose a different default yourself in Settings (stored in the Keychain; choosing a longer default needs Touch ID or your password). A duration the program asks for (
keyhatch with --seconds) is shown as unverified text and never preselected, and the Touch ID prompt names the window you chose. Windows can last up to 24 hours and always end when the screen locks or the Mac sleeps; approvals from a device are limited to 5 minutes. - Settings that loosen. Saving a rule that allows or asks your devices, deleting a rule that denies, letting a device approve more keys, shortening the log retention and resuming paused remote approval all need Touch ID or your password. Changes that make Keyhatch stricter don't. Remote pause and retention live in the Keychain, not in a preferences file another program could edit.
- Rate limits. Three denied or unanswered requests in ten minutes block that key for that app for 15 minutes; ten for one key block it for any app. At most three requests per key can wait at once.
- Sealed files. Rules, key metadata, paired devices and confirmed host names are stored with an HMAC whose key lives in the Keychain, plus a generation counter, so a modified or rolled-back file is detected and ignored (every request then asks).
5. Remote approval (KAP1)
5.1 Keys
| Role | Signing key | Encryption key |
|---|---|---|
| Mac that asks | Secure Enclave P-256, no biometry (the agent signs requests without you) | X-Wing (ML-KEM-768 + X25519), Keychain |
| iPhone | Secure Enclave P-256, .biometryCurrentSet (Face ID for every answer) | X-Wing, Keychain |
| Mac that approves | Secure Enclave P-256, .biometryCurrentSet, or .userPresence on Macs without Touch ID | X-Wing, Keychain |
| Apple Watch | Secure Enclave P-256, unlocked watch on the wrist (no biometry) | X-Wing, Keychain |
Messages are encrypted with HPKE using the X-Wing ciphersuite, so recorded traffic stays confidential even against a future quantum computer. Signatures are P-256: an approval is worthless after 60 seconds, so a forgery would have to succeed within that window.
5.2 Pairing
The Mac shows a short QR code (or a text code for another Mac) containing the relay address, a one-time mailbox and a 256-bit key. The full offer (the Mac's public keys, a pairing secret and a send token only for the new device) waits in that mailbox, encrypted with the key from the QR code. The device deletes it when it fetches it, but the pairing does not rely on that: it is safe even if someone else saw the code. Then three messages:
- The device sends its public keys, its device type, its mailbox, a send token only for this Mac and a commitment to a fresh 256-bit random number, encrypted to the Mac's X-Wing key (the relay sees none of it) and authenticated with the pairing secret.
- Only after that, the Mac sends its own fresh random number, signed with its key.
- The device reveals its number; the Mac checks it against the commitment.
Both screens then show a six-digit comparison code derived from the whole transcript and both random numbers. Someone who knows the code and injects their own answer has to commit before they learn the Mac's number, so they can't steer their code to match yours; the real device never gets a valid step 2 and shows no code at all. You confirm on both sides, on the Mac with Touch ID or your password. Codes expire after five minutes; pairing mailboxes expire on the relay after ten. (Until 2026-10-14 the code depended only on the device's answer, and an attacker who knew the code could find a matching answer in about a second; internal review pass 2, H1.)
The Apple Watch pairs itself: the iPhone scans the Mac's code and hands it to the watch over WatchConnectivity; the watch generates its own keys, and the comparison code appears on the watch. On the Mac, a watch starts with no keys it may approve; you allow it per key, because the watch has no biometry. The device type comes from the encrypted pairing, not from the device's name.
5.3 A request
The Mac builds a request with a random 128-bit ID, its own ID, creation and expiry time (60 s), the key, purpose, server (host key fingerprint, name and whether that name is confirmed), user, forwarding, process details (marked as unverifiable) and the SHA-256 hash of the exact bytes to be signed. It signs the request and encrypts it separately for each paired device that may approve this key. The relay stores it and, for iPhones, sends a push whose only content is the ciphertext. The Notification Service Extension decrypts it on the phone. With "Hide details in notifications" (the default) the notification only names the Mac.
The request carries the same host warnings the Mac shows: name confirmed, only claimed, confirmed for a different host key, or a server that did not prove its identity. Notifications only offer "View" and "Deny"; approving always happens in the review screen, after an explicit tap. The Apple Watch shows no fingerprints, so the Mac never sends it a request with a name conflict, an unverified or unconfirmed host, a forwarded sign-in or an "other" signature, and the watch refuses them too.
Which requests go to your devices: a rule "Ask on this Mac" keeps the request on the Mac. "Ask on my devices", and asking without any rule, sends requests for keys with "Remote approval allowed" or imported keys to the devices allowed for that key, while the window on the Mac stays open in parallel. Touch-ID-only keys always ask on the Mac.
5.4 Checks on the Mac, in this order
- The answer decrypts and is signed by the pinned key of a paired device.
- Device ID and Mac ID in the signed answer match.
- The request is open and was never answered.
- The device may approve this key.
- The hash of the bytes to be signed matches.
- The Mac's clock says the request has not expired; the device's decision time is not more than 30 s in the future.
- A requested approval window was offered.
- The request is consumed, then signed.
Open requests live only in memory: after an agent restart every answer is invalid. The first valid answer wins; all other devices are told the request is resolved. The approval window on the Mac keeps working in parallel, and if the relay or the network is down, you approve at the Mac. Expiry uses the Mac's wall clock; a device clock more than 30 seconds ahead is rejected in step 6.
A host name that was only claimed (from known_hosts) is bound to the host key after an approval only if the approving screen showed the fingerprint: the Mac's window, the iPhone's review screen or another Mac. An approval from the watch never confirms a name.
5.5 Unpairing
Unpairing works in both directions and is signed: a device that unpairs sends the Mac a message signed with its own key, and a Mac that removes a device sends it a message signed with the Mac's key. Both are bound to that one pairing, so a recorded message can't remove a later pairing of the same device. Unsigned or forged unpair messages are ignored. Each side then revokes the send token it gave the other, so a removed device or Mac can't fill your mailbox or trigger pushes.
"This Mac is lost" on the iPhone makes that iPhone ignore the Mac at once. Other devices (watch, another Mac) have to be unpaired on their own until iCloud sync of the device list exists (F-6.8).
6. The relay
The relay is about 1,050 lines of Go (plus tests) and uses only the Go standard library. It knows: random 256-bit mailbox IDs, hashes of a per-mailbox secret and of the send tokens (one per paired peer), APNs device tokens for iPhones, and ciphertext waiting for pickup (at most two minutes, deleted once acknowledged). Pairing mailboxes live only in memory and expire after ten minutes; mailboxes nobody has fetched for 180 days are removed. It keeps no accounts, no e-mail addresses, no access logs and no IP addresses on disk; rate limiting happens in memory (IPv6 per /64). Total queued data is capped at 64 MiB. It runs behind Cloudflare and Caddy on a server in Germany (Hetzner, Falkenstein).
What a fully compromised relay (A7) can do: drop or delay messages (you approve at the Mac instead), and see metadata such as when and how often a mailbox is used and the IDs in "resolved" messages. What it can't do: read a request or a pairing message, change one, approve one, replay an old approval, or insert itself into a pairing, even if it also saw the pairing code (it would have to win the 1-in-a-million comparison code).
7. Data on your devices
| Data | Where | Protection |
|---|---|---|
| Key handles, imported keys | Keychain, Keyhatch access group | WhenUnlockedThisDeviceOnly, never synced |
| Keys of the remote approval channel (Mac that asks) | Keychain / Secure Enclave | AfterFirstUnlockThisDeviceOnly, never synced, so remote approval keeps working while the Mac's screen is locked |
| Device keys on iPhone and approving Mac | Secure Enclave | WhenUnlockedThisDeviceOnly plus Face ID / Touch ID |
| Rules, key list, paired devices, confirmed host names | ~/Library/Application Support/Keyhatch | HMAC + generation counter |
| Activity log | same folder, SQLite | HMAC chain with its first and last entry anchored in the Keychain: changes and truncation at either end are detected, not prevented |
| Approval history (iPhone) | app container | last 50 decisions |
The activity log stores, per request: time, key, purpose, server, process chain (arguments shortened and secrets such as tokens in URLs removed), decision and who decided. It stays on the Mac and is deleted after the retention period you choose (90 days by default, at least 7). Each deletion adds an entry to the chain saying how many entries were removed.
8. Network traffic
Keyhatch sends nothing about you anywhere. There is no telemetry and no crash reporting. The only network connections are:
| Connection | When |
|---|---|
relay.keyhatch.app | only if you paired a device (pairing, requests, answers) |
| Apple Push Notification service | iPhone only, for request notifications (ciphertext) |
github.com via your gh tool | only when you choose "Add to GitHub" |
keyhatch.app/appcast.xml | once a day, update check by the separate updater helper (Sparkle, no system profile; sends IP address and app version) |
keyhatch.app/api/v1/revoked | at most once a week, and only with a license installed (the list of refunded licenses, signed; your license is not sent) |
There is no license activation: the license file is checked offline on the Mac. Updates are signed with a separate Ed25519 key (Sparkle) and the app is notarized; an update that fails either check is not installed.
9. Licensing never locks you out
After the seven-day trial without a license, your keys keep working with approval at the Mac. Only remote approval, rules that allow without asking, and log export pause. An agent that stopped signing in the middle of a deployment would itself be a security problem.
The license file is checked offline. A refunded license is revoked through a signed list the Mac fetches at most once a week; a Mac that never reaches keyhatch.app keeps a refunded license working. This is a deliberate trade-off for an app that must not depend on our servers, and it affects only paid extras, never your keys.
10. Build and supply chain
The agent, the process that uses the keys, has no third-party dependencies (otool -L lists only system libraries). Updates use Sparkle, which runs in a separate helper (Keyhatch Updater.app) without access to the keys' Keychain access group; the agent only starts it once a day. The update feed and every update are signed with Ed25519, the app is notarized, and the agent refuses remote approval and allow rules when an older build than the newest one seen on this Mac is started (protection against rolling back to a version with known bugs). The Mac app is signed with Developer ID, notarised, built with Hardened Runtime and without get-task-allow in release builds; a release check script verifies this before every build is shipped. The iOS and watchOS apps come from the App Store. Reproducible builds and GitHub build attestations are part of the 1.0 release work.
11. Known limitations
- K3 and K4 keys are protected by Keyhatch, not by the hardware. Root malware can use them.
- Process details are shown, never trusted.
- Servers without
publickey-hostbound(gitlab.com as of 2026-10-08) can't be verified; Keyhatch asks every time and offers no approval window for them. - The Apple Watch has no biometry; anyone wearing your unlocked watch can approve keys you allowed for it.
- Watch signing behaviour on real hardware (S-12), K2 (S-13) and end-to-end latency on real devices (S-19) are not yet measured. Approving from a notification action is deliberately not offered (review pass 2, M2).
- No third-party audit. Instead: independent internal review passes after each major phase, published in
docs/security-reviews/, fuzzing in CI, negative tests for every check in 5.4, and the source code.