Skip to content

Commit 9281e76

Browse files
Threat-model hardening pass: envelope, meta HMAC, bridge, process, adapter (#68)
* docs: close DESIGN.md gaps — layout, levels, process hardening, bridge, cache Sweep of DESIGN.md against the code on main: - Workspace layout diagram was missing enclaveapp-app-adapter, enclaveapp-cache, enclaveapp-tpm-bridge, enclaveapp-build-support. Added. - 'Three integration types' while the following paragraphs define four. Fixed; Type 4 (CredentialSource) was already described. - Level 5 'Linux musl plaintext' shown as a production backend on two security-level tables. Actually the only plaintext backend is enclaveapp-test-software (explicitly marked 'NOT for production'), and CLAUDE.md states musl is not supported. Tables now show Level 4 as the terminal glibc-keyring row and reference the test-only crate as an unnumbered out-of-band entry. - 'macOS signed vs. unsigned' section described an auto-detecting two-path runtime that doesn't exist. There is one code path; SE is always in it; Path 1 (entitled) is deferred per fix-macos.md. Rewrote as 'macOS path in practice (signed and unsigned)' framed around Keychain prompt UX. New sections documenting features that were in code but not in DESIGN: - Process hardening (harden_process, PR_SET_DUMPABLE, PR_SET_NO_NEW_PRIVS, RLIMIT_CORE=0, mlock_buffer). - Shared infrastructure crates (app-adapter, cache, tpm-bridge, build-support). - WSL bridge discovery (fixed paths only; PATH fallback was removed; 64 KB cap; ENCLAVEAPP_BRIDGE_TIMEOUT_SECS; BridgeSession::Drop). - Credential cache file tamper (consumer-layer max(header, config) mitigation; AAD binding deferred). Consumer mapping table expanded with shipped binary names, including gitenc and npxenc which were previously invisible. * cache: add APL1 authenticated envelope with header hash + rollback counter Wraps plaintext fed to EncryptionStorage::encrypt in [4B "APL1"][32B SHA-256(header)][8B BE u64 counter][payload] so the unencrypted cache header is bound to the ciphertext and older-ciphertext replay is rejected. - envelope::wrap_plaintext / unwrap_plaintext do the framing; the header SHA covers whatever bytes the caller decides are authoritative. - counter_path / read_counter / write_counter manage a sibling <cache>.counter sidecar guarded by an fs4 exclusive flock. - next_counter(sidecar, prior_observed) takes the max so deleting the sidecar cannot rewind the sequence (prior_observed is re-seeded from the highest counter inside any successfully-decrypted ciphertext). - Legacy plaintext without the APL1 magic is accepted as Unwrapped::Legacy for migration. The first write after upgrade lands in the new format. - Trait signature of EnclaveEncryptor did NOT change; backends (SE, CNG, Linux TPM, keyring, WSL bridge) inherit the protection uniformly through the existing encrypt/decrypt path. Consumed by awsenc and sso-jwt (separate repos). * core,keyring,app-storage: HMAC-authenticate .meta on keyring backend metadata::save_meta_with_hmac and load_meta_with_hmac write and verify a sidecar `<label>.meta.hmac` containing hex HMAC-SHA256 of the .meta JSON. The HMAC is computed inline (RFC 2104 over sha2) so enclaveapp-core picks up a single small sha2 dep and no new HMAC crate. enclaveapp-keyring::meta_hmac_key loads or generates a per-app random 32-byte key in the system keyring at account "__meta_hmac_key__". The key is wrapped in Zeroizing<Vec<u8>> and the local intermediate array is zeroized after copy. enclaveapp-app-storage::ensure_key calls load_meta_with_hmac on Linux when the keyring hands back a key; a meta_hmac_verify mismatch is a hard error and refuses key init. Non-HMAC load errors (missing file, deserialize) fall through to the legacy load_meta path for back-compat. Hardware backends (Apple SE, Windows CNG, Linux TPM) keep the plain save_meta path — .meta tamper on those backends is UI-deception only because the chip enforces the access policy regardless of what .meta claims. delete_key_files now also removes the .meta.hmac extension so key deletion is complete. * keyring,test-software: zeroize plaintext key bytes and derived AES keys - load_private_key_bytes and decrypt_private_key return Zeroizing<Vec<u8>> so the caller-held copy is wiped on drop. - save_encrypted wraps the random KEK in Zeroizing after filling the intermediate [u8; KEK_SIZE] (which is also zeroized via the Zeroize trait on the local array). - generate_and_save holds secret_key.to_bytes() as Zeroizing<Vec<u8>>. - derive_key in both the keyring-backed and test-software ECIES paths now returns Zeroizing<[u8; 32]> so the AES-GCM symmetric key is wiped after each encrypt / decrypt op. This closes the previous gap where plaintext P-256 bytes loaded from the keyring or software store could linger on the Rust heap after the relevant SecretKey was dropped. * bridge: drop legacy biometric field; lock in delete↔destroy alias - BridgeParams no longer carries `biometric: bool` on the wire. access_policy is the only accepted encoding. Stray `biometric` keys in received payloads are ignored by the deserializer and cannot influence the effective policy. Closes the silent-downgrade path where a server that honored only `biometric` could serve a client's BiometricOnly request as None. - effective_access_policy() is kept as a method returning access_policy for source-compatibility with call sites that used to reconcile the two fields. - A new destroy_and_delete_are_aliases test in enclaveapp-tpm-bridge asserts that both wire names route to identical semantics (neither produces the unknown-method error) — the "mitigation: bridge servers should accept both" note in the threat model is upgraded to a compat guarantee. * bridge client: Authenticode signature presence check + session lock Two related bridge-client hardening changes that both live in client.rs: 1. require_bridge_is_authenticode_signed is now called before every BridgeSession::spawn. It parses the PE header's IMAGE_DIRECTORY_ENTRY_SECURITY slot (via pe_has_authenticode_table) and refuses binaries that carry no signature block at all. Opt-out: ENCLAVEAPP_BRIDGE_ALLOW_UNSIGNED=1 for dev / CI. Full WinVerifyTrust chain verification is still out of scope from the WSL side — an admin-on-Windows attacker who plants a validly-signed-but-malicious binary is the acknowledged residual. Non-.exe paths (test shell scripts) bypass the check so the existing test harness keeps working. 2. A process-wide BRIDGE_SESSION_LOCK (Mutex<()>) is held across the full spawn → request → shutdown lifetime of every bridge call. Two threads in the same client process no longer race to spawn independent bridge children against the same TPM, which would otherwise fire Windows Hello twice back-to-back and contend for the server-side key slot. Mutex poisoning is recovered with into_inner() so one crashed session cannot wedge the client for the process lifetime. 3. The renamed bridge_init_encodes_access_policy_only test enforces that the biometric field never leaks onto the wire, aligning with the earlier protocol.rs commit that removed the legacy field. New tests: - pe_has_authenticode_table_detects_signed_pe32 / _pe32plus / _unsigned / _rejects_non_pe - require_signed_skips_non_exe_paths / _rejects_unsigned_exe / _honors_allow_unsigned_env - concurrent_call_bridge_serializes_via_session_lock * apple: open login keychain by absolute path; smooth unsigned-build UX Security.framework's default-keychain lookup goes through CFPreferences, which is keyed off the process's $HOME. Callers that override $HOME (integration tests via assert_cmd, awsenc serve under a launchd sandbox, cron jobs) got errSecNoDefaultKeychain back, which surfaced as the system-modal "A keychain cannot be found to store 'cache-key'" alert — blocking tests and leaving users confused. bridge.swift now resolves the login keychain explicitly via getpwuid(getuid())->pw_dir + "/Library/Keychains/login.keychain-db" (falling back to the older .keychain extension for migrated installs) and passes the resulting SecKeychain handle via kSecUseKeychain / kSecMatchSearchList in every SecItem query. The lookup bypasses CFPreferences / $HOME entirely. Unsigned-build UX is preserved: the first-run "Always Allow" ACL prompt is a SecTrust decision driven by the SecItemAdd itself, not by default-keychain lookup, so it still fires normally. keychain_delete on the Rust side now treats SE_ERR_KEYCHAIN_NOT_FOUND (12) as idempotent success so uninstall / cleanup flows stay quiet when HOME is isolated. Rename service prefix com.enclaveapp.* → com.libenclaveapp.* to match the newly-registered libenclaveapp.com domain. Pre-release rename, no legacy-entry migration path. * apple: tighten SE_ERR_BUFFER_TOO_SMALL retry contract generate_key_with_retry now caps retries at MAX_RESIZE_RETRIES = 4 and refuses to resize when the Swift-reported length does not grow past what we sent. If the FFI ever starts returning SE_ERR_BUFFER_TOO_SMALL for something other than a genuine buffer-sizing shortfall, the Rust side surfaces it as 'Swift bridge contract violation' instead of spinning in a retry loop or masking the real failure. Also validates post-call pub_key_len ≤ 65 (uncompressed P-256 SEC1 is fixed-size); an out-of-range report is a contract violation. Paired with a bridge.swift doc comment asserting that SE_ERR_BUFFER_TOO_SMALL is ONLY used for buffer-sizing failures. * apple: resolve Swift bridge toolchain via absolute /usr/bin/xcrun build.rs invokes the system xcrun at its absolute path /usr/bin/xcrun (system-managed, not user-writable without sudo) and discovers swiftc and ar via `xcrun --find <tool>`. The resolved paths sit inside the active Xcode developer directory (xcode-select -p) rather than walking $PATH. A shadowed xcrun / swiftc / ar earlier on the developer's $PATH can no longer substitute a poisoned Swift object into the static bridge that ends up linked into the binary. Release-tooling PATH hygiene is no longer load-bearing for this crate. * windows: compile-time assert NCRYPT_UI_POLICY struct layout Adds a module-level `const _: () = assert!(size_of::<NCRYPT_UI_POLICY>() == EXPECTED_NCRYPT_UI_POLICY_SIZE, ...)` so a future windows-rs release that silently changes the struct (e.g. reorders LPCWSTR fields or pads differently) fails the build rather than shipping a wrong-sized cbInput to NCryptSetProperty / NCryptGetProperty. Expected size is 32 bytes on x64 (4 + 4 + 3×8) and 20 bytes on x86 (4 + 4 + 3×4). * core: apply SetProcessMitigationPolicy safe subset on Windows harden_process() on Windows now applies three low-risk mitigations at startup: - ProcessStrictHandleCheckPolicy with RaiseExceptionOnInvalidHandleReference + HandleExceptionsPermanentlyEnabled — turns latent handle-confusion bugs into STATUS_INVALID_HANDLE exceptions instead of silently operating on the wrong object. - ProcessExtensionPointDisablePolicy with DisableExtensionPoints — blocks AppInit_DLLs, AppCertDlls, shim engines, IMEs, and winevent hooks from loading into the process. - ProcessImageLoadPolicy with NoRemoteImages + NoLowMandatoryLabelImages — refuses DLL loads from UNC paths and from files at the low-mandatory integrity label. Deliberately not applied: BinarySignaturePolicy.MicrosoftSignedOnly (breaks unsigned cargo builds), DynamicCodePolicy / ACG (breaks some JIT / crypto providers), SystemCallDisablePolicy.DisallowWin32kSystemCalls (breaks any GUI-surface process). Each call is best-effort — failure on older Windows builds is traced via tracing::warn! and does not abort startup. Workspace Cargo.toml adds Win32_System_Threading + Win32_Security to the windows crate's feature list. * adapter: typed SecretRead, opt-in env scrub, RLIMIT_CORE=0 on child Three related adapter changes: 1. SecretStore::get_read returns a typed SecretRead { Present(String), Redacted, Absent } enum. The read-only inspection store surfaces Redacted directly — it no longer round-trips through the "<redacted>" string sentinel, so a stored secret whose bytes happen to equal "<redacted>" is returned as Present("<redacted>") and cannot be misclassified. Legacy SecretStore::get is retained for back-compat and still produces Some(REDACTED_PLACEHOLDER) from the read-only store. MemorySecretStore gains a test-only mark_redacted() helper so tests can inject Redacted without going through the sentinel string. 2. LaunchRequest::with_env_scrub(patterns) — opt-in list of exact variable names ("NPM_TOKEN") or *-suffixed prefix patterns ("NPM_TOKEN_*", "AWS_*"). Matching variables are removed from both the child's Command and our own std::env (so later subprocess spawns without env_clear don't re-inherit), and our owned String copies are zeroized before drop. Case-insensitive on Windows because Windows env names are case-insensitive. Opt-in — existing callers with env_scrub_patterns: Vec::new() behave identically. 3. disable_core_dumps_in_child installs a pre_exec hook on Unix that calls setrlimit(RLIMIT_CORE, 0) before execve. The spawned child (e.g. npm under npmenc) inherits a zero core limit regardless of system-level core_pattern, so a crash of the Type 2 target can no longer dump its interpolated NPM_TOKEN_* / AWS_* environment. * docs: update DESIGN, THREAT_MODEL, fix-macos for hardening pass DESIGN.md: - Rewrite 'Credential cache file tamper' section around the new APL1 envelope (SHA-256(header) + rollback counter) — the old section claimed AAD binding was deferred. - New 'Metadata .meta tamper' section documenting the .meta.hmac sidecar on the keyring backend. - 'Process hardening' extended with the Windows mitigation subset. - 'app-adapter' line extended with SecretRead, with_env_scrub, and per-child RLIMIT_CORE. - Mention Authenticode-presence check and client-side session mutex in the bridge section. - Keychain wrap service name updated to com.libenclaveapp.<app>. THREAT_MODEL.md: - Bridge 'method-name confusion' downgraded from threat to compat guarantee (the destroy_and_delete_are_aliases test locks it in). - Bridge 'serialization' rewritten around the new process-wide BRIDGE_SESSION_LOCK. - App-adapter 'Launcher env inheritance' rewritten around the new opt-in with_env_scrub helper. - Keychain-wrap service name updated throughout. fix-macos.md: - Keychain-wrap service name updated throughout. Cargo.lock reflects the new sha2 dep on enclaveapp-core and the windows feature additions. * fix(test): remove unnecessary borrow in get_read_on_read_only_store test Clippy on Linux flagged `&store.path_for(&id)` as needless — path_for already returns an owned PathBuf. Fix applies to the test-only get_read_on_read_only_store_returns_redacted_for_existing_entry case. * fix(windows): correct PROCESS_MITIGATION_* struct imports windows-rs 0.58 splits the Windows process-mitigation API across two modules: the PROCESS_MITIGATION_*_POLICY structs live in Win32::System::SystemServices while the SetProcessMitigationPolicy function and the PROCESS_MITIGATION_POLICY enum discriminants (ProcessStrictHandleCheckPolicy, etc.) live in Win32::System::Threading. Also add the Win32_System_SystemServices feature to the workspace's windows-crate feature list so the structs are actually available. * fix(windows): set mitigation Flags directly windows-rs 0.58 exposes PROCESS_MITIGATION_* struct layouts in Win32::System::SystemServices but does not generate bitfield setter methods on the inner _0_0 anonymous struct. Instead of calling set_RaiseExceptionOnInvalidHandleReference() etc., write the union's Flags: u32 word directly. Bit positions match the Win32 headers. Also drops redundant std::mem:: qualification on size_of to satisfy -D unused-qualifications. * fix(windows): use addr_of! instead of ptr::from_ref for MSRV 1.75 std::ptr::from_ref is only stable since Rust 1.76 and the workspace pins MSRV at 1.75. std::ptr::addr_of! has been stable since 1.51 and produces the same *const T result without the MSRV floor bump. * fix(bridge): gate make_pe_bytes test helper to unix The helper is only consumed by #[cfg(unix)] PE-parsing tests, so on Windows rustc reports it as dead code and the -D warnings build fails. Mirror the gate on the helper. --------- Co-authored-by: Jay Gowdy <jay@gowdy.me>
1 parent 067b016 commit 9281e76

30 files changed

Lines changed: 2168 additions & 195 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ aes-gcm = "0.10"
6363
elliptic-curve = { version = "0.13", features = ["sec1"] }
6464

6565
# Windows
66-
windows = { version = "0.58", features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Foundation", "Security_Credentials_UI", "Foundation"] }
66+
windows = { version = "0.58", features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemServices", "Security_Credentials_UI", "Foundation"] }
6767

6868
# Linux TPM
6969
tss-esapi = "7"

DESIGN.md

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ It also centralizes WSL bridge lookup, access-policy handling, and app-specific
8282

8383
Beyond the backends, a handful of crates provide cross-consumer utilities:
8484

85-
- **`enclaveapp-app-adapter`** — generic secret-delivery substrate used by Type 1-3 apps. Provides `BindingStore`, `SecretStore`, the program resolver, the `execve()`-based launcher (with `mlock` + zeroize of env-override bytes), provenance tracking, state-locking, and `TempConfig::write` (with the per-platform `create_platform_config()` memfd/tempfile selection).
85+
- **`enclaveapp-app-adapter`** — generic secret-delivery substrate used by Type 1-3 apps. Provides `BindingStore`, `SecretStore` (with typed `SecretRead { Present, Redacted, Absent }` read path that replaces the string-sentinel `"<redacted>"` round-trip), the program resolver, the `execve()`-based launcher (with `mlock` + zeroize of env-override bytes, optional `with_env_scrub(patterns)` to remove matching inherited env vars from both child and parent, and per-child `RLIMIT_CORE = 0`), provenance tracking, state-locking, and `TempConfig::write` (with the per-platform `create_platform_config()` memfd/tempfile selection).
8686
- **`enclaveapp-cache`** — the shared on-disk cache file format (`[magic][version][flags][length-prefixed blobs]`). Consumed by sso-jwt's token cache and awsenc's credential cache.
8787
- **`enclaveapp-tpm-bridge`** — the shared bridge server crate; delegated to by the per-app bridge binaries.
8888
- **`enclaveapp-build-support`** — factored-out helpers for Windows `build.rs` resource compilation.
@@ -94,8 +94,12 @@ Beyond the backends, a handful of crates provide cross-consumer utilities:
9494
- `setrlimit(RLIMIT_CORE, 0)` on all Unix — no core dumps that could capture secret buffers.
9595
- `prctl(PR_SET_DUMPABLE, 0)` on Linux — `/proc/<pid>/mem` becomes root-only, `ptrace` attach from same-UID peers is denied.
9696
- `prctl(PR_SET_NO_NEW_PRIVS, 1)` on Linux — subsequent `exec*()` can't gain setuid/file-capabilities privileges.
97+
- `SetProcessMitigationPolicy` on Windows (safe subset):
98+
- `ProcessStrictHandleCheckPolicy` with `RaiseExceptionOnInvalidHandleReference` + `HandleExceptionsPermanentlyEnabled` — turns handle-confusion bugs into `STATUS_INVALID_HANDLE` rather than silent misuse.
99+
- `ProcessExtensionPointDisablePolicy` with `DisableExtensionPoints` — blocks AppInit_DLLs, shim engines, and other legacy DLL-injection extension points.
100+
- `ProcessImageLoadPolicy` with `NoRemoteImages` + `NoLowMandatoryLabelImages` — refuses DLL loads from UNC paths and low-integrity files.
97101

98-
See `crates/enclaveapp-core/src/process.rs`. `mlock_buffer` / `munlock_buffer` are exposed for consumer crates that want to pin specific byte buffers in RAM.
102+
See `crates/enclaveapp-core/src/process.rs`. `mlock_buffer` / `munlock_buffer` are exposed for consumer crates that want to pin specific byte buffers in RAM. Type 2 apps also clamp `RLIMIT_CORE = 0` on the **spawned child** via a `pre_exec` hook in `enclaveapp-app-adapter::launcher`, so a crash of the wrapped target (e.g. `npm`) cannot core-dump its secret-laden environment.
99103

100104
## Access policy model
101105

@@ -180,7 +184,7 @@ Signing keys are long-lived identity keys (e.g., SSH keys). At Levels 1-3, the h
180184

181185
| Level | Backend | Who signs? | Private key exportable? | User presence | Key storage |
182186
|:-----:|---------|-----------|:----------------------:|---------------|-------------|
183-
| **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). |
187+
| **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.libenclaveapp.<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). |
184188
| **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. |
185189
| **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. |
186190
| **4** | Software (Linux glibc, keyring) | **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. |
@@ -208,7 +212,7 @@ The `com.apple.developer.secure-enclave` entitlement is a **Security.framework**
208212

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

211-
**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`.
215+
**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.libenclaveapp.<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`.
212216

213217
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.
214218

@@ -339,16 +343,31 @@ Operators who install the bridge outside these locations must symlink into one o
339343
- `BRIDGE_SHUTDOWN_TIMEOUT = 5 s` after stdin close before the child is killed.
340344
- `BridgeSession::Drop` kills and reaps the child — no zombie processes.
341345

342-
Authenticode / `WinVerifyTrust` verification on the resolved bridge binary is a tracked hardening gap for environments where the Windows host itself is semi-trusted.
346+
Before spawning the bridge binary, `require_bridge_is_authenticode_signed` (`crates/enclaveapp-bridge/src/client.rs`) parses the PE header's `IMAGE_DIRECTORY_ENTRY_SECURITY` slot and refuses binaries that carry no Authenticode signature block — so the "attacker replaces the admin-path install with their own `cargo build` exe" case fails closed. Full `WinVerifyTrust` chain verification is out of scope from the WSL side and is an acknowledged residual risk. Dev builds can opt out via `ENCLAVEAPP_BRIDGE_ALLOW_UNSIGNED=1`.
343347

344-
## Credential cache file tamper
348+
Concurrent bridge calls from two threads in the same client process are serialized by a process-wide `BRIDGE_SESSION_LOCK: Mutex<()>` held across spawn → request → shutdown. Without it, two threads would fire two back-to-back Windows Hello prompts, contend for the same TPM key slot, and double-bill TPM op quota. Mutex poisoning is recovered with `into_inner()` so one crashed session does not wedge the client for the process lifetime.
345349

346-
Credential caches are stored as `[header][AES-GCM ciphertext]` pairs on disk. The header (magic, version, flags, timestamps, risk level, optional session-expiration fields) is **not** authenticated by AAD — the `EncryptionStorage::encrypt` / `decrypt` trait does not currently accept associated data. A same-UID attacker with file-write access to the cache file can edit header fields without invalidating the ciphertext.
350+
## Credential cache file tamper + rollback
347351

348-
Consumer-layer mitigations already neutralize the practical risk-level-downgrade threat:
352+
Credential caches on disk have an unencrypted header (magic, version, flags, timestamps, risk level, optional session-expiration fields) and an AES-GCM ciphertext. The ciphertext body is already tag-authenticated. Header fields and older-ciphertext replay are addressed by an app-layer envelope that wraps the plaintext before encryption:
349353

350-
- **`max(header, config)` on read** — sso-jwt's `effective_cached_risk_level` (`sso-jwt-lib/src/cache.rs:57-59`) and awsenc's equivalent always clamp the effective risk level back up to the configured minimum. Editing the header down does nothing.
351-
- **Server-side expiration is authoritative** — STS credentials (`awsenc`) carry `Expiration`; JWTs (`sso-jwt`) carry `exp`. Header-rolled timestamps don't extend server acceptance.
352-
- **Payload-embedded timestamps** — both consumers recheck `session_start` / `token_iat` / `expiration` *after* decrypt, ignoring whatever the unencrypted header claims.
354+
- **Envelope format** (`crates/enclaveapp-cache/src/envelope.rs`):
355+
`[4B "APL1"][32B SHA-256(header bytes)][8B BE u64 counter][payload]`
356+
passed to `EncryptionStorage::encrypt`.
357+
- **Header binding.** `SHA-256` covers the exact unencrypted header bytes; tampering with any header field is detected on decrypt as an envelope hash mismatch. The trait signature did not change, so all backends (SE, CNG, Linux TPM, keyring, WSL bridge) inherit the protection uniformly.
358+
- **Rollback counter.** The 8-byte counter is bumped on every successful write and persisted in a sibling `<cache>.counter` sidecar guarded by an exclusive `fs4` flock. On decrypt, the embedded counter must be `>= sidecar`; older ciphertexts are rejected as `Rollback { observed, expected_at_least }`.
359+
- **Legacy-cache migration.** `unwrap_plaintext` accepts pre-envelope payloads (no `APL1` magic) as legacy with `counter = 0`. Existing installs continue to decrypt; the first write after upgrade lands in the new format.
353360

354-
AAD binding the header to the ciphertext (a proper cryptographic fix) is deferred. It would require a trait signature change across all four backends (SE, CNG, keyring, test-software) plus every consumer, plus a one-time on-disk format migration. See `THREAT_MODEL.md` § "Credential cache header tamper" for the full rationale.
361+
Consumer-layer defenses layer on top:
362+
363+
- **`max(header, config)` on read** — sso-jwt and awsenc always clamp the effective risk level to at least the configured minimum (defense-in-depth for pre-migration legacy caches).
364+
- **Server-side expiration is authoritative** — STS credentials carry `Expiration`; JWTs carry `exp`. Even a rolled-back ciphertext expires at the real server-side deadline.
365+
- **Payload-embedded timestamps** — both consumers recheck `session_start` / `token_iat` / `expiration` *after* decrypt.
366+
367+
Residual risk: an attacker who can rewrite **both** the `.enc` cache and the `.counter` sidecar in sync can still replay within the server-side validity window. Same-UID write-access to the cache dir is a generic trust-boundary assumption.
368+
369+
## Metadata `.meta` tamper
370+
371+
`KeyMeta` JSON (type, access policy, app-specific fields) ships next to every key as `<label>.meta`. Hardware backends (Apple SE, Windows CNG, Linux TPM) fix the access policy at key-creation time inside the chip, so `.meta` tamper on those backends is a UI-deception risk only — signing still prompts for Touch ID / Windows Hello regardless of what the JSON claims.
372+
373+
On the **software / keyring backend** the hardware does not re-enforce, so `.meta` tamper was a full policy-downgrade vector. `metadata::save_meta_with_hmac` / `load_meta_with_hmac` now write and verify a `<label>.meta.hmac` HMAC-SHA256 sidecar keyed by a per-app random 32-byte HMAC key stored in the system keyring under `com.libenclaveapp.<app>` / `__meta_hmac_key__`. `enclaveapp-app-storage::ensure_key` verifies the sidecar on Linux; a mismatch yields a hard `meta_hmac_verify` error and refuses to load the key. Pre-upgrade keys without a sidecar fall through to the plain `load_meta` path for migration and pick up the sidecar on next regeneration.

0 commit comments

Comments
 (0)