openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! SPNEGO on Windows, through SSPI.
//!
//! Raw `windows-sys` rather than the `sspi` crate, for two reasons that both matter to this
//! product: the crate cannot use the **logged-on user's** credentials (it wants an explicit
//! username and password, which defeats the entire point of single sign-on and would put a
//! prompt on a daemon's start-up path), and it drags the `windows` crate plus a large crypto
//! tree into a binary that goes through enterprise security review. `windows-sys` is already
//! in the tree; this file adds two feature groups to it and no new crate.
//!
//! ## NTLM is refused, and refused before anything is sent
//!
//! `Negotiate` is an umbrella: on a domain-joined host it selects Kerberos, on a workgroup
//! machine it silently falls back to NTLM. Microsoft deprecated NTLM in 2024 and this
//! product does not speak it (D-2), so after the first `InitializeSecurityContextW` the
//! context is asked which package it actually chose. Anything but Kerberos is
//! [`NegotiateError::NtlmSelected`], returned **instead of** the token — so on a workgroup
//! machine no `Proxy-Authorization` header is ever written, which is exactly what the
//! refusal test asserts on the wire.
//!
//! That also makes the refusal path testable on an ordinary CI runner: a non-domain-joined
//! Windows agent selects NTLM, so the post-merge Windows job exercises the refusal for real
//! rather than through a fake.
//!
//! ## Handles
//!
//! Every handle is owned by a struct with a `Drop`, so a failure part-way through an
//! exchange still frees the credential and the context. `unsafe` is confined to this file.

#![allow(non_snake_case)]

use std::sync::Arc;

use windows_sys::Win32::Foundation::{
    SEC_E_OK, SEC_I_COMPLETE_AND_CONTINUE, SEC_I_COMPLETE_NEEDED, SEC_I_CONTINUE_NEEDED,
};
use windows_sys::Win32::Security::Authentication::Identity::{
    AcquireCredentialsHandleW, CompleteAuthToken, DeleteSecurityContext, FreeContextBuffer,
    FreeCredentialsHandle, InitializeSecurityContextW, QueryContextAttributesW, SecBuffer,
    SecBufferDesc, SecPkgContext_NegotiationInfoW, ISC_REQ_MUTUAL_AUTH, SECBUFFER_TOKEN,
    SECBUFFER_VERSION, SECPKG_ATTR_NEGOTIATION_INFO, SECPKG_CRED_OUTBOUND, SECURITY_NATIVE_DREP,
};
use windows_sys::Win32::Security::Credentials::SecHandle;

use super::{NegotiateError, ProviderFactory, StepResult, TokenProvider};

/// The only package name this transport will use a context from.
const KERBEROS: &str = "Kerberos";

/// The SSPI package requested. It is an umbrella — see the module docs on why the result
/// is then verified rather than trusted.
const NEGOTIATE_PACKAGE: &str = "Negotiate";

/// A NUL-terminated UTF-16 string, as every `...W` entry point wants.
fn wide(value: &str) -> Vec<u16> {
    value.encode_utf16().chain(std::iter::once(0)).collect()
}

/// Read a NUL-terminated UTF-16 string the OS owns.
///
/// # Safety
/// `ptr` must be a live, NUL-terminated UTF-16 string.
unsafe fn from_wide(ptr: *const u16) -> String {
    if ptr.is_null() {
        return String::new();
    }
    let mut len = 0usize;
    while *ptr.add(len) != 0 {
        len += 1;
    }
    String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
}

fn win_error(what: &str, status: i32) -> String {
    format!("{what} failed (0x{:08X})", status as u32)
}

/// An outbound credential handle for the logged-on identity.
///
/// Acquired with a NULL principal and no auth data, which is what makes this single sign-on:
/// the OS supplies the running user's credentials and there is never a prompt — a hard
/// requirement for something that runs as a service.
struct Credentials {
    handle: SecHandle,
}

