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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
//! SPNEGO on Unix, through a GSSAPI library loaded at run time.
//!
//! **`dlopen`, never a link.** A hard link to `libgssapi_krb5` puts it in `DT_NEEDED`, which
//! the dynamic loader resolves *eagerly* — before `main` — whether or not the program ever
//! authenticates to a proxy. On a host without Kerberos installed (stock `ubuntu:24.04` and
//! `debian:12` both qualify) that binary does not start at all: not to run `openlatch
//! doctor`, not to print the remedy, not to do anything. Loading on first use turns "no
//! Kerberos here" from a dead binary into a **reported degradation** with an instruction
//! attached, which is what every production implementation of this does (Chromium, Firefox,
//! and .NET after it migrated to the same model).
//!
//! ## The library ladder
//!
//! | Platform | Candidates, in order |
//! | -------- | -------------------- |
//! | Linux | `libgssapi_krb5.so.2` (MIT), then Heimdal's `libgssapi.so.4`, `.so.3`, `.so.2`, `.so.1` |
//! | macOS | `/System/Library/Frameworks/GSS.framework/GSS` — absolute, OS-shipped |
//!
//! `OPENLATCH_GSSAPI_LIB` overrides the ladder with a single path, and an empty value forces
//! "no library" so the degradation path can be exercised without uninstalling Kerberos.
//!
//! ## Two things this deliberately does not do
//!
//! **Channel bindings are never passed.** Apple's GSS implementation breaks when they are
//! (curl#19109), and the corporate proxies this exists for do not require them.
//!
//! **NTLM is not reachable.** The mechanism is the SPNEGO OID with a krb5 provider
//! underneath; there is no NTLM mechanism in that library to select. The refusal that
//! Windows implements explicitly is structural here.
//!
//! The ~10 hand-declared symbols and four C structs follow RFC 2744, whose ABI has been
//! stable since 1997 — which is what makes hand-declaring them safer than a build-time
//! binding generator that would need `libgssapi` present at compile time.

use std::ffi::c_void;
use std::sync::OnceLock;

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

/// Test-only override of the library ladder. One path, or empty to force "not found".
const LIB_ENV: &str = "OPENLATCH_GSSAPI_LIB";

#[cfg(target_os = "macos")]
const CANDIDATES: &[&str] = &["/System/Library/Frameworks/GSS.framework/GSS"];

#[cfg(not(target_os = "macos"))]
const CANDIDATES: &[&str] = &[
    // MIT krb5, the overwhelmingly common one.
    "libgssapi_krb5.so.2",
    // Heimdal. `.so.3` is Debian's actual SONAME and is easy to miss.
    "libgssapi.so.4",
    "libgssapi.so.3",
    "libgssapi.so.2",
    "libgssapi.so.1",
];

// --- RFC 2744 types --------------------------------------------------------

#[allow(non_camel_case_types)]
type OM_uint32 = u32;

/// `gss_buffer_desc` — a length and a pointer, used for every token and message.
#[repr(C)]
#[derive(Clone, Copy)]
struct GssBufferDesc {
    /// Byte length of `value`.
    length: usize,
    /// The bytes. Null when the buffer is empty.
    value: *mut c_void,
}

impl GssBufferDesc {
    fn empty() -> Self {
        Self {
            length: 0,
            value: std::ptr::null_mut(),
        }
    }

    fn borrowed(bytes: &[u8]) -> Self {
        Self {
            length: bytes.len(),
            value: bytes.as_ptr() as *mut c_void,
        }
    }

    /// Copy the buffer's contents out. Safety: only valid while the library still owns it.
    unsafe fn to_vec(self) -> Vec<u8> {
        if self.value.is_null() || self.length == 0 {
            return Vec::new();
        }
        std::slice::from_raw_parts(self.value as *const u8, self.length).to_vec()
    }
}

