Skip to content
Open
611 changes: 602 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ zeroize = "1.8.1"
arboard = { version = "3.5", default-features = false, features = [
"wayland-data-control",
], optional = true }
chacha20poly1305 = { version = "0.10.1", optional = true }
age = { version = "0.11.1", features = ["armor", "plugin"], optional = true }
keyring = { version = "3.6.3", features = ["apple-native"] }

[features]
default = ["clipboard"]
default = ["clipboard", "pin"]
clipboard = ["arboard"]
pin = ["chacha20poly1305", "age"]

[lints.clippy]
cargo = { level = "warn", priority = -1 }
Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,71 @@ between by using the `RBW_PROFILE` environment variable. Setting it to a name
switch between several different vaults - each will use its own separate
configuration, local vault, and agent.

## Quick Unlock (PIN)

`rbw` supports unlocking your vault with a short PIN instead of your master password. The PIN protects a device-bound local secret that is used to encrypt your vault keys. This provides quick access while maintaining security: an attacker who gains read access to your storage cannot decrypt your vault without both the PIN and the device-bound local secret.

### Supported Backends

Two backend options are available for storing the local secret:

#### `age` Backend

Uses [age](https://github.com/FiloSottile/age) encryption with plugin-based identities. Only age plugins are supported (not regular age identities) to ensure the local secret is bound to hardware.

**Supported age plugins:**
- [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey) - Uses YubiKey PIV
- [`age-plugin-tpm`](https://github.com/Foxboron/age-plugin-tpm) - Uses TPM 2.0
- [`age-plugin-se`](https://github.com/remko/age-plugin-se) - Uses macOS/iOS Secure Enclave

**Setup:**

1. Install your chosen age plugin and generate an identity
2. Configure the path to the identity file in rbw:
```sh
rbw config set age_identity_file_path /path/to/your/age/identity.txt
```
3. Register the PIN:
```sh
rbw pin set --backend age
```

#### `os-keyring` Backend

Uses your operating system's native keyring (macOS Keychain, Windows Credential Manager, or Linux Secret Service).

**Setup:**
```sh
rbw pin set --backend os-keyring
```

### Usage

Once configured, `rbw unlock` will prompt for your PIN instead of your master password. If PIN unlock fails, it automatically falls back to the master password.

**Manage PIN:**
```sh
# Set up PIN with a backend
rbw pin set --backend age
rbw pin set --backend os-keyring

# Check PIN status
rbw pin status

# Remove PIN
rbw pin clear
```

### Empty PIN with Hardware Tokens

For hardware token workflows where the security comes entirely from the hardware (e.g., YubiKey requiring touch), you can use an empty PIN:

```sh
rbw pin set --backend age --empty-pin
```

This skips PIN entry but still requires the hardware token for unlock.

## Usage

Commands can generally be used directly, and will handle logging in or
Expand Down
81 changes: 81 additions & 0 deletions src/bin/rbw-agent/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,44 @@ async fn unlock_state(
environment: &rbw::protocol::Environment,
) -> anyhow::Result<()> {
if state.lock().await.needs_unlock() {
#[cfg(feature = "pin")]
{
// Try PIN unlock first if PIN is configured
let config = rbw::config::Config::load_async().await?;
if let Some(_pin_config) = &config.pin_config {
match rbw::pin::flow::load_pin_state() {
Ok(pin_state) => {
let pin = rbw::pinentry::getpin(
&config.pinentry,
"PIN",
&format!(
"Unlock the local database for '{}'",
rbw::dirs::profile()
),
None,
environment,
true,
)
.await
.context("failed to read PIN from pinentry")?;

match rbw::pin::flow::unlock_with_pin(Some(&pin), &pin_state, config) {
Ok((keys, org_keys)) => {
unlock_success(state, keys, org_keys).await?;
return Ok(());
}
Err(_) => {
// PIN unlock failed, fall through to master password
}
}
}
Err(_) => {
// PIN not configured, fall through to master password
}
}
}
}

let db = load_db().await?;

let Some(kdf) = db.kdf else {
Expand Down Expand Up @@ -820,6 +858,49 @@ async fn config_pinentry() -> anyhow::Result<String> {
Ok(config.pinentry)
}

#[cfg(feature = "pin")]
pub async fn pin_register(
sock: &mut crate::sock::Sock,
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
empty_pin: bool,
backend: rbw::pin::backend::Backend,
environment: &rbw::protocol::Environment,
) -> anyhow::Result<()> {
let config = rbw::config::Config::load_async().await?;

let (keys, org_keys) = {
let state_guard = state.lock().await;
if state_guard.needs_unlock() {
return Err(anyhow::anyhow!("agent is locked"));
}

let keys = state_guard.priv_key.as_ref().unwrap().clone();
let org_keys = state_guard.org_keys.as_ref().unwrap().clone();
(keys, org_keys)
};

let pin = if empty_pin {
None
} else {
Some(rbw::pinentry::getpin(
&config.pinentry,
"PIN",
"Enter PIN for quick unlock",
None,
environment,
true,
)
.await
.context("failed to read PIN from pinentry")?)
};

rbw::pin::flow::register(&keys, &org_keys, pin.as_ref(), &config, backend)?;

respond_ack(sock).await?;

Ok(())
}

pub async fn subscribe_to_notifications(
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
) -> anyhow::Result<()> {
Expand Down
12 changes: 12 additions & 0 deletions src/bin/rbw-agent/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ async fn handle_request(
crate::actions::version(sock).await?;
false
}
#[cfg(feature = "pin")]
rbw::protocol::Action::PinRegister { empty_pin, backend } => {
crate::actions::pin_register(
sock,
state.clone(),
*empty_pin,
backend.clone(),
&environment,
)
.await?;
true
}
};

let mut state = state.lock().await;
Expand Down
8 changes: 8 additions & 0 deletions src/bin/rbw/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ pub fn unlock() -> anyhow::Result<()> {
simple_action(rbw::protocol::Action::Unlock)
}

#[cfg(feature = "pin")]
pub fn register_pin(
empty_pin: bool,
backend: rbw::pin::backend::Backend,
) -> anyhow::Result<()> {
simple_action(rbw::protocol::Action::PinRegister { empty_pin, backend })
}

pub fn unlocked() -> anyhow::Result<()> {
match crate::sock::Sock::connect() {
Ok(mut sock) => {
Expand Down
12 changes: 12 additions & 0 deletions src/bin/rbw/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,18 @@ pub fn unlock() -> anyhow::Result<()> {
Ok(())
}

#[cfg(feature = "pin")]
pub fn register_pin(
empty_pin: bool,
backend: rbw::pin::backend::Backend,
) -> anyhow::Result<()> {
ensure_agent()?;
crate::actions::login()?;
crate::actions::unlock()?;
crate::actions::register_pin(empty_pin, backend)?;
Ok(())
}

pub fn unlocked() -> anyhow::Result<()> {
// ensure_agent()?;
crate::actions::unlocked()?;
Expand Down
17 changes: 17 additions & 0 deletions src/bin/rbw/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ enum Opt {
about = "Generate completion script for the given shell"
)]
GenCompletions { shell: CompletionShell },

#[cfg(feature = "pin")]
#[command(about = "Manage local PIN unlock")]
Pin {
#[command(subcommand)]
cmd: rbw::pin::cli::Pin,
},
}

impl Opt {
Expand All @@ -261,6 +268,8 @@ impl Opt {
Self::Purge => "purge".to_string(),
Self::StopAgent => "stop-agent".to_string(),
Self::GenCompletions { .. } => "gen-completions".to_string(),
#[cfg(feature = "pin")]
Self::Pin { cmd } => format!("pin {}", cmd.subcommand_name()),
}
}
}
Expand Down Expand Up @@ -447,6 +456,14 @@ fn main() {
Opt::Lock => commands::lock(),
Opt::Purge => commands::purge(),
Opt::StopAgent => commands::stop_agent(),
#[cfg(feature = "pin")]
Opt::Pin { cmd } => match cmd {
rbw::pin::cli::Pin::Set { empty_pin, backend } => {
commands::register_pin(empty_pin, backend.clone())
}
rbw::pin::cli::Pin::Clear => rbw::pin::flow::clear(),
rbw::pin::cli::Pin::Status => rbw::pin::flow::status(),
},
Opt::GenCompletions { shell } => {
match shell {
CompletionShell::Bash => {
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub struct Config {
// backcompat, no longer generated in new configs
#[serde(skip_serializing)]
pub device_id: Option<String>,
#[cfg(feature = "pin")]
pub pin_config: Option<crate::pin::backend::PinBackendConfig>,
}

impl Default for Config {
Expand All @@ -38,6 +40,8 @@ impl Default for Config {
pinentry: default_pinentry(),
client_cert_path: None,
device_id: None,
#[cfg(feature = "pin")]
pin_config: Some(crate::pin::backend::PinBackendConfig::new()),
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ pub fn ssh_agent_socket_file() -> std::path::PathBuf {
runtime_dir().join("ssh-agent-socket")
}

#[cfg(feature = "pin")]
pub fn pin_age_wrapped_local_secret_file() -> std::path::PathBuf {
cache_dir().join(format!("{}-pin-wrapped-local-secret.age", &profile()))
}

#[cfg(feature = "pin")]
pub fn pin_state_file() -> std::path::PathBuf {
cache_dir().join(format!("{}-pin-state.json", &profile()))
}

fn config_dir() -> std::path::PathBuf {
let project_dirs =
directories::ProjectDirs::from("", "", &profile()).unwrap();
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ pub enum Error {
#[error("pinentry error: {error}")]
PinentryErrorMessage { error: String },

#[error("PIN error: {message}")]
PinError { message: String },

#[error("error reading pinentry output")]
PinentryReadOutput { source: tokio::io::Error },

Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ mod prelude;
pub mod protocol;
pub mod pwgen;
pub mod wordlist;
#[cfg(feature = "pin")]
pub mod pin;
4 changes: 4 additions & 0 deletions src/pin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod backend;
pub mod cli;
pub mod crypto;
pub mod flow;
Loading
Loading