From ab1579fc21bd6b7f5984ed6b6b72cb3bc035e047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Bl=C3=BCm?= Date: Wed, 8 Jul 2026 12:39:01 +0000 Subject: [PATCH 1/2] feat: add biometric unlock via Bitwarden desktop app Add support for unlocking rbw via the Bitwarden desktop app's biometric prompt, using the same IPC protocol the official browser extension uses. When the config option is enabled, rbw-agent connects to the desktop app's browser-integration socket, performs an RSA handshake to establish a shared secret, and sends an encrypted unlockWithBiometricsForUser request. The desktop app shows the OS biometric prompt and returns the account's user key on success. If the desktop app is unavailable or biometric unlock fails, rbw falls back to the existing master password / pinentry flow. Key implementation details: - Messages are wrapped as LegacyMessageWrapper { appId, message } to match the desktop app's expected wire format - Encrypted messages are serialized as EncString objects (not plain strings) to match the browser extension's serialization - Both string and object EncString forms are accepted on incoming frames - wrongUserId and invalidateEncryption responses are handled explicitly - Broadcast frames from other clients are skipped during handshake - All sensitive material (RSA key, shared secret, user key) is zeroized --- README.md | 8 + src/actions.rs | 11 + src/base64.rs | 18 + src/bin/rbw-agent/actions.rs | 39 ++ src/bin/rbw-agent/bitwarden_desktop.rs | 704 +++++++++++++++++++++++++ src/bin/rbw-agent/main.rs | 1 + src/bin/rbw/commands.rs | 6 + src/config.rs | 3 + 8 files changed, 790 insertions(+) create mode 100644 src/bin/rbw-agent/bitwarden_desktop.rs diff --git a/README.md b/README.md index eb9074b8..047b75bf 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ configuration options: * `pinentry`: The [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) executable to use. Defaults to `pinentry`. +* `biometric_unlock`: If `true`, `rbw` will first try to unlock using the + Bitwarden desktop app's biometric unlock (the same mechanism the official + browser extension uses): the desktop app must be running with "browser + integration" and biometric unlock enabled, and will show the OS biometric + prompt. If the desktop app is not available or the unlock fails, `rbw` + falls back to asking for the master password as usual. Note that this + relies on an internal Bitwarden protocol which may change in future + desktop app releases. Defaults to `false`. ### Profiles diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..c0be0069 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -110,6 +110,17 @@ pub fn unlock( Err(e) => return Err(e), }; + unlock_with_user_key(key, protected_private_key, protected_org_keys) +} + +pub fn unlock_with_user_key( + key: crate::locked::Keys, + protected_private_key: &str, + protected_org_keys: &std::collections::HashMap, +) -> Result<( + crate::locked::Keys, + std::collections::HashMap, +)> { let protected_private_key = crate::cipherstring::CipherString::new(protected_private_key)?; let private_key = diff --git a/src/base64.rs b/src/base64.rs index 86971bc8..a3f28831 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -13,3 +13,21 @@ pub fn decode>( ) -> Result, base64::DecodeError> { base64::engine::general_purpose::STANDARD.decode(input) } + +pub fn decode_url_safe_no_pad>( + input: T, +) -> Result, base64::DecodeError> { + base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(input) +} + +#[cfg(test)] +mod tests { + #[test] + fn decode_url_safe_no_pad() { + assert_eq!( + super::decode_url_safe_no_pad("eyJzdWIiOiJhYmMifQ").unwrap(), + br#"{"sub":"abc"}"# + ); + assert!(super::decode_url_safe_no_pad("!!!").is_err()); + } +} diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..2c0bfa73 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -404,6 +404,27 @@ async fn unlock_state( let email = config_email().await?; + if rbw::config::Config::load_async().await?.biometric_unlock { + match biometric_unlock( + db.access_token.as_deref(), + &protected_private_key, + &db.protected_org_keys, + ) + .await + { + Ok((keys, org_keys)) => { + unlock_success(state, keys, org_keys).await?; + return Ok(()); + } + Err(e) => { + log::debug!( + "biometric unlock failed, falling back to \ + pinentry: {e:#}" + ); + } + } + } + let mut err_msg = None; for i in 1_u8..=3 { let err = if i > 1 { @@ -481,6 +502,24 @@ async fn unlock_success( Ok(()) } +async fn biometric_unlock( + access_token: Option<&str>, + protected_private_key: &str, + protected_org_keys: &std::collections::HashMap, +) -> anyhow::Result<( + rbw::locked::Keys, + std::collections::HashMap, +)> { + let access_token = access_token.context("not logged in")?; + let key = + crate::bitwarden_desktop::unlock_user_key(access_token).await?; + Ok(rbw::actions::unlock_with_user_key( + key, + protected_private_key, + protected_org_keys, + )?) +} + pub async fn lock( sock: &mut crate::sock::Sock, state: std::sync::Arc>, diff --git a/src/bin/rbw-agent/bitwarden_desktop.rs b/src/bin/rbw-agent/bitwarden_desktop.rs new file mode 100644 index 00000000..ba88e1b6 --- /dev/null +++ b/src/bin/rbw-agent/bitwarden_desktop.rs @@ -0,0 +1,704 @@ +// Client for the Bitwarden desktop app's browser-integration IPC interface. +// This is the same protocol the official browser extension uses for "unlock +// with biometrics": we ask the desktop app to unlock the account, it shows +// the OS biometric prompt, and on success it hands back the account's user +// key. +// +// The protocol is internal to Bitwarden and can change between desktop app +// releases. + +use anyhow::Context as _; +use serde::Deserialize as _; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +fn user_id_from_access_token(access_token: &str) -> anyhow::Result { + #[derive(serde::Deserialize)] + struct Claims { + sub: String, + } + + let payload = access_token + .split('.') + .nth(1) + .context("access token is not a jwt")?; + let payload = rbw::base64::decode_url_safe_no_pad(payload) + .context("failed to decode jwt payload")?; + let claims: Claims = serde_json::from_slice(&payload) + .context("failed to parse jwt claims")?; + Ok(claims.sub) +} + +// the desktop app frames its ipc messages with a 4-byte native-endian +// length prefix (tokio's LengthDelimitedCodec configured as native_endian, +// max frame 1MiB) +const MAX_FRAME_LEN: u32 = 1024 * 1024; + +// convert a CipherString into the EncStringObj format expected by the +// desktop app (matching the browser extension's serialization) +fn cipherstring_to_enc_obj( + cs: &rbw::cipherstring::CipherString, +) -> anyhow::Result { + match cs { + rbw::cipherstring::CipherString::Symmetric { + iv, + ciphertext, + mac, + } => Ok(EncStringObj { + encrypted_string: cs.to_string(), + encryption_type: 2, + data: rbw::base64::encode(ciphertext), + iv: rbw::base64::encode(iv), + mac: mac.as_ref().map(rbw::base64::encode), + }), + rbw::cipherstring::CipherString::Asymmetric { .. } => { + Err(anyhow::anyhow!( + "asymmetric cipherstring not supported for ipc" + )) + } + } +} + +async fn write_frame( + stream: &mut W, + data: &[u8], +) -> anyhow::Result<()> { + let len = u32::try_from(data.len()).context("ipc message too large")?; + if len > MAX_FRAME_LEN { + return Err(anyhow::anyhow!("ipc message too large ({len} bytes)")); + } + stream.write_all(&len.to_ne_bytes()).await?; + stream.write_all(data).await?; + stream.flush().await?; + Ok(()) +} + +async fn read_frame( + stream: &mut R, +) -> anyhow::Result> { + let mut len = [0; 4]; + stream.read_exact(&mut len).await?; + let len = u32::from_ne_bytes(len); + if len > MAX_FRAME_LEN { + return Err(anyhow::anyhow!("oversized ipc frame ({len} bytes)")); + } + let mut buf = vec![0; usize::try_from(len)?]; + stream.read_exact(&mut buf).await?; + Ok(buf) +} + +#[derive(serde::Serialize)] +struct SetupEncryption<'a> { + command: &'static str, + #[serde(rename = "publicKey")] + public_key: String, + #[serde(rename = "userId")] + user_id: &'a str, + #[serde(rename = "messageId")] + message_id: u32, +} + +#[derive(serde::Serialize)] +struct EncryptedWrapper<'a> { + #[serde(rename = "appId")] + app_id: &'a str, + #[serde(rename = "messageId")] + message_id: u32, + message: EncStringObj, +} + +// the desktop app expects the encrypted message as an object (matching +// the browser extension's EncString serialization), not a plain string. +// see: apps/browser/src/background/nativeMessaging.background.ts postMessage() +#[derive(serde::Serialize)] +struct EncStringObj { + #[serde(rename = "encryptedString")] + encrypted_string: String, + #[serde(rename = "encryptionType")] + encryption_type: u8, + data: String, + iv: String, + mac: Option, +} + +#[derive(serde::Serialize)] +struct UnlockRequest<'a> { + command: &'static str, + #[serde(rename = "userId")] + user_id: &'a str, + #[serde(rename = "messageId")] + message_id: u32, + timestamp: u64, +} + +// the desktop app expects all messages wrapped as +// { "appId": , "message": } +#[derive(serde::Serialize)] +struct LegacyMessageWrapper<'a, T> { + #[serde(rename = "appId")] + app_id: &'a str, + message: T, +} + +// frames from the app are a mix of plaintext control messages and +// encrypted wrappers; parse leniently and pick out what we need +#[derive(serde::Deserialize)] +struct IncomingFrame { + command: Option, + #[serde(rename = "sharedSecret")] + shared_secret: Option, + #[serde(default, deserialize_with = "deserialize_enc_string")] + message: Option, +} + +// the desktop app may send the encrypted message as either a plain +// string ("2.iv|ct|mac") or as an EncString object +// ({encryptedString, encryptionType, data, iv, mac}). extract the +// string form either way. +fn deserialize_enc_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = + Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s)), + Some(serde_json::Value::Object(obj)) => { + obj.get("encryptedString") + .and_then(|v| v.as_str()) + .map(|s| Some(s.to_string())) + .ok_or_else(|| { + serde::de::Error::custom( + "EncString object missing encryptedString field", + ) + }) + } + _ => Err(serde::de::Error::custom( + "expected message to be a string or EncString object", + )), + } +} + +#[derive(serde::Deserialize)] +struct UnlockResponse { + command: Option, + response: Option, + #[serde(rename = "userKeyB64")] + user_key_b64: Option, +} + +pub async fn unlock_user_key( + access_token: &str, +) -> anyhow::Result { + let user_id = user_id_from_access_token(access_token)?; + let mut stream = connect().await?; + // the response only arrives once the user passes (or cancels) the os + // biometric prompt, so allow plenty of time before giving up + tokio::time::timeout( + std::time::Duration::from_secs(120), + unlock_on_stream(&mut stream, &user_id), + ) + .await + .context("timed out waiting for biometric unlock")? +} + +fn candidate_socket_paths() -> Vec { + let Some(home) = + std::env::var_os("HOME").map(std::path::PathBuf::from) + else { + return vec![]; + }; + let mut paths = vec![]; + if cfg!(target_os = "macos") { + paths.push(home.join( + "Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop/s.bw", + )); + // non-sandboxed (e.g. homebrew) desktop app uses the rust cache + // dir, which is ~/Library/Caches on macos + paths.push(home.join("Library/Caches/com.bitwarden.desktop/s.bw")); + } + paths.push(home.join(".cache/com.bitwarden.desktop/s.bw")); + // a flatpak-sandboxed desktop app can't use the path above, so it + // creates its socket inside browser config dirs instead + for path in [ + ".var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/.app.bw.socket", + ".var/app/com.google.Chrome/config/google-chrome/NativeMessagingHosts/.app.bw.socket", + ".var/app/org.chromium.Chromium/config/chromium/NativeMessagingHosts/.app.bw.socket", + ".var/app/com.microsoft.Edge/config/microsoft-edge/NativeMessagingHosts/.app.bw.socket", + ".mozilla/native-messaging-hosts/.app.bw.socket", + ".config/google-chrome/NativeMessagingHosts/.app.bw.socket", + ".config/chromium/NativeMessagingHosts/.app.bw.socket", + ".config/microsoft-edge/NativeMessagingHosts/.app.bw.socket", + ] { + paths.push(home.join(path)); + } + paths +} + +async fn connect() -> anyhow::Result { + for path in candidate_socket_paths() { + if let Ok(stream) = tokio::net::UnixStream::connect(&path).await { + log::debug!( + "connected to bitwarden desktop app at {}", + path.display() + ); + return Ok(stream); + } + } + Err(anyhow::anyhow!( + "couldn't find the bitwarden desktop app socket (is the app \ + running with browser integration enabled?)" + )) +} + +async fn unlock_on_stream( + stream: &mut S, + user_id: &str, +) -> anyhow::Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + // items before statements, or clippy::items_after_statements fires + use rsa::pkcs8::EncodePublicKey as _; + use zeroize::Zeroize as _; + + // establish the encrypted channel: send an ephemeral rsa public key, + // get back a 64-byte shared secret encrypted to it + let mut rng = rand_8::rngs::OsRng; + let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048) + .context("failed to generate rsa key")?; + + let public_key_der = private_key + .to_public_key() + .to_public_key_der() + .context("failed to encode rsa public key")?; + + let app_id = uuid::Uuid::new_v4().hyphenated().to_string(); + + let setup_msg = serde_json::to_vec(&LegacyMessageWrapper { + app_id: &app_id, + message: SetupEncryption { + command: "setupEncryption", + public_key: rbw::base64::encode( + public_key_der.as_bytes(), + ), + user_id, + message_id: 0, + }, + })?; + log::debug!( + "sending setupEncryption: appId={}, userId={}, payload={}", + app_id, + user_id, + String::from_utf8_lossy(&setup_msg) + ); + write_frame(stream, &setup_msg).await?; + log::debug!("setupEncryption sent, waiting for response..."); + + let shared_secret = loop { + let frame = read_frame(stream).await?; + log::debug!( + "received ipc frame: {}", + String::from_utf8_lossy(&frame) + ); + let msg: IncomingFrame = serde_json::from_slice(&frame)?; + if let Some(shared_secret) = msg.shared_secret { + break shared_secret; + } + if msg.command.as_deref() == Some("wrongUserId") { + return Err(anyhow::anyhow!( + "bitwarden desktop app doesn't recognize this user \ + id (is the same account logged in in the desktop \ + app?)" + )); + } + // the app can broadcast unrelated messages at any time; skip them + log::debug!("skipping ipc message: {:?}", msg.command); + }; + + let shared_secret = rbw::base64::decode(&shared_secret) + .map_err(|e| anyhow::anyhow!("invalid shared secret: {e}"))?; + log::debug!("shared secret decoded ({} bytes), decrypting with RSA...", shared_secret.len()); + let mut shared_secret = private_key + .decrypt(rsa::Oaep::new::(), &shared_secret) + .context("failed to decrypt shared secret")?; + log::debug!("shared secret decrypted, len={}", shared_secret.len()); + if shared_secret.len() != 64 { + return Err(anyhow::anyhow!( + "unexpected shared secret length {}", + shared_secret.len() + )); + } + let mut secret = rbw::locked::Vec::new(); + secret.extend(shared_secret.iter().copied()); + shared_secret.zeroize(); + let channel_keys = rbw::locked::Keys::new(secret); + + // ask the app to unlock; it shows the os biometric prompt and answers + // with the account's user key + let timestamp = u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis(), + )?; + let mut inner = serde_json::to_vec(&UnlockRequest { + command: "unlockWithBiometricsForUser", + user_id, + message_id: 1, + timestamp, + })?; + log::debug!("encrypting unlock request, timestamp={timestamp}, user_id={user_id}"); + let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( + &channel_keys, + &inner, + )?; + inner.zeroize(); + let enc_obj = cipherstring_to_enc_obj(&encrypted)?; + let unlock_msg = serde_json::to_vec(&EncryptedWrapper { + app_id: &app_id, + message_id: 1, + message: enc_obj, + })?; + log::debug!("sending unlock request (appId={app_id}): {}", String::from_utf8_lossy(&unlock_msg)); + write_frame(stream, &unlock_msg).await?; + log::debug!("unlock request sent, waiting for biometric response..."); + + loop { + let frame = read_frame(stream).await?; + log::debug!("received response frame: {}", String::from_utf8_lossy(&frame)); + let msg: IncomingFrame = serde_json::from_slice(&frame)?; + let Some(message) = msg.message else { + if msg.command.as_deref() + == Some("invalidateEncryption") + { + return Err(anyhow::anyhow!( + "bitwarden desktop app invalidated the \ + encryption channel" + )); + } + log::debug!("skipping ipc message: {:?}", msg.command); + continue; + }; + // the app can broadcast unrelated encrypted messages too (e.g. + // other accounts' unlock notifications); anything that doesn't + // decrypt and parse as our response is just skipped + let Ok(cipherstring) = + rbw::cipherstring::CipherString::new(&message) + else { + log::debug!("skipping ipc message: not a cipherstring"); + continue; + }; + let Ok(mut plaintext) = + cipherstring.decrypt_symmetric(&channel_keys, None) + else { + log::debug!("skipping ipc message: failed to decrypt"); + continue; + }; + let response: Result = + serde_json::from_slice(&plaintext); + plaintext.zeroize(); + let Ok(response) = response else { + log::debug!("skipping ipc message: failed to parse"); + continue; + }; + if response.command.as_deref() + != Some("unlockWithBiometricsForUser") + { + log::debug!("skipping ipc message: {:?}", response.command); + continue; + } + let Some(mut user_key_b64) = response.user_key_b64 else { + return Err(anyhow::anyhow!( + "bitwarden desktop app refused to unlock: {:?}", + response.response + )); + }; + let user_key = rbw::base64::decode(&user_key_b64); + user_key_b64.zeroize(); + let mut user_key = + user_key.map_err(|e| anyhow::anyhow!("invalid user key: {e}"))?; + if user_key.len() != 64 { + let len = user_key.len(); + user_key.zeroize(); + return Err(anyhow::anyhow!("unexpected user key length {len}")); + } + let mut key = rbw::locked::Vec::new(); + key.extend(user_key.iter().copied()); + user_key.zeroize(); + return Ok(rbw::locked::Keys::new(key)); + } +} + +#[cfg(test)] +mod tests { + use tokio::io::AsyncWriteExt as _; + + #[test] + fn user_id_from_access_token() { + // only the payload matters; header and signature are not validated + let token = format!( + "x.{}.y", + rbw::base64::encode_url_safe_no_pad( + br#"{"sub":"11111111-2222-3333-4444-555555555555","email":"me@example.com"}"# + ) + ); + assert_eq!( + super::user_id_from_access_token(&token).unwrap(), + "11111111-2222-3333-4444-555555555555" + ); + assert!(super::user_id_from_access_token("garbage").is_err()); + + // payload isn't valid base64url + assert!(super::user_id_from_access_token("x.!!!.y").is_err()); + + // payload is valid base64url but not json + let token = format!( + "x.{}.y", + rbw::base64::encode_url_safe_no_pad(b"notjson") + ); + assert!(super::user_id_from_access_token(&token).is_err()); + + // valid json but missing the sub claim + let token = format!( + "x.{}.y", + rbw::base64::encode_url_safe_no_pad(br#"{"email":"a@b.c"}"#) + ); + assert!(super::user_id_from_access_token(&token).is_err()); + } + + #[tokio::test] + async fn frame_round_trip() { + let (mut a, mut b) = tokio::io::duplex(4096); + super::write_frame(&mut a, b"{\"hello\":true}").await.unwrap(); + assert_eq!( + super::read_frame(&mut b).await.unwrap(), + b"{\"hello\":true}" + ); + } + + #[tokio::test] + async fn read_frame_rejects_oversized_length() { + let (mut a, mut b) = tokio::io::duplex(4096); + let oversized_len = 2 * 1024 * 1024_u32; + a.write_all(&oversized_len.to_ne_bytes()).await.unwrap(); + assert!(super::read_frame(&mut b).await.is_err()); + } + + #[tokio::test] + async fn write_frame_rejects_oversized_payload() { + let (mut a, _b) = tokio::io::duplex(4096); + let data = vec![0; 2 * 1024 * 1024]; + assert!(super::write_frame(&mut a, &data).await.is_err()); + } + + // server side of the handshake plus reading the client's unlock + // request; returns the shared channel keys so the caller can send + // whatever frames it wants (real response, broadcasts, ...) afterward + async fn mock_handshake_and_unlock_request( + server: &mut S, + ) -> rbw::locked::Keys + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + use rsa::pkcs8::DecodePublicKey as _; + + // setupEncryption + let frame = super::read_frame(server).await.unwrap(); + let msg: serde_json::Value = serde_json::from_slice(&frame).unwrap(); + assert_eq!(msg["message"]["command"], "setupEncryption"); + assert_eq!(msg["message"]["userId"], "some-user"); + assert!(msg["appId"].is_string()); + let client_pubkey = rsa::RsaPublicKey::from_public_key_der( + &rbw::base64::decode( + msg["message"]["publicKey"].as_str().unwrap(), + ) + .unwrap(), + ) + .unwrap(); + + let secret: Vec = (100..164).collect(); // 64 bytes + let mut rng = rand_8::rngs::OsRng; + let encrypted_secret = client_pubkey + .encrypt(&mut rng, rsa::Oaep::new::(), &secret) + .unwrap(); + super::write_frame( + server, + &serde_json::to_vec(&serde_json::json!({ + "command": "setupEncryption", + "appId": "test-app", + "messageId": -1, + "sharedSecret": rbw::base64::encode(&encrypted_secret), + })) + .unwrap(), + ) + .await + .unwrap(); + + let mut locked = rbw::locked::Vec::new(); + locked.extend(secret.iter().copied()); + let keys = rbw::locked::Keys::new(locked); + + // unlock request + let frame = super::read_frame(server).await.unwrap(); + let msg: serde_json::Value = serde_json::from_slice(&frame).unwrap(); + // message is now an EncString object, not a plain string + let enc_str = msg["message"]["encryptedString"] + .as_str() + .unwrap(); + let cipherstring = + rbw::cipherstring::CipherString::new(enc_str).unwrap(); + let inner = cipherstring.decrypt_symmetric(&keys, None).unwrap(); + let inner: serde_json::Value = + serde_json::from_slice(&inner).unwrap(); + assert_eq!(inner["command"], "unlockWithBiometricsForUser"); + assert_eq!(inner["userId"], "some-user"); + assert!(inner["timestamp"].is_u64()); + + keys + } + + // mock desktop app: performs the server side of the handshake, then + // answers one unlock request with the given closure's response body + async fn mock_desktop_server( + mut server: S, + respond: impl FnOnce() -> serde_json::Value, + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let keys = mock_handshake_and_unlock_request(&mut server).await; + + // encrypted response + let response = serde_json::to_vec(&respond()).unwrap(); + let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( + &keys, &response, + ) + .unwrap(); + super::write_frame( + &mut server, + &serde_json::to_vec(&serde_json::json!({ + "appId": "test-app", + "message": encrypted.to_string(), + })) + .unwrap(), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn handshake_and_unlock() { + let (mut client, server) = tokio::io::duplex(1024 * 1024); + let user_key: Vec = (0..64).collect(); + let expected = user_key.clone(); + let server_task = tokio::spawn(mock_desktop_server(server, move || { + serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": 1, + "userKeyB64": rbw::base64::encode(&user_key), + }) + })); + + let keys = super::unlock_on_stream(&mut client, "some-user") + .await + .unwrap(); + assert_eq!(keys.enc_key(), &expected[..32]); + assert_eq!(keys.mac_key(), &expected[32..]); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn unlock_refused() { + let (mut client, server) = tokio::io::duplex(1024 * 1024); + let server_task = tokio::spawn(mock_desktop_server(server, || { + serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": false, + "messageId": 1, + }) + })); + + assert!(super::unlock_on_stream(&mut client, "some-user") + .await + .is_err()); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn interleaved_broadcast_frames_are_skipped() { + let (mut client, server) = tokio::io::duplex(1024 * 1024); + let user_key: Vec = (0..64).collect(); + let expected = user_key.clone(); + let server_task = tokio::spawn(async move { + let mut server = server; + let keys = + mock_handshake_and_unlock_request(&mut server).await; + + // (a) unrelated plaintext broadcast frame + super::write_frame( + &mut server, + &serde_json::to_vec(&serde_json::json!({ + "command": "status", + })) + .unwrap(), + ) + .await + .unwrap(); + + // (b) unrelated encrypted broadcast frame (e.g. another + // account's unlock notification) + let broadcast = serde_json::to_vec(&serde_json::json!({ + "command": "status", + "userId": "some-other-user", + })) + .unwrap(); + let encrypted_broadcast = + rbw::cipherstring::CipherString::encrypt_symmetric( + &keys, &broadcast, + ) + .unwrap(); + super::write_frame( + &mut server, + &serde_json::to_vec(&serde_json::json!({ + "appId": "test-app", + "message": encrypted_broadcast.to_string(), + })) + .unwrap(), + ) + .await + .unwrap(); + + // (c) the real encrypted unlock response + let response = serde_json::to_vec(&serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": 1, + "userKeyB64": rbw::base64::encode(&user_key), + })) + .unwrap(); + let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( + &keys, &response, + ) + .unwrap(); + super::write_frame( + &mut server, + &serde_json::to_vec(&serde_json::json!({ + "appId": "test-app", + "message": encrypted.to_string(), + })) + .unwrap(), + ) + .await + .unwrap(); + }); + + let keys = super::unlock_on_stream(&mut client, "some-user") + .await + .unwrap(); + assert_eq!(keys.enc_key(), &expected[..32]); + assert_eq!(keys.mac_key(), &expected[32..]); + server_task.await.unwrap(); + } +} diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 225fb436..269d02e6 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; mod actions; mod agent; +mod bitwarden_desktop; mod daemon; mod debugger; mod notifications; diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..d9deb7b4 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1269,6 +1269,11 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), + "biometric_unlock" => { + config.biometric_unlock = value + .parse() + .context("failed to parse value for biometric_unlock")?; + } _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1298,6 +1303,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), + "biometric_unlock" => config.biometric_unlock = false, _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; diff --git a/src/config.rs b/src/config.rs index 248c603c..39d30d21 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,8 @@ pub struct Config { #[serde(default = "default_pinentry")] pub pinentry: String, pub client_cert_path: Option, + #[serde(default)] + pub biometric_unlock: bool, // backcompat, no longer generated in new configs #[serde(skip_serializing)] pub device_id: Option, @@ -37,6 +39,7 @@ impl Default for Config { sync_interval: default_sync_interval(), pinentry: default_pinentry(), client_cert_path: None, + biometric_unlock: false, device_id: None, } } From 9d77ddb94ae0d802771317d3b6dbf01b3f3db35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Bl=C3=BCm?= Date: Tue, 14 Jul 2026 12:12:42 +0200 Subject: [PATCH 2/2] fix: harden biometric unlock ipc and reuse the desktop app channel Validate incoming ipc frames the way the browser extension does: filter by appId (the desktop app broadcasts replies to all connected clients), match response messageId to the request, and reject responses with timestamps outside the 10s validity window. Zeroize the decrypted shared secret on error paths and stop logging raw ipc frames. Cache the established channel (socket + shared secret) in the agent and pre-connect at startup, like the browser extension does, so the biometric prompt appears immediately instead of paying for a new rsa handshake on every unlock. A cancelled prompt no longer retries, and the unlock timeout now matches the extension (60s). Commit was made in assistance with AI --- src/bin/rbw-agent/actions.rs | 23 +- src/bin/rbw-agent/bitwarden_desktop.rs | 876 +++++++++++++++++-------- src/bin/rbw-agent/main.rs | 22 + src/bin/rbw-agent/state.rs | 7 + 4 files changed, 640 insertions(+), 288 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 2c0bfa73..0082e931 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -406,6 +406,7 @@ async fn unlock_state( if rbw::config::Config::load_async().await?.biometric_unlock { match biometric_unlock( + state.clone(), db.access_token.as_deref(), &protected_private_key, &db.protected_org_keys, @@ -417,7 +418,7 @@ async fn unlock_state( return Ok(()); } Err(e) => { - log::debug!( + log::warn!( "biometric unlock failed, falling back to \ pinentry: {e:#}" ); @@ -503,6 +504,7 @@ async fn unlock_success( } async fn biometric_unlock( + state: std::sync::Arc>, access_token: Option<&str>, protected_private_key: &str, protected_org_keys: &std::collections::HashMap, @@ -511,8 +513,14 @@ async fn biometric_unlock( std::collections::HashMap, )> { let access_token = access_token.context("not logged in")?; - let key = - crate::bitwarden_desktop::unlock_user_key(access_token).await?; + // reuse the already established channel if there is one; don't hold the + // state lock while waiting for the user to answer the biometric prompt + let cached = state.lock().await.bitwarden_desktop_channel.take(); + let (res, channel) = + crate::bitwarden_desktop::unlock_user_key(cached, access_token) + .await; + state.lock().await.bitwarden_desktop_channel = channel; + let key = res?; Ok(rbw::actions::unlock_with_user_key( key, protected_private_key, @@ -520,6 +528,15 @@ async fn biometric_unlock( )?) } +// used at agent startup to pre-establish the desktop app channel, so the +// first biometric unlock doesn't have to wait for it +pub async fn connect_bitwarden_desktop( +) -> anyhow::Result { + let db = load_db().await?; + let access_token = db.access_token.context("not logged in")?; + crate::bitwarden_desktop::connect_channel(&access_token).await +} + pub async fn lock( sock: &mut crate::sock::Sock, state: std::sync::Arc>, diff --git a/src/bin/rbw-agent/bitwarden_desktop.rs b/src/bin/rbw-agent/bitwarden_desktop.rs index ba88e1b6..3dafc89d 100644 --- a/src/bin/rbw-agent/bitwarden_desktop.rs +++ b/src/bin/rbw-agent/bitwarden_desktop.rs @@ -4,6 +4,12 @@ // the OS biometric prompt, and on success it hands back the account's user // key. // +// Upstream calls this message format "legacy" (see LegacyMessageWrapper in +// apps/desktop/src/models/native-messaging in bitwarden/clients), but it is +// the protocol the current browser extension speaks. The message shapes, +// validation, and timeouts here mirror the extension's implementation in +// apps/browser/src/background/nativeMessaging.background.ts. +// // The protocol is internal to Bitwarden and can change between desktop app // releases. @@ -11,6 +17,26 @@ use anyhow::Context as _; use serde::Deserialize as _; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +// the desktop app frames its ipc messages with a 4-byte native-endian +// length prefix (tokio's LengthDelimitedCodec configured as native_endian, +// max frame 1MiB) +const MAX_FRAME_LEN: u32 = 1024 * 1024; + +// the browser extension ignores messages whose timestamp deviates more than +// this from the local clock (MessageValidTimeout upstream) +const MESSAGE_VALID_TIMEOUT_MS: u64 = 10 * 1000; + +// the browser extension gives up on a request after this long without a +// response (MessageNoResponseTimeout upstream); the os biometric prompt has +// to be answered within this window +const NO_RESPONSE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(60); + +// establishing the encrypted channel requires no user interaction, so it +// should complete quickly +const HANDSHAKE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(10); + fn user_id_from_access_token(access_token: &str) -> anyhow::Result { #[derive(serde::Deserialize)] struct Claims { @@ -28,10 +54,13 @@ fn user_id_from_access_token(access_token: &str) -> anyhow::Result { Ok(claims.sub) } -// the desktop app frames its ipc messages with a 4-byte native-endian -// length prefix (tokio's LengthDelimitedCodec configured as native_endian, -// max frame 1MiB) -const MAX_FRAME_LEN: u32 = 1024 * 1024; +fn now_millis() -> anyhow::Result { + Ok(u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis(), + )?) +} // convert a CipherString into the EncStringObj format expected by the // desktop app (matching the browser extension's serialization) @@ -95,14 +124,15 @@ struct SetupEncryption<'a> { user_id: &'a str, #[serde(rename = "messageId")] message_id: u32, + timestamp: u64, } +// encrypted messages are wrapped as { "appId": ..., "message": } +// (matching the browser extension's postMessage()) #[derive(serde::Serialize)] struct EncryptedWrapper<'a> { #[serde(rename = "appId")] app_id: &'a str, - #[serde(rename = "messageId")] - message_id: u32, message: EncStringObj, } @@ -144,6 +174,8 @@ struct LegacyMessageWrapper<'a, T> { #[derive(serde::Deserialize)] struct IncomingFrame { command: Option, + #[serde(rename = "appId")] + app_id: Option, #[serde(rename = "sharedSecret")] shared_secret: Option, #[serde(default, deserialize_with = "deserialize_enc_string")] @@ -160,21 +192,19 @@ fn deserialize_enc_string<'de, D>( where D: serde::Deserializer<'de>, { - let opt: Option = - Option::deserialize(deserializer)?; + let opt: Option = Option::deserialize(deserializer)?; match opt { None => Ok(None), Some(serde_json::Value::String(s)) => Ok(Some(s)), - Some(serde_json::Value::Object(obj)) => { - obj.get("encryptedString") - .and_then(|v| v.as_str()) - .map(|s| Some(s.to_string())) - .ok_or_else(|| { - serde::de::Error::custom( - "EncString object missing encryptedString field", - ) - }) - } + Some(serde_json::Value::Object(obj)) => obj + .get("encryptedString") + .and_then(|v| v.as_str()) + .map(|s| Some(s.to_string())) + .ok_or_else(|| { + serde::de::Error::custom( + "EncString object missing encryptedString field", + ) + }), _ => Err(serde::de::Error::custom( "expected message to be a string or EncString object", )), @@ -185,28 +215,111 @@ where struct UnlockResponse { command: Option, response: Option, + #[serde(rename = "messageId")] + message_id: Option, + timestamp: Option, #[serde(rename = "userKeyB64")] user_key_b64: Option, } -pub async fn unlock_user_key( +// distinguishes "the app answered, but didn't hand out a usable key" (the +// user cancelled the prompt, biometric unlock isn't set up, ...) from a +// broken channel. only the latter is worth retrying on a fresh connection; +// retrying the former would pop up a second biometric prompt. +#[derive(Debug)] +enum UnlockError { + Refused(String), + Channel(anyhow::Error), +} + +// an established encrypted channel to the desktop app. the browser +// extension keeps its channel alive for the whole browser session so that +// unlock requests don't have to wait for connection + rsa handshake; we get +// the same effect by caching this in the agent state and reusing it. +pub struct Channel { + stream: S, + keys: rbw::locked::Keys, + app_id: String, + user_id: String, + next_message_id: u32, +} + +pub type DesktopChannel = Channel; + +// establish a channel to the locally running desktop app, with the +// encryption handshake already done +pub async fn connect_channel( access_token: &str, -) -> anyhow::Result { +) -> anyhow::Result { let user_id = user_id_from_access_token(access_token)?; - let mut stream = connect().await?; - // the response only arrives once the user passes (or cancels) the os - // biometric prompt, so allow plenty of time before giving up + let stream = connect().await?; tokio::time::timeout( - std::time::Duration::from_secs(120), - unlock_on_stream(&mut stream, &user_id), + HANDSHAKE_TIMEOUT, + Channel::handshake(stream, user_id), ) .await - .context("timed out waiting for biometric unlock")? + .context("timed out establishing channel to bitwarden desktop app")? +} + +// unlock via the desktop app, reusing a previously established channel when +// possible (this is what makes the biometric prompt appear immediately, and +// matches the browser extension keeping its connection alive). returns the +// channel for reuse if it is still usable. +pub async fn unlock_user_key( + cached: Option, + access_token: &str, +) -> (anyhow::Result, Option) { + let user_id = match user_id_from_access_token(access_token) { + Ok(user_id) => user_id, + Err(e) => return (Err(e), None), + }; + + if let Some(mut channel) = cached { + if channel.user_id == user_id { + match tokio::time::timeout(NO_RESPONSE_TIMEOUT, channel.unlock()) + .await + { + Ok(Ok(keys)) => return (Ok(keys), Some(channel)), + Ok(Err(UnlockError::Refused(msg))) => { + return (Err(anyhow::anyhow!(msg)), Some(channel)); + } + Ok(Err(UnlockError::Channel(e))) => { + log::debug!( + "cached bitwarden desktop app channel failed \ + ({e:#}), reconnecting" + ); + } + Err(_) => { + return ( + Err(anyhow::anyhow!( + "timed out waiting for biometric unlock" + )), + None, + ); + } + } + } + } + + let mut channel = match connect_channel(access_token).await { + Ok(channel) => channel, + Err(e) => return (Err(e), None), + }; + match tokio::time::timeout(NO_RESPONSE_TIMEOUT, channel.unlock()).await { + Ok(Ok(keys)) => (Ok(keys), Some(channel)), + Ok(Err(UnlockError::Refused(msg))) => { + (Err(anyhow::anyhow!(msg)), Some(channel)) + } + Ok(Err(UnlockError::Channel(e))) => (Err(e), None), + Err(_) => ( + Err(anyhow::anyhow!("timed out waiting for biometric unlock")), + None, + ), + } } fn candidate_socket_paths() -> Vec { - let Some(home) = - std::env::var_os("HOME").map(std::path::PathBuf::from) + let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) else { return vec![]; }; @@ -253,181 +366,241 @@ async fn connect() -> anyhow::Result { )) } -async fn unlock_on_stream( - stream: &mut S, - user_id: &str, -) -> anyhow::Result +impl Channel where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, { - // items before statements, or clippy::items_after_statements fires - use rsa::pkcs8::EncodePublicKey as _; - use zeroize::Zeroize as _; - // establish the encrypted channel: send an ephemeral rsa public key, // get back a 64-byte shared secret encrypted to it - let mut rng = rand_8::rngs::OsRng; - let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048) - .context("failed to generate rsa key")?; - - let public_key_der = private_key - .to_public_key() - .to_public_key_der() - .context("failed to encode rsa public key")?; - - let app_id = uuid::Uuid::new_v4().hyphenated().to_string(); - - let setup_msg = serde_json::to_vec(&LegacyMessageWrapper { - app_id: &app_id, - message: SetupEncryption { - command: "setupEncryption", - public_key: rbw::base64::encode( - public_key_der.as_bytes(), - ), - user_id, - message_id: 0, - }, - })?; - log::debug!( - "sending setupEncryption: appId={}, userId={}, payload={}", - app_id, - user_id, - String::from_utf8_lossy(&setup_msg) - ); - write_frame(stream, &setup_msg).await?; - log::debug!("setupEncryption sent, waiting for response..."); - - let shared_secret = loop { - let frame = read_frame(stream).await?; - log::debug!( - "received ipc frame: {}", - String::from_utf8_lossy(&frame) - ); - let msg: IncomingFrame = serde_json::from_slice(&frame)?; - if let Some(shared_secret) = msg.shared_secret { - break shared_secret; - } - if msg.command.as_deref() == Some("wrongUserId") { + async fn handshake( + mut stream: S, + user_id: String, + ) -> anyhow::Result { + // items before statements, or clippy::items_after_statements fires + use rsa::pkcs8::EncodePublicKey as _; + use zeroize::Zeroize as _; + + let mut rng = rand_8::rngs::OsRng; + let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048) + .context("failed to generate rsa key")?; + + let public_key_der = private_key + .to_public_key() + .to_public_key_der() + .context("failed to encode rsa public key")?; + + let app_id = uuid::Uuid::new_v4().hyphenated().to_string(); + + let setup_msg = serde_json::to_vec(&LegacyMessageWrapper { + app_id: &app_id, + message: SetupEncryption { + command: "setupEncryption", + public_key: rbw::base64::encode(public_key_der.as_bytes()), + user_id: &user_id, + message_id: 0, + timestamp: now_millis()?, + }, + })?; + write_frame(&mut stream, &setup_msg).await?; + log::debug!("sent setupEncryption request to bitwarden desktop app"); + + let shared_secret = loop { + let frame = read_frame(&mut stream).await?; + let msg: IncomingFrame = serde_json::from_slice(&frame)?; + // the desktop app broadcasts its responses to all connected + // clients (e.g. a real browser extension running next to us); + // only look at frames that are actually addressed to us + if msg.app_id.as_deref() != Some(app_id.as_str()) { + log::debug!( + "skipping ipc message for a different client: {:?}", + msg.command + ); + continue; + } + if let Some(shared_secret) = msg.shared_secret { + break shared_secret; + } + if msg.command.as_deref() == Some("wrongUserId") { + return Err(anyhow::anyhow!( + "bitwarden desktop app doesn't recognize this user \ + id (is the same account logged in in the desktop \ + app?)" + )); + } + // the app can broadcast unrelated messages at any time + log::debug!("skipping ipc message: {:?}", msg.command); + }; + + let shared_secret = rbw::base64::decode(&shared_secret) + .map_err(|e| anyhow::anyhow!("invalid shared secret: {e}"))?; + let mut shared_secret = private_key + .decrypt(rsa::Oaep::new::(), &shared_secret) + .context("failed to decrypt shared secret")?; + if shared_secret.len() != 64 { + let len = shared_secret.len(); + shared_secret.zeroize(); return Err(anyhow::anyhow!( - "bitwarden desktop app doesn't recognize this user \ - id (is the same account logged in in the desktop \ - app?)" + "unexpected shared secret length {len}" )); } - // the app can broadcast unrelated messages at any time; skip them - log::debug!("skipping ipc message: {:?}", msg.command); - }; - - let shared_secret = rbw::base64::decode(&shared_secret) - .map_err(|e| anyhow::anyhow!("invalid shared secret: {e}"))?; - log::debug!("shared secret decoded ({} bytes), decrypting with RSA...", shared_secret.len()); - let mut shared_secret = private_key - .decrypt(rsa::Oaep::new::(), &shared_secret) - .context("failed to decrypt shared secret")?; - log::debug!("shared secret decrypted, len={}", shared_secret.len()); - if shared_secret.len() != 64 { - return Err(anyhow::anyhow!( - "unexpected shared secret length {}", - shared_secret.len() - )); + let mut secret = rbw::locked::Vec::new(); + secret.extend(shared_secret.iter().copied()); + shared_secret.zeroize(); + log::debug!("secure channel to bitwarden desktop app established"); + + Ok(Self { + stream, + keys: rbw::locked::Keys::new(secret), + app_id, + user_id, + next_message_id: 1, + }) } - let mut secret = rbw::locked::Vec::new(); - secret.extend(shared_secret.iter().copied()); - shared_secret.zeroize(); - let channel_keys = rbw::locked::Keys::new(secret); // ask the app to unlock; it shows the os biometric prompt and answers // with the account's user key - let timestamp = u64::try_from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis(), - )?; - let mut inner = serde_json::to_vec(&UnlockRequest { - command: "unlockWithBiometricsForUser", - user_id, - message_id: 1, - timestamp, - })?; - log::debug!("encrypting unlock request, timestamp={timestamp}, user_id={user_id}"); - let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( - &channel_keys, - &inner, - )?; - inner.zeroize(); - let enc_obj = cipherstring_to_enc_obj(&encrypted)?; - let unlock_msg = serde_json::to_vec(&EncryptedWrapper { - app_id: &app_id, - message_id: 1, - message: enc_obj, - })?; - log::debug!("sending unlock request (appId={app_id}): {}", String::from_utf8_lossy(&unlock_msg)); - write_frame(stream, &unlock_msg).await?; - log::debug!("unlock request sent, waiting for biometric response..."); - - loop { - let frame = read_frame(stream).await?; - log::debug!("received response frame: {}", String::from_utf8_lossy(&frame)); - let msg: IncomingFrame = serde_json::from_slice(&frame)?; - let Some(message) = msg.message else { - if msg.command.as_deref() - == Some("invalidateEncryption") + async fn unlock(&mut self) -> Result { + // items before statements, or clippy::items_after_statements fires + use zeroize::Zeroize as _; + + fn broken(e: impl Into) -> UnlockError { + UnlockError::Channel(e.into()) + } + + let message_id = self.next_message_id; + self.next_message_id += 1; + + let mut inner = serde_json::to_vec(&UnlockRequest { + command: "unlockWithBiometricsForUser", + user_id: &self.user_id, + message_id, + timestamp: now_millis().map_err(broken)?, + }) + .map_err(broken)?; + let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( + &self.keys, &inner, + ) + .map_err(broken)?; + inner.zeroize(); + let enc_obj = cipherstring_to_enc_obj(&encrypted).map_err(broken)?; + let unlock_msg = serde_json::to_vec(&EncryptedWrapper { + app_id: &self.app_id, + message: enc_obj, + }) + .map_err(broken)?; + write_frame(&mut self.stream, &unlock_msg) + .await + .map_err(broken)?; + log::debug!( + "sent biometric unlock request, waiting for the os prompt..." + ); + + loop { + let frame = + read_frame(&mut self.stream).await.map_err(broken)?; + let msg: IncomingFrame = + serde_json::from_slice(&frame).map_err(broken)?; + if msg.app_id.as_deref() != Some(self.app_id.as_str()) { + log::debug!( + "skipping ipc message for a different client: {:?}", + msg.command + ); + continue; + } + let Some(message) = msg.message else { + match msg.command.as_deref() { + Some("invalidateEncryption") => { + return Err(broken(anyhow::anyhow!( + "bitwarden desktop app invalidated the \ + encryption channel" + ))); + } + Some("wrongUserId") => { + return Err(broken(anyhow::anyhow!( + "bitwarden desktop app doesn't recognize \ + this user id (is the same account logged \ + in in the desktop app?)" + ))); + } + command => { + log::debug!("skipping ipc message: {command:?}"); + continue; + } + } + }; + let Ok(cipherstring) = + rbw::cipherstring::CipherString::new(&message) + else { + log::debug!("skipping ipc message: not a cipherstring"); + continue; + }; + // frames for other clients were filtered out by app id above, + // so a message addressed to us that doesn't decrypt means our + // channel key is no longer valid + let mut plaintext = cipherstring + .decrypt_symmetric(&self.keys, None) + .map_err(|e| { + broken(anyhow::anyhow!( + "failed to decrypt ipc message: {e}" + )) + })?; + let response: Result = + serde_json::from_slice(&plaintext); + plaintext.zeroize(); + let Ok(response) = response else { + log::debug!("skipping ipc message: failed to parse"); + continue; + }; + if response.command.as_deref() + != Some("unlockWithBiometricsForUser") { - return Err(anyhow::anyhow!( - "bitwarden desktop app invalidated the \ - encryption channel" - )); + log::debug!("skipping ipc message: {:?}", response.command); + continue; } - log::debug!("skipping ipc message: {:?}", msg.command); - continue; - }; - // the app can broadcast unrelated encrypted messages too (e.g. - // other accounts' unlock notifications); anything that doesn't - // decrypt and parse as our response is just skipped - let Ok(cipherstring) = - rbw::cipherstring::CipherString::new(&message) - else { - log::debug!("skipping ipc message: not a cipherstring"); - continue; - }; - let Ok(mut plaintext) = - cipherstring.decrypt_symmetric(&channel_keys, None) - else { - log::debug!("skipping ipc message: failed to decrypt"); - continue; - }; - let response: Result = - serde_json::from_slice(&plaintext); - plaintext.zeroize(); - let Ok(response) = response else { - log::debug!("skipping ipc message: failed to parse"); - continue; - }; - if response.command.as_deref() - != Some("unlockWithBiometricsForUser") - { - log::debug!("skipping ipc message: {:?}", response.command); - continue; - } - let Some(mut user_key_b64) = response.user_key_b64 else { - return Err(anyhow::anyhow!( - "bitwarden desktop app refused to unlock: {:?}", - response.response - )); - }; - let user_key = rbw::base64::decode(&user_key_b64); - user_key_b64.zeroize(); - let mut user_key = - user_key.map_err(|e| anyhow::anyhow!("invalid user key: {e}"))?; - if user_key.len() != 64 { - let len = user_key.len(); + // like the browser extension, only accept the response that + // matches the request we sent, and ignore stale or replayed + // responses + if response.message_id != Some(i64::from(message_id)) { + log::debug!( + "skipping unlock response for message {:?}", + response.message_id + ); + continue; + } + let fresh = response.timestamp.is_some_and(|timestamp| { + now_millis().is_ok_and(|now| { + now.abs_diff(timestamp) <= MESSAGE_VALID_TIMEOUT_MS + }) + }); + if !fresh { + log::debug!( + "skipping unlock response with a stale timestamp" + ); + continue; + } + let Some(mut user_key_b64) = response.user_key_b64 else { + return Err(UnlockError::Refused(format!( + "bitwarden desktop app refused to unlock: {:?}", + response.response + ))); + }; + let user_key = rbw::base64::decode(&user_key_b64); + user_key_b64.zeroize(); + let mut user_key = user_key.map_err(|e| { + UnlockError::Refused(format!("invalid user key: {e}")) + })?; + if user_key.len() != 64 { + let len = user_key.len(); + user_key.zeroize(); + return Err(UnlockError::Refused(format!( + "unexpected user key length {len}" + ))); + } + let mut key = rbw::locked::Vec::new(); + key.extend(user_key.iter().copied()); user_key.zeroize(); - return Err(anyhow::anyhow!("unexpected user key length {len}")); + return Ok(rbw::locked::Keys::new(key)); } - let mut key = rbw::locked::Vec::new(); - key.extend(user_key.iter().copied()); - user_key.zeroize(); - return Ok(rbw::locked::Keys::new(key)); } } @@ -435,6 +608,10 @@ where mod tests { use tokio::io::AsyncWriteExt as _; + fn now() -> u64 { + super::now_millis().unwrap() + } + #[test] fn user_id_from_access_token() { // only the payload matters; header and signature are not validated @@ -493,23 +670,20 @@ mod tests { assert!(super::write_frame(&mut a, &data).await.is_err()); } - // server side of the handshake plus reading the client's unlock - // request; returns the shared channel keys so the caller can send - // whatever frames it wants (real response, broadcasts, ...) afterward - async fn mock_handshake_and_unlock_request( - server: &mut S, - ) -> rbw::locked::Keys + // server side of the handshake; returns the shared channel keys and + // the client's app id + async fn mock_handshake(server: &mut S) -> (rbw::locked::Keys, String) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { use rsa::pkcs8::DecodePublicKey as _; - // setupEncryption let frame = super::read_frame(server).await.unwrap(); let msg: serde_json::Value = serde_json::from_slice(&frame).unwrap(); assert_eq!(msg["message"]["command"], "setupEncryption"); assert_eq!(msg["message"]["userId"], "some-user"); - assert!(msg["appId"].is_string()); + assert!(msg["message"]["timestamp"].is_u64()); + let app_id = msg["appId"].as_str().unwrap().to_string(); let client_pubkey = rsa::RsaPublicKey::from_public_key_der( &rbw::base64::decode( msg["message"]["publicKey"].as_str().unwrap(), @@ -527,7 +701,7 @@ mod tests { server, &serde_json::to_vec(&serde_json::json!({ "command": "setupEncryption", - "appId": "test-app", + "appId": app_id, "messageId": -1, "sharedSecret": rbw::base64::encode(&encrypted_secret), })) @@ -538,47 +712,50 @@ mod tests { let mut locked = rbw::locked::Vec::new(); locked.extend(secret.iter().copied()); - let keys = rbw::locked::Keys::new(locked); + (rbw::locked::Keys::new(locked), app_id) + } - // unlock request + // read and validate the client's unlock request; returns its message id + async fn mock_read_unlock_request( + server: &mut S, + keys: &rbw::locked::Keys, + ) -> i64 + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { let frame = super::read_frame(server).await.unwrap(); let msg: serde_json::Value = serde_json::from_slice(&frame).unwrap(); - // message is now an EncString object, not a plain string - let enc_str = msg["message"]["encryptedString"] - .as_str() - .unwrap(); + // message is an EncString object, not a plain string + let enc_str = msg["message"]["encryptedString"].as_str().unwrap(); let cipherstring = rbw::cipherstring::CipherString::new(enc_str).unwrap(); - let inner = cipherstring.decrypt_symmetric(&keys, None).unwrap(); + let inner = cipherstring.decrypt_symmetric(keys, None).unwrap(); let inner: serde_json::Value = serde_json::from_slice(&inner).unwrap(); assert_eq!(inner["command"], "unlockWithBiometricsForUser"); assert_eq!(inner["userId"], "some-user"); assert!(inner["timestamp"].is_u64()); - - keys + inner["messageId"].as_i64().unwrap() } - // mock desktop app: performs the server side of the handshake, then - // answers one unlock request with the given closure's response body - async fn mock_desktop_server( - mut server: S, - respond: impl FnOnce() -> serde_json::Value, + async fn mock_send_encrypted( + server: &mut S, + keys: &rbw::locked::Keys, + app_id: &str, + body: &serde_json::Value, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - let keys = mock_handshake_and_unlock_request(&mut server).await; - - // encrypted response - let response = serde_json::to_vec(&respond()).unwrap(); + let response = serde_json::to_vec(body).unwrap(); let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( - &keys, &response, + keys, &response, ) .unwrap(); super::write_frame( - &mut server, + server, &serde_json::to_vec(&serde_json::json!({ - "appId": "test-app", + "appId": app_id, + "messageId": body["messageId"], "message": encrypted.to_string(), })) .unwrap(), @@ -589,54 +766,120 @@ mod tests { #[tokio::test] async fn handshake_and_unlock() { - let (mut client, server) = tokio::io::duplex(1024 * 1024); + let (client, mut server) = tokio::io::duplex(1024 * 1024); let user_key: Vec = (0..64).collect(); let expected = user_key.clone(); - let server_task = tokio::spawn(mock_desktop_server(server, move || { - serde_json::json!({ - "command": "unlockWithBiometricsForUser", - "response": true, - "messageId": 1, - "userKeyB64": rbw::base64::encode(&user_key), - }) - })); - - let keys = super::unlock_on_stream(&mut client, "some-user") - .await - .unwrap(); + let server_task = tokio::spawn(async move { + let (keys, app_id) = mock_handshake(&mut server).await; + let message_id = + mock_read_unlock_request(&mut server, &keys).await; + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": message_id, + "timestamp": now(), + "userKeyB64": rbw::base64::encode(&user_key), + }), + ) + .await; + }); + + let mut channel = + super::Channel::handshake(client, "some-user".to_string()) + .await + .unwrap(); + let keys = channel.unlock().await.unwrap(); assert_eq!(keys.enc_key(), &expected[..32]); assert_eq!(keys.mac_key(), &expected[32..]); server_task.await.unwrap(); } + #[tokio::test] + async fn channel_reuse() { + let (client, mut server) = tokio::io::duplex(1024 * 1024); + let user_key: Vec = (0..64).collect(); + let expected = user_key.clone(); + let server_task = tokio::spawn(async move { + let (keys, app_id) = mock_handshake(&mut server).await; + for expected_message_id in [1, 2] { + let message_id = + mock_read_unlock_request(&mut server, &keys).await; + assert_eq!(message_id, expected_message_id); + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": message_id, + "timestamp": now(), + "userKeyB64": rbw::base64::encode(&user_key), + }), + ) + .await; + } + }); + + let mut channel = + super::Channel::handshake(client, "some-user".to_string()) + .await + .unwrap(); + for _ in 0..2 { + let keys = channel.unlock().await.unwrap(); + assert_eq!(keys.enc_key(), &expected[..32]); + assert_eq!(keys.mac_key(), &expected[32..]); + } + server_task.await.unwrap(); + } + #[tokio::test] async fn unlock_refused() { - let (mut client, server) = tokio::io::duplex(1024 * 1024); - let server_task = tokio::spawn(mock_desktop_server(server, || { - serde_json::json!({ - "command": "unlockWithBiometricsForUser", - "response": false, - "messageId": 1, - }) - })); - - assert!(super::unlock_on_stream(&mut client, "some-user") - .await - .is_err()); + let (client, mut server) = tokio::io::duplex(1024 * 1024); + let server_task = tokio::spawn(async move { + let (keys, app_id) = mock_handshake(&mut server).await; + let message_id = + mock_read_unlock_request(&mut server, &keys).await; + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": false, + "messageId": message_id, + "timestamp": now(), + }), + ) + .await; + }); + + let mut channel = + super::Channel::handshake(client, "some-user".to_string()) + .await + .unwrap(); + assert!(matches!( + channel.unlock().await, + Err(super::UnlockError::Refused(_)) + )); server_task.await.unwrap(); } #[tokio::test] - async fn interleaved_broadcast_frames_are_skipped() { - let (mut client, server) = tokio::io::duplex(1024 * 1024); + async fn interleaved_and_invalid_frames_are_skipped() { + let (client, mut server) = tokio::io::duplex(1024 * 1024); let user_key: Vec = (0..64).collect(); let expected = user_key.clone(); let server_task = tokio::spawn(async move { - let mut server = server; - let keys = - mock_handshake_and_unlock_request(&mut server).await; + let (keys, app_id) = mock_handshake(&mut server).await; + let message_id = + mock_read_unlock_request(&mut server, &keys).await; - // (a) unrelated plaintext broadcast frame + // (a) unrelated plaintext broadcast frame without an app id super::write_frame( &mut server, &serde_json::to_vec(&serde_json::json!({ @@ -647,58 +890,121 @@ mod tests { .await .unwrap(); - // (b) unrelated encrypted broadcast frame (e.g. another - // account's unlock notification) - let broadcast = serde_json::to_vec(&serde_json::json!({ - "command": "status", - "userId": "some-other-user", - })) - .unwrap(); - let encrypted_broadcast = - rbw::cipherstring::CipherString::encrypt_symmetric( - &keys, &broadcast, - ) - .unwrap(); + // (b) frame addressed to a different client (the desktop app + // broadcasts responses to everyone connected) super::write_frame( &mut server, &serde_json::to_vec(&serde_json::json!({ - "appId": "test-app", - "message": encrypted_broadcast.to_string(), + "appId": "some-other-app", + "message": "2.notavalidcipherstring", })) .unwrap(), ) .await .unwrap(); - // (c) the real encrypted unlock response - let response = serde_json::to_vec(&serde_json::json!({ - "command": "unlockWithBiometricsForUser", - "response": true, - "messageId": 1, - "userKeyB64": rbw::base64::encode(&user_key), - })) - .unwrap(); - let encrypted = rbw::cipherstring::CipherString::encrypt_symmetric( - &keys, &response, + // (c) encrypted frame for us, but an unrelated command + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "status", + "timestamp": now(), + }), ) - .unwrap(); - super::write_frame( + .await; + + // (d) unlock response with the wrong message id + mock_send_encrypted( &mut server, - &serde_json::to_vec(&serde_json::json!({ - "appId": "test-app", - "message": encrypted.to_string(), - })) - .unwrap(), + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": message_id + 1000, + "timestamp": now(), + "userKeyB64": rbw::base64::encode([0x41; 64]), + }), ) - .await - .unwrap(); + .await; + + // (e) unlock response with a stale timestamp (replay) + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": message_id, + "timestamp": now() - 60 * 1000, + "userKeyB64": rbw::base64::encode([0x41; 64]), + }), + ) + .await; + + // (f) the real response + mock_send_encrypted( + &mut server, + &keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": message_id, + "timestamp": now(), + "userKeyB64": rbw::base64::encode(&user_key), + }), + ) + .await; }); - let keys = super::unlock_on_stream(&mut client, "some-user") - .await - .unwrap(); + let mut channel = + super::Channel::handshake(client, "some-user".to_string()) + .await + .unwrap(); + let keys = channel.unlock().await.unwrap(); assert_eq!(keys.enc_key(), &expected[..32]); assert_eq!(keys.mac_key(), &expected[32..]); server_task.await.unwrap(); } + + #[tokio::test] + async fn undecryptable_frame_for_us_breaks_the_channel() { + let (client, mut server) = tokio::io::duplex(1024 * 1024); + let server_task = tokio::spawn(async move { + let (keys, app_id) = mock_handshake(&mut server).await; + let _ = mock_read_unlock_request(&mut server, &keys).await; + + // encrypted with a different key: our channel key must be + // stale, so the client should give up rather than hang + let mut other = rbw::locked::Vec::new(); + other.extend((0..64).map(|_| 0x42)); + let other_keys = rbw::locked::Keys::new(other); + mock_send_encrypted( + &mut server, + &other_keys, + &app_id, + &serde_json::json!({ + "command": "unlockWithBiometricsForUser", + "response": true, + "messageId": 1, + "timestamp": now(), + }), + ) + .await; + }); + + let mut channel = + super::Channel::handshake(client, "some-user".to_string()) + .await + .unwrap(); + assert!(matches!( + channel.unlock().await, + Err(super::UnlockError::Channel(_)) + )); + server_task.await.unwrap(); + } } diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 269d02e6..a98a05b8 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -43,6 +43,7 @@ async fn tokio_main( master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, last_environment: rbw::protocol::Environment::default(), + bitwarden_desktop_channel: None, #[cfg(feature = "clipboard")] clipboard: arboard::Clipboard::new() .inspect_err(|e| { @@ -51,6 +52,27 @@ async fn tokio_main( .ok(), })); + // like the browser extension, pre-establish the channel to the desktop + // app so that the first biometric unlock doesn't have to wait for the + // connection + rsa handshake + if config.biometric_unlock { + let state = state.clone(); + tokio::spawn(async move { + match crate::actions::connect_bitwarden_desktop().await { + Ok(channel) => { + state.lock().await.bitwarden_desktop_channel = + Some(channel); + } + Err(e) => { + log::debug!( + "couldn't pre-connect to bitwarden desktop app: \ + {e:#}" + ); + } + } + }); + } + let agent = crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 15ba565d..ced63496 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -24,6 +24,13 @@ pub struct State { // should all send their own environment over. pub last_environment: rbw::protocol::Environment, + // established channel to the bitwarden desktop app, kept alive across + // unlocks (like the browser extension does) so the biometric prompt + // appears without waiting for connection + rsa handshake. it only holds + // a transport key, no vault material, so it survives locking the agent. + pub bitwarden_desktop_channel: + Option, + #[cfg(feature = "clipboard")] pub clipboard: Option, }