1use 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
24pub const MAX_STORE_FILES: usize = 1024;
26pub const MAX_STORE_FILE_BYTES: u64 = 16 * 1024;
29const EXTENSION: &str = "grant";
30
31#[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#[derive(Debug, Default)]
43pub struct LoadReport {
44 pub grants: Vec<StoredGrant>,
45 pub warnings: Vec<String>,
46}
47
48#[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#[derive(Debug, Clone)]
71pub struct GrantStore {
72 dir: PathBuf,
73}
74
75impl GrantStore {
76 pub fn default_location() -> Result<PathBuf, String> {
83 Ok(crate::config::xdg_config_home_absolute()?
84 .join("mkit")
85 .join("grants"))
86 }
87
88 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 #[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 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 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 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 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#[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
335fn 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 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 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 fs::write(
442 store.dir().join(format!("{}.grant", "00".repeat(32))),
443 &good,
444 )
445 .unwrap();
446 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 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 let path = GrantStore::default_location().unwrap();
592 assert!(path.ends_with("mkit/grants"), "{}", path.display());
593 }
594}