Skip to main content

passless_rs/storage/
index.rs

1use crate::storage::CredentialFilter;
2use crate::storage::rp_id::ValidatedRpId;
3use crate::util::bytes_to_hex;
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};
8
9use log::{debug, warn};
10use sha2::{Digest, Sha256};
11
12pub const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(30);
13pub const MAX_CACHE_SIZE: usize = 10;
14
15#[derive(Debug, Clone)]
16pub struct CredentialPathInfo {
17    pub rp_id: ValidatedRpId,
18    pub cred_id: Vec<u8>,
19    pub extension: String,
20}
21
22impl CredentialPathInfo {
23    pub fn new(rp_id: ValidatedRpId, cred_id: Vec<u8>, extension: String) -> Self {
24        Self {
25            rp_id,
26            cred_id,
27            extension,
28        }
29    }
30
31    pub fn from_path(path: &Path, extension: &str) -> Option<Self> {
32        let filename = path.file_name()?.to_str()?;
33        let cred_id = parse_cred_id_from_filename(filename, extension)?;
34
35        let parent = path.parent()?;
36        let rp_id_str = parent.file_name()?.to_str()?;
37
38        let rp_id = match ValidatedRpId::try_from(rp_id_str) {
39            Ok(id) => id,
40            Err(e) => {
41                warn!(
42                    "Skipping credential at {}: invalid RP ID directory name: {}",
43                    path.display(),
44                    e
45                );
46                return None;
47            }
48        };
49
50        Some(Self {
51            rp_id,
52            cred_id,
53            extension: extension.to_string(),
54        })
55    }
56
57    pub fn to_path(&self, base_dir: &Path) -> PathBuf {
58        get_credential_path(base_dir, &self.rp_id, &self.cred_id, &self.extension)
59    }
60
61    pub fn rp_id_hash(&self) -> [u8; 32] {
62        let mut hasher = Sha256::new();
63        hasher.update(self.rp_id.as_bytes());
64        hasher.finalize().into()
65    }
66}
67
68#[derive(Default)]
69pub struct CredentialIndexes {
70    pub id: HashMap<Vec<u8>, CredentialPathInfo>,
71    pub rp: HashMap<String, Vec<Vec<u8>>>,
72    pub rp_hash: HashMap<[u8; 32], Vec<Vec<u8>>>,
73}
74
75impl CredentialIndexes {
76    pub fn resolve_filter(&self, filter: &CredentialFilter, base_dir: &Path) -> Vec<PathBuf> {
77        match filter {
78            CredentialFilter::None => self
79                .id
80                .values()
81                .map(|path_info| path_info.to_path(base_dir))
82                .collect(),
83            CredentialFilter::ById(id) => {
84                if let Some(path_info) = self.id.get(id) {
85                    vec![path_info.to_path(base_dir)]
86                } else {
87                    Vec::new()
88                }
89            }
90            CredentialFilter::ByRp(rp_id) => self
91                .rp
92                .get(rp_id)
93                .map(|cred_ids| {
94                    cred_ids
95                        .iter()
96                        .filter_map(|cred_id| {
97                            self.id
98                                .get(cred_id)
99                                .map(|path_info| path_info.to_path(base_dir))
100                        })
101                        .collect()
102                })
103                .unwrap_or_default(),
104            CredentialFilter::ByHash(hash) => self
105                .rp_hash
106                .get(hash)
107                .map(|cred_ids| {
108                    cred_ids
109                        .iter()
110                        .filter_map(|cred_id| {
111                            self.id
112                                .get(cred_id)
113                                .map(|path_info| path_info.to_path(base_dir))
114                        })
115                        .collect()
116                })
117                .unwrap_or_default(),
118        }
119    }
120}
121
122pub struct CachedCredential {
123    pub credential: soft_fido2::Credential,
124    pub expires_at: Instant,
125}
126
127pub struct CredentialCache {
128    cache: HashMap<PathBuf, CachedCredential>,
129}
130
131impl CredentialCache {
132    pub fn new() -> Self {
133        Self {
134            cache: HashMap::new(),
135        }
136    }
137
138    pub fn get(&self, path: &Path) -> Option<&CachedCredential> {
139        self.cache.get(path)
140    }
141
142    pub fn insert(&mut self, path: PathBuf, credential: soft_fido2::Credential) {
143        let cached = CachedCredential {
144            credential,
145            expires_at: Instant::now() + CREDENTIAL_CACHE_TTL,
146        };
147        self.cache.insert(path, cached);
148        debug!(
149            "Cached credential (expires in {}s)",
150            CREDENTIAL_CACHE_TTL.as_secs()
151        );
152    }
153
154    pub fn remove(&mut self, path: &Path) {
155        if let Some(cached) = self.cache.remove(path) {
156            drop(cached.credential);
157        }
158    }
159
160    pub fn evict_expired(&mut self) {
161        let now = Instant::now();
162
163        let expired: Vec<PathBuf> = self
164            .cache
165            .iter()
166            .filter(|(_, cached)| now >= cached.expires_at)
167            .map(|(path, _)| path.clone())
168            .collect();
169
170        for path in expired {
171            debug!("Evicting expired cache entry: {:?}", path);
172            if let Some(cached) = self.cache.remove(&path) {
173                drop(cached.credential);
174            }
175        }
176    }
177
178    fn find_oldest(&self) -> Option<PathBuf> {
179        self.cache
180            .iter()
181            .min_by_key(|(_, cached)| cached.expires_at)
182            .map(|(path, _)| path.clone())
183    }
184
185    pub fn evict_oldest_if_full(&mut self) {
186        if self.cache.len() >= MAX_CACHE_SIZE
187            && let Some(oldest) = self.find_oldest()
188        {
189            debug!("Cache full - evicting oldest entry: {:?}", oldest);
190            if let Some(cached) = self.cache.remove(&oldest) {
191                drop(cached.credential);
192            }
193        }
194    }
195}
196
197impl Default for CredentialCache {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203pub fn get_filename(cred_id: &[u8], extension: &str) -> String {
204    format!("{}.{}", bytes_to_hex(cred_id), extension)
205}
206
207pub fn parse_cred_id_from_filename(filename: &str, extension: &str) -> Option<Vec<u8>> {
208    let name = filename.strip_suffix(&format!(".{}", extension))?;
209    if name.len() % 2 != 0 {
210        return None;
211    }
212
213    let mut bytes = Vec::with_capacity(name.len() / 2);
214    for i in (0..name.len()).step_by(2) {
215        let byte = u8::from_str_radix(&name[i..i + 2], 16).ok()?;
216        bytes.push(byte);
217    }
218    Some(bytes)
219}
220
221pub fn get_credential_path(
222    storage_dir: &Path,
223    rp_id: &ValidatedRpId,
224    cred_id: &[u8],
225    extension: &str,
226) -> PathBuf {
227    storage_dir
228        .join(rp_id.as_str())
229        .join(get_filename(cred_id, extension))
230}
231
232pub fn load_credential_paths(
233    storage_dir: &Path,
234    extension: &str,
235) -> std::io::Result<CredentialIndexes> {
236    debug!("Loading credential paths (extension: {})", extension);
237
238    let entries = match std::fs::read_dir(storage_dir) {
239        Ok(entries) => entries,
240        Err(e) => {
241            debug!("Failed to read storage directory: {}", e);
242            return Ok(CredentialIndexes::default());
243        }
244    };
245
246    let rp_dirs: Vec<PathBuf> = entries
247        .filter_map(|entry| entry.ok())
248        .filter_map(|entry| {
249            let path = entry.path();
250            if !path.is_dir()
251                || path
252                    .file_name()
253                    .and_then(|s| s.to_str())
254                    .is_some_and(|s| s.starts_with('.'))
255            {
256                return None;
257            }
258
259            Some(path)
260        })
261        .collect();
262
263    debug!("Found {} RP directories", rp_dirs.len());
264
265    let mut indexes = CredentialIndexes::default();
266
267    for rp_dir in rp_dirs {
268        let cred_files = match std::fs::read_dir(&rp_dir) {
269            Ok(entries) => entries
270                .filter_map(|entry| entry.ok())
271                .filter_map(|entry| {
272                    let path = entry.path();
273                    if path.extension().and_then(|s| s.to_str()) == Some(extension) {
274                        Some(path)
275                    } else {
276                        None
277                    }
278                })
279                .collect::<Vec<_>>(),
280            Err(_) => Vec::new(),
281        };
282
283        for cred_path in cred_files {
284            if let Some(path_info) = CredentialPathInfo::from_path(&cred_path, extension) {
285                let cred_id = path_info.cred_id.clone();
286                let rp_id = path_info.rp_id.clone();
287                let rp_hash = path_info.rp_id_hash();
288
289                indexes.id.insert(cred_id.clone(), path_info);
290                indexes
291                    .rp
292                    .entry(rp_id.to_string())
293                    .or_default()
294                    .push(cred_id.clone());
295                indexes.rp_hash.entry(rp_hash).or_default().push(cred_id);
296            }
297        }
298    }
299
300    debug!("Loaded {} credentials into indexes", indexes.id.len());
301    Ok(indexes)
302}
303
304pub fn update_indexes_on_write(indexes: &mut CredentialIndexes, path_info: CredentialPathInfo) {
305    let cred_id = path_info.cred_id.clone();
306    let rp_id = path_info.rp_id.clone();
307    let rp_hash = path_info.rp_id_hash();
308
309    let is_new = !indexes.id.contains_key(&cred_id);
310
311    indexes.id.insert(cred_id.clone(), path_info);
312
313    if is_new {
314        indexes
315            .rp
316            .entry(rp_id.to_string())
317            .or_default()
318            .push(cred_id.clone());
319        indexes.rp_hash.entry(rp_hash).or_default().push(cred_id);
320    }
321}
322
323pub fn update_indexes_on_delete(indexes: &mut CredentialIndexes, cred_id: &[u8]) {
324    if let Some(path_info) = indexes.id.remove(cred_id) {
325        let rp_id_str = path_info.rp_id.to_string();
326        let rp_hash = path_info.rp_id_hash();
327
328        if let Some(cred_ids) = indexes.rp.get_mut(&rp_id_str) {
329            cred_ids.retain(|id| id != cred_id);
330            if cred_ids.is_empty() {
331                indexes.rp.remove(&rp_id_str);
332            }
333        }
334
335        if let Some(cred_ids) = indexes.rp_hash.get_mut(&rp_hash) {
336            cred_ids.retain(|id| id != cred_id);
337            if cred_ids.is_empty() {
338                indexes.rp_hash.remove(&rp_hash);
339            }
340        }
341    }
342}