Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,9 @@ impl Commands {
(None, keyring)
}

(Some(_), None) => {
return Err(Error::new("A keyring requires a secret."));
(Some(path), None) => {
let keyring = Keyring::File(oo7::file::UnlockedKeyring::load(path, None).await?);
(None, keyring)
}
(None, Some(_)) => {
return Err(Error::new("A secret requires a keyring."));
Expand Down
12 changes: 6 additions & 6 deletions client/examples/file_tracing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ async fn test_keyring_lifecycle() -> oo7::Result<()> {

// Measure keyring creation
let start = Instant::now();
let keyring = UnlockedKeyring::load(&keyring_path, secret.clone()).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret.clone())).await?;
let create_time = start.elapsed();
info!("Fresh keyring creation: {:?}", create_time);

Expand All @@ -79,7 +79,7 @@ async fn test_keyring_lifecycle() -> oo7::Result<()> {
// Measure keyring reload (with existing data)
drop(keyring);
let start = Instant::now();
let keyring = UnlockedKeyring::load(&keyring_path, secret).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret)).await?;
let reload_time = start.elapsed();
info!("Keyring reload with 1 item: {:?}", reload_time);

Expand All @@ -103,7 +103,7 @@ async fn test_bulk_operations() -> oo7::Result<()> {
let temp_dir = tempdir().unwrap();
let keyring_path = temp_dir.path().join("bulk_test.keyring");
let secret = oo7::Secret::from("test-secret-key-that-is-long-enough".as_bytes());
let keyring = UnlockedKeyring::load(&keyring_path, secret).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret)).await?;

// Test creating multiple items individually
let item_counts = [10, 50, 100];
Expand Down Expand Up @@ -166,7 +166,7 @@ async fn test_scaling_behavior() -> oo7::Result<()> {

// Create fresh keyring with 'size' items
std::fs::remove_file(&keyring_path).ok(); // Remove if exists
let keyring = UnlockedKeyring::load(&keyring_path, secret.clone()).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret.clone())).await?;

// Populate keyring
if size > 0 {
Expand All @@ -188,7 +188,7 @@ async fn test_scaling_behavior() -> oo7::Result<()> {
// Test reload performance
drop(keyring);
let start = Instant::now();
let keyring = UnlockedKeyring::load(&keyring_path, secret.clone()).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret.clone())).await?;
let reload_time = start.elapsed();
info!("Reload with {} items: {:?}", size, reload_time);

Expand Down Expand Up @@ -227,7 +227,7 @@ async fn test_search_performance() -> oo7::Result<()> {
let temp_dir = tempdir().unwrap();
let keyring_path = temp_dir.path().join("search_test.keyring");
let secret = oo7::Secret::from("test-secret-key-that-is-long-enough".as_bytes());
let keyring = UnlockedKeyring::load(&keyring_path, secret).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(secret)).await?;

