Skip to content
Draft
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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
# Changelog

## Unreleased

## Added

* `rbw edit --field=<name>` updates an existing custom field on login,
secure note, card, and identity entries. Piped stdin is stored as the
new value; an interactive terminal opens the editor. SSH key entries
and linked fields are refused; boolean fields must be `true` or
`false`. Empty values are refused. The editor help block is removed by
content, not only as a suffix (#370).
* `rbw get --list-custom-fields` lists editable custom field names
without decrypting hidden values (#370).

## Changed

* `db::Entry` now includes `favorite`, `archived_date`, and
`revision_date` so a field-only PUT can round-trip those values.
This is a Rust source break for struct literals and exhaustive
matches; JSON cache load stays compatible via `serde(default)`.
* `actions::edit` and `Client::edit` are removed. They omitted item
`key` and wrote `favorite=false` / `archivedDate=null`, which could
mix-key-corrupt keyed items and unfavorite/unarchive others. Use
`edit_with_meta`.

## Fixed

* `rbw edit` encrypts with the entry's individual item key when present
and preserves `key` on the cipher PUT, so keyed items are not corrupted
(#364, #368).
* Cipher PUT now sends `reprompt`, `favorite`, `archivedDate`, and
`lastKnownRevisionDate`. `rbw edit` syncs before resolving the
name/URI selector, and again after a successful PUT so the agent
rebuilds its master-password-reprompt set. The agent writes the
vault to disk before replacing that in-memory set; a refresh
failure after a successful PUT is an error (`rbw sync`), not a
silent success. After the server write, any local follow-up failure
(token save, cache refresh, reload) makes `rbw add`, `rbw edit`, and
`rbw generate` exit 2 (not 1) and tells you not to retry the write.
Stale-cipher
responses (HTTP 400 "out of date" or 409) map to a retryable
conflict error. Field conflict retries compare the target field's
plaintext: official clients re-encrypt every custom field on save,
so a notes-only edit changes ciphertext without changing the value.
* A rejected interactive editor (`rbw add`, `rbw edit`, `rbw edit
--field`) keeps the plaintext in `$RBW runtime/unsaved-edits/`
(directory mode 0700, files 0600, `O_EXCL`) until you delete it.
Piped stdin is not written to disk. `rbw purge` removes that
directory.

## [1.15.0] - 2025-12-31

## Added
Expand Down
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,26 @@ 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.

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`
flag will show the output as JSON. In addition to matching against the name,
you can pass a UUID as the name to search for the entry with that id, or a
URL to search for an entry with a matching website entry.
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` flag will show the output as JSON. Run
`rbw edit --field={field} <name>` to change an existing custom field on
login, secure note, card, and identity entries. `rbw edit` syncs the
vault before opening the editor, so it needs network access. Piped
stdin is stored as the new value (trailing newlines are stripped), or an
editor is opened when stdin is a terminal. Empty values are refused.
SSH key entries and linked fields cannot be edited this way.
If an interactive editor save fails before the server write, the typed
buffer is kept under the rbw runtime `unsaved-edits/` directory; delete
it after recovering, or run `rbw purge`. Piped stdin is never written
there. If the server write succeeded but the local cache could not be
refreshed, the command exits 2 — run `rbw sync` and do not retry the
write.
`rbw get --list-custom-fields` lists the editable custom field names for
an entry. In addition to matching against the name, you can pass a UUID
as the name to search for the entry with that id, or a URL to search for
an entry with a matching website entry.

*Note to users of the official Bitwarden server (at bitwarden.com)*: The
official server has a tendency to detect command line traffic as bot traffic
Expand Down
8 changes: 6 additions & 2 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ fn add_once(
Ok(())
}

pub fn edit(
pub fn edit_with_meta(
access_token: &str,
refresh_token: &str,
id: &str,
Expand All @@ -204,6 +204,7 @@ pub fn edit(
notes: Option<&str>,
folder_uuid: Option<&str>,
history: &[crate::db::HistoryEntry],
meta: &crate::db::CipherWriteMeta<'_>,
) -> Result<(Option<String>, ())> {
with_exchange_refresh_token(access_token, refresh_token, |access_token| {
edit_once(
Expand All @@ -216,6 +217,7 @@ pub fn edit(
notes,
folder_uuid,
history,
meta,
)
})
}
Expand All @@ -230,9 +232,10 @@ fn edit_once(
notes: Option<&str>,
folder_uuid: Option<&str>,
history: &[crate::db::HistoryEntry],
meta: &crate::db::CipherWriteMeta<'_>,
) -> Result<()> {
let (client, _) = api_client()?;
client.edit(
client.edit_with_meta(
access_token,
id,
org_id,
Expand All @@ -242,6 +245,7 @@ fn edit_once(
notes,
folder_uuid,
history,
meta,
)?;
Ok(())
}
Expand Down
105 changes: 96 additions & 9 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ struct SyncResCipher {
key: Option<String>,
#[serde(rename = "Reprompt", alias = "reprompt")]
reprompt: CipherRepromptType,
#[serde(default, rename = "Favorite", alias = "favorite")]
favorite: bool,
#[serde(default, rename = "ArchivedDate", alias = "archivedDate")]
archived_date: Option<String>,
#[serde(default, rename = "RevisionDate", alias = "revisionDate")]
revision_date: Option<String>,
}

impl SyncResCipher {
Expand Down Expand Up @@ -551,6 +557,9 @@ impl SyncResCipher {
history,
key: self.key.clone(),
master_password_reprompt: self.reprompt,
favorite: self.favorite,
archived_date: self.archived_date.clone(),
revision_date: self.revision_date.clone(),
})
}
}
Expand Down Expand Up @@ -780,6 +789,21 @@ struct CiphersPutReq {
secure_note: Option<CipherSecureNote>,
#[serde(rename = "passwordHistory")]
password_history: Vec<CiphersPutReqHistory>,
key: Option<String>,
// Non-optional on the official server; omitting them resets the
// live cipher (see CipherRequestModel.ToCipherDetails).
reprompt: CipherRepromptType,
favorite: bool,
#[serde(rename = "archivedDate")]
archived_date: Option<String>,
// Official server only conflict-checks when this is present. An
// omitted value allows a stale full PUT to overwrite a password or
// notes another client just saved.
#[serde(
rename = "lastKnownRevisionDate",
skip_serializing_if = "Option::is_none"
)]
last_known_revision_date: Option<String>,
}

#[derive(serde::Serialize, Debug)]
Expand Down Expand Up @@ -1317,7 +1341,7 @@ impl Client {
}
}

pub fn edit(
pub fn edit_with_meta(
&self,
access_token: &str,
id: &str,
Expand All @@ -1328,6 +1352,7 @@ impl Client {
notes: Option<&str>,
folder_uuid: Option<&str>,
history: &[crate::db::HistoryEntry],
meta: &crate::db::CipherWriteMeta<'_>,
) -> Result<()> {
let mut req = CiphersPutReq {
ty: match data {
Expand Down Expand Up @@ -1361,6 +1386,15 @@ impl Client {
password: entry.password.clone(),
})
.collect(),
key: meta.key.map(std::string::ToString::to_string),
reprompt: meta.reprompt,
favorite: meta.favorite,
archived_date: meta
.archived_date
.map(std::string::ToString::to_string),
last_known_revision_date: meta
.last_known_revision_date
.map(std::string::ToString::to_string),
};
match data {
crate::db::EntryData::Login {
Expand Down Expand Up @@ -1456,15 +1490,12 @@ impl Client {
.json(&req)
.send()
.map_err(|source| Error::Reqwest { source })?;
match res.status() {
reqwest::StatusCode::OK => Ok(()),
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
let status = res.status();
if status == reqwest::StatusCode::OK {
return Ok(());
}
let body = res.text().unwrap_or_default();
Err(cipher_write_error(status, &body))
}

pub fn remove(&self, access_token: &str, id: &str) -> Result<()> {
Expand Down Expand Up @@ -1766,3 +1797,59 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error {
log::warn!("unexpected error received during login: {error_res:?}");
Error::RequestFailed { status: code }
}

fn cipher_write_error(status: reqwest::StatusCode, body: &str) -> Error {
// Official Bitwarden throws BadRequestException ("out of date") → 400.
// Vaultwarden uses err!("... out of date.") → also 400. Some forks
// might still use 409. Matching the body is what surfaces
// CipherRevisionConflict; status 409 is kept as a belt.
if status == reqwest::StatusCode::CONFLICT
|| is_stale_cipher_message(body)
{
Error::CipherRevisionConflict
} else if status == reqwest::StatusCode::UNAUTHORIZED {
Error::RequestUnauthorized
} else {
Error::RequestFailed {
status: status.as_u16(),
}
}
}

fn is_stale_cipher_message(body: &str) -> bool {
body.to_ascii_lowercase().contains("out of date")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn revision_conflict_from_official_and_vaultwarden_bodies() {
assert!(matches!(
cipher_write_error(
reqwest::StatusCode::BAD_REQUEST,
"The item cannot be saved because it is out of date. Please reload and try again.",
),
Error::CipherRevisionConflict
));
assert!(matches!(
cipher_write_error(
reqwest::StatusCode::BAD_REQUEST,
r#"{"message":"The client copy of this cipher is out of date."}"#,
),
Error::CipherRevisionConflict
));
assert!(matches!(
cipher_write_error(reqwest::StatusCode::CONFLICT, ""),
Error::CipherRevisionConflict
));
assert!(matches!(
cipher_write_error(
reqwest::StatusCode::BAD_REQUEST,
"Name is required",
),
Error::RequestFailed { status: 400 }
));
}
}
30 changes: 27 additions & 3 deletions src/bin/rbw-agent/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,15 +527,26 @@ pub async fn sync(
) = rbw::actions::sync(&access_token, &refresh_token)
.await
.context("failed to sync database from server")?;
state.lock().await.set_master_password_reprompt(&entries);
if let Some(access_token) = access_token {
db.access_token = Some(access_token);
}
db.protected_key = Some(protected_key);
db.protected_private_key = Some(protected_private_key);
db.protected_org_keys = protected_org_keys;
db.entries = entries;
save_db(&db).await?;
// Disk first, then the in-memory ciphertext set, under the same
// lock. The reverse order (or updating memory before a failed
// save) leaves the set matching the server while the on-disk
// cache still has the previous vault: decrypting an old hidden
// value would miss the set and skip master-password reprompt.
// Holding the lock across the save closes the window where a
// concurrent decrypt would see new disk bytes against the old
// set (the original skip-prompt bug this sync exists to fix).
{
let mut state = state.lock().await;
save_db(&db).await?;
state.set_master_password_reprompt(&db.entries);
}

if let Err(e) = subscribe_to_notifications(state.clone()).await {
eprintln!("failed to subscribe to notifications: {e}");
Expand Down Expand Up @@ -692,6 +703,7 @@ pub async fn encrypt(
sock: &mut crate::sock::Sock,
state: std::sync::Arc<tokio::sync::Mutex<crate::state::State>>,
plaintext: &str,
entry_key: Option<&str>,
org_id: Option<&str>,
) -> anyhow::Result<()> {
let state = state.lock().await;
Expand All @@ -700,8 +712,20 @@ pub async fn encrypt(
"failed to find encryption keys in in-memory state"
));
};
let entry_keys = if let Some(entry_key) = entry_key {
let key_cipherstring =
rbw::cipherstring::CipherString::new(entry_key)
.context("failed to parse individual item encryption key")?;
Some(rbw::locked::Keys::new(
key_cipherstring.decrypt_locked_symmetric(keys).context(
"failed to decrypt individual item encryption key",
)?,
))
} else {
None
};
let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric(
keys,
entry_keys.as_ref().unwrap_or(keys),
plaintext.as_bytes(),
)
.context("failed to encrypt plaintext secret")?;
Expand Down
7 changes: 6 additions & 1 deletion src/bin/rbw-agent/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,16 @@ async fn handle_request(
.await?;
true
}
rbw::protocol::Action::Encrypt { plaintext, org_id } => {
rbw::protocol::Action::Encrypt {
plaintext,
entry_key,
org_id,
} => {
crate::actions::encrypt(
sock,
state.clone(),
plaintext,
entry_key.as_deref(),
org_id.as_deref(),
)
.await?;
Expand Down
2 changes: 2 additions & 0 deletions src/bin/rbw/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,15 @@ pub fn decrypt(

pub fn encrypt(
plaintext: &str,
entry_key: Option<&str>,
org_id: Option<&str>,
) -> anyhow::Result<String> {
let mut sock = connect()?;
sock.send(&rbw::protocol::Request::new(
get_environment(),
rbw::protocol::Action::Encrypt {
plaintext: plaintext.to_string(),
entry_key: entry_key.map(std::string::ToString::to_string),
org_id: org_id.map(std::string::ToString::to_string),
},
))?;
Expand Down
Loading
Loading