impl Credentials {
    fn acquire() -> Result<Self, NegotiateError> {
        let mut package = wide(NEGOTIATE_PACKAGE);
        let mut handle = SecHandle {
            dwLower: 0,
            dwUpper: 0,
        };
        let mut expiry: i64 = 0;
        // SAFETY: `package` is a live NUL-terminated wide string; every other pointer is
        // either null (the documented "default" value) or an out-parameter to a live local.
        let status = unsafe {
            AcquireCredentialsHandleW(
                std::ptr::null(),
                package.as_mut_ptr(),
                SECPKG_CRED_OUTBOUND,
                std::ptr::null(),
                std::ptr::null(),
                None,
                std::ptr::null(),
                &mut handle,
                &mut expiry,
            )
        };
        if status != SEC_E_OK {
            return Err(NegotiateError::NoTicket(win_error(
                "AcquireCredentialsHandleW",
                status,
            )));
        }
        Ok(Self { handle })
    }
}

impl Drop for Credentials {
    fn drop(&mut self) {
        // SAFETY: the handle was produced by AcquireCredentialsHandleW and is freed once.
        unsafe { FreeCredentialsHandle(&self.handle) };
    }
}

/// The factory. Credentials are acquired per connection, alongside the context, so a
/// long-running daemon picks up a refreshed TGT instead of pinning the one it started with.
pub struct SspiProvider;

impl SspiProvider {
    /// A provider that acquires credentials on first use.
    pub fn new() -> Self {
        Self
    }
}

impl Default for SspiProvider {
    fn default() -> Self {
        Self::new()
    }
}

impl ProviderFactory for SspiProvider {
    fn new_provider(&self, spn: &str) -> Result<Box<dyn TokenProvider>, NegotiateError> {
        Ok(Box::new(SspiContext::new(spn)?))
    }

    fn name(&self) -> &'static str {
        "sspi"
    }
}

/// One security context — one TCP connection's worth of SPNEGO.
struct SspiContext {
    credentials: Arc<Credentials>,
    target: Vec<u16>,
    context: SecHandle,
    /// False until the first `InitializeSecurityContextW` has produced a context handle.
    established: bool,
    /// Set once the exchange has concluded, so a finished context cannot be driven again.
    done: bool,
    /// Whether the negotiated package has been verified. Checked exactly once, after the
    /// first leg, which is the earliest point SSPI can answer.
    package_checked: bool,
}

impl SspiContext {
    fn new(spn: &str) -> Result<Self, NegotiateError> {
        Ok(Self {
            credentials: Arc::new(Credentials::acquire()?),
            target: wide(spn),
            context: SecHandle {
                dwLower: 0,
                dwUpper: 0,
            },
            established: false,
            done: false,
            package_checked: false,
        })
    }

    /// D-2 enforced against the OS's own answer: which package did `Negotiate` pick?
    ///
    /// `SECPKG_ATTR_NEGOTIATION_INFO` is answerable after the first
    /// `InitializeSecurityContextW`, which is what lets this run *before* the token is
    /// released rather than after it has already gone out.
    fn assert_kerberos(&mut self) -> Result<(), NegotiateError> {
        if self.package_checked {
            return Ok(());
        }
        let mut info = SecPkgContext_NegotiationInfoW {
            PackageInfo: std::ptr::null_mut(),
            NegotiationState: 0,
        };
        // SAFETY: the context handle is live, and `info` is an out-parameter whose
        // `PackageInfo` the OS allocates and this function frees below.
        let status = unsafe {
            QueryContextAttributesW(
                &self.context,
                SECPKG_ATTR_NEGOTIATION_INFO,
                &mut info as *mut _ as *mut std::ffi::c_void,
            )
        };
        if status != SEC_E_OK {
            return Err(NegotiateError::Provider(win_error(
                "QueryContextAttributesW(SECPKG_ATTR_NEGOTIATION_INFO)",
                status,
            )));
        }

        let package = if info.PackageInfo.is_null() {
            String::new()
        } else {
            // SAFETY: PackageInfo points at an OS-allocated SecPkgInfoW whose Name is a
            // NUL-terminated wide string.
            unsafe { from_wide((*info.PackageInfo).Name) }
        };
        if !info.PackageInfo.is_null() {
            // SAFETY: freeing exactly what QueryContextAttributesW allocated.
            unsafe { FreeContextBuffer(info.PackageInfo as *mut std::ffi::c_void) };
        }

        self.package_checked = true;
        if package.eq_ignore_ascii_case(KERBEROS) {
            Ok(())
        } else {
            // A workgroup machine lands here. The token is discarded rather than sent, so
            // nothing NTLM-shaped ever reaches the proxy.
            Err(NegotiateError::NtlmSelected(format!(
                "Negotiate selected \"{package}\" on this host, not Kerberos"
            )))
        }
    }
}

