Skip to content

Commit 9489c3e

Browse files
apple: AES-GCM-wrap the SE dataRepresentation under a Keychain key (#65)
Implements Steps 1-6 of fix-macos.md: close the same-UID `.handle` theft threat that made the SE backend's security promise leaky on Homebrew / cargo-install distribution. Before this change: the SE `dataRepresentation` returned by CryptoKit was written to `<label>.handle` as plaintext (0600). Same-UID attacker copies the file → can drive SE operations as the user from another process. SE key itself still never exports, but that distinction is useless when the attacker can force SE operations at will. After this change: 1. Swift bridge gains keychain_store / keychain_load / keychain_delete helpers that wrap kSecClassGenericPassword items in the login keychain. Uses legacy (not Data Protection) keychain because the modern one fails with -34018 on unsigned builds. 2. Rust FFI declarations for the three new helpers. 3. New module keychain_wrap.rs with: - WRAP_MAGIC (b"EHW1") + 32-byte key + 12-byte nonce + 16-byte tag file format - generate_wrapping_key / encrypt_blob / decrypt_blob using aes-gcm + rand::rngs::OsRng - keychain_store / keychain_load / keychain_delete thin wrappers around the FFI 4. generate_and_save_key: generates a wrapping key, stores it in the keychain, AES-GCM-encrypts the dataRepresentation, and persists the sealed blob. On any failure after the SE key is minted, rolls back the SE key + keychain entry so labels stay reusable. 5. load_handle: detects EHW1 magic and unwraps via the keychain key; falls back to returning raw bytes for legacy plaintext `.handle` files (transparent backward-compat migration — no on-disk rewrite required to keep existing keys working). 6. delete_key: also removes the keychain wrapping-key entry. Best-effort — a stale entry without its handle is inert. ## Testing 29 tests covering the new code: - 20 pure-Rust unit tests for AES-GCM wrap/unwrap: empty / short / long plaintext, magic prefix, round-trip consistency across independent calls, tamper detection on ciphertext / tag / nonce, truncation, wrong key, missing magic, legacy-plaintext rejection. - 9 real-Keychain integration tests (macOS only, run by default): basic round-trip, missing-entry returns None, store is idempotent upsert, delete is idempotent, delete actually removes, per-label isolation, per-app isolation, full wrap + keychain + unwrap lifecycle, swapped-wrapping-key decrypt fails. All tests use unique test-keyed service/account pairs and a RAII KeychainEntryGuard so a failing test never leaks a keychain entry. ## Scope / deferred Steps 7-8 (entitled Path 1 — SecKeyCreateRandomKey with kSecAttrTokenIDSecureEnclave) are deferred. They require a provisioning profile that self-signed and ad-hoc-signed binaries cannot obtain; testing the entitled path needs a signed build with AMFI-approved profile. For distribution via Homebrew / cargo install, Path 2 (this PR) is sufficient. THREAT_MODEL, DESIGN, fix-macos.md all updated to match the new reality. Co-authored-by: Jay Gowdy <jay@gowdy.me>
1 parent 2215a4e commit 9489c3e

10 files changed

Lines changed: 951 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DESIGN.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ Signing keys are long-lived identity keys (e.g., SSH keys). At Levels 1-3, the h
156156

157157
| Level | Backend | Who signs? | Private key exportable? | User presence | Key storage |
158158
|:-----:|---------|-----------|:----------------------:|---------------|-------------|
159-
| **1** | macOS Secure Enclave | **The SE hardware.** `sshenc` sends data to the SE via CryptoKit; the SE performs ECDSA P-256 internally and returns the signature. The private key never exists outside the chip. Works for both signed and unsigned binaries on Apple Silicon. | **No** — impossible. Even root cannot extract it. The `dataRepresentation` on disk is an opaque SE handle (not key material); it is written with 0600 permissions today. AES-GCM wrapping under a Keychain-stored key is planned (see `fix-macos.md`) and not yet implemented. | Touch ID / biometric enforced by SE hardware per-signature (when access policy is set). | Secure Enclave coprocessor + handle file on disk (0600). Keychain-wrapped handle is a planned hardening. |
159+
| **1** | macOS Secure Enclave | **The SE hardware.** `sshenc` sends data to the SE via CryptoKit; the SE performs ECDSA P-256 internally and returns the signature. The private key never exists outside the chip. Works for both signed and unsigned binaries on Apple Silicon. | **No** — impossible. Even root cannot extract it. The `dataRepresentation` on disk is an opaque SE handle (not key material); it is AES-256-GCM wrapped under a 32-byte key stored in the login Keychain (service `com.enclaveapp.<app>`, account `<label>`). File format `[EHW1 magic][nonce][ciphertext][tag]`. | Touch ID / biometric enforced by SE hardware per-signature (when access policy is set). | Secure Enclave coprocessor + Keychain-wrapped handle on disk (0600). |
160160
| **2** | Windows TPM 2.0 | **The TPM hardware.** CNG sends signing requests to the TPM via NCrypt. | **No** — key is a non-exportable TPM object. | Windows Hello (biometric/PIN) enforced per-signature via `NCRYPT_UI_POLICY`. | TPM 2.0 chip. |
161161
| **3** | Linux TPM 2.0 | **The TPM hardware.** Signing performed by the TPM via `tss-esapi`. | **No** — key is TPM-resident. | Not enforced (no standard Linux biometric API). | TPM 2.0 device (`/dev/tpmrm0`). glibc only. |
162162
| **4** | Software (Linux glibc) | **Software.** The P-256 private key is decrypted from the keyring into memory and used for signing via the `p256` crate. | **Yes (encrypted at rest)** — P-256 private key on disk, encrypted via system keyring (D-Bus Secret Service / GNOME Keyring / KWallet). | Not enforced. | `~/.config/{app}/keys/` encrypted via keyring. |
@@ -170,7 +170,7 @@ The blast radius of encryption key compromise is further bounded by the expirati
170170

171171
| Level | Backend | Who decrypts? | Private key exportable? | User presence | Cached data protection |
172172
|:-----:|---------|--------------|:----------------------:|---------------|----------------------|
173-
| **1** | macOS Secure Enclave | **The SE hardware.** ECDH key agreement happens inside the SE. The shared secret is derived internally; only the AES-GCM decryption of the ciphertext body happens in software. Handle blob stored 0600 on disk; Keychain-wrapped handle is a planned hardening (see `fix-macos.md`). | **No** — impossible. | Touch ID / biometric can be required per-decrypt. | Ciphertext on disk can only be decrypted by the SE that created the key. Full disk access is insufficient without the SE. |
173+
| **1** | macOS Secure Enclave | **The SE hardware.** ECDH key agreement happens inside the SE. The shared secret is derived internally; only the AES-GCM decryption of the ciphertext body happens in software. Handle blob is AES-256-GCM wrapped under a Keychain-held key (see `crates/enclaveapp-apple/src/keychain_wrap.rs`). | **No** — impossible. | Touch ID / biometric can be required per-decrypt. | Ciphertext on disk can only be decrypted by the SE that created the key. Full disk access is insufficient without the SE, and the Keychain wrapping key gates same-UID handle theft. |
174174
| **2** | Windows TPM 2.0 | **The TPM hardware** performs ECDH. | **No** — TPM-bound. | Windows Hello can be required per-decrypt. | Only decryptable on the same machine's TPM. |
175175
| **3** | Linux TPM 2.0 | **The TPM hardware** via `tss-esapi`. | **No** — TPM-bound. | Not enforced. | Same machine-binding as Windows TPM. |
176176
| **4** | Software (Linux glibc) | **Software.** P-256 key decrypted from keyring. | **Yes (encrypted at rest)** — keyring-protected. | Not enforced. | Keyring-encrypted key protects cache at rest. |
@@ -182,11 +182,13 @@ All supported macOS hardware (Apple Silicon) has a Secure Enclave, and **CryptoK
182182

183183
The `com.apple.developer.secure-enclave` entitlement is a **Security.framework** concept for Keychain-stored SE keys (`SecItemAdd` with `kSecAttrTokenIDSecureEnclave`). Since libenclaveapp uses CryptoKit's `dataRepresentation` for key persistence (not Security.framework), the entitlement is not required.
184184

185-
**Handle protection for unsigned apps — planned, not yet implemented.** The SE's `dataRepresentation` is an opaque handle blob that allows the same device's SE to reconstruct the key reference. While the private key itself cannot be extracted from this blob, the blob is stored as a file on disk — and another process running as the same user could copy it and use it to request SE operations.
185+
**Handle protection for unsigned apps.** The SE's `dataRepresentation` is an opaque handle blob that allows the same device's SE to reconstruct the key reference. While the private key itself cannot be extracted from this blob, the blob is stored as a file on disk — and another process running as the same user could copy it and use it to request SE operations.
186186

187-
**Current state.** `.handle` is written with 0600 permissions and no further wrapping (see `crates/enclaveapp-apple/src/keychain.rs`). A same-UID attacker can read the handle and replay it against the SE from another process.
187+
**Implemented.** `generate_and_save_key` creates a fresh 32-byte AES-256 wrapping key per label, stores it in the login keychain as a `kSecClassGenericPassword` item (service `com.enclaveapp.<app>`, account `<label>`), AES-256-GCM encrypts the `dataRepresentation` under that key, and writes the sealed blob to `.handle` with the magic prefix `EHW1`. Format: `[magic(4)][nonce(12)][ciphertext][tag(16)]`. See `crates/enclaveapp-apple/src/keychain_wrap.rs`.
188188

189-
**Planned state** (see `fix-macos.md`). Wrap the `dataRepresentation` with **AES-256-GCM** using a Keychain-stored wrapping key. The Keychain's per-application access control then gates same-user handle theft:
189+
Legacy plaintext `.handle` files are accepted by `load_handle` for transparent migration; they re-wrap on the next rotation. `delete_key` removes the keychain entry alongside the on-disk artifacts.
190+
191+
The Keychain's per-application access control then gates same-user handle theft:
190192

191193
- **Signed app (code-signed with Developer ID):** The Keychain ACL is bound to the app's code signature. Other apps cannot access the wrapping key. Handle files are protected against same-user attacks.
192194

@@ -200,7 +202,7 @@ On macOS, the `enclaveapp-apple` backend auto-detects which path to use at runti
200202

201203
- **Signed/entitled (Level 1):** The app is code-signed with a Developer ID certificate and has the Secure Enclave entitlement. Keys are created inside SE hardware via `SecureEnclave.P256.Signing.PrivateKey` / `SecureEnclave.P256.KeyAgreement.PrivateKey`. The key material physically cannot be extracted. This is the production path for distributed binaries.
202204

203-
- **Unsigned/development (Level 4):** The app is not code-signed or lacks entitlements (typical during local development with `cargo build`). Keys are created via regular `CryptoKit.P256` (not SE-bound). The private key's `dataRepresentation` is currently written to disk as a 0600 `.handle` file without additional wrapping; Keychain-backed AES-GCM wrapping is planned (see `fix-macos.md`). Even today the keys are not hardware-bound in this mode.
205+
- **Unsigned/development (Level 4):** The app is not code-signed or lacks entitlements (typical during local development with `cargo build`). Keys are created via regular `CryptoKit.P256` (not SE-bound). The private key's `dataRepresentation` is AES-256-GCM wrapped under a Keychain-held key and written to a 0600 `.handle` file. Even with the wrapping, the keys are not hardware-bound in this mode — the SE isn't in the path.
204206

205207
#### Linux glibc vs. musl
206208

THREAT_MODEL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ Unsafe FFI surfaces are trusted by design but fragile.
8383

8484
### Keychain and key-backend-specific risks
8585

86-
- **macOS `.handle` storage is currently plaintext (0600).** DESIGN.md describes AES-GCM wrapping under a Keychain-stored key as the handle-theft defense. That code is not yet merged (see `fix-macos.md`). Until it lands, a same-UID attacker can copy a `.handle` file and replay it against the SE from another process. The SE still refuses to export the private key itself.
87-
- **Cross-binary Keychain access on macOS** for ad-hoc signed builds (Homebrew, `cargo build`) is controlled by binary hash; every rebuild invalidates the ACL and reprompts the user. This is the Keychain enforcing its ACL, not a vulnerability, but it becomes load-bearing once Keychain wrapping lands (see above).
86+
- **macOS `.handle` storage is AES-256-GCM wrapped under a Keychain-held key.** `generate_and_save_key` creates a fresh 32-byte wrapping key per label, stores it in the login keychain as a `kSecClassGenericPassword` item (service `com.enclaveapp.<app>`, account `<label>`), and writes the AES-GCM-sealed SE `dataRepresentation` to `.handle` (magic `EHW1`, format `[magic][nonce][ciphertext][tag]`). A same-UID attacker who copies the `.handle` file still needs the keychain-held wrapping key to replay SE operations — and the keychain's code-signature-bound ACL blocks access from a different binary, prompting the user on first use of a rebuilt binary. Legacy plaintext `.handle` files are accepted transparently for migration; they upgrade to wrapped format on the next rotation. See `crates/enclaveapp-apple/src/keychain_wrap.rs`.
87+
- **Cross-binary Keychain access on macOS** for ad-hoc signed builds (Homebrew, `cargo build`) is controlled by binary hash; every rebuild invalidates the ACL and reprompts the user. This is the Keychain enforcing its ACL — it's now load-bearing because the wrapping key is what gates same-UID handle theft (above). Trusted signing identities eliminate the per-upgrade prompt.
8888
- **Keyring D-Bus peer trust.** The keyring backend talks to the session D-Bus Secret Service. A hostile session bus (another process running as the user that took over the bus) could intercept unlock / decrypt requests. Same-user already-compromised session; out of scope for the library.
8989

9090
### Filesystem races and metadata tamper

crates/enclaveapp-apple/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ encryption = []
1717

1818
[dependencies]
1919
enclaveapp-core = { workspace = true }
20+
aes-gcm = { workspace = true }
2021
base64 = { workspace = true }
2122
dirs = { workspace = true }
23+
rand = { workspace = true }
2224
serde = { workspace = true }
2325
serde_json = { workspace = true }
2426
libc = { workspace = true }
27+
tracing = "0.1"
28+
29+
[dev-dependencies]
30+
tempfile = { workspace = true }

crates/enclaveapp-apple/src/ffi.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,38 @@ extern "C" {
7272
plaintext_out: *mut u8,
7373
plaintext_len: *mut i32,
7474
) -> i32;
75+
76+
// Keychain generic-password helpers (wrapping-key storage).
77+
//
78+
// Return codes:
79+
// 0 SE_OK
80+
// 4 SE_ERR_BUFFER_TOO_SMALL
81+
// 9 SE_ERR_KEYCHAIN_STORE
82+
// 10 SE_ERR_KEYCHAIN_LOAD
83+
// 11 SE_ERR_KEYCHAIN_DELETE
84+
// 12 SE_ERR_KEYCHAIN_NOT_FOUND
85+
pub fn enclaveapp_keychain_store(
86+
service: *const u8,
87+
service_len: i32,
88+
account: *const u8,
89+
account_len: i32,
90+
secret: *const u8,
91+
secret_len: i32,
92+
) -> i32;
93+
94+
pub fn enclaveapp_keychain_load(
95+
service: *const u8,
96+
service_len: i32,
97+
account: *const u8,
98+
account_len: i32,
99+
secret_out: *mut u8,
100+
secret_len: *mut i32,
101+
) -> i32;
102+
103+
pub fn enclaveapp_keychain_delete(
104+
service: *const u8,
105+
service_len: i32,
106+
account: *const u8,
107+
account_len: i32,
108+
) -> i32;
75109
}

