Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 8 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Signing keys are long-lived identity keys (e.g., SSH keys). At Levels 1-3, the h

| Level | Backend | Who signs? | Private key exportable? | User presence | Key storage |
|:-----:|---------|-----------|:----------------------:|---------------|-------------|
| **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. |
| **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). |
| **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. |
| **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. |
| **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. |
Expand All @@ -170,7 +170,7 @@ The blast radius of encryption key compromise is further bounded by the expirati

| Level | Backend | Who decrypts? | Private key exportable? | User presence | Cached data protection |
|:-----:|---------|--------------|:----------------------:|---------------|----------------------|
| **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. |
| **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. |
| **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. |
| **3** | Linux TPM 2.0 | **The TPM hardware** via `tss-esapi`. | **No** — TPM-bound. | Not enforced. | Same machine-binding as Windows TPM. |
| **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. |
Expand All @@ -182,11 +182,13 @@ All supported macOS hardware (Apple Silicon) has a Secure Enclave, and **CryptoK

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.

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

**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.
**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`.

**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:
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.

The Keychain's per-application access control then gates same-user handle theft:

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

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

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

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

#### Linux glibc vs. musl

Expand Down
4 changes: 2 additions & 2 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ Unsafe FFI surfaces are trusted by design but fragile.

### Keychain and key-backend-specific risks

- **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.
- **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).
- **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`.
- **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.
- **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.

### Filesystem races and metadata tamper
Expand Down
6 changes: 6 additions & 0 deletions crates/enclaveapp-apple/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ encryption = []

[dependencies]
enclaveapp-core = { workspace = true }
aes-gcm = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
libc = { workspace = true }
tracing = "0.1"

[dev-dependencies]
tempfile = { workspace = true }
34 changes: 34 additions & 0 deletions crates/enclaveapp-apple/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,38 @@ extern "C" {
plaintext_out: *mut u8,
plaintext_len: *mut i32,
) -> i32;

// Keychain generic-password helpers (wrapping-key storage).
//
// Return codes:
// 0 SE_OK
// 4 SE_ERR_BUFFER_TOO_SMALL
// 9 SE_ERR_KEYCHAIN_STORE
// 10 SE_ERR_KEYCHAIN_LOAD
// 11 SE_ERR_KEYCHAIN_DELETE
// 12 SE_ERR_KEYCHAIN_NOT_FOUND
pub fn enclaveapp_keychain_store(
service: *const u8,
service_len: i32,
account: *const u8,
account_len: i32,
secret: *const u8,
secret_len: i32,
) -> i32;

pub fn enclaveapp_keychain_load(
service: *const u8,
service_len: i32,
account: *const u8,
account_len: i32,
secret_out: *mut u8,
secret_len: *mut i32,
) -> i32;

pub fn enclaveapp_keychain_delete(
service: *const u8,
service_len: i32,
account: *const u8,
account_len: i32,
) -> i32;
}
105 changes: 101 additions & 4 deletions crates/enclaveapp-apple/src/keychain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ where
}

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

let (pub_key, data_rep) = generate_key(key_type, policy.as_ffi_value())?;
persist_saved_key_material(&dir, label, key_type, policy, &data_rep, &pub_key, || {

// Generate a fresh wrapping key and store it in the keychain BEFORE
// encrypting, so a failure on either side leaves a consistent state.
let wrapping_key = crate::keychain_wrap::generate_wrapping_key();
let app_name = config.app_name.clone();
let app_name_for_cleanup = app_name.clone();
let label_owned = label.to_string();
if let Err(error) = crate::keychain_wrap::keychain_store(&app_name, label, &wrapping_key) {
// The SE key was created but we can't store its wrapping key —
// roll back the SE key so we don't leave an orphaned key that
// we can never reload.
drop(delete_key_from_data_rep(&data_rep));
return Err(error);
}

let wrapped_blob = match crate::keychain_wrap::encrypt_blob(&wrapping_key, &data_rep) {
Ok(blob) => blob,
Err(error) => {
drop(crate::keychain_wrap::keychain_delete(&app_name, label));
drop(delete_key_from_data_rep(&data_rep));
return Err(error);
}
};

let cleanup = move || {
drop(crate::keychain_wrap::keychain_delete(
&app_name_for_cleanup,
&label_owned,
));
delete_key_from_data_rep(&data_rep)
})?;
};
persist_saved_key_material(
&dir,
label,
key_type,
policy,
&wrapped_blob,
&pub_key,
cleanup,
)?;