impl TokenProvider for SspiContext {
    fn step(&mut self, peer: Option<&[u8]>) -> StepResult {
        if self.done {
            return StepResult::Done(None);
        }

        let mut input_buffer = SecBuffer {
            cbBuffer: peer.map_or(0, |t| t.len() as u32),
            BufferType: SECBUFFER_TOKEN,
            pvBuffer: peer.map_or(std::ptr::null_mut(), |t| {
                t.as_ptr() as *mut std::ffi::c_void
            }),
        };
        let mut input_desc = SecBufferDesc {
            ulVersion: SECBUFFER_VERSION,
            cBuffers: 1,
            pBuffers: &mut input_buffer,
        };

        let mut output_buffer = SecBuffer {
            cbBuffer: 0,
            BufferType: SECBUFFER_TOKEN,
            pvBuffer: std::ptr::null_mut(),
        };
        let mut output_desc = SecBufferDesc {
            ulVersion: SECBUFFER_VERSION,
            cBuffers: 1,
            pBuffers: &mut output_buffer,
        };

        let mut new_context = SecHandle {
            dwLower: 0,
            dwUpper: 0,
        };
        let mut attrs: u32 = 0;
        let mut expiry: i64 = 0;

        // Mutual auth only. `ISC_REQ_CONFIDENTIALITY` is deliberately absent: this context
        // authenticates a CONNECT, it never encrypts application data — that is TLS's job,
        // and asking for a sealing-capable context would need a stronger ticket for no gain.
        // SAFETY: every pointer is to a live local or a null the API documents as "none".
        let status = unsafe {
            InitializeSecurityContextW(
                &self.credentials.handle,
                if self.established {
                    &self.context
                } else {
                    std::ptr::null()
                },
                self.target.as_ptr(),
                ISC_REQ_MUTUAL_AUTH,
                0,
                SECURITY_NATIVE_DREP,
                if peer.is_some() {
                    &input_desc
                } else {
                    std::ptr::null()
                },
                0,
                &mut new_context,
                &mut output_desc,
                &mut attrs,
                &mut expiry,
            )
        };
        // Silence the "assigned but never read" that the conditional borrows above hide.
        let _ = &mut input_desc;

        if status < 0 {
            self.done = true;
            return StepResult::Failed(NegotiateError::NoTicket(win_error(
                "InitializeSecurityContextW",
                status,
            )));
        }

        // The handle is valid from the first call onward, including when a continuation is
        // required, so it is adopted before anything else can fail.
        self.context = new_context;
        self.established = true;

        // Copy the token out and give the OS its buffer back, whatever happens next.
        let token = if output_buffer.pvBuffer.is_null() || output_buffer.cbBuffer == 0 {
            Vec::new()
        } else {
            // SAFETY: the OS allocated `cbBuffer` bytes at `pvBuffer`.
            unsafe {
                std::slice::from_raw_parts(
                    output_buffer.pvBuffer as *const u8,
                    output_buffer.cbBuffer as usize,
                )
                .to_vec()
            }
        };
        if !output_buffer.pvBuffer.is_null() {
            // SAFETY: freeing exactly what InitializeSecurityContextW allocated.
            unsafe { FreeContextBuffer(output_buffer.pvBuffer) };
        }

        // D-2, before the token is released to the connector.
        if let Err(e) = self.assert_kerberos() {
            self.done = true;
            return StepResult::Failed(e);
        }

        if status == SEC_I_COMPLETE_NEEDED || status == SEC_I_COMPLETE_AND_CONTINUE {
            // The mechanism wants the token finished before it goes out. Skipping this
            // produces a token the peer rejects, with no local error to explain it.
            let mut complete_buffer = SecBuffer {
                cbBuffer: token.len() as u32,
                BufferType: SECBUFFER_TOKEN,
                pvBuffer: token.as_ptr() as *mut std::ffi::c_void,
            };
            let complete_desc = SecBufferDesc {
                ulVersion: SECBUFFER_VERSION,
                cBuffers: 1,
                pBuffers: &mut complete_buffer,
            };
            // SAFETY: the context is established and `complete_desc` borrows `token`,
            // which outlives the call.
            let complete = unsafe { CompleteAuthToken(&self.context, &complete_desc) };
            if complete < 0 {
                self.done = true;
                return StepResult::Failed(NegotiateError::Provider(win_error(
                    "CompleteAuthToken",
                    complete,
                )));
            }
        }

        if status == SEC_I_CONTINUE_NEEDED || status == SEC_I_COMPLETE_AND_CONTINUE {
            return StepResult::Continue(token);
        }

        self.done = true;
        StepResult::Done((!token.is_empty()).then_some(token))
    }
}

