Skip to content

Commit 2215a4e

Browse files
windows: re-verify NCRYPT_UI_POLICY before every sign and decrypt (#62)
* windows: re-verify NCRYPT_UI_POLICY before every sign and decrypt The UI policy (NCRYPT_UI_PROTECT_KEY_FLAG) is set on CNG keys once, at key-creation time, via set_ui_policy. If an attacker with write access to the user's CNG key store pre-plants a key under the same name but without the UI_PROTECT_KEY_FLAG, the app would open and sign with that key — and because Windows Hello only fires when the flag is set, the prompt never comes up and the intended user-presence gate is bypassed. The flag is the only CNG-side defense; nothing else makes the hardware require biometrics. Add ui_policy::verify_ui_policy_matches that reads the key's actual NCRYPT_UI_POLICY via NCryptGetProperty and compares UI_PROTECT_KEY_FLAG against the metadata's AccessPolicy. Called from both TpmSigner::sign and TpmEncryptor::decrypt right after open_key. A policy mismatch returns Error::KeyOperation and the sign/decrypt is aborted before any TPM operation happens. Threat-model section on NCRYPT UI policy rewritten to describe the new runtime check. * Fix Windows types in verify_ui_policy_matches Three type-mismatch errors caught by Windows CI: - NCRYPT_UI_POLICY.dwFlags is u32, not NCRYPT_FLAGS (set_ui_policy was already using u32 correctly; only the new function got it wrong). - NCryptGetProperty's dwflags parameter is OBJECT_SECURITY_INFORMATION, not NCRYPT_FLAGS. Pass OBJECT_SECURITY_INFORMATION(0) for no-flags. - NCRYPT_UI_PROTECT_KEY_FLAG is a bare u32 constant, not a newtype-wrapped flag. Bitwise-AND it directly; no .0 accessor. Add Win32_Security to the windows crate feature list because OBJECT_SECURITY_INFORMATION lives in Win32::Security, not in the already-enabled Win32_Security_Cryptography. * Rewrite UI-policy check as bool comparison to silence match_same_arms --------- Co-authored-by: Jay Gowdy <jay@gowdy.me>
1 parent 7c22610 commit 2215a4e

File tree

5 files changed

+113
-2
lines changed

5 files changed

+113
-2
lines changed

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_Cryptography", "Win32_Foundation", "Security_Credentials_UI", "Foundation"] }
66+
windows = { version = "0.58", features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Foundation", "Security_Credentials_UI", "Foundation"] }
6767

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

THREAT_MODEL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Unsafe FFI surfaces are trusted by design but fragile.
7979

8080
- **Swift ↔ Rust bridge** (`crates/enclaveapp-apple/src/ffi.rs` + `crates/enclaveapp-apple/swift/bridge.swift`). Out-buffer convention relies on the caller sizing buffers correctly; `SE_ERR_BUFFER_TOO_SMALL` return codes can mask unrelated errors if the Rust side assumes buffer sizing is the only failure mode. Any change to Swift bridge signatures requires a coordinated Rust FFI update.
8181
- **Windows CNG raw-pointer casts** (`crates/enclaveapp-windows/src/ui_policy.rs`). `NCRYPT_UI_POLICY` is passed to `NCryptSetProperty` via `&ui_policy as *const _ as *const u8` with a computed `size_of::<NCRYPT_UI_POLICY>()`. This is correct today but fragile to future struct layout changes.
82-
- **NCRYPT UI policy is set at key creation only.** The library does not re-read `NCRYPT_UI_PROTECT_KEY_FLAG` before signing. An attacker-planted TPM key with the expected name would sign without triggering Windows Hello. Integration testing on real Windows TPM hardware is a tracked follow-up.
82+
- **NCRYPT UI policy is re-verified before every sign/decrypt.** `ui_policy::verify_ui_policy_matches` reads the CNG key's actual `NCRYPT_UI_POLICY` via `NCryptGetProperty` and rejects the operation if the `UI_PROTECT_KEY_FLAG` does not match the metadata's `AccessPolicy`. Closes the attacker-planted-TPM-key bypass: an attacker who writes a TPM key with the expected CNG name but no UI protect flag no longer gets signatures without Windows Hello. Integration testing on real Windows TPM hardware is still a tracked follow-up.
8383

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

crates/enclaveapp-windows/src/encrypt.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ impl EnclaveEncryptor for TpmEncryptor {
202202
let provider = provider::open_provider()?;
203203
let key_handle = key::open_key(&provider, &self.app_name, label)?;
204204

205+
// Re-verify the key's NCRYPT_UI_POLICY matches metadata's
206+
// AccessPolicy before decrypt. See the same check in
207+
// TpmSigner::sign for the rationale — closes the
208+
// pre-planted-key bypass on the encryption path too.
209+
let dir = self.keys_dir();
210+
let expected_policy = match metadata::load_meta(&dir, label) {
211+
Ok(meta) => meta.access_policy,
212+
Err(Error::KeyNotFound { .. }) => AccessPolicy::None,
213+
Err(err) => return Err(err),
214+
};
215+
crate::ui_policy::verify_ui_policy_matches(&key_handle, expected_policy)?;
216+
205217
unsafe { ecies_decrypt(&key_handle, ephemeral_pub, nonce, ct, tag) }
206218
}
207219
}

crates/enclaveapp-windows/src/sign.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,21 @@ impl EnclaveSigner for TpmSigner {
124124
let provider = provider::open_provider()?;
125125
let key_handle = key::open_key(&provider, &self.app_name, label)?;
126126

127+
// Re-verify the key's NCRYPT_UI_POLICY matches the metadata's
128+
// AccessPolicy before signing. Without this check, a same-user
129+
// attacker who pre-planted a TPM key with the expected CNG
130+
// name (but without UI_PROTECT_KEY_FLAG) could get sshenc to
131+
// sign without triggering Windows Hello — the hardware enforces
132+
// the policy that's actually set on the key, not what the app
133+
// thinks was set.
134+
let dir = self.keys_dir();
135+
let expected_policy = match metadata::load_meta(&dir, label) {
136+
Ok(meta) => meta.access_policy,
137+
Err(Error::KeyNotFound { .. }) => AccessPolicy::None,
138+
Err(err) => return Err(err),
139+
};
140+
crate::ui_policy::verify_ui_policy_matches(&key_handle, expected_policy)?;
141+
127142
// Pre-hash with SHA-256.
128143
let digest = Sha256::digest(data);
129144

crates/enclaveapp-windows/src/ui_policy.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::provider::NcryptHandle;
1010
use enclaveapp_core::AccessPolicy;
1111
use windows::core::PCWSTR;
1212
use windows::Win32::Security::Cryptography::*;
13+
use windows::Win32::Security::OBJECT_SECURITY_INFORMATION;
1314

1415
/// Set a Windows Hello UI policy on a key handle.
1516
///
@@ -54,3 +55,86 @@ pub fn set_ui_policy(
5455
detail: format!("NCryptSetProperty(UI Policy): {e}"),
5556
})
5657
}
58+
59+
/// Read back the `NCRYPT_UI_POLICY` actually set on a CNG key and
60+
/// assert that it matches the requested `AccessPolicy`.
61+
///
62+
/// Defends against a same-user attacker who pre-plants a TPM key with
63+
/// the expected CNG name but without `NCRYPT_UI_PROTECT_KEY_FLAG`. If
64+
/// the app were to open and sign with such a key, the Windows Hello
65+
/// prompt would not fire and the intended presence check would be
66+
/// bypassed. Re-verifying the flag before signing closes that gap.
67+
///
68+
/// Returns `Ok(())` if the key carries the correct `NCRYPT_UI_PROTECT_KEY_FLAG`
69+
/// for the requested policy (or no flag when `expected == None`). Returns
70+
/// an `Error::KeyOperation` if the policy does not match or the query
71+
/// itself fails.
72+
pub fn verify_ui_policy_matches(
73+
key_handle: &NcryptHandle,
74+
expected: AccessPolicy,
75+
) -> enclaveapp_core::Result<()> {
76+
let prop_name: Vec<u16> = "UI Policy"
77+
.encode_utf16()
78+
.chain(std::iter::once(0))
79+
.collect();
80+
81+
let mut actual = NCRYPT_UI_POLICY {
82+
dwVersion: 0,
83+
dwFlags: 0,
84+
pszCreationTitle: PCWSTR::null(),
85+
pszFriendlyName: PCWSTR::null(),
86+
pszDescription: PCWSTR::null(),
87+
};
88+
let mut actual_size: u32 = 0;
89+
let buf = unsafe {
90+
std::slice::from_raw_parts_mut(
91+
&mut actual as *mut _ as *mut u8,
92+
std::mem::size_of::<NCRYPT_UI_POLICY>(),
93+
)
94+
};
95+
96+
let result = unsafe {
97+
NCryptGetProperty(
98+
NCRYPT_HANDLE(key_handle.as_key().0),
99+
PCWSTR(prop_name.as_ptr()),
100+
Some(buf),
101+
&mut actual_size,
102+
OBJECT_SECURITY_INFORMATION(0),
103+
)
104+
};
105+
106+
let actual_flags: u32 = match result {
107+
Ok(()) => actual.dwFlags,
108+
Err(e) => {
109+
// SPC_E_NO_POLICY / NTE_NOT_FOUND both surface as a missing
110+
// policy — translate to "no flag set" so the comparison
111+
// below treats it as AccessPolicy::None.
112+
if expected == AccessPolicy::None {
113+
return Ok(());
114+
}
115+
return Err(enclaveapp_core::Error::KeyOperation {
116+
operation: "verify_ui_policy".into(),
117+
detail: format!(
118+
"NCryptGetProperty(UI Policy) for key with expected policy {expected:?}: {e}",
119+
),
120+
});
121+
}
122+
};
123+
124+
let has_protect_flag =
125+
(actual_flags & NCRYPT_UI_PROTECT_KEY_FLAG) == NCRYPT_UI_PROTECT_KEY_FLAG;
126+
let expected_protect_flag = expected != AccessPolicy::None;
127+
128+
if has_protect_flag == expected_protect_flag {
129+
return Ok(());
130+
}
131+
let detail = if expected_protect_flag {
132+
format!("key is missing NCRYPT_UI_PROTECT_KEY_FLAG but metadata expects {expected:?}")
133+
} else {
134+
"key has NCRYPT_UI_PROTECT_KEY_FLAG set but metadata expects AccessPolicy::None".into()
135+
};
136+
Err(enclaveapp_core::Error::KeyOperation {
137+
operation: "verify_ui_policy".into(),
138+
detail,
139+
})
140+
}

0 commit comments

Comments
 (0)