Implement biometric-protected GPG unlock

This commit is contained in:
2026-08-11 18:32:53 +02:00
parent 3295761bcf
commit 873db91204
15 changed files with 2043 additions and 59 deletions

View File

@@ -33,6 +33,7 @@ struct BackendState {
retrieves: usize,
locks: usize,
unlocks: usize,
last_protection: Option<SecretProtection>,
}
#[derive(Clone, Default)]
@@ -51,6 +52,14 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").locks
}
fn last_protection(&self) -> Option<SecretProtection> {
self.0.lock().expect("test mutex").last_protection
}
fn invalidate_enrollment(&self) {
self.0.lock().expect("test mutex").values.clear();
}
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
match state.fault.take() {
Some(error) => Err(error),
@@ -63,11 +72,12 @@ impl SecretStoreBackend for MemoryBackend {
fn create(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists);
}
@@ -80,10 +90,11 @@ impl SecretStoreBackend for MemoryBackend {
fn retrieve(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state.retrieves += 1;
state
.values
@@ -95,11 +106,12 @@ impl SecretStoreBackend for MemoryBackend {
fn replace(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
let existing = state
.values
.get_mut(locator)
@@ -111,10 +123,11 @@ impl SecretStoreBackend for MemoryBackend {
fn delete(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state
.values
.remove(locator)
@@ -356,3 +369,71 @@ fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> Te
assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
Ok(())
}
#[test]
fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture_key(&fixture, &keys, "alice")?;
let backend = MemoryBackend::default();
let clock = ManualClock::default();
let session = AuthenticationSession::with_clock(
backend.clone(),
SecretProtectionPolicy::current_biometry_for_openpgp(),
AuthenticationTimeout::new(Duration::from_secs(30))?,
clock.clone(),
);
let reader = VaultReader::new(&repository, &keys);
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
let mut manual = session.authenticate_with_passphrase(
&alice,
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut manual)?,
ShowResult::Entry(_)
));
manual.persist_passphrase(&alice)?;
assert_eq!(
backend.last_protection(),
Some(SecretProtection::BiometryCurrentSet)
);
session.manual_lock()?;
let mut biometric = session.authenticate(&alice)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut biometric)?,
ShowResult::Entry(_)
));
clock.advance(Duration::from_secs(30));
assert!(session.expire()?);
assert_eq!(biometric.ensure_active(), Err(AuthenticationError::Expired));
backend.invalidate_enrollment();
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
let mut recovered = session.authenticate_with_passphrase(
&alice,
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut recovered)?,
ShowResult::Entry(_)
));
recovered.persist_passphrase(&alice)?;
session.delete_key_passphrase(&alice)?;
session.manual_lock()?;
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
Ok(())
}

View File

@@ -210,6 +210,31 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul
Ok(())
}
#[test]
fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert!(!config.biometric_unlock_enabled());
config.update_biometric_unlock(true)?;
let contents = fs::read_to_string(fixture.explicit_path())?;
assert!(contents.contains("biometric_unlock_enabled = true"));
assert!(!contents.to_ascii_lowercase().contains("passphrase"));
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert!(reloaded.biometric_unlock_enabled());
reloaded.update_biometric_unlock(false)?;
assert!(
!fixture
.loader()
.load(Some(&fixture.explicit_path()))?
.biometric_unlock_enabled()
);
Ok(())
}
#[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?;