crates/enclaveapp-apple/src/keychain.rs

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ where
124124
}
125125

126126
/// Generate a Secure Enclave key and persist its local metadata atomically.
127+
///
128+
/// The SE `dataRepresentation` handle is wrapped with AES-256-GCM under a
129+
/// fresh 32-byte key stored in the macOS login keychain before being
130+
/// written to `.handle` on disk. See [`crate::keychain_wrap`] for the
131+
/// rationale and ciphertext format.
132+
///
133+
/// If any step after the SE key was created fails, the SE key is deleted
134+
/// and the keychain wrapping-key entry is cleaned up so the label is
135+
/// free to reuse.
127136
pub fn generate_and_save_key(
128137
config: &KeychainConfig,
129138
label: &str,
@@ -137,9 +146,46 @@ pub fn generate_and_save_key(
137146
prepare_label_for_save(&dir, label)?;
138147

139148
let (pub_key, data_rep) = generate_key(key_type, policy.as_ffi_value())?;
140-
persist_saved_key_material(&dir, label, key_type, policy, &data_rep, &pub_key, || {
149+
150+
// Generate a fresh wrapping key and store it in the keychain BEFORE
151+
// encrypting, so a failure on either side leaves a consistent state.
152+
let wrapping_key = crate::keychain_wrap::generate_wrapping_key();
153+
let app_name = config.app_name.clone();
154+
let app_name_for_cleanup = app_name.clone();
155+
let label_owned = label.to_string();
156+
if let Err(error) = crate::keychain_wrap::keychain_store(&app_name, label, &wrapping_key) {
157+
// The SE key was created but we can't store its wrapping key —
158+
// roll back the SE key so we don't leave an orphaned key that
159+
// we can never reload.
160+
drop(delete_key_from_data_rep(&data_rep));
161+
return Err(error);
162+
}
163+
164+
let wrapped_blob = match crate::keychain_wrap::encrypt_blob(&wrapping_key, &data_rep) {
165+
Ok(blob) => blob,
166+
Err(error) => {
167+
drop(crate::keychain_wrap::keychain_delete(&app_name, label));
168+
drop(delete_key_from_data_rep(&data_rep));
169+
return Err(error);
170+
}
171+
};
172+
173+
let cleanup = move || {
174+
drop(crate::keychain_wrap::keychain_delete(
175+
&app_name_for_cleanup,
176+
&label_owned,
177+
));
141178
delete_key_from_data_rep(&data_rep)
142-
})?;
179+
};
180+
persist_saved_key_material(
181+
&dir,
182+
label,
183+
key_type,
184+
policy,
185+
&wrapped_blob,
186+
&pub_key,
187+
cleanup,
188+
)?;
143189

144190
Ok(pub_key)
145191
}
@@ -204,6 +250,13 @@ fn save_key(
204250
}
205251

206252
/// Load a key's data representation from the keys directory.
253+
///
254+
/// The `.handle` file may be either a wrapped blob (magic prefix `EHW1`)
255+
/// or a legacy plaintext CryptoKit `dataRepresentation`. Wrapped blobs
256+
/// are decrypted with the wrapping key loaded from the login keychain;
257+
/// legacy plaintext blobs are returned unchanged for transparent
258+
/// migration — they'll be re-wrapped the next time `generate_and_save_key`
259+
/// replaces the label.
207260
pub fn load_handle(config: &KeychainConfig, label: &str) -> Result<Vec<u8>> {
208261
validate_label(label)?;
209262
let path = config.keys_dir().join(format!("{label}.handle"));
@@ -212,7 +265,32 @@ pub fn load_handle(config: &KeychainConfig, label: &str) -> Result<Vec<u8>> {
212265
label: label.to_string(),
213266
});
214267
}
215-
metadata::read_no_follow(&path)
268+
let contents = metadata::read_no_follow(&path)?;
269+
270+
if !crate::keychain_wrap::is_wrapped_handle(&contents) {
271+
// Legacy plaintext handle (pre-EHW1). Return as-is; the caller
272+
// can sign/decrypt directly, and the next rotation picks up
273+
// the wrapping. Logged for visibility.
274+
tracing::debug!(
275+
label = label,
276+
"loaded legacy plaintext SE handle; re-save to upgrade to wrapped format"
277+
);
278+
return Ok(contents);
279+
}
280+
281+
let wrapping_key = match crate::keychain_wrap::keychain_load(&config.app_name, label)? {
282+
Some(k) => k,
283+
None => {
284+
return Err(Error::KeyOperation {
285+
operation: "load_handle".into(),
286+
detail: format!(
287+
"wrapped handle for label `{label}` is missing its keychain wrapping key; \
288+
the keychain entry may have been deleted or the user denied access"
289+
),
290+
});
291+
}
292+
};
293+
crate::keychain_wrap::decrypt_blob(&wrapping_key, &contents)
216294
}
217295

