rullst_core/security/
machine.rs1use axum::{
4 extract::Request,
5 http::{Method, StatusCode, header},
6};
7use std::sync::Arc;
8use subtle::ConstantTimeEq;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum MachineAuthentication {
14 Bearer,
16 SignedWebhook,
18 MutualTls,
20}
21
22#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum MachineEndpointError {
26 #[error("machine endpoint requires an exact canonical path and a write method")]
28 InvalidRoute,
29 #[error("machine bearer credential must contain 32–200 non-whitespace ASCII bytes")]
31 InvalidCredential,
32 #[error("machine endpoint policy exceeds 32 routes or contains a duplicate")]
34 InvalidPolicy,
35 #[error("machine request authentication failed")]
37 Unauthorized,
38}
39
40#[async_trait::async_trait]
48pub trait MachineRequestVerifier: Send + Sync {
49 async fn verify(&self, request: Request) -> Result<Request, MachineEndpointError>;
51}
52
53enum Credential {
54 Bearer([u8; 32]),
55 Verified(Arc<dyn MachineRequestVerifier>),
56}
57
58pub struct MachineEndpoint {
60 method: Method,
61 path: String,
62 kind: MachineAuthentication,
63 credential: Credential,
64}
65
66impl MachineEndpoint {
67 pub fn authentication(&self) -> MachineAuthentication {
69 self.kind
70 }
71
72 pub fn bearer(
74 method: Method,
75 path: impl Into<String>,
76 token: impl Into<String>,
77 ) -> Result<Self, MachineEndpointError> {
78 let token = token.into();
79 if !(32..=200).contains(&token.len()) || !token.bytes().all(|b| b.is_ascii_graphic()) {
80 return Err(MachineEndpointError::InvalidCredential);
81 }
82 Self::new(
83 method,
84 path.into(),
85 MachineAuthentication::Bearer,
86 Credential::Bearer(digest(token.as_bytes())),
87 )
88 }
89
90 pub fn signed_webhook(
92 method: Method,
93 path: impl Into<String>,
94 verifier: impl MachineRequestVerifier + 'static,
95 ) -> Result<Self, MachineEndpointError> {
96 Self::new(
97 method,
98 path.into(),
99 MachineAuthentication::SignedWebhook,
100 Credential::Verified(Arc::new(verifier)),
101 )
102 }
103
104 pub fn mutual_tls(
106 method: Method,
107 path: impl Into<String>,
108 verifier: impl MachineRequestVerifier + 'static,
109 ) -> Result<Self, MachineEndpointError> {
110 Self::new(
111 method,
112 path.into(),
113 MachineAuthentication::MutualTls,
114 Credential::Verified(Arc::new(verifier)),
115 )
116 }
117
118 fn new(
119 method: Method,
120 path: String,
121 kind: MachineAuthentication,
122 credential: Credential,
123 ) -> Result<Self, MachineEndpointError> {
124 if !matches!(
125 method,
126 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
127 ) || !path.starts_with('/')
128 || path.len() > 512
129 || path.contains("//")
130 || path.split('/').any(|part| matches!(part, "." | ".."))
131 || !path
132 .bytes()
133 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.'))
134 {
135 return Err(MachineEndpointError::InvalidRoute);
136 }
137 Ok(Self {
138 method,
139 path,
140 kind,
141 credential,
142 })
143 }
144}
145
146fn digest(value: &[u8]) -> [u8; 32] {
147 let hash = ring::digest::digest(&ring::digest::SHA256, value);
148 let mut result = [0; 32];
149 result.copy_from_slice(hash.as_ref());
150 result
151}
152
153#[derive(Clone)]
156pub struct MachineEndpointPolicy(Arc<[MachineEndpoint]>);
157
158impl std::fmt::Debug for MachineEndpointPolicy {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("MachineEndpointPolicy")
161 .field("routes", &self.0.len())
162 .finish()
163 }
164}
165
166#[derive(Clone)]
167struct MachineAuthenticated {
168 method: Method,
169 path: String,
170 policy: MachineEndpointPolicy,
171}
172
173impl MachineEndpointPolicy {
174 pub fn new(endpoints: Vec<MachineEndpoint>) -> Result<Self, MachineEndpointError> {
176 if endpoints.len() > 32
177 || endpoints.iter().enumerate().any(|(i, endpoint)| {
178 endpoints[..i]
179 .iter()
180 .any(|prior| prior.method == endpoint.method && prior.path == endpoint.path)
181 })
182 {
183 return Err(MachineEndpointError::InvalidPolicy);
184 }
185 Ok(Self(endpoints.into()))
186 }
187
188 pub(crate) fn matches(&self, request: &Request) -> bool {
189 self.0
190 .iter()
191 .any(|e| e.method == request.method() && e.path == request.uri().path())
192 }
193
194 pub(crate) async fn authenticate(&self, mut request: Request) -> Result<Request, StatusCode> {
195 if request
196 .extensions()
197 .get::<MachineAuthenticated>()
198 .is_some_and(|proof| {
199 proof.method == request.method()
200 && proof.path == request.uri().path()
201 && Arc::ptr_eq(&proof.policy.0, &self.0)
202 })
203 {
204 return Ok(request);
205 }
206 let endpoint = self
207 .0
208 .iter()
209 .find(|e| e.method == request.method() && e.path == request.uri().path())
210 .ok_or(StatusCode::UNAUTHORIZED)?;
211 if request.headers().contains_key(header::COOKIE)
213 || request.headers().contains_key(header::ORIGIN)
214 || request.headers().contains_key("sec-fetch-site")
215 {
216 return Err(StatusCode::UNAUTHORIZED);
217 }
218 if let Credential::Bearer(expected) = &endpoint.credential {
219 let mut values = request.headers().get_all(header::AUTHORIZATION).iter();
220 let token = values
221 .next()
222 .and_then(|v| v.to_str().ok())
223 .and_then(|v| v.strip_prefix("Bearer "))
224 .ok_or(StatusCode::UNAUTHORIZED)?;
225 if values.next().is_some()
226 || !(32..=200).contains(&token.len())
227 || !bool::from(digest(token.as_bytes()).ct_eq(expected))
228 {
229 return Err(StatusCode::UNAUTHORIZED);
230 }
231 }
232 let method = request.method().clone();
233 let uri = request.uri().clone();
234 let (parts, body) = request.into_parts();
235 let bytes = axum::body::to_bytes(body, 1024 * 1024)
236 .await
237 .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
238 request = Request::from_parts(parts, axum::body::Body::from(bytes));
239 if let Credential::Verified(verifier) = &endpoint.credential {
240 request = verifier
241 .verify(request)
242 .await
243 .map_err(|_| StatusCode::UNAUTHORIZED)?;
244 if request.method() != method || request.uri() != &uri {
245 return Err(StatusCode::UNAUTHORIZED);
246 }
247 }
248 request.extensions_mut().insert(MachineAuthenticated {
249 method,
250 path: uri.path().to_owned(),
251 policy: self.clone(),
252 });
253 Ok(request)
254 }
255}
256
257pub(crate) async fn authenticate_machine_request(
258 request: Request,
259 next: axum::middleware::Next,
260) -> axum::response::Response {
261 use axum::response::IntoResponse;
262 if let Some(policy) = request.extensions().get::<MachineEndpointPolicy>().cloned()
263 && policy.matches(&request)
264 {
265 return match policy.authenticate(request).await {
266 Ok(request) => next.run(request).await,
267 Err(status) => (status, "Machine request rejected").into_response(),
268 };
269 }
270 next.run(request).await
271}