Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ reqwest = { version = "0.12.28", default-features = false, features = [
rmpv = "1.3.0"
rsa = "0.9.9"
rustix = { version = "1.1.3", features = [
"event",
"termios",
"process",
"pipe",
Expand Down Expand Up @@ -88,6 +89,9 @@ arboard = { version = "3.6.1", default-features = false, features = [
"wayland-data-control",
], optional = true }

[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { version = "3.5.1", features = ["OSX_10_13"] }

[features]
default = ["clipboard"]
clipboard = ["arboard"]
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,18 @@ configuration options:
* `notifications_url`: The URL of the Bitwarden notifications server to use.
If unset, will use the `/notifications` path on the configured `base_url`,
or `https://notifications.bitwarden.com/` if no `base_url` is set.
* `lock_timeout`: The number of seconds to keep the master keys in memory for
before requiring the password to be entered again. Defaults to `3600` (one
hour).
* `lock_timeout`: The number of seconds to keep the master keys in memory before
requiring the database to be unlocked again. Defaults to `3600` (one hour).
* `sync_interval`: `rbw` will automatically sync the database from the server
at an interval of this many seconds, while the agent is running. Setting
this value to `0` disables this behavior. Defaults to `3600` (one hour).
* `pinentry`: The
[pinentry](https://www.gnupg.org/related_software/pinentry/index.html)
executable to use. Defaults to `pinentry`.
* `touch_id_unlock`: On macOS, allow Touch ID to unlock the database again
after the master password has unlocked it once in the current agent process.
The Secure Enclave key and wrapped vault key are kept only in memory and are
discarded when the agent exits. Defaults to `false`.

### Profiles

Expand Down Expand Up @@ -123,6 +126,29 @@ out by running `rbw purge`, and you can explicitly lock the database by running
`rbw help` can be used to get more information about the available
functionality.

### Touch ID

On macOS, `rbw` can use Touch ID to unlock the database again after it has been
unlocked once with the master password in the current agent process. Enable it
with:

```sh
rbw config set touch_id_unlock true
```

The first unlock after enabling this setting, rebooting, or running `rbw
stop-agent` still requires the master password. During that agent process,
automatic timeout and `rbw lock` discard the decrypted vault keys but allow the
next unlock to use Touch ID. `rbw purge`, a logout notification from the server,
or a change to the account's protected vault key also discards the Touch ID
session.

The private key is generated in the Secure Enclave and is not exportable. Both
it and the wrapped vault key exist only for the lifetime of `rbw-agent`; no
master password or additional vault key material is written to disk. Entries
configured to require master password reprompt continue to require the master
password.

Run `rbw get <name>` to get your passwords. If you also want to get the username
or the note associated, you can use the flag `--full`. You can also use the flag
`--field={field}` to get whatever default or custom field you want. The `--raw`
Expand Down
27 changes: 17 additions & 10 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,23 +110,30 @@ pub fn unlock<S: std::hash::BuildHasher>(
Err(e) => return Err(e),
};

unlock_with_key(key, protected_private_key, protected_org_keys)
}

pub fn unlock_with_key<S: std::hash::BuildHasher>(
key: crate::locked::Keys,
protected_private_key: &str,
protected_org_keys: &std::collections::HashMap<String, String, S>,
) -> Result<(
crate::locked::Keys,
std::collections::HashMap<String, crate::locked::Keys>,
)> {
let protected_private_key =
crate::cipherstring::CipherString::new(protected_private_key)?;
let private_key =
match protected_private_key.decrypt_locked_symmetric(&key) {
Ok(private_key) => crate::locked::PrivateKey::new(private_key),
Err(e) => return Err(e),
};
let private_key = crate::locked::PrivateKey::new(
protected_private_key.decrypt_locked_symmetric(&key)?,
);

let mut org_keys = std::collections::HashMap::new();
for (org_id, protected_org_key) in protected_org_keys {
let protected_org_key =
crate::cipherstring::CipherString::new(protected_org_key)?;
let org_key =
match protected_org_key.decrypt_locked_asymmetric(&private_key) {
Ok(org_key) => crate::locked::Keys::new(org_key),
Err(e) => return Err(e),
};
let org_key = crate::locked::Keys::new(
protected_org_key.decrypt_locked_asymmetric(&private_key)?,
);
org_keys.insert(org_id.clone(), org_key);
}

Expand Down
163 changes: 154 additions & 9 deletions src/bin/rbw-agent/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,7 @@ async fn login_success(

match res {
Ok((keys, org_keys)) => {
let mut state = state.lock().await;
state.priv_key = Some(keys);
state.org_keys = Some(org_keys);
unlock_success(state, keys, org_keys, &protected_key).await?;
}
Err(e) => return Err(e).context("failed to unlock database"),
}
Expand All @@ -375,6 +373,9 @@ async fn unlock_state(
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
environment: &rbw::protocol::Environment,
) -> anyhow::Result<()> {
let unlock_lock = state.lock().await.unlock_lock.clone();
let _unlock_guard = unlock_lock.lock().await;

if state.lock().await.needs_unlock() {
let db = load_db().await?;

Expand All @@ -391,18 +392,90 @@ async fn unlock_state(
let memory = db.memory;
let parallelism = db.parallelism;

let Some(protected_key) = db.protected_key else {
let Some(protected_key) = db.protected_key.as_deref() else {
return Err(anyhow::anyhow!(
"failed to find protected key in db"
));
};
let Some(protected_private_key) = db.protected_private_key else {
let Some(protected_private_key) = db.protected_private_key.as_deref()
else {
return Err(anyhow::anyhow!(
"failed to find protected private key in db"
));
};

let email = config_email().await?;
let config = rbw::config::Config::load_async().await?;
let email = config.email.ok_or_else(|| {
anyhow::anyhow!("failed to find email address in config")
})?;

#[cfg(target_os = "macos")]
{
let biometric_unlock = {
let state = state.lock().await;
if state.touch_id_unlock {
state.biometric_unlock.clone()
} else {
None
}
};
if let Some(biometric_unlock) = biometric_unlock {
if biometric_unlock.matches(protected_key) {
let biometric_unlock_for_task = biometric_unlock.clone();
let biometric_key =
tokio::task::spawn_blocking(move || {
biometric_unlock_for_task.unlock()
})
.await
.context("failed to join Touch ID task")?;
match biometric_key {
Ok(key) => match rbw::actions::unlock_with_key(
key,
protected_private_key,
&db.protected_org_keys,
) {
Ok((keys, org_keys)) => {
if set_biometric_unlocked(
state,
&biometric_unlock,
keys,
org_keys,
)
.await
{
return Ok(());
}
return Err(anyhow::anyhow!(
"Touch ID session was revoked"
));
}
Err(error) => {
log::warn!(
"failed to unlock with cached vault key: \
{error}"
);
state.lock().await.biometric_unlock = None;
}
},
Err(crate::biometric::UnlockError::Canceled) => {
return Err(anyhow::anyhow!(
"Touch ID was canceled"
));
}
Err(
crate::biometric::UnlockError::PasswordRequested,
) => {}
Err(crate::biometric::UnlockError::Unavailable(
error,
)) => {
log::warn!("Touch ID is unavailable: {error}");
}
}
} else {
state.lock().await.biometric_unlock = None;
}
}
}

let mut err_msg = None;
for i in 1_u8..=3 {
Expand Down Expand Up @@ -433,12 +506,13 @@ async fn unlock_state(
iterations,
memory,
parallelism,
&protected_key,
&protected_private_key,
protected_key,
protected_private_key,
&db.protected_org_keys,
) {
Ok((keys, org_keys)) => {
unlock_success(state, keys, org_keys).await?;
unlock_success(state, keys, org_keys, protected_key)
.await?;
break;
}
Err(rbw::error::Error::IncorrectPassword { message }) => {
Expand Down Expand Up @@ -474,13 +548,71 @@ async fn unlock_success(
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
keys: rbw::locked::Keys,
org_keys: std::collections::HashMap<String, rbw::locked::Keys>,
protected_key: &str,
) -> anyhow::Result<()> {
#[cfg(not(target_os = "macos"))]
let _ = protected_key;

#[cfg(target_os = "macos")]
let biometric_unlock = {
let enabled = state.lock().await.touch_id_unlock;
if enabled {
let keys = keys.clone();
let protected_key = protected_key.to_string();
match tokio::task::spawn_blocking(move || {
crate::biometric::Session::new(&keys, &protected_key)
})
.await
{
Ok(Ok(session)) => Some(std::sync::Arc::new(session)),
Ok(Err(error)) => {
log::warn!(
"failed to initialize Touch ID unlock: {error}"
);
None
}
Err(error) => {
log::warn!(
"failed to join Touch ID initialization task: {error}"
);
None
}
}
} else {
None
}
};

let mut state = state.lock().await;
state.priv_key = Some(keys);
state.org_keys = Some(org_keys);
#[cfg(target_os = "macos")]
{
state.biometric_unlock = biometric_unlock;
}
Ok(())
}

#[cfg(target_os = "macos")]
async fn set_biometric_unlocked(
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
biometric_unlock: &std::sync::Arc<crate::biometric::Session>,
keys: rbw::locked::Keys,
org_keys: std::collections::HashMap<String, rbw::locked::Keys>,
) -> bool {
let mut state = state.lock().await;
let is_current_session =
state.biometric_unlock.as_ref().is_some_and(|session| {
std::sync::Arc::ptr_eq(session, biometric_unlock)
});
if !is_current_session {
return false;
}
state.priv_key = Some(keys);
state.org_keys = Some(org_keys);
true
}

pub async fn lock(
sock: &mut crate::sock::Sock,
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
Expand Down Expand Up @@ -527,6 +659,11 @@ pub async fn sync(
) = rbw::actions::sync(&access_token, &refresh_token)
.await
.context("failed to sync database from server")?;
#[cfg(target_os = "macos")]
let protected_key_changed = db
.protected_key
.as_deref()
.is_some_and(|old_protected_key| old_protected_key != protected_key);
state.lock().await.set_master_password_reprompt(&entries);
if let Some(access_token) = access_token {
db.access_token = Some(access_token);
Expand All @@ -536,6 +673,14 @@ pub async fn sync(
db.protected_org_keys = protected_org_keys;
db.entries = entries;
save_db(&db).await?;
#[cfg(target_os = "macos")]
if protected_key_changed {
// Changing the password wrapper does not necessarily rotate the live
// vault key. Revoke only the cached alternative unlock route; changing
// the cross-platform lock state here would be an unrelated behavior
// change. A later unlock also checks the digest before using a session.
state.lock().await.biometric_unlock = None;
}

if let Err(e) = subscribe_to_notifications(state.clone()).await {
eprintln!("failed to subscribe to notifications: {e}");
Expand Down
6 changes: 5 additions & 1 deletion src/bin/rbw-agent/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ impl Agent {
pub enum Event {
Request(std::io::Result<tokio::net::UnixStream>),
Timeout(()),
Logout(()),
Sync(()),
}

Expand All @@ -42,7 +43,7 @@ impl Agent {
notifications,
)
.map(|message| match message {
crate::notifications::Message::Logout => Event::Timeout(()),
crate::notifications::Message::Logout => Event::Logout(()),
crate::notifications::Message::Sync => Event::Sync(()),
})
.boxed();
Expand Down Expand Up @@ -86,6 +87,9 @@ impl Agent {
Event::Timeout(()) => {
self.state.lock().await.clear();
}
Event::Logout(()) => {
self.state.lock().await.clear_all();
}
Event::Sync(()) => {
let state = self.state.clone();
tokio::spawn(async move {
Expand Down
Loading
Loading