Skip to main content

gaze_proxy/
principal.rs

1use std::fmt;
2use std::panic::{catch_unwind, AssertUnwindSafe, PanicHookInfo};
3use std::sync::{Mutex, MutexGuard};
4
5use zeroize::{Zeroize, Zeroizing};
6
7/// Maximum canonical principal size accepted at the trusted resolver boundary.
8pub const MAX_AUTHENTICATED_PRINCIPAL_BYTES: usize = 1024;
9
10/// Maximum separately configured local-auth credential size.
11pub const MAX_LOCAL_AUTH_CREDENTIAL_BYTES: usize = 8 * 1024;
12
13type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
14
15static RESOLVER_PANIC_HOOK_BOUNDARY: Mutex<()> = Mutex::new(());
16
17struct PanicHookRestore(Option<PanicHook>);
18
19impl Drop for PanicHookRestore {
20    fn drop(&mut self) {
21        if let Some(hook) = self.0.take() {
22            std::panic::set_hook(hook);
23        }
24    }
25}
26
27fn lock_panic_hook_boundary() -> MutexGuard<'static, ()> {
28    match RESOLVER_PANIC_HOOK_BOUNDARY.lock() {
29        Ok(boundary) => boundary,
30        Err(poisoned) => poisoned.into_inner(),
31    }
32}
33
34/// Closed listener exposure supplied to a trusted principal resolver.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36#[repr(u8)]
37pub enum ListenerScope {
38    Loopback = 0,
39    NonLoopback = 1,
40}
41
42/// Closed peer exposure supplied to a trusted principal resolver.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44#[repr(u8)]
45pub enum PeerScope {
46    Loopback = 0,
47    NonLoopback = 1,
48}
49
50/// Borrowed trusted TLS client-certificate hash.
51///
52/// This wrapper intentionally has no `Debug`, `Display`, or serialization surface.
53pub struct TlsClientCertificateHash<'a> {
54    bytes: &'a [u8; 32],
55}
56
57impl TlsClientCertificateHash<'_> {
58    /// Reveals the fixed-size hash only inside the trusted resolver callback.
59    pub fn with_bytes<R>(&self, reveal: impl FnOnce(&[u8; 32]) -> R) -> R {
60        reveal(self.bytes)
61    }
62}
63
64/// Borrowed, separately configured local-auth credential.
65///
66/// This wrapper intentionally has no `Debug`, `Display`, or serialization surface.
67///
68/// ```compile_fail
69/// use gaze_proxy::LocalAuthCredential;
70///
71/// fn requires_debug<T: std::fmt::Debug>() {}
72/// requires_debug::<LocalAuthCredential<'static>>();
73/// ```
74pub struct LocalAuthCredential<'a> {
75    bytes: &'a [u8],
76}
77
78impl LocalAuthCredential<'_> {
79    /// Reveals the credential only inside the trusted resolver callback.
80    pub fn with_bytes<R>(&self, reveal: impl FnOnce(&[u8]) -> R) -> R {
81        reveal(self.bytes)
82    }
83}
84
85/// Sealed, non-content request context supplied to [`PrincipalResolver`].
86///
87/// It cannot carry provider credentials, raw headers, a body, URL/query data,
88/// or a caller continuity identifier. Fields and construction stay inside the
89/// proxy runtime.
90///
91/// ```compile_fail
92/// use gaze_proxy::{ListenerScope, PeerScope, PrincipalContext};
93///
94/// let _ = PrincipalContext {
95///     listener_scope: ListenerScope::Loopback,
96///     peer_scope: PeerScope::Loopback,
97/// };
98/// ```
99pub struct PrincipalContext<'a> {
100    listener_scope: ListenerScope,
101    peer_scope: PeerScope,
102    tls_client_certificate_hash: Option<&'a [u8; 32]>,
103    local_auth_credential: Option<&'a [u8]>,
104}
105
106impl<'a> PrincipalContext<'a> {
107    #[allow(
108        dead_code,
109        reason = "constructed by direct-profile request validation in P3/P4"
110    )]
111    pub(crate) fn new(
112        listener_scope: ListenerScope,
113        peer_scope: PeerScope,
114        tls_client_certificate_hash: Option<&'a [u8; 32]>,
115        local_auth_credential: Option<&'a [u8]>,
116    ) -> Result<Self, PrincipalResolveError> {
117        if matches!(local_auth_credential, Some(bytes) if bytes.is_empty() || bytes.len() > MAX_LOCAL_AUTH_CREDENTIAL_BYTES)
118        {
119            return Err(PrincipalResolveError::InvalidLocalAuthCredential);
120        }
121        Ok(Self {
122            listener_scope,
123            peer_scope,
124            tls_client_certificate_hash,
125            local_auth_credential,
126        })
127    }
128
129    #[must_use]
130    pub const fn listener_scope(&self) -> ListenerScope {
131        self.listener_scope
132    }
133
134    #[must_use]
135    pub const fn peer_scope(&self) -> PeerScope {
136        self.peer_scope
137    }
138
139    #[must_use]
140    pub fn tls_client_certificate_hash(&self) -> Option<TlsClientCertificateHash<'a>> {
141        self.tls_client_certificate_hash
142            .map(|bytes| TlsClientCertificateHash { bytes })
143    }
144
145    #[must_use]
146    pub fn local_auth_credential(&self) -> Option<LocalAuthCredential<'a>> {
147        self.local_auth_credential
148            .map(|bytes| LocalAuthCredential { bytes })
149    }
150}
151
152/// Opaque, bounded canonical principal returned by a trusted resolver.
153///
154/// Canonical bytes are zeroized on drop. The type intentionally implements no
155/// `Debug`, `Display`, serialization, cloning, equality, `AsRef`, `Deref`, or
156/// implicit conversion traits.
157///
158/// ```compile_fail
159/// use gaze_proxy::AuthenticatedPrincipal;
160///
161/// let principal = AuthenticatedPrincipal::try_from_canonical_bytes(b"tenant-a".to_vec())?;
162/// let _ = format!("{principal:?}");
163/// # Ok::<(), Box<dyn std::error::Error>>(())
164/// ```
165///
166/// ```compile_fail
167/// use gaze_proxy::AuthenticatedPrincipal;
168///
169/// let principal = AuthenticatedPrincipal::try_from_canonical_bytes(b"tenant-a".to_vec())?;
170/// let _ = format!("{principal}");
171/// # Ok::<(), Box<dyn std::error::Error>>(())
172/// ```
173///
174/// ```compile_fail
175/// use gaze_proxy::AuthenticatedPrincipal;
176///
177/// let principal = AuthenticatedPrincipal::try_from_canonical_bytes(b"tenant-a".to_vec())?;
178/// let _ = principal.clone();
179/// # Ok::<(), Box<dyn std::error::Error>>(())
180/// ```
181///
182/// ```compile_fail
183/// use gaze_proxy::AuthenticatedPrincipal;
184///
185/// let principal = AuthenticatedPrincipal::try_from_canonical_bytes(b"tenant-a".to_vec())?;
186/// let _ = serde_json::to_vec(&principal)?;
187/// # Ok::<(), Box<dyn std::error::Error>>(())
188/// ```
189///
190/// ```compile_fail
191/// use gaze_proxy::AuthenticatedPrincipal;
192///
193/// fn requires_as_ref<T: AsRef<[u8]>>() {}
194/// requires_as_ref::<AuthenticatedPrincipal>();
195/// ```
196///
197/// ```compile_fail
198/// use gaze_proxy::AuthenticatedPrincipal;
199///
200/// let _ = AuthenticatedPrincipal { canonical_bytes: Vec::new() };
201/// ```
202pub struct AuthenticatedPrincipal {
203    canonical_bytes: Zeroizing<Vec<u8>>,
204}
205
206impl AuthenticatedPrincipal {
207    /// Constructs a bounded canonical identity for return from a trusted resolver.
208    pub fn try_from_canonical_bytes(
209        mut canonical_bytes: Vec<u8>,
210    ) -> Result<Self, PrincipalResolveError> {
211        if canonical_bytes.is_empty() {
212            canonical_bytes.zeroize();
213            return Err(PrincipalResolveError::EmptyPrincipal);
214        }
215        if canonical_bytes.len() > MAX_AUTHENTICATED_PRINCIPAL_BYTES {
216            canonical_bytes.zeroize();
217            return Err(PrincipalResolveError::PrincipalTooLong);
218        }
219        Ok(Self {
220            canonical_bytes: Zeroizing::new(canonical_bytes),
221        })
222    }
223
224    pub(crate) fn with_canonical_bytes<R>(&self, reveal: impl FnOnce(&[u8]) -> R) -> R {
225        reveal(self.canonical_bytes.as_slice())
226    }
227}
228
229/// Closed, sanitized principal resolution failure.
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
231pub enum PrincipalResolveError {
232    EmptyPrincipal,
233    PrincipalTooLong,
234    InvalidLocalAuthCredential,
235    LoopbackScopeRequired,
236    ResolverRejected,
237    ResolverPanicked,
238    EntropyUnavailable,
239}
240
241impl fmt::Display for PrincipalResolveError {
242    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243        formatter.write_str(match self {
244            Self::EmptyPrincipal => "principal_empty",
245            Self::PrincipalTooLong => "principal_too_long",
246            Self::InvalidLocalAuthCredential => "principal_local_auth_invalid",
247            Self::LoopbackScopeRequired => "principal_loopback_scope_required",
248            Self::ResolverRejected => "principal_rejected",
249            Self::ResolverPanicked => "principal_resolver_panicked",
250            Self::EntropyUnavailable => "principal_entropy_unavailable",
251        })
252    }
253}
254
255impl std::error::Error for PrincipalResolveError {}
256
257/// Trusted synchronous principal authentication boundary.
258pub trait PrincipalResolver: Send + Sync + 'static {
259    fn resolve(
260        &self,
261        context: PrincipalContext<'_>,
262    ) -> Result<AuthenticatedPrincipal, PrincipalResolveError>;
263}
264
265/// Invokes adopter code before body/session creation and sanitizes unwind failures.
266#[allow(
267    dead_code,
268    reason = "called by direct-profile request validation in P3/P4"
269)]
270pub(crate) fn invoke_resolver(
271    resolver: &dyn PrincipalResolver,
272    context: PrincipalContext<'_>,
273) -> Result<AuthenticatedPrincipal, PrincipalResolveError> {
274    let _boundary = lock_panic_hook_boundary();
275    let prior_hook = std::panic::take_hook();
276    std::panic::set_hook(Box::new(|_| {}));
277    let restore_hook = PanicHookRestore(Some(prior_hook));
278    let result = match catch_unwind(AssertUnwindSafe(|| resolver.resolve(context))) {
279        Ok(result) => result,
280        Err(_) => Err(PrincipalResolveError::ResolverPanicked),
281    };
282    drop(restore_hook);
283    result
284}
285
286/// Launch-local loopback identity source.
287///
288/// The random identity is non-observable and zeroized when the resolver drops.
289pub struct ProcessLocalLoopbackResolver {
290    identity: Zeroizing<[u8; 32]>,
291}
292
293impl ProcessLocalLoopbackResolver {
294    /// Creates a fresh process-local loopback resolver from the operating system RNG.
295    pub fn new() -> Result<Self, PrincipalResolveError> {
296        let mut identity = [0_u8; 32];
297        if getrandom::fill(&mut identity).is_err() {
298            identity.zeroize();
299            return Err(PrincipalResolveError::EntropyUnavailable);
300        }
301        Ok(Self {
302            identity: Zeroizing::new(identity),
303        })
304    }
305}
306
307impl PrincipalResolver for ProcessLocalLoopbackResolver {
308    fn resolve(
309        &self,
310        context: PrincipalContext<'_>,
311    ) -> Result<AuthenticatedPrincipal, PrincipalResolveError> {
312        if context.listener_scope() != ListenerScope::Loopback
313            || context.peer_scope() != PeerScope::Loopback
314        {
315            return Err(PrincipalResolveError::LoopbackScopeRequired);
316        }
317        AuthenticatedPrincipal::try_from_canonical_bytes(self.identity.as_slice().to_vec())
318    }
319}
320
321/// Test-only serialization for the process-global panic hook.
322///
323/// [`invoke_resolver`] swaps the global panic hook for the duration of the trusted
324/// resolver call, so *every* caller of it is a global-hook writer. A test that
325/// installs its own hook and then observes whether it fires therefore races any
326/// concurrent `invoke_resolver` in the same test binary: the concurrent boundary
327/// window can mask the installed hook (observed hit count too low) or restore a
328/// stale hook over it.
329///
330/// [`RESOLVER_PANIC_HOOK_BOUNDARY`] cannot be reused for this -- `invoke_resolver`
331/// takes it internally and [`Mutex`] is not reentrant. This second lock is always
332/// acquired strictly *outside* that boundary and is never taken by production code,
333/// so the ordering cannot deadlock.
334///
335/// Every test that touches [`std::panic::set_hook`] / [`std::panic::take_hook`],
336/// directly or through [`invoke_resolver`], MUST hold this guard across its whole
337/// hook window. That includes tests in `tests/session_registry.rs`, which
338/// `#[path]`-includes this module and so shares this exact lock.
339///
340/// `#[cfg(test)]` keeps this out of every non-test build; production behavior is
341/// unchanged.
342#[cfg(test)]
343pub(crate) static PANIC_HOOK_TEST_LOCK: Mutex<()> = Mutex::new(());
344
345/// Acquires [`PANIC_HOOK_TEST_LOCK`], tolerating poisoning from an unrelated
346/// failing test so the real assertion failure is what gets reported.
347#[cfg(test)]
348pub(crate) fn lock_panic_hook_for_test() -> MutexGuard<'static, ()> {
349    match PANIC_HOOK_TEST_LOCK.lock() {
350        Ok(guard) => guard,
351        Err(poisoned) => poisoned.into_inner(),
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn principal_bounds_fail_closed() {
361        assert!(matches!(
362            AuthenticatedPrincipal::try_from_canonical_bytes(Vec::new()),
363            Err(PrincipalResolveError::EmptyPrincipal)
364        ));
365        assert!(matches!(
366            AuthenticatedPrincipal::try_from_canonical_bytes(vec![
367                0x41;
368                MAX_AUTHENTICATED_PRINCIPAL_BYTES
369                    + 1
370            ]),
371            Err(PrincipalResolveError::PrincipalTooLong)
372        ));
373    }
374
375    #[test]
376    fn loopback_identity_is_stable_for_one_resolver() -> Result<(), PrincipalResolveError> {
377        let _panic_hook_guard = lock_panic_hook_for_test();
378        let resolver = ProcessLocalLoopbackResolver::new()?;
379        let context =
380            || PrincipalContext::new(ListenerScope::Loopback, PeerScope::Loopback, None, None);
381        let first = invoke_resolver(&resolver, context()?)?;
382        let second = invoke_resolver(&resolver, context()?)?;
383        assert!(first
384            .with_canonical_bytes(|left| { second.with_canonical_bytes(|right| left == right) }));
385        Ok(())
386    }
387}