Skip to content

Commit 03ee8d0

Browse files
feat(pku2u): close Windows parity gaps (#733)
## Prerequisite fixes Merged into `master` and consumed by this branch: - [#736](#736) — propagate Kerberos `EXTENDED_ERROR` and `IDENTIFY` GSS request flags - [#737](#737) — generate Kerberos MICs with the negotiated AES-128/AES-256 size ## Summary Closes the PKU2U gaps identified by comparing `sspi-rs` with Windows 11 `pku2u.dll` 10.0.26100.8737 and the MS-PKU2U/MS-NEGOEX specifications. - completes the PKU2U initiator and acceptor state machines, including PKINIT AS, Ticket/AP, Finished, VERIFY, and channel-binding flows - adds negotiated MIC, signature, sealed Wrap, and integrity-only Wrap support with strict direction, sequence, header, EC/RRC, and buffer validation - aligns package metadata, SPNEGO/NEGOEX routing and mechanism selection, checksum selection, GSS flags, nonce handling, and KRB-ERROR propagation - hardens CMS signer/content binding, exact peer-certificate trust, certificate time/usage policy, DH parameter validation, and bounded process-wide authenticator replay protection - replaces the captured machine identifier with a cryptographically random process-lifetime identifier matching MS-KILE per-boot semantics - supports multiple credentials and Windows CNG-backed non-exportable private keys - exposes portable peer-name, peer-certificate, and certificate-trust context queries - adds end-to-end coverage and production-path malformed-token fuzzing ## PKU2U parity roadmap - [x] package metadata and context-size parity - [x] negotiated checksum and GSS flag handling - [x] MIC, signature, channel binding, and per-direction sequence state - [x] sealed and integrity-only multi-buffer Wrap/Unwrap - [x] correct SPNEGO/NEGOEX layering, mechanism selection, and routing - [x] CMS content, signer, nonce, and KRB-ERROR validation - [x] trust metadata, credential selection, and non-exportable CNG keys - [x] complete secured PKU2U acceptor - [x] exact client/server certificate trust and certificate-bound principals - [x] allowlisted DH parameters and full-window cross-context replay protection - [x] portable peer-name, certificate, and trust context queries - [x] end-to-end, tamper, replay, and malformed framing tests - [x] dedicated PKU2U fuzz target using the production NEGOEX parsers - [x] final IDA-parity and security audit ## Static-analysis follow-up - [x] trace peer-certificate policy: both directions call `IntPstValidate` with the current system time and role - [x] enforce certificate validity, digital-signature KeyUsage, and PKINIT/client-auth EKU before trust decisions - [x] trace machine identity to LSA supplemental token information and replace the captured constant with per-process/per-boot random state - [x] map Windows ticket-cache keys, contents, expiry, invalidation, and cache-hit AS bypass - [x] verify this Windows build accepts only DH `PA-PK-AS-REP`; `encKeyPack` is intentionally not implemented - [x] map package context attributes plus native token/export/import surfaces - [ ] implement cross-context ticket reuse — blocked on real-wire confirmation of cache-hit Finished/VERIFY transcript semantics and acceptor long-term ticket-key lifecycle ## Validation - `cargo test -p sspi --all-features` - `cargo clippy -p sspi --all-targets --all-features -- -D warnings -A clippy::non_ascii_literal` - `cargo +nightly fuzz build pku2u_tokens` The current host has no `MS-Organization-P2P-Access` certificate with a private key, so real Windows wire capture and safe implementation of ticket-cache handshake bypass remain outside the statically verified scope. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7a3de33 commit 03ee8d0

18 files changed

Lines changed: 3231 additions & 460 deletions

File tree

‎fuzz/Cargo.toml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ cargo-fuzz = true
1111
[dependencies]
1212
dpapi-fuzzing.path = "../crates/dpapi-fuzzing"
1313
libfuzzer-sys = "0.4"
14-
sspi.path = ".."
14+
sspi = { path = "..", features = ["__test-data"] }
1515

1616
# Prevent this from interfering with workspaces
1717
[workspace]
@@ -34,3 +34,10 @@ path = "fuzz_targets/dpapi_decoding.rs"
3434
test = false
3535
doc = false
3636
bench = false
37+
38+
[[bin]]
39+
name = "pku2u_tokens"
40+
path = "fuzz_targets/pku2u_tokens.rs"
41+
test = false
42+
doc = false
43+
bench = false

‎fuzz/fuzz_targets/pku2u_tokens.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
5+
fuzz_target!(|data: &[u8]| {
6+
sspi::pku2u::fuzz_token(data);
7+
});

‎src/auth_identity.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ impl Username {
206206
})
207207
}
208208