impl Drop for SspiContext {
    fn drop(&mut self) {
        if self.established {
            // SAFETY: the handle came from InitializeSecurityContextW and is deleted once.
            unsafe { DeleteSecurityContext(&self.context) };
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wide_strings_are_nul_terminated() {
        let encoded = wide("HTTP/proxy.corp");
        assert_eq!(*encoded.last().expect("non-empty"), 0);
        assert_eq!(encoded.len(), "HTTP/proxy.corp".len() + 1);
    }

    #[test]
    fn a_wide_string_round_trips() {
        let encoded = wide("HTTP/pröxy.corp");
        // SAFETY: `encoded` is a live NUL-terminated wide string.
        let decoded = unsafe { from_wide(encoded.as_ptr()) };
        assert_eq!(decoded, "HTTP/pröxy.corp");
    }

    #[test]
    fn a_null_package_name_reads_as_empty_rather_than_dereferencing() {
        // SAFETY: the null case is the one this function documents as handled.
        assert_eq!(unsafe { from_wide(std::ptr::null()) }, "");
    }

    /// The package name comparison is what D-2 hangs on, so it is asserted rather than
    /// assumed: `NTLM` must not pass as `Kerberos` under any casing.
    #[test]
    fn only_kerberos_satisfies_the_package_check() {
        assert!(KERBEROS.eq_ignore_ascii_case("kerberos"));
        assert!(!"NTLM".eq_ignore_ascii_case(KERBEROS));
        assert!(!"Negotiate".eq_ignore_ascii_case(KERBEROS));
    }

    /// Acquiring an outbound `Negotiate` credential works on any Windows host, domain-joined
    /// or not -- what differs is which package the *context* then selects. A failure here is
    /// therefore a real one and worth asserting; the Kerberos-vs-NTLM outcome is asserted by
    /// the connector tests, which can see whether a token reached the wire.
    #[test]
    fn an_outbound_credential_can_be_acquired_on_this_host() {
        Credentials::acquire().expect("Negotiate credentials must be acquirable on Windows");
    }

    /// On a workgroup runner this returns `NtlmSelected` and on a domain-joined one it mints
    /// a token. Both are correct; what must never happen is a panic, a hang, or a *prompt* --
    /// this code path runs inside a service.
    #[test]
    fn minting_a_first_leg_either_produces_kerberos_or_refuses_ntlm() {
        let provider = SspiProvider::new();
        let mut context = match provider.new_provider("HTTP/localhost") {
            Ok(context) => context,
            // No credential at all on this host: a legitimate outcome, not a failure.
            Err(NegotiateError::NoTicket(_)) => return,
            Err(other) => panic!("unexpected provider error: {other}"),
        };
        match context.step(None) {
            StepResult::Continue(token) | StepResult::Done(Some(token)) => {
                assert!(!token.is_empty(), "a produced token must not be empty");
            }
            StepResult::Done(None) => {}
            StepResult::Failed(NegotiateError::NtlmSelected(detail)) => {
                // The workgroup path -- the refusal, with the token discarded.
                assert!(detail.contains("not Kerberos"), "{detail}");
            }
            StepResult::Failed(NegotiateError::NoTicket(_)) => {}
            StepResult::Failed(other) => panic!("unexpected failure: {other}"),
        }
    }
}