Skip to main content

mkit_cli/grants/
store.rs

1//! The user grant store (WP-2.13, R-155).
2//!
3//! One file per grant at `$XDG_CONFIG_HOME/mkit/grants/<grant id hex>.grant`,
4//! holding the raw SPEC-WRITE-GRANTS §4.2 header value and nothing else;
5//! everything else is derived by parsing. The directory is 0700 and each file
6//! 0600, written atomically. The store is never repository-scoped: its
7//! location comes from the XDG base directory alone, and it never reads a
8//! repository path.
9//!
10//! Grants are not secret (§12: disclosing one is harmless), but a planted
11//! file could steer grant selection, so every file is re-parsed and its owner
12//! signature re-verified on load. A bad file costs one warning and is skipped.
13
14use std::ffi::OsString;
15use std::fs;
16use std::io::{self, Write as _};
17use std::path::{Path, PathBuf};
18
19use mkit_attest::grant::{Grant, OwnerScheme, RelyingParty};
20use mkit_core::hash::to_hex_bytes;
21
22use super::{HeaderError, verify_grant_header};
23
24/// Most files the store loads.
25pub const MAX_STORE_FILES: usize = 1024;
26/// Largest file the store loads. A header is at most 8,192 bytes; this bound
27/// leaves room for nothing else.
28pub const MAX_STORE_FILE_BYTES: u64 = 16 * 1024;
29const EXTENSION: &str = "grant";
30
31/// A grant read back from the store, already verified.
32#[derive(Debug, Clone)]
33pub struct StoredGrant {
34    pub id: [u8; 32],
35    pub header: String,
36    pub grant: Grant,
37    pub scheme: OwnerScheme,
38}
39
40/// The result of loading the store: what verified, and one warning per
41/// problem.
42#[derive(Debug, Default)]
43pub struct LoadReport {
44    pub grants: Vec<StoredGrant>,
45    pub warnings: Vec<String>,
46}
47
48/// What [`GrantStore::add`] did.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum AddOutcome {
51    Added,
52    AlreadyStored,
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum StoreError {
57    #[error("grant store {path}: {source}")]
58    Io { path: PathBuf, source: io::Error },
59    #[error("{0}")]
60    Rejected(#[from] HeaderError),
61    #[error("{0}")]
62    Unsafe(String),
63    #[error(
64        "the grant store already holds {MAX_STORE_FILES} grants; remove some with `mkit grant revoke --prune` or delete files under {0}"
65    )]
66    Full(PathBuf),
67}
68
69/// The user grant store.
70#[derive(Debug, Clone)]
71pub struct GrantStore {
72    dir: PathBuf,
73}
74
75impl GrantStore {
76    /// The store beside the user config. Derived from the XDG base directory
77    /// only, so no repository configuration can relocate it.
78    ///
79    /// # Errors
80    /// No absolute config base directory: a store never falls back to the
81    /// working directory.
82    pub fn default_location() -> Result<PathBuf, String> {
83        Ok(crate::config::xdg_config_home_absolute()?
84            .join("mkit")
85            .join("grants"))
86    }
87
88    /// # Errors
89    /// As [`Self::default_location`].
90    pub fn open_default() -> Result<Self, String> {
91        Self::default_location().map(Self::at)
92    }
93
94    #[must_use]
95    pub fn at(dir: PathBuf) -> Self {
96        Self { dir }
97    }
98
99    #[must_use]
100    pub fn dir(&self) -> &Path {
101        &self.dir
102    }
103
104    /// Load and verify every grant, within the store bounds.
105    #[must_use]
106    pub fn load(&self, rps: &[RelyingParty]) -> LoadReport {
107        self.load_bounded(rps, MAX_STORE_FILES, MAX_STORE_FILE_BYTES)
108    }
109
110    pub(crate) fn load_bounded(
111        &self,
112        rps: &[RelyingParty],
113        max_files: usize,
114        max_bytes: u64,
115    ) -> LoadReport {
116        let mut report = LoadReport::default();
117        let meta = match fs::symlink_metadata(&self.dir) {
118            Ok(meta) => meta,
119            Err(e) if e.kind() == io::ErrorKind::NotFound => return report,
120            Err(e) => {
121                report.warnings.push(format!(
122                    "cannot read grant store {}: {e}",
123                    self.dir.display()
124                ));
125                return report;
126            }
127        };
128        if !meta.is_dir() {
129            report.warnings.push(format!(
130                "grant store {} is not a directory; ignoring it",
131                self.dir.display()
132            ));
133            return report;
134        }
135        if let Some(problem) = writable_by_others(&meta) {
136            report.warnings.push(format!(
137                "refusing to load grant store {}: {problem}",
138                self.dir.display()
139            ));
140            return report;
141        }
142        let mut names: Vec<OsString> = match fs::read_dir(&self.dir) {
143            Ok(entries) => entries
144                .filter_map(Result::ok)
145                .map(|entry| entry.file_name())
146                .filter(|name| is_grant_file_name(name))
147                .collect(),
148            Err(e) => {
149                report.warnings.push(format!(
150                    "cannot list grant store {}: {e}",
151                    self.dir.display()
152                ));
153                return report;
154            }
155        };
156        names.sort();
157        if names.len() > max_files {
158            report.warnings.push(format!(
159                "grant store {} holds {} grant files; loading the first {max_files} and skipping {}",
160                self.dir.display(),
161                names.len(),
162                names.len() - max_files
163            ));
164            names.truncate(max_files);
165        }
166        for name in names {
167            let path = self.dir.join(&name);
168            match read_verified(&path, &name, rps, max_bytes) {
169                Ok(grant) => report.grants.push(grant),
170                Err(reason) => report.warnings.push(format!(
171                    "skipping {}: {reason}",
172                    path.display().to_string().escape_debug()
173                )),
174            }
175        }
176        report
177    }
178
179    /// Verify `header` and store it. Adding a grant that is already stored
180    /// (same grant id, §3.4) changes nothing.
181    ///
182    /// # Errors
183    /// The verifier's rule, an unsafe store directory, a full store, or I/O.
184    pub fn add(&self, header: &str, rps: &[RelyingParty]) -> Result<AddOutcome, StoreError> {
185        let verified = verify_grant_header(header, rps)?;
186        self.ensure_dir()?;
187        let path = self.dir.join(file_name(&verified.id));
188        if let Ok(existing) = read_verified(
189            &path,
190            path.file_name().unwrap_or_default(),
191            rps,
192            MAX_STORE_FILE_BYTES,
193        ) && existing.id == verified.id
194        {
195            return Ok(AddOutcome::AlreadyStored);
196        }
197        if !path.exists() && self.count_files() >= MAX_STORE_FILES {
198            return Err(StoreError::Full(self.dir.clone()));
199        }
200        self.write_atomic(&path, header.as_bytes())?;
201        Ok(AddOutcome::Added)
202    }
203
204    /// Delete the grant with `id`, if stored.
205    ///
206    /// # Errors
207    /// I/O other than the file being absent.
208    pub fn remove(&self, id: &[u8; 32]) -> Result<bool, StoreError> {
209        let path = self.dir.join(file_name(id));
210        match fs::remove_file(&path) {
211            Ok(()) => Ok(true),
212            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
213            Err(source) => Err(StoreError::Io { path, source }),
214        }
215    }
216
217    fn count_files(&self) -> usize {
218        fs::read_dir(&self.dir).map_or(0, |entries| {
219            entries
220                .filter_map(Result::ok)
221                .filter(|e| is_grant_file_name(&e.file_name()))
222                .count()
223        })
224    }
225
226    fn ensure_dir(&self) -> Result<(), StoreError> {
227        let io_err = |source| StoreError::Io {
228            path: self.dir.clone(),
229            source,
230        };
231        match fs::symlink_metadata(&self.dir) {
232            Ok(meta) => {
233                if !meta.is_dir() {
234                    return Err(StoreError::Unsafe(format!(
235                        "{} exists and is not a directory",
236                        self.dir.display()
237                    )));
238                }
239                if let Some(problem) = writable_by_others(&meta) {
240                    return Err(StoreError::Unsafe(format!(
241                        "refusing to use grant store {}: {problem}",
242                        self.dir.display()
243                    )));
244                }
245                Ok(())
246            }
247            Err(e) if e.kind() == io::ErrorKind::NotFound => {
248                if let Some(parent) = self.dir.parent() {
249                    fs::create_dir_all(parent).map_err(io_err)?;
250                }
251                create_private_dir(&self.dir).map_err(io_err)
252            }
253            Err(e) => Err(io_err(e)),
254        }
255    }
256
257    fn write_atomic(&self, path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
258        let io_err = |source| StoreError::Io {
259            path: path.to_path_buf(),
260            source,
261        };
262        // `NamedTempFile` creates the file 0600 and the rename is atomic, so
263        // a reader sees the old state or the whole new file.
264        let mut tmp = tempfile::NamedTempFile::new_in(&self.dir).map_err(io_err)?;
265        tmp.write_all(bytes).map_err(io_err)?;
266        tmp.as_file().sync_all().map_err(io_err)?;
267        #[cfg(unix)]
268        {
269            use std::os::unix::fs::PermissionsExt as _;
270            tmp.as_file()
271                .set_permissions(fs::Permissions::from_mode(0o600))
272                .map_err(io_err)?;
273        }
274        tmp.persist(path).map_err(|e| io_err(e.error))?;
275        if let Ok(dir) = fs::File::open(&self.dir) {
276            let _ = dir.sync_all();
277        }
278        Ok(())
279    }
280}
281
282fn file_name(id: &[u8; 32]) -> String {
283    format!("{}.{EXTENSION}", to_hex_bytes(id))
284}
285
286fn read_verified(
287    path: &Path,
288    name: &std::ffi::OsStr,
289    rps: &[RelyingParty],
290    max_bytes: u64,
291) -> Result<StoredGrant, String> {
292    // Open first, then judge the opened file: a symlink or FIFO swapped in
293    // after a path check can't be followed or block the read.
294    let file = open_regular(path).map_err(|e| e.to_string())?;
295    let meta = file.metadata().map_err(|e| e.to_string())?;
296    if !meta.file_type().is_file() {
297        return Err("not a regular file".to_owned());
298    }
299    if meta.len() > max_bytes {
300        return Err(format!("larger than {max_bytes} bytes"));
301    }
302    let bytes = super::read_bounded(file, max_bytes).map_err(|e| e.to_string())?;
303    let header = String::from_utf8(bytes).map_err(|_| "not UTF-8".to_owned())?;
304    let verified = verify_grant_header(&header, rps).map_err(|e| e.to_string())?;
305    if Path::new(name).file_stem().and_then(|s| s.to_str()) != Some(&to_hex_bytes(&verified.id)) {
306        return Err("file name is not the grant id".to_owned());
307    }
308    Ok(StoredGrant {
309        id: verified.id,
310        header,
311        grant: verified.grant,
312        scheme: verified.scheme,
313    })
314}
315
316/// Open `path` without following a final symlink and without blocking on a
317/// FIFO or device.
318#[cfg(unix)]
319fn open_regular(path: &Path) -> io::Result<fs::File> {
320    use std::os::unix::fs::OpenOptionsExt as _;
321    fs::OpenOptions::new()
322        .read(true)
323        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
324        .open(path)
325}
326
327#[cfg(not(unix))]
328fn open_regular(path: &Path) -> io::Result<fs::File> {
329    if !fs::symlink_metadata(path)?.file_type().is_file() {
330        return Err(io::Error::other("not a regular file"));
331    }
332    fs::File::open(path)
333}
334
335/// `<64 lowercase hex>.grant`: the only names the store reads.
336fn is_grant_file_name(name: &std::ffi::OsStr) -> bool {
337    let Some(name) = name.to_str() else {
338        return false;
339    };
340    name.strip_suffix(".grant").is_some_and(|stem| {
341        stem.len() == 64 && stem.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
342    })
343}
344
345#[cfg(unix)]
346fn create_private_dir(path: &Path) -> io::Result<()> {
347    use std::os::unix::fs::DirBuilderExt as _;
348    fs::DirBuilder::new().mode(0o700).create(path)
349}
350
351#[cfg(not(unix))]
352fn create_private_dir(path: &Path) -> io::Result<()> {
353    fs::create_dir(path)
354}
355
356#[cfg(unix)]
357fn writable_by_others(meta: &fs::Metadata) -> Option<&'static str> {
358    use std::os::unix::fs::PermissionsExt as _;
359    (meta.permissions().mode() & 0o022 != 0).then_some("it is group- or world-writable")
360}
361
362#[cfg(not(unix))]
363fn writable_by_others(_meta: &fs::Metadata) -> Option<&'static str> {
364    None
365}
366
367#[cfg(test)]
368mod tests {
369    use super::super::testutil::signed_grant;
370    use super::*;
371
372    const AUDIENCE: &str = "https://git.example.com";
373
374    fn store() -> (tempfile::TempDir, GrantStore) {
375        let dir = tempfile::tempdir().unwrap();
376        let store = GrantStore::at(dir.path().join("mkit").join("grants"));
377        (dir, store)
378    }
379
380    fn id_of(header: &str) -> [u8; 32] {
381        verify_grant_header(header, &[]).unwrap().id
382    }
383
384    #[test]
385    fn add_then_load_round_trips_and_stores_only_the_header() {
386        let (_tmp, store) = store();
387        let header = signed_grant(1, 1, 0, AUDIENCE);
388        assert_eq!(store.add(&header, &[]).unwrap(), AddOutcome::Added);
389        let file = store.dir().join(file_name(&id_of(&header)));
390        assert_eq!(fs::read_to_string(&file).unwrap(), header);
391        let report = store.load(&[]);
392        assert!(report.warnings.is_empty(), "{:?}", report.warnings);
393        assert_eq!(report.grants.len(), 1);
394        assert_eq!(report.grants[0].header, header);
395        assert_eq!(report.grants[0].id, id_of(&header));
396        // Nothing but the grant is left behind, temp files included.
397        assert_eq!(fs::read_dir(store.dir()).unwrap().count(), 1);
398    }
399
400    #[test]
401    fn adding_the_same_grant_id_twice_changes_nothing() {
402        let (_tmp, store) = store();
403        let header = signed_grant(1, 1, 0, AUDIENCE);
404        assert_eq!(store.add(&header, &[]).unwrap(), AddOutcome::Added);
405        let file = store.dir().join(file_name(&id_of(&header)));
406        let before = fs::metadata(&file).unwrap().modified().unwrap();
407        assert_eq!(store.add(&header, &[]).unwrap(), AddOutcome::AlreadyStored);
408        assert_eq!(fs::metadata(&file).unwrap().modified().unwrap(), before);
409        assert_eq!(store.load(&[]).grants.len(), 1);
410    }
411
412    #[cfg(unix)]
413    #[test]
414    fn directory_is_0700_and_files_are_0600() {
415        use std::os::unix::fs::PermissionsExt as _;
416        let (_tmp, store) = store();
417        let header = signed_grant(1, 1, 0, AUDIENCE);
418        store.add(&header, &[]).unwrap();
419        let dir_mode = fs::metadata(store.dir()).unwrap().permissions().mode() & 0o777;
420        assert_eq!(dir_mode, 0o700);
421        let file = store.dir().join(file_name(&id_of(&header)));
422        assert_eq!(
423            fs::metadata(file).unwrap().permissions().mode() & 0o777,
424            0o600
425        );
426    }
427
428    #[test]
429    fn a_tampered_or_forged_file_is_skipped_with_one_warning() {
430        let (_tmp, store) = store();
431        let good = signed_grant(1, 1, 0, AUDIENCE);
432        let victim = signed_grant(1, 2, 0, AUDIENCE);
433        store.add(&good, &[]).unwrap();
434        store.add(&victim, &[]).unwrap();
435        // Flip one byte of the signed statement in place.
436        let path = store.dir().join(file_name(&id_of(&victim)));
437        let mut bytes = fs::read(&path).unwrap();
438        bytes[10] = if bytes[10] == b'A' { b'B' } else { b'A' };
439        fs::write(&path, &bytes).unwrap();
440        // A file whose name isn't its grant id (a copy planted under another name).
441        fs::write(
442            store.dir().join(format!("{}.grant", "00".repeat(32))),
443            &good,
444        )
445        .unwrap();
446        // Garbage, and a non-grant file that must be ignored silently.
447        fs::write(
448            store.dir().join(format!("{}.grant", "11".repeat(32))),
449            b"not a header",
450        )
451        .unwrap();
452        fs::write(store.dir().join("notes.txt"), b"ignored").unwrap();
453        let report = store.load(&[]);
454        assert_eq!(report.grants.len(), 1);
455        assert_eq!(report.grants[0].header, good);
456        assert_eq!(report.warnings.len(), 3, "{:?}", report.warnings);
457        assert!(report.warnings.iter().all(|w| w.starts_with("skipping ")));
458    }
459
460    #[test]
461    fn oversize_files_are_skipped_and_reported() {
462        let (_tmp, store) = store();
463        store.add(&signed_grant(1, 1, 0, AUDIENCE), &[]).unwrap();
464        let big = store.dir().join(format!("{}.grant", "22".repeat(32)));
465        fs::write(
466            &big,
467            vec![b'a'; usize::try_from(MAX_STORE_FILE_BYTES).unwrap() + 1],
468        )
469        .unwrap();
470        let report = store.load(&[]);
471        assert_eq!(report.grants.len(), 1);
472        assert_eq!(report.warnings.len(), 1);
473        assert!(
474            report.warnings[0].contains("larger than"),
475            "{:?}",
476            report.warnings
477        );
478    }
479
480    #[test]
481    fn the_file_count_bound_holds() {
482        let (_tmp, store) = store();
483        for nonce in 1..=3 {
484            store
485                .add(&signed_grant(1, nonce, 0, AUDIENCE), &[])
486                .unwrap();
487        }
488        let report = store.load_bounded(&[], 2, MAX_STORE_FILE_BYTES);
489        assert_eq!(report.grants.len(), 2);
490        assert_eq!(report.warnings.len(), 1);
491        assert!(
492            report.warnings[0].contains("skipping 1"),
493            "{:?}",
494            report.warnings
495        );
496        // The advertised bounds are the specified ones.
497        assert_eq!(MAX_STORE_FILES, 1024);
498        assert_eq!(MAX_STORE_FILE_BYTES, 16 * 1024);
499    }
500
501    #[cfg(unix)]
502    #[test]
503    fn a_group_writable_store_is_refused() {
504        use std::os::unix::fs::PermissionsExt as _;
505        let (_tmp, store) = store();
506        store.add(&signed_grant(1, 1, 0, AUDIENCE), &[]).unwrap();
507        fs::set_permissions(store.dir(), fs::Permissions::from_mode(0o770)).unwrap();
508        let report = store.load(&[]);
509        assert!(report.grants.is_empty());
510        assert!(report.warnings[0].contains("group- or world-writable"));
511        assert!(matches!(
512            store.add(&signed_grant(1, 2, 0, AUDIENCE), &[]),
513            Err(StoreError::Unsafe(_))
514        ));
515    }
516
517    #[cfg(unix)]
518    #[test]
519    fn a_symlinked_grant_file_is_not_followed() {
520        let (_tmp, store) = store();
521        let header = signed_grant(1, 1, 0, AUDIENCE);
522        store.add(&header, &[]).unwrap();
523        let real = store.dir().join(file_name(&id_of(&header)));
524        let target = store.dir().parent().unwrap().join("elsewhere");
525        fs::rename(&real, &target).unwrap();
526        std::os::unix::fs::symlink(&target, &real).unwrap();
527        let report = store.load(&[]);
528        assert!(report.grants.is_empty());
529        assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
530        assert!(report.warnings[0].starts_with("skipping "));
531    }
532
533    #[test]
534    fn stray_files_do_not_use_up_the_file_cap() {
535        let (_tmp, store) = store();
536        let header = signed_grant(1, 1, 0, AUDIENCE);
537        store.add(&header, &[]).unwrap();
538        for name in [
539            "0.grant",
540            "1.grant",
541            "not-a-grant.grant",
542            "\u{1b}[31m.grant",
543        ] {
544            fs::write(store.dir().join(name), "junk").unwrap();
545        }
546        let report = store.load_bounded(&[], 1, MAX_STORE_FILE_BYTES);
547        assert_eq!(report.grants.len(), 1);
548        assert!(report.warnings.is_empty(), "{:?}", report.warnings);
549    }
550
551    #[test]
552    fn a_webauthn_grant_is_refused_without_a_pinned_relying_party() {
553        let (_tmp, store) = store();
554        let mut signed =
555            mkit_attest::grant::SignedHeader::parse(&signed_grant(1, 1, 0, AUDIENCE)).unwrap();
556        signed.scheme = mkit_attest::grant::OwnerScheme::WebAuthnP256;
557        let header = signed.encode().unwrap();
558        let error = store.add(&header, &[]).unwrap_err().to_string();
559        assert!(error.contains("pinned relying party"), "{error}");
560        assert!(!store.dir().exists() || store.load(&[]).grants.is_empty());
561    }
562
563    #[test]
564    fn a_rejected_add_stores_nothing_and_names_the_rule() {
565        let (_tmp, store) = store();
566        let mut signed =
567            mkit_attest::grant::SignedHeader::parse(&signed_grant(1, 1, 0, AUDIENCE)).unwrap();
568        signed.blob[0] ^= 1;
569        let error = store
570            .add(&signed.encode().unwrap(), &[])
571            .unwrap_err()
572            .to_string();
573        assert_eq!(error, "bad signature");
574        assert!(!store.dir().exists());
575    }
576
577    #[test]
578    fn remove_deletes_the_file() {
579        let (_tmp, store) = store();
580        let header = signed_grant(1, 1, 0, AUDIENCE);
581        store.add(&header, &[]).unwrap();
582        assert!(store.remove(&id_of(&header)).unwrap());
583        assert!(!store.remove(&id_of(&header)).unwrap());
584        assert!(store.load(&[]).grants.is_empty());
585    }
586
587    #[test]
588    fn the_default_location_ignores_the_repository() {
589        // It is derived from the XDG base directory alone: no argument, no
590        // config field and no repository path can move it.
591        let path = GrantStore::default_location().unwrap();
592        assert!(path.ends_with("mkit/grants"), "{}", path.display());
593    }
594}