Skip to main content

passless_rs/pin_storage/
pass.rs

1//! Pass (password-store) PIN storage
2
3use crate::pin_storage::{
4    PinStorage, SerializablePinConfig, SerializablePinRetries, SerializablePinState,
5};
6use crate::storage::pass::GpgBackend;
7use crate::storage::pass::gpg_id;
8use crate::util::create_secure_dir_all;
9
10use passless_core::error::{Error, Result};
11
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14
15use log::{debug, info, warn};
16use prs_lib::crypto::IsContext;
17use prs_lib::{Ciphertext, Plaintext, Store};
18
19const PIN_CONFIG_ENTRY: &str = "pin_state";
20const PIN_RETRIES_ENTRY: &str = "pin_retries";
21
22/// Tracked state for conflict detection
23#[derive(Debug, Clone)]
24struct SyncedConfig {
25    version: u64,
26    #[allow(dead_code)]
27    modified_at: Option<u64>,
28    pin_hash: Option<Vec<u8>>,
29}
30
31/// Pass (password-store) PIN storage
32///
33/// Stores PIN state as two GPG-encrypted entries in the password store:
34/// - `pin_state.gpg`: PIN config (hash, min length, version) - synced to git
35/// - `pin_retries.gpg`: Retry counters (retries, uv_retries, locked_until) - local only
36pub struct PassPinStorage {
37    store_path: PathBuf,
38    fido2_path: PathBuf,
39    gpg_backend: GpgBackend,
40    /// Last known config state for change detection (for git sync optimization)
41    last_config: RwLock<Option<SerializablePinConfig>>,
42    /// Last synced config state for conflict detection
43    last_synced: RwLock<Option<SyncedConfig>>,
44}
45
46impl PassPinStorage {
47    /// Create a new Pass PIN storage
48    pub fn new(store_path: PathBuf, fido2_path: PathBuf, gpg_backend: GpgBackend) -> Self {
49        debug!(
50            "Pass PIN storage: store={}, path={}",
51            store_path.display(),
52            fido2_path.display()
53        );
54        Self {
55            store_path,
56            fido2_path,
57            gpg_backend,
58            last_config: RwLock::new(None),
59            last_synced: RwLock::new(None),
60        }
61    }
62
63    fn get_config_path(&self) -> PathBuf {
64        self.store_path
65            .join(&self.fido2_path)
66            .join(format!("{}.gpg", PIN_CONFIG_ENTRY))
67    }
68
69    fn get_retries_path(&self) -> PathBuf {
70        self.store_path
71            .join(&self.fido2_path)
72            .join(format!("{}.gpg", PIN_RETRIES_ENTRY))
73    }
74
75    fn create_crypto_context(&self) -> Result<prs_lib::crypto::Context> {
76        let proto = match self.gpg_backend {
77            GpgBackend::Gpgme | GpgBackend::GnupgBin => prs_lib::crypto::Proto::Gpg,
78        };
79
80        let config = prs_lib::crypto::Config::from(proto);
81        prs_lib::crypto::context(&config).map_err(|e| {
82            warn!("Failed to create crypto context: {:?}", e);
83            Error::Storage(format!("Failed to create crypto context: {:?}", e))
84        })
85    }
86
87    fn resolve_recipients_for_target(&self, target: &Path) -> Result<prs_lib::Recipients> {
88        gpg_id::resolve_recipients_for_target(&self.store_path, target)
89    }
90
91    fn sync_prepare(&self) -> Result<()> {
92        debug!("Preparing password store sync for PIN config");
93
94        let store = Store::open(self.store_path.to_string_lossy().as_ref()).map_err(|e| {
95            debug!("Failed to open store for sync: {:?}", e);
96            Error::Storage(format!("Failed to open store for sync: {:?}", e))
97        })?;
98
99        let sync = store.sync();
100
101        match sync.prepare() {
102            Ok(()) => {
103                debug!("Successfully prepared store sync (pulled if remote configured)");
104                Ok(())
105            }
106            Err(e) => {
107                warn!("Failed to prepare store sync: {:?}", e);
108                Ok(())
109            }
110        }
111    }
112
113    fn sync_finalize(&self, message: &str) -> Result<()> {
114        debug!("Finalizing password store sync: {}", message);
115
116        let store = Store::open(self.store_path.to_string_lossy().as_ref()).map_err(|e| {
117            debug!("Failed to open store for sync: {:?}", e);
118            Error::Storage(format!("Failed to open store for sync: {:?}", e))
119        })?;
120
121        let sync = store.sync();
122
123        match sync.finalize(message) {
124            Ok(()) => {
125                debug!(
126                    "Successfully finalized store sync (committed and pushed if remote configured)"
127                );
128                Ok(())
129            }
130            Err(e) => {
131                debug!("Failed to finalize store sync: {:?}", e);
132                warn!("Failed to finalize store sync: {:?}", e);
133                Ok(())
134            }
135        }
136    }
137
138    fn load_config(&self) -> std::result::Result<SerializablePinConfig, soft_fido2::StatusCode> {
139        debug!("Loading PIN config from pass store");
140
141        let path = self.get_config_path();
142
143        if !path.exists() {
144            debug!("PIN config entry does not exist, returning default state");
145            return Ok(SerializablePinConfig::default());
146        }
147
148        let encrypted_data = std::fs::read(&path).map_err(|e| {
149            warn!("Failed to read PIN config entry: {}", e);
150            soft_fido2::StatusCode::Other
151        })?;
152
153        let mut context = self.create_crypto_context().map_err(|e| {
154            warn!("Failed to create crypto context: {:?}", e);
155            soft_fido2::StatusCode::Other
156        })?;
157
158        let ciphertext = Ciphertext::from(encrypted_data);
159        let plaintext = context.decrypt(ciphertext).map_err(|e| {
160            warn!("Failed to decrypt PIN config: {:?}", e);
161            soft_fido2::StatusCode::Other
162        })?;
163
164        SerializablePinConfig::from_json_bytes(plaintext.unsecure_ref()).map_err(|e| {
165            warn!("Failed to parse PIN config: {:?}", e);
166            soft_fido2::StatusCode::InvalidParameter
167        })
168    }
169
170    fn load_retries(&self) -> std::result::Result<SerializablePinRetries, soft_fido2::StatusCode> {
171        debug!("Loading PIN retries from pass store");
172
173        let path = self.get_retries_path();
174
175        if !path.exists() {
176            debug!("PIN retries entry does not exist, returning default state");
177            return Ok(SerializablePinRetries::default());
178        }
179
180        let encrypted_data = std::fs::read(&path).map_err(|e| {
181            warn!("Failed to read PIN retries entry: {}", e);
182            soft_fido2::StatusCode::Other
183        })?;
184
185        let mut context = self.create_crypto_context().map_err(|e| {
186            warn!("Failed to create crypto context: {:?}", e);
187            soft_fido2::StatusCode::Other
188        })?;
189
190        let ciphertext = Ciphertext::from(encrypted_data);
191        let plaintext = context.decrypt(ciphertext).map_err(|e| {
192            warn!("Failed to decrypt PIN retries: {:?}", e);
193            soft_fido2::StatusCode::Other
194        })?;
195
196        SerializablePinRetries::from_json_bytes(plaintext.unsecure_ref()).map_err(|e| {
197            warn!("Failed to parse PIN retries: {:?}", e);
198            soft_fido2::StatusCode::InvalidParameter
199        })
200    }
201
202    fn save_config(
203        &self,
204        config: &SerializablePinConfig,
205    ) -> std::result::Result<(), soft_fido2::StatusCode> {
206        debug!("Saving PIN config to pass store");
207
208        let bytes = config.to_json_bytes()?;
209
210        let path = self.get_config_path();
211        let recipients = self.resolve_recipients_for_target(&path).map_err(|e| {
212            warn!("Failed to load GPG recipients: {:?}", e);
213            soft_fido2::StatusCode::Other
214        })?;
215
216        let mut context = self.create_crypto_context().map_err(|e| {
217            warn!("Failed to create crypto context: {:?}", e);
218            soft_fido2::StatusCode::Other
219        })?;
220
221        if let Some(parent) = path.parent() {
222            create_secure_dir_all(parent).map_err(|e| {
223                warn!("Failed to create directory: {}", e);
224                soft_fido2::StatusCode::Other
225            })?;
226        }
227
228        let plaintext = Plaintext::from(bytes);
229        context
230            .encrypt_file(&recipients, plaintext, &path)
231            .map_err(|e| {
232                warn!("Failed to encrypt PIN config: {:?}", e);
233                soft_fido2::StatusCode::Other
234            })?;
235
236        let relative_path = path
237            .strip_prefix(&self.store_path)
238            .unwrap_or(&path)
239            .display();
240        let commit_message = format!("Update PIN configuration: {}.", relative_path);
241        self.sync_finalize(&commit_message).map_err(|e| {
242            warn!("Failed to sync PIN config: {:?}", e);
243            soft_fido2::StatusCode::Other
244        })?;
245
246        *self.last_config.write().map_err(|e| {
247            warn!("Failed to acquire config lock: {:?}", e);
248            soft_fido2::StatusCode::Other
249        })? = Some(config.clone());
250        debug!("PIN config saved and synced successfully");
251        Ok(())
252    }
253
254    fn save_retries(
255        &self,
256        retries: &SerializablePinRetries,
257    ) -> std::result::Result<(), soft_fido2::StatusCode> {
258        debug!("Saving PIN retries to pass store (local only, no sync)");
259
260        let bytes = retries.to_json_bytes()?;
261
262        let path = self.get_retries_path();
263        let recipients = self.resolve_recipients_for_target(&path).map_err(|e| {
264            warn!("Failed to load GPG recipients: {:?}", e);
265            soft_fido2::StatusCode::Other
266        })?;
267
268        let mut context = self.create_crypto_context().map_err(|e| {
269            warn!("Failed to create crypto context: {:?}", e);
270            soft_fido2::StatusCode::Other
271        })?;
272
273        if let Some(parent) = path.parent() {
274            create_secure_dir_all(parent).map_err(|e| {
275                warn!("Failed to create directory: {}", e);
276                soft_fido2::StatusCode::Other
277            })?;
278        }
279
280        let plaintext = Plaintext::from(bytes);
281        context
282            .encrypt_file(&recipients, plaintext, &path)
283            .map_err(|e| {
284                warn!("Failed to encrypt PIN retries: {:?}", e);
285                soft_fido2::StatusCode::Other
286            })?;
287
288        debug!("PIN retries saved successfully (no git sync)");
289        Ok(())
290    }
291
292    fn config_changed(&self, new_config: &SerializablePinConfig) -> bool {
293        let last = match self.last_config.read() {
294            Ok(l) => l,
295            Err(_) => return true,
296        };
297        match last.as_ref() {
298            Some(old) => old != new_config,
299            None => true,
300        }
301    }
302
303    /// Check for conflict between local changes and remote config
304    /// Returns Some(resolved_config) if conflict was resolved, None if no conflict
305    fn check_and_resolve_conflict(
306        &self,
307        local_config: &SerializablePinConfig,
308    ) -> std::result::Result<Option<SerializablePinConfig>, soft_fido2::StatusCode> {
309        let last_synced = match self.last_synced.read() {
310            Ok(l) => l.clone(),
311            Err(_) => return Ok(None),
312        };
313
314        let Some(synced) = last_synced else {
315            debug!("No previous synced state, no conflict check needed");
316            return Ok(None);
317        };
318
319        // Pull latest from remote to check for conflicts
320        if let Err(e) = self.sync_prepare() {
321            warn!("Failed to prepare sync for conflict check: {:?}", e);
322        }
323
324        // Load remote config after pull
325        let remote_config = self.load_config()?;
326
327        // Check if remote changed since we last synced
328        let remote_changed =
329            remote_config.version != synced.version || remote_config.pin_hash != synced.pin_hash;
330
331        if !remote_changed {
332            debug!("Remote config unchanged since last sync, no conflict");
333            return Ok(None);
334        }
335
336        // Check if local changed since we last synced
337        let local_changed =
338            local_config.version != synced.version || local_config.pin_hash != synced.pin_hash;
339
340        if !local_changed {
341            debug!("Remote changed but local unchanged, using remote config");
342            return Ok(Some(remote_config));
343        }
344
345        // Both changed - conflict detected
346        warn!("PIN config conflict detected: both local and remote changed since last sync");
347        warn!(
348            "Local: version={}, modified_at={:?}",
349            local_config.version, local_config.modified_at
350        );
351        warn!(
352            "Remote: version={}, modified_at={:?}",
353            remote_config.version, remote_config.modified_at
354        );
355
356        // Timestamp-based resolution: newer wins
357        let local_time = local_config.modified_at.unwrap_or(0);
358        let remote_time = remote_config.modified_at.unwrap_or(0);
359
360        if remote_time > local_time {
361            info!(
362                "Conflict resolved: remote config is newer ({} > {}), using remote",
363                remote_time, local_time
364            );
365            return Ok(Some(remote_config));
366        } else if local_time > remote_time {
367            info!(
368                "Conflict resolved: local config is newer ({} > {}), keeping local",
369                local_time, remote_time
370            );
371            return Ok(None);
372        }
373
374        // Same timestamp or both missing - use version as tie-breaker
375        if remote_config.version > local_config.version {
376            info!(
377                "Conflict resolved: remote has higher version ({} > {})",
378                remote_config.version, local_config.version
379            );
380            return Ok(Some(remote_config));
381        }
382
383        info!(
384            "Conflict resolved: local has higher or equal version ({} >= {})",
385            local_config.version, remote_config.version
386        );
387        Ok(None)
388    }
389}
390
391impl PinStorage for PassPinStorage {
392    fn load_pin_state(&self) -> std::result::Result<soft_fido2::PinState, soft_fido2::StatusCode> {
393        // Pull latest changes from git remote if configured
394        if let Err(e) = self.sync_prepare() {
395            warn!("Failed to prepare sync for PIN config: {:?}", e);
396        }
397
398        let config = self.load_config()?;
399        let retries = self.load_retries()?;
400
401        // Track synced state for conflict detection
402        *self.last_synced.write().map_err(|e| {
403            warn!("Failed to acquire synced lock: {:?}", e);
404            soft_fido2::StatusCode::Other
405        })? = Some(SyncedConfig {
406            version: config.version,
407            modified_at: config.modified_at,
408            pin_hash: config.pin_hash.clone(),
409        });
410
411        *self.last_config.write().map_err(|e| {
412            warn!("Failed to acquire config lock: {:?}", e);
413            soft_fido2::StatusCode::Other
414        })? = Some(config.clone());
415
416        let state = SerializablePinState::from_parts(&config, &retries);
417        Ok(state.into())
418    }
419
420    fn save_pin_state(
421        &self,
422        state: &soft_fido2::PinState,
423    ) -> std::result::Result<(), soft_fido2::StatusCode> {
424        let new_config = SerializablePinConfig::from(state);
425        let new_retries = SerializablePinRetries::from(state);
426
427        if state.is_pin_set() {
428            info!(
429                "Saving PIN state (PIN is set, {} retries remaining)",
430                state.retries
431            );
432        } else {
433            info!("Saving PIN state (no PIN set)");
434        }
435
436        if self.config_changed(&new_config) {
437            debug!("PIN config changed, checking for conflicts...");
438
439            // Check for conflicts with remote
440            match self.check_and_resolve_conflict(&new_config)? {
441                Some(resolved_config) => {
442                    warn!("Conflict resolved by using remote config, updating local state");
443                    // Use resolved config from remote, but update retries locally
444                    let resolved_state =
445                        SerializablePinState::from_parts(&resolved_config, &new_retries);
446                    let _resolved_pin_state: soft_fido2::PinState = resolved_state.into();
447
448                    // Update our tracked state
449                    *self.last_synced.write().map_err(|e| {
450                        warn!("Failed to acquire synced lock: {:?}", e);
451                        soft_fido2::StatusCode::Other
452                    })? = Some(SyncedConfig {
453                        version: resolved_config.version,
454                        modified_at: resolved_config.modified_at,
455                        pin_hash: resolved_config.pin_hash.clone(),
456                    });
457
458                    *self.last_config.write().map_err(|e| {
459                        warn!("Failed to acquire config lock: {:?}", e);
460                        soft_fido2::StatusCode::Other
461                    })? = Some(resolved_config);
462
463                    // Save retries locally (no sync)
464                    self.save_retries(&new_retries)?;
465
466                    // Return error to indicate the operation should be retried with updated state
467                    return Err(soft_fido2::StatusCode::Other);
468                }
469                None => {
470                    debug!("No conflict, proceeding with save");
471                }
472            }
473
474            debug!("Saving PIN config and syncing");
475            self.save_config(&new_config)?;
476
477            // Update synced state after successful save
478            *self.last_synced.write().map_err(|e| {
479                warn!("Failed to acquire synced lock: {:?}", e);
480                soft_fido2::StatusCode::Other
481            })? = Some(SyncedConfig {
482                version: new_config.version,
483                modified_at: new_config.modified_at,
484                pin_hash: new_config.pin_hash.clone(),
485            });
486        } else {
487            debug!("PIN config unchanged, skipping save");
488        }
489
490        self.save_retries(&new_retries)?;
491
492        Ok(())
493    }
494}