/// `gss_OID_desc` — a DER-encoded object identifier body, without the tag and length.
#[repr(C)]
#[derive(Clone, Copy)]
struct GssOidDesc {
    /// Byte length of `elements`.
    length: OM_uint32,
    /// The OID body.
    elements: *const c_void,
}

/// SPNEGO: 1.3.6.1.5.5.2.
const SPNEGO_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x02];
/// `GSS_C_NT_HOSTBASED_SERVICE`: 1.2.840.113554.1.2.1.4.
const NT_HOSTBASED_SERVICE_OID: &[u8] =
    &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x01, 0x04];

/// Mutual authentication, replay detection and sequencing — what a proxy exchange wants,
/// and what makes a final token on the 200 meaningful.
const GSS_C_MUTUAL_FLAG: OM_uint32 = 2;
const GSS_C_REPLAY_FLAG: OM_uint32 = 4;
const GSS_C_SEQUENCE_FLAG: OM_uint32 = 8;

/// Supplementary bit: the context needs another leg.
const GSS_S_CONTINUE_NEEDED: OM_uint32 = 1;
/// Calling and routine errors both live in the top 16 bits; anything set there is a failure.
const GSS_ERROR_MASK: OM_uint32 = 0xFFFF_0000;

const GSS_C_GSS_CODE: i32 = 1;
const GSS_C_MECH_CODE: i32 = 2;

type GssImportName = unsafe extern "C" fn(
    *mut OM_uint32,
    *const GssBufferDesc,
    *const GssOidDesc,
    *mut *mut c_void,
) -> OM_uint32;

#[allow(clippy::type_complexity)]
type GssInitSecContext = unsafe extern "C" fn(
    *mut OM_uint32,         // minor_status
    *mut c_void,            // claimant_cred_handle (NULL = the logged-on identity)
    *mut *mut c_void,       // context_handle
    *mut c_void,            // target_name
    *const GssOidDesc,      // mech_type
    OM_uint32,              // req_flags
    OM_uint32,              // time_req
    *const c_void,          // input_chan_bindings — always NULL, see the module docs
    *const GssBufferDesc,   // input_token
    *mut *const GssOidDesc, // actual_mech_type
    *mut GssBufferDesc,     // output_token
    *mut OM_uint32,         // ret_flags
    *mut OM_uint32,         // time_rec
) -> OM_uint32;

type GssReleaseName = unsafe extern "C" fn(*mut OM_uint32, *mut *mut c_void) -> OM_uint32;
type GssReleaseBuffer = unsafe extern "C" fn(*mut OM_uint32, *mut GssBufferDesc) -> OM_uint32;
type GssDeleteSecContext =
    unsafe extern "C" fn(*mut OM_uint32, *mut *mut c_void, *mut GssBufferDesc) -> OM_uint32;
type GssDisplayStatus = unsafe extern "C" fn(
    *mut OM_uint32,
    OM_uint32,
    i32,
    *const GssOidDesc,
    *mut OM_uint32,
    *mut GssBufferDesc,
) -> OM_uint32;

/// A loaded GSSAPI library and the handful of entry points this transport calls.
struct Gssapi {
    /// Kept alive for the process: the function pointers below point into it.
    _library: libloading::Library,
    which: String,
    import_name: GssImportName,
    init_sec_context: GssInitSecContext,
    release_name: GssReleaseName,
    release_buffer: GssReleaseBuffer,
    delete_sec_context: GssDeleteSecContext,
    display_status: GssDisplayStatus,
}

// The library is loaded once and only read afterwards; GSSAPI's per-context calls are
// serialized by `&mut self` on the context that owns them.
unsafe impl Send for Gssapi {}
unsafe impl Sync for Gssapi {}

static LIBRARY: OnceLock<Result<&'static Gssapi, NegotiateError>> = OnceLock::new();

/// Load the library once per process, walking the ladder.
fn library() -> Result<&'static Gssapi, NegotiateError> {
    LIBRARY.get_or_init(load).clone()
}