209+
pub(crate) fn new_qualified_down_level_logon_name(account_name: &str, netbios_domain_name: &str) -> Self {
210+
Self {
211+
value: format!("{netbios_domain_name}\\{account_name}"),
212+
format: UserNameFormat::DownLevelLogonName,
213+
sep_idx: Some(netbios_domain_name.len()),
214+
}
215+
}
216+
209217
/// Attempts to guess the right name format for the account name/domain combo
210218
///
211219
/// If no netbios domain name is provided, or if it is an empty string, the username will

‎src/kerberos/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod pa_datas;
77
pub mod server;
88
#[cfg(test)]
99
mod tests;
10-
mod utils;
10+
pub(crate) mod utils;
1111

1212
use std::fmt::Debug;
1313
use std::sync::LazyLock;

‎src/kerberos/utils.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub(super) fn serialize_message<T: ?Sized + Serialize>(v: &T) -> Result<Vec<u8>>
2121
Ok(data)
2222
}
2323

24-
pub(super) fn validate_mic_token(
24+
pub(crate) fn validate_mic_token(
2525
is_client: bool,
2626
expected_seq_number: u64,
2727
raw_token: &[u8],
@@ -97,7 +97,7 @@ pub(super) fn validate_mic_token(
9797
Ok(())
9898
}
9999

100-
pub(super) fn generate_mic_token(
100+
pub(crate) fn generate_mic_token(
101101
is_client: bool,
102102
seq_number: u64,
103103
mut payload: Vec<u8>,

‎src/lib.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ pub use self::negotiate::client::FALLBACK_ERROR_KINDS;
124124
pub use self::negotiate::{Negotiate, NegotiateConfig, NegotiatedProtocol};
125125
pub use self::ntlm::Ntlm;
126126
pub use self::ntlm::hash::{NTLM_HASH_PREFIX, NtlmHash, NtlmHashError};
127-
pub use self::pku2u::{Pku2u, Pku2uConfig, Pku2uState};
127+
pub use self::pku2u::{Pku2u, Pku2uConfig, Pku2uCredential, Pku2uPrivateKey, Pku2uState};
128128
pub use self::secret::Secret;
129129
use crate::builders::{
130130
EmptyAcceptSecurityContext, EmptyAcquireCredentialsHandle, EmptyInitializeSecurityContext,

‎src/negotiate/client.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ pub(crate) async fn initialize_security_context<'a>(
8585
}
8686
}
8787

88+
// PKU2U contains its own SPNEGO/NEGOEX exchange. Wrapping it in this
89+
// Negotiate context again would advertise NTLM while carrying a nested
90+
// SPNEGO token, which Windows rejects.
91+
if matches!(negotiate.protocol, NegotiatedProtocol::Pku2u(_)) {
92+
return negotiate
93+
.protocol
94+
.initialize_security_context(negotiate.auth_identity.as_ref(), yield_point, builder)
95+
.await;
96+
}
97+
8898
match negotiate.state {
8999
NegotiateState::Initial => {
90100
if builder

‎src/negotiate/config.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use crate::{NegotiatedProtocol, Result};
44

55
pub trait ProtocolConfig: Debug + Send + Sync {
66
fn new_instance(&self) -> Result<NegotiatedProtocol>;
7+
fn new_server_instance(&self) -> Result<NegotiatedProtocol> {
8+
self.new_instance()
9+
}
710
fn box_clone(&self) -> Box<dyn ProtocolConfig>;
811
}
912

‎src/negotiate/mod.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,11 @@ impl Negotiate {
276276
}
277277

278278
fn new(config: NegotiateConfig, mode: NegotiateMode) -> Result<Self> {
279-
let mut protocol = config.protocol_config.new_instance()?;
279+
let mut protocol = if mode.is_client() {
280+
config.protocol_config.new_instance()?
281+
} else {
282+
config.protocol_config.new_server_instance()?
283+
};
280284

281285
let package_list = PackageListConfig::parse(&config.package_list);
282286
if let Some(filtered_protocol) =

‎src/negotiate/server.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ pub(crate) async fn accept_security_context(
2222
yield_point: &mut YieldPointLocal,
2323
mut builder: FilledAcceptSecurityContext<'_, <Negotiate as SspiImpl>::CredentialsHandle>,
2424
) -> Result<AcceptSecurityContextResult> {
25+
if matches!(negotiate.protocol, NegotiatedProtocol::Pku2u(_)) {
26+
return negotiate
27+
.protocol
28+
.accept_security_context(yield_point, &mut builder)
29+
.await;
30+
}
31+
2532
let input = builder
2633
.input
2734
.as_mut()

0 commit comments

Comments
 (0)