Present desktop authentication and automatic relocking
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -3653,6 +3653,7 @@ dependencies = [
|
|||||||
"iced_core",
|
"iced_core",
|
||||||
"log",
|
"log",
|
||||||
"rustc-hash 2.1.3",
|
"rustc-hash 2.1.3",
|
||||||
|
"tokio",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasmtimer",
|
"wasmtimer",
|
||||||
]
|
]
|
||||||
@@ -3989,6 +3990,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
|
"nix 0.31.3",
|
||||||
"pgp",
|
"pgp",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
@@ -3996,7 +3998,6 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rqrr",
|
"rqrr",
|
||||||
"rustix 1.1.4",
|
|
||||||
"secret-service",
|
"secret-service",
|
||||||
"security-framework",
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ flate2 = "1.1"
|
|||||||
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
|
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
|
||||||
gix-config = "0.59"
|
gix-config = "0.59"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
iced = "0.14"
|
iced = { version = "0.14", features = ["tokio"] }
|
||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
keyring-core = "1.0"
|
keyring-core = "1.0"
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use ironstorage::{
|
|||||||
secret_store::{
|
secret_store::{
|
||||||
NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
|
NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
|
||||||
SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
|
SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
|
||||||
|
SecretStoreError,
|
||||||
},
|
},
|
||||||
write::{
|
write::{
|
||||||
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
|
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
|
||||||
@@ -65,6 +66,26 @@ fn run() -> Result<u8, ()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
|
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<OsString> + Clone,
|
||||||
|
O: Write,
|
||||||
|
E: Write,
|
||||||
|
{
|
||||||
|
run_with_secret_store(arguments, &mut stdout, &mut stderr, || {
|
||||||
|
NativeSecretStore::system(
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with_secret_store<I, T, O, E>(
|
||||||
|
arguments: I,
|
||||||
|
mut stdout: O,
|
||||||
|
mut stderr: E,
|
||||||
|
open_secret_store: impl FnOnce() -> Result<NativeSecretStore, SecretStoreError>,
|
||||||
|
) -> Result<u8, ()>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = T>,
|
I: IntoIterator<Item = T>,
|
||||||
T: Into<OsString> + Clone,
|
T: Into<OsString> + Clone,
|
||||||
@@ -122,11 +143,7 @@ where
|
|||||||
}
|
}
|
||||||
request => match Config::load(invocation.config()) {
|
request => match Config::load(invocation.config()) {
|
||||||
Ok(config) if needs_secret_store(request) => {
|
Ok(config) if needs_secret_store(request) => {
|
||||||
let mut secrets = match NativeSecretStore::system(
|
let mut secrets = match open_secret_store().and_then(|store| {
|
||||||
SecretCachePolicy::Disabled,
|
|
||||||
SecretProtectionPolicy::device_unlocked(),
|
|
||||||
)
|
|
||||||
.and_then(|store| {
|
|
||||||
store.unlock()?;
|
store.unlock()?;
|
||||||
Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
|
Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
|
||||||
}) {
|
}) {
|
||||||
@@ -1533,7 +1550,7 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local,
|
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local,
|
||||||
execute_secure, execute_secure_with, execute_secure_with_services, run_with,
|
execute_secure, execute_secure_with, execute_secure_with_services, run_with,
|
||||||
wait_for_clipboard,
|
run_with_secret_store, wait_for_clipboard,
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error>>;
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
@@ -1803,7 +1820,7 @@ mod tests {
|
|||||||
)?;
|
)?;
|
||||||
let mut stdout = Vec::new();
|
let mut stdout = Vec::new();
|
||||||
let mut stderr = Vec::new();
|
let mut stderr = Vec::new();
|
||||||
let code = run_with(
|
let code = run_with_secret_store(
|
||||||
[
|
[
|
||||||
OsString::from("ironstorage"),
|
OsString::from("ironstorage"),
|
||||||
OsString::from("--config"),
|
OsString::from("--config"),
|
||||||
@@ -1813,6 +1830,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
&mut stdout,
|
&mut stdout,
|
||||||
&mut stderr,
|
&mut stderr,
|
||||||
|
|| Err(SecretStoreError::Unavailable),
|
||||||
)
|
)
|
||||||
.expect("writing to memory cannot fail");
|
.expect("writing to memory cannot fail");
|
||||||
assert_eq!(code, EXIT_UNAVAILABLE);
|
assert_eq!(code, EXIT_UNAVAILABLE);
|
||||||
|
|||||||
@@ -1,23 +1,511 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![deny(clippy::disallowed_types)]
|
#![deny(clippy::disallowed_types)]
|
||||||
|
|
||||||
use iced::{Element, Length, widget::container, widget::text};
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iced::{
|
||||||
|
Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch,
|
||||||
|
widget::{button, column, container, text},
|
||||||
|
};
|
||||||
|
use ironstorage::{
|
||||||
|
authentication::{
|
||||||
|
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||||||
|
NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||||
|
},
|
||||||
|
crypto::{KeyInfo, KeyStore},
|
||||||
|
repository::SecretBytes,
|
||||||
|
secret_store::{SecretProtectionPolicy, SecretStoreBackend},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Message {}
|
enum Message {
|
||||||
|
StartupLoaded(Result<(NativeAuthenticationSession, KeyInfo), String>),
|
||||||
|
UnlockProtectedContent,
|
||||||
|
AuthenticationFinished {
|
||||||
|
generation: u64,
|
||||||
|
result: Result<NativeAuthenticationHandle, String>,
|
||||||
|
},
|
||||||
|
UserActivity,
|
||||||
|
Tick,
|
||||||
|
Lock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AuthenticationView {
|
||||||
|
Loading,
|
||||||
|
Locked,
|
||||||
|
Authenticating,
|
||||||
|
Unlocked(Duration),
|
||||||
|
Unavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SensitiveUiState {
|
||||||
|
decrypted: Option<SecretBytes>,
|
||||||
|
editor: Option<SecretBytes>,
|
||||||
|
clipboard_presenting: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SensitiveUiState {
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.decrypted = None;
|
||||||
|
self.editor = None;
|
||||||
|
self.clipboard_presenting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn is_clear(&self) -> bool {
|
||||||
|
self.decrypted.is_none() && self.editor.is_none() && !self.clipboard_presenting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
authentication: AuthenticationView,
|
||||||
|
session: Option<NativeAuthenticationSession>,
|
||||||
|
key: Option<KeyInfo>,
|
||||||
|
handle: Option<NativeAuthenticationHandle>,
|
||||||
|
sensitive: SensitiveUiState,
|
||||||
|
generation: u64,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
enum LeasePoll {
|
||||||
|
Idle,
|
||||||
|
Active(Duration),
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn main() -> iced::Result {
|
||||||
iced::application(|| (), update, view)
|
iced::application(App::new, App::update, App::view)
|
||||||
.title(ironstorage::PRODUCT_NAME)
|
.title(ironstorage::PRODUCT_NAME)
|
||||||
|
.subscription(App::subscription)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(_: &mut (), message: Message) {
|
impl App {
|
||||||
match message {}
|
fn new() -> (Self, Task<Message>) {
|
||||||
}
|
(
|
||||||
|
Self {
|
||||||
|
authentication: AuthenticationView::Loading,
|
||||||
|
session: None,
|
||||||
|
key: None,
|
||||||
|
handle: None,
|
||||||
|
sensitive: SensitiveUiState::default(),
|
||||||
|
generation: 0,
|
||||||
|
status: "Loading shared configuration…".to_owned(),
|
||||||
|
},
|
||||||
|
Task::perform(load_authentication(), Message::StartupLoaded),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn view(_: &()) -> Element<'_, Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
container(text("No password store is open."))
|
match message {
|
||||||
|
Message::StartupLoaded(Ok((session, key))) => {
|
||||||
|
self.session = Some(session);
|
||||||
|
self.key = Some(key);
|
||||||
|
self.authentication = AuthenticationView::Locked;
|
||||||
|
self.status = "No password store content is unlocked.".to_owned();
|
||||||
|
}
|
||||||
|
Message::StartupLoaded(Err(error)) => {
|
||||||
|
self.authentication = AuthenticationView::Unavailable(error.clone());
|
||||||
|
self.status = error;
|
||||||
|
}
|
||||||
|
Message::UnlockProtectedContent => {
|
||||||
|
let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
if matches!(
|
||||||
|
self.authentication,
|
||||||
|
AuthenticationView::Authenticating | AuthenticationView::Unlocked(_)
|
||||||
|
) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.generation = self.generation.wrapping_add(1);
|
||||||
|
let generation = self.generation;
|
||||||
|
self.authentication = AuthenticationView::Authenticating;
|
||||||
|
self.status = "Waiting for secure-storage authentication…".to_owned();
|
||||||
|
return Task::perform(
|
||||||
|
async move {
|
||||||
|
session
|
||||||
|
.authenticate(&key)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
},
|
||||||
|
move |result| Message::AuthenticationFinished { generation, result },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Message::AuthenticationFinished { generation, result } => {
|
||||||
|
if generation != self.generation {
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(session) = &self.session
|
||||||
|
{
|
||||||
|
let _ignored = session.manual_lock();
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(handle) => match handle.remaining_time() {
|
||||||
|
Ok(remaining) => {
|
||||||
|
self.handle = Some(handle);
|
||||||
|
self.authentication = AuthenticationView::Unlocked(remaining);
|
||||||
|
self.status = "Protected content is unlocked.".to_owned();
|
||||||
|
}
|
||||||
|
Err(error) => self.authentication_lost(error.to_string()),
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
self.authentication_lost(error.clone());
|
||||||
|
self.status = format!("Authentication failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::UserActivity => {
|
||||||
|
if let Some(handle) = &self.handle {
|
||||||
|
match handle.touch_user_activity() {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(remaining) = handle.remaining_time() {
|
||||||
|
self.authentication = AuthenticationView::Unlocked(remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => self.authentication_lost(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Tick => {
|
||||||
|
if let Some(session) = &self.session {
|
||||||
|
match poll_lease(session, &mut self.handle, &mut self.sensitive) {
|
||||||
|
Ok(LeasePoll::Active(remaining)) => {
|
||||||
|
self.authentication = AuthenticationView::Unlocked(remaining);
|
||||||
|
}
|
||||||
|
Ok(LeasePoll::Expired) => {
|
||||||
|
self.authentication_lost("the authentication lease expired".to_owned());
|
||||||
|
}
|
||||||
|
Ok(LeasePoll::Idle) => {}
|
||||||
|
Err(error) => self.authentication_lost(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Lock => {
|
||||||
|
self.generation = self.generation.wrapping_add(1);
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Ok(()), NativeAuthenticationSession::manual_lock);
|
||||||
|
self.authentication_lost("manually locked".to_owned());
|
||||||
|
if let Err(error) = result {
|
||||||
|
self.authentication = AuthenticationView::Unavailable(error.to_string());
|
||||||
|
self.status = format!("Lock failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authentication_lost(&mut self, reason: String) {
|
||||||
|
self.handle = None;
|
||||||
|
self.sensitive.clear();
|
||||||
|
self.authentication = AuthenticationView::Locked;
|
||||||
|
self.status = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
Subscription::batch([
|
||||||
|
time::every(Duration::from_secs(1)).map(|_| Message::Tick),
|
||||||
|
event::listen_with(|event, _status, _window| {
|
||||||
|
is_deliberate_activity(&event).then_some(Message::UserActivity)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
let (heading, detail, action) = match &self.authentication {
|
||||||
|
AuthenticationView::Loading => (
|
||||||
|
"Loading",
|
||||||
|
"Reading the shared IronStorage configuration.",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
AuthenticationView::Locked => (
|
||||||
|
"Locked",
|
||||||
|
"Encrypted entry names remain browsable. Protected content requires authentication.",
|
||||||
|
Some(button("Unlock protected content").on_press(Message::UnlockProtectedContent)),
|
||||||
|
),
|
||||||
|
AuthenticationView::Authenticating => (
|
||||||
|
"Authenticating",
|
||||||
|
"Complete or cancel the native secure-storage prompt.",
|
||||||
|
Some(button("Cancel and lock").on_press(Message::Lock)),
|
||||||
|
),
|
||||||
|
AuthenticationView::Unlocked(remaining) => (
|
||||||
|
"Unlocked",
|
||||||
|
if remaining.as_secs() == 1 {
|
||||||
|
"Authentication expires after 1 second of inactivity."
|
||||||
|
} else {
|
||||||
|
"Protected content is available until the inactivity lease expires."
|
||||||
|
},
|
||||||
|
Some(button("Lock now").on_press(Message::Lock)),
|
||||||
|
),
|
||||||
|
AuthenticationView::Unavailable(error) => ("Unavailable", error.as_str(), None),
|
||||||
|
};
|
||||||
|
let remaining = match self.authentication {
|
||||||
|
AuthenticationView::Unlocked(remaining) => {
|
||||||
|
format!("{} seconds remaining", remaining.as_secs())
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let mut content = column![
|
||||||
|
text(heading).size(28),
|
||||||
|
text(detail),
|
||||||
|
text(remaining),
|
||||||
|
text(&self.status).size(14),
|
||||||
|
]
|
||||||
|
.spacing(12);
|
||||||
|
if let Some(action) = action {
|
||||||
|
content = content.push(action);
|
||||||
|
}
|
||||||
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
.center(Length::Fill)
|
.center(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_authentication() -> Result<(NativeAuthenticationSession, KeyInfo), String> {
|
||||||
|
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
||||||
|
let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?;
|
||||||
|
let handle = keys
|
||||||
|
.resolve(config.default_key().as_str())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let key = keys
|
||||||
|
.infos()
|
||||||
|
.find(|key| key.fingerprint() == handle.fingerprint())
|
||||||
|
.ok_or_else(|| "the configured GPG key is unavailable".to_owned())?;
|
||||||
|
let session = NativeAuthenticationSession::system(
|
||||||
|
SecretProtectionPolicy::default(),
|
||||||
|
config.authentication_timeout(),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok((session, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
|
||||||
|
session: &AuthenticationSession<B, C>,
|
||||||
|
handle: &mut Option<AuthenticationHandle<B, C>>,
|
||||||
|
sensitive: &mut SensitiveUiState,
|
||||||
|
) -> Result<LeasePoll, AuthenticationError> {
|
||||||
|
if session.expire()? {
|
||||||
|
*handle = None;
|
||||||
|
sensitive.clear();
|
||||||
|
return Ok(LeasePoll::Expired);
|
||||||
|
}
|
||||||
|
handle
|
||||||
|
.as_ref()
|
||||||
|
.map(AuthenticationHandle::remaining_time)
|
||||||
|
.transpose()
|
||||||
|
.map(|remaining| remaining.map_or(LeasePoll::Idle, LeasePoll::Active))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_deliberate_activity(event: &Event) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||||
|
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
|
||||||
|
| Event::Touch(
|
||||||
|
touch::Event::FingerPressed { .. }
|
||||||
|
| touch::Event::FingerMoved { .. }
|
||||||
|
| touch::Event::FingerLifted { .. }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use iced::{Point, window};
|
||||||
|
use ironstorage::{
|
||||||
|
authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT},
|
||||||
|
secret_store::{
|
||||||
|
SecretCachePolicy, SecretLocator, SecretProtection, SecretReference, SecretStore,
|
||||||
|
SecretStoreError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct ManualClock(Arc<Mutex<Duration>>);
|
||||||
|
|
||||||
|
impl ManualClock {
|
||||||
|
fn advance(&self, duration: Duration) {
|
||||||
|
*self.0.lock().expect("test clock") += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticationClock for ManualClock {
|
||||||
|
fn now(&self) -> Duration {
|
||||||
|
*self.0.lock().expect("test clock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
||||||
|
|
||||||
|
impl SecretStoreBackend for MemoryBackend {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.expect("test backend")
|
||||||
|
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.expect("test backend")
|
||||||
|
.get(locator)
|
||||||
|
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||||
|
.ok_or(SecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.create(locator, SecretProtection::DeviceUnlocked, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
_protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0.lock().expect("test backend").remove(locator);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(
|
||||||
|
timeout: Duration,
|
||||||
|
) -> (
|
||||||
|
AuthenticationSession<MemoryBackend, ManualClock>,
|
||||||
|
KeyInfo,
|
||||||
|
ManualClock,
|
||||||
|
) {
|
||||||
|
let mut keys = KeyStore::new();
|
||||||
|
let [key] = keys
|
||||||
|
.import(include_bytes!(
|
||||||
|
"../../../crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc"
|
||||||
|
))
|
||||||
|
.expect("fixture key")
|
||||||
|
.try_into()
|
||||||
|
.expect("one fixture key");
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let store = SecretStore::new(
|
||||||
|
backend.clone(),
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
);
|
||||||
|
store.unlock().expect("unlock fixture store");
|
||||||
|
store
|
||||||
|
.create(
|
||||||
|
&SecretReference::openpgp_passphrase(key.fingerprint().as_str())
|
||||||
|
.expect("fixture reference"),
|
||||||
|
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
||||||
|
)
|
||||||
|
.expect("provision fixture passphrase");
|
||||||
|
store.lock().expect("lock fixture store");
|
||||||
|
let clock = ManualClock::default();
|
||||||
|
let session = AuthenticationSession::with_clock(
|
||||||
|
backend,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
AuthenticationTimeout::new(timeout).expect("valid timeout"),
|
||||||
|
clock.clone(),
|
||||||
|
);
|
||||||
|
(session, key, clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensitive_state() -> SensitiveUiState {
|
||||||
|
SensitiveUiState {
|
||||||
|
decrypted: Some(SecretBytes::new(b"decrypted".to_vec())),
|
||||||
|
editor: Some(SecretBytes::new(b"dirty draft".to_vec())),
|
||||||
|
clipboard_presenting: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_and_configured_timeouts_clear_all_sensitive_ui_state() {
|
||||||
|
for timeout in [DEFAULT_AUTHENTICATION_TIMEOUT, Duration::from_secs(7)] {
|
||||||
|
let (session, key, clock) = session(timeout);
|
||||||
|
let mut handle = Some(session.authenticate(&key).expect("authenticate"));
|
||||||
|
let mut sensitive = sensitive_state();
|
||||||
|
|
||||||
|
clock.advance(timeout);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
poll_lease(&session, &mut handle, &mut sensitive).expect("poll lease"),
|
||||||
|
LeasePoll::Expired
|
||||||
|
);
|
||||||
|
assert!(handle.is_none());
|
||||||
|
assert!(sensitive.is_clear());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_deliberate_input_renews_the_storage_owned_lease() {
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
let (session, key, clock) = session(timeout);
|
||||||
|
let mut handle = Some(session.authenticate(&key).expect("authenticate"));
|
||||||
|
let mut sensitive = sensitive_state();
|
||||||
|
|
||||||
|
assert!(!is_deliberate_activity(&Event::Window(
|
||||||
|
window::Event::Focused
|
||||||
|
)));
|
||||||
|
assert!(!is_deliberate_activity(&Event::Mouse(
|
||||||
|
mouse::Event::CursorMoved {
|
||||||
|
position: Point::ORIGIN,
|
||||||
|
}
|
||||||
|
)));
|
||||||
|
clock.advance(timeout);
|
||||||
|
assert_eq!(
|
||||||
|
poll_lease(&session, &mut handle, &mut sensitive).expect("passive poll"),
|
||||||
|
LeasePoll::Expired
|
||||||
|
);
|
||||||
|
|
||||||
|
handle = Some(session.authenticate(&key).expect("reauthenticate"));
|
||||||
|
clock.advance(Duration::from_secs(9));
|
||||||
|
let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left));
|
||||||
|
assert!(is_deliberate_activity(&click));
|
||||||
|
handle
|
||||||
|
.as_ref()
|
||||||
|
.expect("active handle")
|
||||||
|
.touch_user_activity()
|
||||||
|
.expect("renew from deliberate input");
|
||||||
|
clock.advance(Duration::from_secs(9));
|
||||||
|
assert_eq!(
|
||||||
|
poll_lease(&session, &mut handle, &mut sensitive).expect("active poll"),
|
||||||
|
LeasePoll::Active(Duration::from_secs(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ arboard.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
nix = { version = "0.31", features = ["fs"] }
|
||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
rqrr.workspace = true
|
rqrr.workspace = true
|
||||||
rustix = { version = "1.1", features = ["fs"] }
|
|
||||||
smallvec = "1.15"
|
smallvec = "1.15"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
|||||||
.loader()
|
.loader()
|
||||||
.load(Some(Path::new("config/config.toml")))?;
|
.load(Some(Path::new("config/config.toml")))?;
|
||||||
|
|
||||||
assert_eq!(config.source(), fixture.explicit_path());
|
assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?);
|
||||||
assert_eq!(config.vault(), fixture.temporary.path().join("cwd/vault"));
|
assert_eq!(
|
||||||
|
config.vault(),
|
||||||
|
fs::canonicalize(fixture.temporary.path())?.join("cwd/vault")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.key_material(),
|
config.key_material(),
|
||||||
fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))?
|
fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))?
|
||||||
@@ -170,7 +173,7 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
|
|||||||
fs::write(&default, fixture.valid_contents())?;
|
fs::write(&default, fixture.valid_contents())?;
|
||||||
|
|
||||||
let config = fixture.loader().load(None)?;
|
let config = fixture.loader().load(None)?;
|
||||||
assert_eq!(config.source(), default);
|
assert_eq!(config.source(), fs::canonicalize(default)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ fn nested_repository_selection_is_innermost() -> TestResult {
|
|||||||
|
|
||||||
let selected =
|
let selected =
|
||||||
GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?;
|
GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?;
|
||||||
assert_eq!(selected.root(), temporary.path().join("nested"));
|
assert_eq!(
|
||||||
|
selected.root(),
|
||||||
|
fs::canonicalize(temporary.path().join("nested"))?
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ fn hostile_logical_paths_are_rejected_before_filesystem_access() {
|
|||||||
fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult {
|
fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
use rustix::fs::{CWD, Mode, mkfifoat};
|
use nix::{sys::stat::Mode, unistd::mkfifo};
|
||||||
|
|
||||||
let temporary = tempfile::tempdir()?;
|
let temporary = tempfile::tempdir()?;
|
||||||
let vault = temporary.path().join("vault");
|
let vault = temporary.path().join("vault");
|
||||||
@@ -174,10 +174,9 @@ fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult {
|
|||||||
assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside");
|
assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside");
|
||||||
|
|
||||||
fs::remove_file(vault.join("escape.gpg"))?;
|
fs::remove_file(vault.join("escape.gpg"))?;
|
||||||
mkfifoat(
|
mkfifo(
|
||||||
CWD,
|
&vault.join("unsupported.gpg"),
|
||||||
vault.join("unsupported.gpg"),
|
Mode::from_bits_truncate(0o600),
|
||||||
Mode::from_raw_mode(0o600),
|
|
||||||
)?;
|
)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
repository.snapshot(),
|
repository.snapshot(),
|
||||||
|
|||||||
Reference in New Issue
Block a user