fn candidates() -> Vec<String> {
    match std::env::var(LIB_ENV) {
        // An explicit empty value forces the "no library" branch, so the degradation path is
        // testable without uninstalling Kerberos.
        Ok(path) if path.trim().is_empty() => Vec::new(),
        Ok(path) => vec![path],
        Err(_) => CANDIDATES.iter().map(|s| (*s).to_string()).collect(),
    }
}

fn load() -> Result<&'static Gssapi, NegotiateError> {
    let mut tried = Vec::new();
    for candidate in candidates() {
        // SAFETY: loading a shared library runs its initializers. These are the OS's own
        // Kerberos libraries, named by an allow-list (or by an operator's explicit
        // override), never by anything a remote peer controls.
        let library = match unsafe { libloading::Library::new(&candidate) } {
            Ok(library) => library,
            Err(e) => {
                tried.push(format!("{candidate}: {e}"));
                continue;
            }
        };
        match unsafe { bind(library, &candidate) } {
            Ok(bound) => return Ok(Box::leak(Box::new(bound))),
            Err(missing) => tried.push(format!("{candidate}: {missing}")),
        }
    }

    // A distinct state with its own remedy: "install krb5-libs" is not "run kinit", and an
    // operator sent to the wrong one loses an afternoon.
    Err(NegotiateError::LibraryUnavailable(if tried.is_empty() {
        "no candidate libraries to try".to_string()
    } else {
        tried.join("; ")
    }))
}

/// Resolve every symbol, or report the first that is missing.
///
/// # Safety
/// `library` must be a GSSAPI implementation; the signatures below are RFC 2744's.
unsafe fn bind(library: libloading::Library, which: &str) -> Result<Gssapi, String> {
    macro_rules! sym {
        ($name:literal, $ty:ty) => {{
            let symbol: libloading::Symbol<$ty> = library
                .get(concat!($name, "\0").as_bytes())
                .map_err(|e| format!("{} is missing: {e}", $name))?;
            *symbol
        }};
    }

    Ok(Gssapi {
        import_name: sym!("gss_import_name", GssImportName),
        init_sec_context: sym!("gss_init_sec_context", GssInitSecContext),
        release_name: sym!("gss_release_name", GssReleaseName),
        release_buffer: sym!("gss_release_buffer", GssReleaseBuffer),
        delete_sec_context: sym!("gss_delete_sec_context", GssDeleteSecContext),
        display_status: sym!("gss_display_status", GssDisplayStatus),
        which: which.to_string(),
        _library: library,
    })
}

/// Turn a major/minor status pair into text, using the library's own message table.
fn describe(lib: &Gssapi, major: OM_uint32, minor: OM_uint32) -> String {
    let mut parts = Vec::new();
    for (code, kind) in [(major, GSS_C_GSS_CODE), (minor, GSS_C_MECH_CODE)] {
        let mut context: OM_uint32 = 0;
        let mut minor_out: OM_uint32 = 0;
        let mut buffer = GssBufferDesc::empty();
        // SAFETY: every pointer is to a live local; the buffer is released below.
        let status = unsafe {
            (lib.display_status)(
                &mut minor_out,
                code,
                kind,
                std::ptr::null(),
                &mut context,
                &mut buffer,
            )
        };
        if status & GSS_ERROR_MASK == 0 {
            let text = unsafe { buffer.to_vec() };
            let mut release_minor: OM_uint32 = 0;
            unsafe { (lib.release_buffer)(&mut release_minor, &mut buffer) };
            if !text.is_empty() {
                parts.push(String::from_utf8_lossy(&text).into_owned());
            }
        }
    }
    if parts.is_empty() {
        format!("GSSAPI status major={major:#x} minor={minor:#x}")
    } else {
        parts.join(": ")
    }
}

/// The factory. Loading is deferred to the first mint, so a host with no Kerberos still
/// starts, still runs `doctor`, and still says why Negotiate is unavailable.
pub struct GssapiProvider;