Ok(pub_key)
}
Expand Down Expand Up @@ -204,6 +250,13 @@ fn save_key(
}

/// Load a key's data representation from the keys directory.
///
/// The `.handle` file may be either a wrapped blob (magic prefix `EHW1`)
/// or a legacy plaintext CryptoKit `dataRepresentation`. Wrapped blobs
/// are decrypted with the wrapping key loaded from the login keychain;
/// legacy plaintext blobs are returned unchanged for transparent
/// migration — they'll be re-wrapped the next time `generate_and_save_key`
/// replaces the label.
pub fn load_handle(config: &KeychainConfig, label: &str) -> Result<Vec<u8>> {
validate_label(label)?;
let path = config.keys_dir().join(format!("{label}.handle"));
Expand All @@ -212,7 +265,32 @@ pub fn load_handle(config: &KeychainConfig, label: &str) -> Result<Vec<u8>> {
label: label.to_string(),
});
}
metadata::read_no_follow(&path)
let contents = metadata::read_no_follow(&path)?;

if !crate::keychain_wrap::is_wrapped_handle(&contents) {
// Legacy plaintext handle (pre-EHW1). Return as-is; the caller
// can sign/decrypt directly, and the next rotation picks up
// the wrapping. Logged for visibility.
tracing::debug!(
label = label,
"loaded legacy plaintext SE handle; re-save to upgrade to wrapped format"
);
return Ok(contents);
}

let wrapping_key = match crate::keychain_wrap::keychain_load(&config.app_name, label)? {
Some(k) => k,
None => {
return Err(Error::KeyOperation {
operation: "load_handle".into(),
detail: format!(
"wrapped handle for label `{label}` is missing its keychain wrapping key; \
the keychain entry may have been deleted or the user denied access"
),
});
}
};
crate::keychain_wrap::decrypt_blob(&wrapping_key, &contents)
}

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

/// Delete a key and all associated files.
///
/// Also removes the key's wrapping-key entry from the login keychain.
/// The keychain removal is best-effort — if it fails for any reason
/// other than "not found" the overall delete still proceeds so the
/// on-disk state isn't left half-cleaned. A leftover keychain entry
/// is harmless if the `.handle` file is gone (no one can use it).
pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
validate_label(label)?;
let dir = config.keys_dir();
Expand All @@ -242,7 +326,7 @@ pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
});
}
let _lock = metadata::DirLock::acquire(&dir)?;
match load_handle(config, label) {
let result = match load_handle(config, label) {
Ok(data_rep) => {
delete_key_from_data_rep(&data_rep).map_err(|error| Error::KeyOperation {
operation: "delete_key".into(),
Expand All @@ -257,7 +341,20 @@ pub fn delete_key(config: &KeychainConfig, label: &str) -> Result<()> {
"failed to read Secure Enclave handle; preserving local key material for retry: {error}"
),
}),
};

// Clean up the keychain wrapping-key entry regardless of whether
// the local files were fully removed. If it fails here, log but
// don't propagate — a stale keychain entry without its handle is
// useless.
if let Err(error) = crate::keychain_wrap::keychain_delete(&config.app_name, label) {
tracing::warn!(
label = label,
"keychain_delete failed during delete_key (harmless if the handle is already gone): {error}"
);
}

result
}

#[allow(unsafe_code)] // FFI call to CryptoKit Swift bridge
Expand Down
Loading