Skip to main content

riffdb_client_rust/
credential_file.rs

1//! Protected presentation-only bearer credential loading.
2
3use 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/// A closed, redaction-safe failure to load one protected bearer credential.
25#[derive(Clone, Copy, Eq, PartialEq)]
26pub enum BearerCredentialFileError {
27    /// The exact protected-file procedure is unavailable on this platform.
28    UnsupportedPlatform,
29    /// The file, its metadata, or the effective-user source failed closed.
30    ProtectedFileRejected,
31    /// The exact 43-byte file is not a valid bearer presentation.
32    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
59/// Loads one exact bearer presentation through the protected Linux file rule.
60///
61/// The loader performs no token decoding, hashing, authentication, or
62/// capability lookup. All temporary credential bytes are bounded and zeroized.
63pub 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}