impl GssapiProvider {
    /// A provider that loads the library on first use.
    pub fn new() -> Self {
        Self
    }
}

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

impl ProviderFactory for GssapiProvider {
    fn new_provider(&self, spn: &str) -> Result<Box<dyn TokenProvider>, NegotiateError> {
        let lib = library()?;
        Ok(Box::new(GssContext::new(lib, spn)?))
    }

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

/// One security context — one TCP connection's worth of SPNEGO.
struct GssContext {
    lib: &'static Gssapi,
    target: *mut c_void,
    context: *mut c_void,
    /// Set once the exchange has concluded, so a caller cannot drive a finished context.
    done: bool,
}

// The raw handles are owned exclusively by this struct and touched only through `&mut self`.
unsafe impl Send for GssContext {}

impl GssContext {
    fn new(lib: &'static Gssapi, spn: &str) -> Result<Self, NegotiateError> {
        // GSSAPI spells a service principal `service@host`, SSPI and our config spell it
        // `service/host`. One translation, here, so the `[proxy] spn` an operator writes is
        // the same string on both platforms.
        let hostbased = spn.replacen('/', "@", 1);
        let name_buffer = GssBufferDesc::borrowed(hostbased.as_bytes());
        let oid = GssOidDesc {
            length: NT_HOSTBASED_SERVICE_OID.len() as OM_uint32,
            elements: NT_HOSTBASED_SERVICE_OID.as_ptr() as *const c_void,
        };
        let mut minor: OM_uint32 = 0;
        let mut target: *mut c_void = std::ptr::null_mut();
        // SAFETY: `name_buffer` borrows `hostbased`, which outlives the call; `oid` borrows
        // a `'static` constant; `target` is an out-parameter.
        let major = unsafe { (lib.import_name)(&mut minor, &name_buffer, &oid, &mut target) };
        if major & GSS_ERROR_MASK != 0 {
            return Err(NegotiateError::Provider(format!(
                "the SPN \"{spn}\" could not be imported ({}): {}",
                lib.which,
                describe(lib, major, minor)
            )));
        }
        Ok(Self {
            lib,
            target,
            context: std::ptr::null_mut(),
            done: false,
        })
    }
}

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

        let lib = self.lib;
        let mech = GssOidDesc {
            length: SPNEGO_OID.len() as OM_uint32,
            elements: SPNEGO_OID.as_ptr() as *const c_void,
        };
        let input = peer.map(GssBufferDesc::borrowed);
        let input_ptr = input
            .as_ref()
            .map_or(std::ptr::null(), |b| b as *const GssBufferDesc);

        let mut minor: OM_uint32 = 0;
        let mut output = GssBufferDesc::empty();
        let mut actual_mech: *const GssOidDesc = std::ptr::null();
        let mut ret_flags: OM_uint32 = 0;

        // SAFETY: every pointer is either null (the documented "use the default" value), an
        // out-parameter to a live local, or a borrow that outlives the call. The channel
        // bindings argument is deliberately null — Apple's GSS breaks on a real one
        // (curl#19109) and no proxy in scope requires it.
        let major = unsafe {
            (lib.init_sec_context)(
                &mut minor,
                std::ptr::null_mut(), // the logged-on identity; never a prompt
                &mut self.context,
                self.target,
                &mech,
                GSS_C_MUTUAL_FLAG | GSS_C_REPLAY_FLAG | GSS_C_SEQUENCE_FLAG,
                0,
                std::ptr::null(),
                input_ptr,
                &mut actual_mech,
                &mut output,
                &mut ret_flags,
                std::ptr::null_mut(),
            )
        };

        let token = unsafe { output.to_vec() };
        if !token.is_empty() {
            let mut release_minor: OM_uint32 = 0;
            unsafe { (lib.release_buffer)(&mut release_minor, &mut output) };
        }