218296
/// Load the cached public key for a label. Falls back to extracting from data rep.
@@ -230,6 +308,12 @@ pub fn list_labels(config: &KeychainConfig) -> Result<Vec<String>> {
230308
}
231309

232310
/// Delete a key and all associated files.
311+
///
312+
/// Also removes the key's wrapping-key entry from the login keychain.
313+
/// The keychain removal is best-effort — if it fails for any reason
314+
/// other than "not found" the overall delete still proceeds so the
315+
/// on-disk state isn't left half-cleaned. A leftover keychain entry
316+
/// is harmless if the `.handle` file is gone (no one can use it).
233317
pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
234318
validate_label(label)?;
235319
let dir = config.keys_dir();
@@ -242,7 +326,7 @@ pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
242326
});
243327
}
244328
let _lock = metadata::DirLock::acquire(&dir)?;
245-
match load_handle(config, label) {
329+
let result = match load_handle(config, label) {
246330
Ok(data_rep) => {
247331
delete_key_from_data_rep(&data_rep).map_err(|error| Error::KeyOperation {
248332
operation: "delete_key".into(),
@@ -257,7 +341,20 @@ pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
257341
"failed to read Secure Enclave handle; preserving local key material for retry: {error}"
258342
),
259343
}),
344+
};
345+
346+
// Clean up the keychain wrapping-key entry regardless of whether
347+
// the local files were fully removed. If it fails here, log but
348+
// don't propagate — a stale keychain entry without its handle is
349+
// useless.
350+
if let Err(error) = crate::keychain_wrap::keychain_delete(&config.app_name, label) {
351+
tracing::warn!(
352+
label = label,
353+
"keychain_delete failed during delete_key (harmless if the handle is already gone): {error}"
354+
);
260355
}
356+
357+
result
261358
}
262359

263360
#[allow(unsafe_code)] // FFI call to CryptoKit Swift bridge

0 commit comments

Comments
 (0)