1use std::fmt;
2use std::panic::{catch_unwind, AssertUnwindSafe, PanicHookInfo};
3use std::sync::{Mutex, MutexGuard};
4
5use zeroize::{Zeroize, Zeroizing};
6
7pub const MAX_AUTHENTICATED_PRINCIPAL_BYTES: usize = 1024;
9
10pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36#[repr(u8)]
37pub enum ListenerScope {
38 Loopback = 0,
39 NonLoopback = 1,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44#[repr(u8)]
45pub enum PeerScope {
46 Loopback = 0,
47 NonLoopback = 1,
48}
49
50pub struct TlsClientCertificateHash<'a> {
54 bytes: &'a [u8; 32],
55}
56
57impl TlsClientCertificateHash<'_> {
58 pub fn with_bytes<R>(&self, reveal: impl FnOnce(&[u8; 32]) -> R) -> R {
60 reveal(self.bytes)
61 }
62}
63
64pub struct LocalAuthCredential<'a> {
75 bytes: &'a [u8],
76}
77
78impl LocalAuthCredential<'_> {
79 pub fn with_bytes<R>(&self, reveal: impl FnOnce(&[u8]) -> R) -> R {
81 reveal(self.bytes)
82 }
83}
84
85pub 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
152pub struct AuthenticatedPrincipal {
203 canonical_bytes: Zeroizing<Vec<u8>>,
204}
205
206impl AuthenticatedPrincipal {
207 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#[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
257pub trait PrincipalResolver: Send + Sync + 'static {
259 fn resolve(
260 &self,
261 context: PrincipalContext<'_>,
262 ) -> Result<AuthenticatedPrincipal, PrincipalResolveError>;
263}
264
265#[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
286pub struct ProcessLocalLoopbackResolver {
290 identity: Zeroizing<[u8; 32]>,
291}
292
293impl ProcessLocalLoopbackResolver {
294 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#[cfg(test)]
343pub(crate) static PANIC_HOOK_TEST_LOCK: Mutex<()> = Mutex::new(());
344
345#[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}