        if major & GSS_ERROR_MASK != 0 {
            self.done = true;
            let detail = describe(lib, major, minor);
            // "No credential" is its own remedy (kinit), distinct from every other GSSAPI
            // failure, so it gets its own variant rather than being flattened into one.
            return if looks_like_no_credential(&detail) {
                StepResult::Failed(NegotiateError::NoTicket(detail))
            } else {
                StepResult::Failed(NegotiateError::Provider(detail))
            };
        }

        if major & GSS_S_CONTINUE_NEEDED != 0 {
            return StepResult::Continue(token);
        }

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

/// Whether a GSSAPI failure is the "there is no ticket" one.
///
/// Matched on the message rather than the minor code because the minor codes are
/// mechanism-specific: MIT and Heimdal use different numbers for the same condition, and
/// both render it in words that contain one of these phrases.
fn looks_like_no_credential(detail: &str) -> bool {
    let lower = detail.to_ascii_lowercase();
    [
        "no credentials",
        "credentials cache",
        "credential cache",
        "no key table",
        "can't find client principal",
        "ticket expired",
        "no ticket",
    ]
    .iter()
    .any(|needle| lower.contains(needle))
}

impl Drop for GssContext {
    fn drop(&mut self) {
        let mut minor: OM_uint32 = 0;
        let mut output = GssBufferDesc::empty();
        // SAFETY: both handles were produced by this library and are released exactly once.
        unsafe {
            if !self.context.is_null() {
                (self.lib.delete_sec_context)(&mut minor, &mut self.context, &mut output);
                (self.lib.release_buffer)(&mut minor, &mut output);
            }
            if !self.target.is_null() {
                (self.lib.release_name)(&mut minor, &mut self.target);
            }
        }
    }
}

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

    #[test]
    fn the_ladder_names_mit_before_heimdal() {
        // MIT is the overwhelmingly common implementation, and Debian's Heimdal SONAME is
        // `.so.3` -- easy to omit, and its absence is invisible until a Debian host fails.
        #[cfg(not(target_os = "macos"))]
        {
            assert_eq!(CANDIDATES[0], "libgssapi_krb5.so.2");
            assert!(CANDIDATES.contains(&"libgssapi.so.3"));
        }
        #[cfg(target_os = "macos")]
        {
            // Absolute: the framework is OS-shipped and is not on any search path.
            assert!(CANDIDATES[0].starts_with('/'));
        }
    }

    #[test]
    fn the_oids_are_the_der_bodies_the_rfcs_specify() {
        // 1.3.6.1.5.5.2 and 1.2.840.113554.1.2.1.4, tag and length excluded.
        assert_eq!(SPNEGO_OID, &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x02]);
        assert_eq!(NT_HOSTBASED_SERVICE_OID[0], 0x2a);
        assert_eq!(NT_HOSTBASED_SERVICE_OID.len(), 10);
    }

    #[test]
    fn a_missing_credential_is_told_apart_from_every_other_failure() {
        assert!(looks_like_no_credential(
            "No credentials cache found (filename: /tmp/krb5cc_1000)"
        ));
        assert!(looks_like_no_credential("Ticket expired"));
        assert!(!looks_like_no_credential(
            "Server not found in Kerberos database"
        ));
    }

    /// The one thing this file can prove on a machine with no KDC: an absent library is a
    /// distinct, reportable degradation and never a load failure or a panic.
    #[test]
    fn an_absent_library_is_a_named_degradation() {
        let err = NegotiateError::LibraryUnavailable("nothing on the ladder".into()).into_ol();
        assert!(err
            .suggestion
            .as_deref()
            .is_some_and(|s| s.contains("krb5-libs")));
    }

    #[test]
    fn an_empty_override_forces_the_no_library_branch() {
        // The seam that makes the degradation path testable without uninstalling Kerberos.
        // Reading it through `candidates()` rather than mutating the process environment
        // here, because `load()` memoizes and a racing test would poison the memo.
        assert!(!CANDIDATES.is_empty());
        assert_eq!(LIB_ENV, "OPENLATCH_GSSAPI_LIB");
    }
}