1use std::error::Error;
4use std::fmt;
5use std::io::{self, Read};
6use std::path::Path;
7
8use zeroize::Zeroizing;
9
10use crate::{BearerCredential, MetadataError};
11
12#[cfg(target_os = "linux")]
13use std::fs::{File, OpenOptions};
14#[cfg(target_os = "linux")]
15use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
16
17const BEARER_PRESENTATION_BYTES: usize = 43;
18const PROC_STATUS_MAX_BYTES: usize = 65_536;
19const PROC_STATUS_PATH: &str = "/proc/self/status";
20const O_NOFOLLOW: i32 = 0o00400000;
21const O_NONBLOCK: i32 = 0o00004000;
22const PROTECTED_OPEN_FLAGS: i32 = O_NOFOLLOW | O_NONBLOCK;
23
24#[derive(Clone, Copy, Eq, PartialEq)]
26pub enum BearerCredentialFileError {
27 UnsupportedPlatform,
29 ProtectedFileRejected,
31 InvalidPresentation,
33}
34
35impl fmt::Debug for BearerCredentialFileError {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(match self {
38 Self::UnsupportedPlatform => "UnsupportedPlatform",
39 Self::ProtectedFileRejected => "ProtectedFileRejected",
40 Self::InvalidPresentation => "InvalidPresentation",
41 })
42 }
43}
44
45impl fmt::Display for BearerCredentialFileError {
46 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47 formatter.write_str(match self {
48 Self::UnsupportedPlatform => {
49 "protected bearer credential files are unsupported on this platform"
50 }
51 Self::ProtectedFileRejected => "protected bearer credential file was rejected",
52 Self::InvalidPresentation => "bearer credential presentation is invalid",
53 })
54 }
55}
56
57impl Error for BearerCredentialFileError {}
58
59pub fn load_protected_bearer_credential(
64 path: &Path,
65) -> Result<BearerCredential, BearerCredentialFileError> {
66 #[cfg(target_os = "linux")]
67 {
68 load_with(&LinuxPlatform, path)
69 }
70
71 #[cfg(not(target_os = "linux"))]
72 {
73 let _ = path;
74 Err(BearerCredentialFileError::UnsupportedPlatform)
75 }
76}
77
78#[derive(Clone, Copy)]
79struct FileMetadata {
80 is_regular: bool,
81 uid: u32,
82 mode: u32,
83 device: u64,
84 inode: u64,
85}
86
87trait ProtectedFilePlatform {
88 type ProcStatusReader: Read;
89 type OpenedFile: Read;
90
91 fn open_proc_status(&self) -> io::Result<Self::ProcStatusReader>;
92 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
93 fn open_file(&self, path: &Path, flags: i32) -> io::Result<Self::OpenedFile>;
94 fn opened_metadata(&self, file: &Self::OpenedFile) -> io::Result<FileMetadata>;
95}
96
97#[cfg(target_os = "linux")]
98struct LinuxPlatform;
99
100#[cfg(target_os = "linux")]
101impl ProtectedFilePlatform for LinuxPlatform {
102 type ProcStatusReader = File;
103 type OpenedFile = File;
104
105 fn open_proc_status(&self) -> io::Result<Self::ProcStatusReader> {
106 File::open(PROC_STATUS_PATH)
107 }
108
109 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
110 std::fs::symlink_metadata(path).map(metadata_snapshot)
111 }
112
113 fn open_file(&self, path: &Path, flags: i32) -> io::Result<Self::OpenedFile> {
114 OpenOptions::new().read(true).custom_flags(flags).open(path)
115 }
116
117 fn opened_metadata(&self, file: &Self::OpenedFile) -> io::Result<FileMetadata> {
118 file.metadata().map(metadata_snapshot)
119 }
120}
121
122#[cfg(target_os = "linux")]
123fn metadata_snapshot(metadata: std::fs::Metadata) -> FileMetadata {
124 FileMetadata {
125 is_regular: metadata.is_file(),
126 uid: metadata.uid(),
127 mode: metadata.mode(),
128 device: metadata.dev(),
129 inode: metadata.ino(),
130 }
131}
132
133fn load_with<P: ProtectedFilePlatform>(
134 platform: &P,
135 path: &Path,
136) -> Result<BearerCredential, BearerCredentialFileError> {
137 let effective_uid = read_effective_uid(platform)?;
138 let before = platform
139 .symlink_metadata(path)
140 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
141 if !before.is_regular {
142 return Err(BearerCredentialFileError::ProtectedFileRejected);
143 }
144
145 let mut file = platform
146 .open_file(path, PROTECTED_OPEN_FLAGS)
147 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
148 let opened = platform
149 .opened_metadata(&file)
150 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
151 if !opened.is_regular
152 || opened.uid != effective_uid
153 || opened.mode & 0o077 != 0
154 || (opened.device, opened.inode) != (before.device, before.inode)
155 {
156 return Err(BearerCredentialFileError::ProtectedFileRejected);
157 }
158
159 let mut presentation = Zeroizing::new(Vec::with_capacity(BEARER_PRESENTATION_BYTES + 1));
160 file.by_ref()
161 .take((BEARER_PRESENTATION_BYTES + 1) as u64)
162 .read_to_end(&mut presentation)
163 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
164 if presentation.len() != BEARER_PRESENTATION_BYTES {
165 return Err(BearerCredentialFileError::ProtectedFileRejected);
166 }
167 let presentation = std::str::from_utf8(&presentation)
168 .map_err(|_| BearerCredentialFileError::InvalidPresentation)?;
169 BearerCredential::new(presentation).map_err(map_presentation_error)
170}
171
172fn read_effective_uid<P: ProtectedFilePlatform>(
173 platform: &P,
174) -> Result<u32, BearerCredentialFileError> {
175 let mut status = platform
176 .open_proc_status()
177 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
178 let mut bytes = Vec::with_capacity(8 * 1024);
179 status
180 .by_ref()
181 .take((PROC_STATUS_MAX_BYTES + 1) as u64)
182 .read_to_end(&mut bytes)
183 .map_err(|_| BearerCredentialFileError::ProtectedFileRejected)?;
184 if bytes.len() > PROC_STATUS_MAX_BYTES {
185 return Err(BearerCredentialFileError::ProtectedFileRejected);
186 }
187 parse_effective_uid(&bytes).ok_or(BearerCredentialFileError::ProtectedFileRejected)
188}
189
190fn parse_effective_uid(status: &[u8]) -> Option<u32> {
191 let mut effective_uid = None;
192 for line in status.split(|byte| *byte == b'\n') {
193 let Some(fields) = line.strip_prefix(b"Uid:") else {
194 continue;
195 };
196 if effective_uid.is_some() {
197 return None;
198 }
199 effective_uid = Some(parse_uid_fields(fields)?[1]);
200 }
201 effective_uid
202}
203
204fn parse_uid_fields(fields: &[u8]) -> Option<[u32; 4]> {
205 let mut cursor = 0;
206 require_separator(fields, &mut cursor)?;
207 let mut values = [0_u32; 4];
208 for (index, value) in values.iter_mut().enumerate() {
209 if index != 0 {
210 require_separator(fields, &mut cursor)?;
211 }
212 let start = cursor;
213 while fields.get(cursor).is_some_and(u8::is_ascii_digit) {
214 cursor += 1;
215 }
216 *value = parse_canonical_u32(&fields[start..cursor])?;
217 }
218 (cursor == fields.len()).then_some(values)
219}
220
221fn require_separator(fields: &[u8], cursor: &mut usize) -> Option<()> {
222 let start = *cursor;
223 while fields
224 .get(*cursor)
225 .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
226 {
227 *cursor += 1;
228 }
229 (*cursor != start).then_some(())
230}
231
232fn parse_canonical_u32(bytes: &[u8]) -> Option<u32> {
233 if bytes.is_empty() || (bytes.len() > 1 && bytes[0] == b'0') {
234 return None;
235 }
236 bytes.iter().try_fold(0_u32, |value, byte| {
237 byte.is_ascii_digit()
238 .then_some(*byte - b'0')
239 .and_then(|digit| value.checked_mul(10)?.checked_add(u32::from(digit)))
240 })
241}
242
243fn map_presentation_error(_: MetadataError) -> BearerCredentialFileError {
244 BearerCredentialFileError::InvalidPresentation
245}
246
247#[cfg(test)]
248mod tests {
249 use std::cell::Cell;
250
251 use super::*;
252
253 const TOKEN: &[u8] = b"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
254 const TEST_PATH: &str = "/not-opened-by-test-platform";
255 const EFFECTIVE_UID: u32 = 1_234;
256
257 struct InjectedReader {
258 bytes: Vec<u8>,
259 position: usize,
260 fail_at: Option<usize>,
261 }
262
263 impl InjectedReader {
264 fn new(bytes: Vec<u8>, fail_at: Option<usize>) -> Self {
265 Self {
266 bytes,
267 position: 0,
268 fail_at,
269 }
270 }
271 }
272
273 impl Read for InjectedReader {
274 fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
275 if self.fail_at == Some(self.position) {
276 return Err(io::Error::other("injected read failure"));
277 }
278 if self.position == self.bytes.len() || output.is_empty() {
279 return Ok(0);
280 }
281
282 let readable_end = self
283 .fail_at
284 .unwrap_or(self.bytes.len())
285 .min(self.bytes.len());
286 let count = output.len().min(readable_end.saturating_sub(self.position));
287 if count == 0 {
288 return Err(io::Error::other("injected read failure"));
289 }
290 output[..count].copy_from_slice(&self.bytes[self.position..self.position + count]);
291 self.position += count;
292 Ok(count)
293 }
294 }
295
296 struct TestPlatform {
297 proc_status: io::Result<Vec<u8>>,
298 proc_status_fail_at: Option<usize>,
299 before: io::Result<FileMetadata>,
300 opened: io::Result<FileMetadata>,
301 bytes: io::Result<Vec<u8>>,
302 bytes_fail_at: Option<usize>,
303 flags: Cell<Option<i32>>,
304 }
305
306 impl TestPlatform {
307 fn valid() -> Self {
308 let metadata = FileMetadata {
309 is_regular: true,
310 uid: EFFECTIVE_UID,
311 mode: 0o100600,
312 device: 11,
313 inode: 17,
314 };
315 Self {
316 proc_status: Ok(b"Name:\triffdb\nUid:\t1234\t1234 1234\t1234\n".to_vec()),
317 proc_status_fail_at: None,
318 before: Ok(metadata),
319 opened: Ok(metadata),
320 bytes: Ok(TOKEN.to_vec()),
321 bytes_fail_at: None,
322 flags: Cell::new(None),
323 }
324 }
325 }
326
327 impl ProtectedFilePlatform for TestPlatform {
328 type ProcStatusReader = InjectedReader;
329 type OpenedFile = InjectedReader;
330
331 fn open_proc_status(&self) -> io::Result<Self::ProcStatusReader> {
332 self.proc_status
333 .as_ref()
334 .map(|bytes| InjectedReader::new(bytes.clone(), self.proc_status_fail_at))
335 .map_err(|error| io::Error::new(error.kind(), "injected"))
336 }
337
338 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
339 self.before
340 .as_ref()
341 .copied()
342 .map_err(|error| io::Error::new(error.kind(), "injected"))
343 }
344
345 fn open_file(&self, _path: &Path, flags: i32) -> io::Result<Self::OpenedFile> {
346 self.flags.set(Some(flags));
347 self.bytes
348 .as_ref()
349 .map(|bytes| InjectedReader::new(bytes.clone(), self.bytes_fail_at))
350 .map_err(|error| io::Error::new(error.kind(), "injected"))
351 }
352
353 fn opened_metadata(&self, _file: &Self::OpenedFile) -> io::Result<FileMetadata> {
354 self.opened
355 .as_ref()
356 .copied()
357 .map_err(|error| io::Error::new(error.kind(), "injected"))
358 }
359 }
360
361 #[test]
362 fn exact_linux_bounds_flags_and_presentation_are_frozen() {
363 assert_eq!(PROC_STATUS_MAX_BYTES, 65_536);
364 assert_eq!(BEARER_PRESENTATION_BYTES, 43);
365 assert_eq!(O_NOFOLLOW, 0o00400000);
366 assert_eq!(O_NONBLOCK, 0o00004000);
367 assert_eq!(PROTECTED_OPEN_FLAGS, 0o00404000);
368
369 let platform = TestPlatform::valid();
370 let loaded = load_with(&platform, Path::new(TEST_PATH)).expect("credential");
371 let expected = BearerCredential::new(std::str::from_utf8(TOKEN).expect("ASCII"))
372 .expect("expected credential");
373 assert!(loaded.has_same_presentation(&expected));
374 assert_eq!(platform.flags.get(), Some(PROTECTED_OPEN_FLAGS));
375 }
376
377 #[test]
378 fn effective_uid_requires_one_canonical_four_field_line() {
379 assert_eq!(
380 parse_effective_uid(b"Name:\triffdb\nUid:\t1 4294967295\t3 4\n"),
381 Some(u32::MAX)
382 );
383 assert_eq!(parse_effective_uid(b"Uid: 0\t0 0\t0"), Some(0));
384 for malformed in [
385 b"Name:\triffdb\n".as_slice(),
386 b"Uid:\t1 2 3 4\nUid:\t1 2 3 4\n",
387 b"Uid:1 2 3 4\n",
388 b"Uid:\t1 2 3\n",
389 b"Uid:\t1 2 3 4 5\n",
390 b"Uid:\t01 2 3 4\n",
391 b"Uid:\t1 +2 3 4\n",
392 b"Uid:\t1 4294967296 3 4\n",
393 b"Uid:\t1 2 3 4 \n",
394 b"Uid:\t1 2 3 4\r\n",
395 ] {
396 assert_eq!(parse_effective_uid(malformed), None);
397 }
398 }
399
400 #[test]
401 fn proc_status_must_reach_eof_within_the_exact_bound() {
402 let mut exact = b"Uid:\t1234\t1234\t1234\t1234\n".to_vec();
403 exact.resize(PROC_STATUS_MAX_BYTES, b'x');
404 let mut platform = TestPlatform::valid();
405 platform.proc_status = Ok(exact);
406 assert!(load_with(&platform, Path::new(TEST_PATH)).is_ok());
407
408 let mut over = b"Uid:\t1234\t1234\t1234\t1234\n".to_vec();
409 over.resize(PROC_STATUS_MAX_BYTES + 1, b'x');
410 let mut platform = TestPlatform::valid();
411 platform.proc_status = Ok(over);
412 assert_eq!(
413 load_with(&platform, Path::new(TEST_PATH)).err(),
414 Some(BearerCredentialFileError::ProtectedFileRejected)
415 );
416 }
417
418 #[test]
419 fn proc_status_failures_stop_before_the_credential_open() {
420 let canonical = b"Uid:\t1234\t1234\t1234\t1234\n";
421 for fail_at in [0, canonical.len() / 2, canonical.len()] {
422 let mut platform = TestPlatform::valid();
423 platform.proc_status = Ok(canonical.to_vec());
424 platform.proc_status_fail_at = Some(fail_at);
425 assert_eq!(
426 load_with(&platform, Path::new(TEST_PATH)).err(),
427 Some(BearerCredentialFileError::ProtectedFileRejected)
428 );
429 assert_eq!(platform.flags.get(), None);
430 }
431
432 let mut unavailable = TestPlatform::valid();
433 unavailable.proc_status = Err(io::Error::new(
434 io::ErrorKind::NotFound,
435 "secret proc failure canary",
436 ));
437 assert_eq!(
438 load_with(&unavailable, Path::new(TEST_PATH)).err(),
439 Some(BearerCredentialFileError::ProtectedFileRejected)
440 );
441 assert_eq!(unavailable.flags.get(), None);
442
443 for malformed in [
444 b"Name:\triffdb\n".as_slice(),
445 b"Uid:\t1 2 3 4\nUid:\t1 2 3 4\n",
446 b"Uid:\t1 4294967296 3 4\n",
447 ] {
448 let mut platform = TestPlatform::valid();
449 platform.proc_status = Ok(malformed.to_vec());
450 assert_eq!(
451 load_with(&platform, Path::new(TEST_PATH)).err(),
452 Some(BearerCredentialFileError::ProtectedFileRejected)
453 );
454 assert_eq!(platform.flags.get(), None);
455 }
456 }
457
458 #[test]
459 fn path_open_handle_owner_mode_and_identity_failures_are_closed() {
460 let mutations: [fn(&mut TestPlatform); 8] = [
461 |platform| platform.before = Err(io::Error::other("pre-open metadata failure")),
462 |platform| platform.before.as_mut().expect("metadata").is_regular = false,
463 |platform| platform.bytes = Err(io::Error::other("open failure")),
464 |platform| platform.opened = Err(io::Error::other("opened metadata failure")),
465 |platform| platform.opened.as_mut().expect("metadata").is_regular = false,
466 |platform| platform.opened.as_mut().expect("metadata").uid += 1,
467 |platform| platform.opened.as_mut().expect("metadata").device += 1,
468 |platform| platform.opened.as_mut().expect("metadata").inode += 1,
469 ];
470 for mutate in mutations {
471 let mut platform = TestPlatform::valid();
472 mutate(&mut platform);
473 assert_eq!(
474 load_with(&platform, Path::new(TEST_PATH)).err(),
475 Some(BearerCredentialFileError::ProtectedFileRejected)
476 );
477 }
478
479 let mut preopen_nonregular = TestPlatform::valid();
480 preopen_nonregular
481 .before
482 .as_mut()
483 .expect("metadata")
484 .is_regular = false;
485 assert_eq!(
486 load_with(&preopen_nonregular, Path::new(TEST_PATH)).err(),
487 Some(BearerCredentialFileError::ProtectedFileRejected)
488 );
489 assert_eq!(preopen_nonregular.flags.get(), None);
490
491 for forbidden_bit in [0o001, 0o002, 0o004, 0o010, 0o020, 0o040] {
492 let mut platform = TestPlatform::valid();
493 platform.opened.as_mut().expect("metadata").mode |= forbidden_bit;
494 assert_eq!(
495 load_with(&platform, Path::new(TEST_PATH)).err(),
496 Some(BearerCredentialFileError::ProtectedFileRejected),
497 "mode bit {forbidden_bit:#05o} must reject"
498 );
499 }
500 }
501
502 #[test]
503 fn symlink_and_replacement_races_fail_before_a_credential_is_returned() {
504 let mut raced_to_symlink = TestPlatform::valid();
505 raced_to_symlink.bytes = Err(io::Error::other("O_NOFOLLOW raced symlink"));
506 assert_eq!(
507 load_with(&raced_to_symlink, Path::new(TEST_PATH)).err(),
508 Some(BearerCredentialFileError::ProtectedFileRejected)
509 );
510 assert_eq!(raced_to_symlink.flags.get(), Some(O_NOFOLLOW | O_NONBLOCK));
511
512 for replace in [
513 |metadata: &mut FileMetadata| metadata.device += 1,
514 |metadata: &mut FileMetadata| metadata.inode += 1,
515 |metadata: &mut FileMetadata| metadata.is_regular = false,
516 ] {
517 let mut platform = TestPlatform::valid();
518 replace(platform.opened.as_mut().expect("opened metadata"));
519 assert_eq!(
520 load_with(&platform, Path::new(TEST_PATH)).err(),
521 Some(BearerCredentialFileError::ProtectedFileRejected)
522 );
523 }
524 }
525
526 #[test]
527 fn credential_requires_exactly_43_bytes_and_a_confirmed_final_eof() {
528 for bytes in [TOKEN[..42].to_vec(), [TOKEN, b"A"].concat()] {
529 let mut platform = TestPlatform::valid();
530 platform.bytes = Ok(bytes);
531 assert_eq!(
532 load_with(&platform, Path::new(TEST_PATH)).err(),
533 Some(BearerCredentialFileError::ProtectedFileRejected)
534 );
535 }
536
537 for fail_at in [0, BEARER_PRESENTATION_BYTES / 2, BEARER_PRESENTATION_BYTES] {
538 let mut platform = TestPlatform::valid();
539 platform.bytes_fail_at = Some(fail_at);
540 assert_eq!(
541 load_with(&platform, Path::new(TEST_PATH)).err(),
542 Some(BearerCredentialFileError::ProtectedFileRejected),
543 "read failure at byte {fail_at} must reject"
544 );
545 }
546 }
547
548 #[test]
549 fn every_invalid_alphabet_byte_rejects_at_every_presentation_position() {
550 for position in 0..BEARER_PRESENTATION_BYTES {
551 for byte in u8::MIN..=u8::MAX {
552 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') {
553 continue;
554 }
555 let mut bytes = TOKEN.to_vec();
556 bytes[position] = byte;
557 let mut platform = TestPlatform::valid();
558 platform.bytes = Ok(bytes);
559 assert_eq!(
560 load_with(&platform, Path::new(TEST_PATH)).err(),
561 Some(BearerCredentialFileError::InvalidPresentation),
562 "byte {byte:#04x} at position {position} must reject"
563 );
564 }
565 }
566 }
567
568 #[test]
569 fn presentation_valid_noncanonical_token_is_left_for_server_rejection() {
570 let noncanonical = vec![b'A'; BEARER_PRESENTATION_BYTES];
571 let mut platform = TestPlatform::valid();
572 platform.bytes = Ok(noncanonical.clone());
573 let loaded = load_with(&platform, Path::new(TEST_PATH))
574 .expect("presentation-only validation accepts URL-safe alphabet text");
575 let expected =
576 BearerCredential::new(std::str::from_utf8(&noncanonical).expect("ASCII presentation"))
577 .expect("same presentation is accepted by the metadata boundary");
578 assert!(loaded.has_same_presentation(&expected));
579 }
580
581 #[test]
582 fn public_errors_are_fixed_and_redacted() {
583 let canary = "credential-file-secret-canary";
584 for error in [
585 BearerCredentialFileError::UnsupportedPlatform,
586 BearerCredentialFileError::ProtectedFileRejected,
587 BearerCredentialFileError::InvalidPresentation,
588 ] {
589 assert!(!format!("{error:?}").contains(canary));
590 assert!(!error.to_string().contains(canary));
591 assert!(Error::source(&error).is_none());
592 }
593 }
594}