// Create diverse set of items for search testing
let apps = ["browser", "email", "social", "development", "finance"];
Expand Down
2 changes: 1 addition & 1 deletion client/examples/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct PasswordSchema {
async fn main() -> oo7::Result<()> {
let temp_dir = tempfile::tempdir().unwrap();
let keyring_path = temp_dir.path().join("test.keyring");
let keyring = UnlockedKeyring::load(&keyring_path, Secret::text("test_password")).await?;
let keyring = UnlockedKeyring::load(&keyring_path, Some(Secret::text("test_password"))).await?;

println!("=== Creating items ===");

Expand Down
36 changes: 32 additions & 4 deletions client/src/file/api/encrypted_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use zbus::zvariant::Type;
use zeroize::{Zeroize, ZeroizeOnDrop};

use super::{Error, UnlockedItem};
use crate::{Key, Mac, crypto};
use crate::{AsAttributes, Key, Mac, crypto};

#[derive(Deserialize, Serialize, Type, Debug, Clone, Zeroize, ZeroizeOnDrop)]
pub(crate) struct EncryptedItem {
Expand All @@ -20,7 +20,35 @@ impl EncryptedItem {
self.hashed_attributes.get(key) == Some(value_mac)
}

fn try_decrypt_inner(&self, key: &Key) -> Result<UnlockedItem, Error> {
fn has_plaintext_attribute(&self, key: &str, value: &str) -> bool {
self.hashed_attributes
.get(key)
.is_some_and(|mac| mac.as_slice() == value.as_bytes())
}

pub fn matches(&self, attributes: &impl AsAttributes, key: Option<&Key>) -> bool {
match key {
Some(key) => {
let hashed = attributes.hash(key);
hashed
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| self.has_attribute(k.as_str(), v)))
}
None => attributes
.as_attributes()
.iter()
.all(|(k, v)| self.has_plaintext_attribute(k.as_str(), v.as_str())),
}
}

fn try_decrypt_inner(&self, key: Option<&Key>) -> Result<UnlockedItem, Error> {
match key {
Some(key) => self.try_decrypt_encrypted(key),
None => UnlockedItem::try_from(self.blob.as_slice()),
}
}

fn try_decrypt_encrypted(&self, key: &Key) -> Result<UnlockedItem, Error> {
let n = self.blob.len();
let n_mac = crypto::mac_len();
let n_iv = crypto::iv_len();
Expand All @@ -45,11 +73,11 @@ impl EncryptedItem {
Ok(item)
}

pub fn is_valid(&self, key: &Key) -> bool {
pub fn is_valid(&self, key: Option<&Key>) -> bool {
self.try_decrypt_inner(key).is_ok()
}

pub fn decrypt(self, key: &Key) -> Result<UnlockedItem, Error> {
pub fn decrypt(self, key: Option<&Key>) -> Result<UnlockedItem, Error> {
self.try_decrypt_inner(key)
}

Expand Down
83 changes: 32 additions & 51 deletions client/src/file/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,71 +190,47 @@ impl Keyring {
pub fn search_items(
&self,
attributes: &impl AsAttributes,
key: &Key,
key: Option<&Key>,
) -> Result<Vec<UnlockedItem>, Error> {
let hashed_search = attributes.hash(key);

self.items
.iter()
.filter(|e| {
hashed_search
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| e.has_attribute(k.as_str(), v)))
})
.filter(|e| e.matches(attributes, key))
.map(|e| (*e).clone().decrypt(key))
.collect()
}

pub fn lookup_item(
&self,
attributes: &impl AsAttributes,
key: &Key,
key: Option<&Key>,
) -> Result<Option<UnlockedItem>, Error> {
let hashed_search = attributes.hash(key);

self.items
.iter()
.find(|e| {
hashed_search
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| e.has_attribute(k.as_str(), v)))
})
.find(|e| e.matches(attributes, key))
.map(|e| (*e).clone().decrypt(key))
.transpose()
}

pub fn lookup_item_index(&self, attributes: &impl AsAttributes, key: &Key) -> Option<usize> {
let hashed_search = attributes.hash(key);

self.items.iter().position(|e| {
hashed_search
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| e.has_attribute(k.as_str(), v)))
})
pub fn lookup_item_index(
&self,
attributes: &impl AsAttributes,
key: Option<&Key>,
) -> Option<usize> {
self.items.iter().position(|e| e.matches(attributes, key))
}

pub fn remove_items(&mut self, attributes: &impl AsAttributes, key: &Key) -> Result<(), Error> {
let hashed_search = attributes.hash(key);

// Validate items to be removed before actually removing them
pub fn remove_items(
&mut self,
attributes: &impl AsAttributes,
key: Option<&Key>,
) -> Result<(), Error> {
for item in &self.items {
if hashed_search
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| item.has_attribute(k.as_str(), v)))
{
// Validate by checking if it can be decrypted
if !item.is_valid(key) {
return Err(Error::MacError);
}
if item.matches(attributes, key) && !item.is_valid(key) {
return Err(Error::MacError);
}
}

// Remove matching items
self.items.retain(|e| {
!hashed_search
.iter()
.all(|(k, v)| v.as_ref().is_ok_and(|v| e.has_attribute(k.as_str(), v)))
});
self.items.retain(|e| !e.matches(attributes, key));

Ok(())
}
Expand Down Expand Up @@ -328,7 +304,11 @@ impl Keyring {

// Check if at least one item can be decrypted with this key
// We only need to check one item to validate the password
Ok(self.items.iter().any(|item| item.is_valid(&key)))
Ok(self.items.iter().any(|item| item.is_valid(Some(&key))))
}

pub fn validate_unencrypted(&self) -> bool {
self.items.iter().all(|item| item.is_valid(None))
}

/// Get the modification timestamp
Expand Down Expand Up @@ -405,15 +385,15 @@ mod tests {
let mut keyring = Keyring::new()?;
let key = keyring.derive_key(&SECRET.to_vec().into())?;

keyring
.items
.push(UnlockedItem::new("Label", needle, Secret::blob("MyPassword")).encrypt(&key)?);
keyring.items.push(
UnlockedItem::new("Label", needle, Secret::blob("MyPassword")).encrypt(Some(&key))?,
);

assert_eq!(keyring.search_items(needle, &key)?.len(), 1);
assert_eq!(keyring.search_items(needle, Some(&key))?.len(), 1);

keyring.remove_items(needle, &key)?;
keyring.remove_items(needle, Some(&key))?;

assert_eq!(keyring.search_items(needle, &key)?.len(), 0);
assert_eq!(keyring.search_items(needle, Some(&key))?.len(), 0);

Ok(())
}
Expand All @@ -427,14 +407,15 @@ mod tests {

new_keyring.items.push(
UnlockedItem::new("My Label", &[("my-tag", "my tag value")], "A Password")
.encrypt(&key)?,
.encrypt(Some(&key))?,
);
new_keyring.dump("/tmp/test.keyring", None).await?;

let blob = tokio::fs::read("/tmp/test.keyring").await?;

let loaded_keyring = Keyring::try_from(blob.as_slice())?;
let loaded_items = loaded_keyring.search_items(&[("my-tag", "my tag value")], &key)?;
let loaded_items =
loaded_keyring.search_items(&[("my-tag", "my tag value")], Some(&key))?;

assert_eq!(loaded_items[0].secret(), Secret::text("A Password"));
assert_eq!(loaded_items[0].secret().content_type(), ContentType::Text);
Expand Down
2 changes: 1 addition & 1 deletion client/src/file/locked_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub struct LockedItem {

impl LockedItem {
/// Unlocks the item.
pub fn unlock(self, key: &Key) -> Result<UnlockedItem, file::Error> {
pub fn unlock(self, key: Option<&Key>) -> Result<UnlockedItem, file::Error> {
self.inner.decrypt(key)
}
}
32 changes: 30 additions & 2 deletions client/src/file/locked_keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ impl LockedKeyring {
Ok(keyring.validate_secret(secret)?)
}

pub async fn validate_unencrypted(&self) -> Result<bool, Error> {
let keyring = self.keyring.read().await;
Ok(keyring.validate_unencrypted())
}

/// Return the associated file if any.
pub fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
Expand Down Expand Up @@ -99,7 +104,7 @@ impl LockedKeyring {
let mut n_broken_items = 0;
let mut n_valid_items = 0;
for encrypted_item in &inner_keyring.items {
if encrypted_item.is_valid(&key) {
if encrypted_item.is_valid(Some(&key)) {
n_valid_items += 1;
} else {
n_broken_items += 1;
Expand Down Expand Up @@ -139,7 +144,30 @@ impl LockedKeyring {
path: self.path,
mtime: self.mtime,
key: Mutex::new(key),
secret: Mutex::new(Arc::new(secret)),
secret: Mutex::new(Some(Arc::new(secret))),
})
}

/// Unlocks a keyring without a secret, for unencrypted keyrings.
///
/// Validates that existing items (if any) can be read without
/// encryption. Returns [`Error::IncorrectSecret`] if encrypted items
/// are found.
pub async fn unlock_unencrypted(self) -> Result<UnlockedKeyring, Error> {
let inner_keyring = self.keyring.read().await;
for encrypted_item in &inner_keyring.items {
if !encrypted_item.is_valid(None) {
return Err(Error::IncorrectSecret);
}
}
drop(inner_keyring);

Ok(UnlockedKeyring {
keyring: self.keyring,
path: self.path,
mtime: self.mtime,
key: Mutex::new(None),
secret: Mutex::new(None),
})
}

Expand Down
23 changes: 10 additions & 13 deletions client/src/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! use oo7::{Secret, file::UnlockedKeyring};
//!
//! # async fn run() -> oo7::Result<()> {
//! let keyring = UnlockedKeyring::load("default.keyring", Secret::text("some_text")).await?;
//! let keyring = UnlockedKeyring::load("default.keyring", Some(Secret::text("some_text"))).await?;
//! keyring
//! .create_item("My Label", &[("account", "alice")], "My Password", true)
//! .await?;
Expand Down Expand Up @@ -82,25 +82,15 @@ impl Item {
}

/// Check if this item matches the given attributes
pub fn matches_attributes(&self, attributes: &impl AsAttributes, key: &Key) -> bool {
pub fn matches_attributes(&self, attributes: &impl AsAttributes, key: Option<&Key>) -> bool {
match self {
Self::Unlocked(unlocked) => {
let item_attrs = unlocked.attributes();
attributes.as_attributes().iter().all(|(k, value)| {
item_attrs.get(k.as_str()).map(|v| v.as_ref()) == Some(value.as_str())
})
}
Self::Locked(locked) => {
let hashed_attrs = attributes.hash(key);

hashed_attrs.iter().all(|(attr_key, mac_result)| {
mac_result
.as_ref()
.ok()
.map(|mac| locked.inner.has_attribute(attr_key.as_str(), mac))
.unwrap_or(false)
})
}
Self::Locked(locked) => locked.inner.matches(attributes, key),
}
}
}
Expand Down Expand Up @@ -133,6 +123,13 @@ impl Keyring {
}
}

pub async fn validate_unencrypted(&self) -> Result<bool, Error> {
match self {
Self::Locked(keyring) => keyring.validate_unencrypted().await,
Self::Unlocked(keyring) => keyring.validate_unencrypted().await,
}
}

/// Get the modification timestamp
pub async fn modified_time(&self) -> std::time::Duration {
match self {
Expand Down
Loading
Loading