passless_rs/storage/
index.rs1use 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}