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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
helpers. `login` and `logout` manage credentials isolated by settings scope
and API resource; custom manifests remain available through `--file` (0.21+).

- Bitwarden Password Manager references accept an exact item UUID in 0.21+ for
reads and writes, so one of several same-named items can be addressed without
renaming it.

- OpenPGP and OpenSSH private keys can be generated entirely in Rust (0.21+).
OpenPGP generation uses an explicit User ID and signing, encryption, or
combined capability profiles; Ed25519/Curve25519 is the default, with
Expand Down
24 changes: 17 additions & 7 deletions docs/src/content/docs/providers/bw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,8 @@ of the title so the same collection or personal vault can safely hold
`secretspec/{project}/{profile}` prefix, and the key is appended to it.

The prefix is an item-title namespace, not a Bitwarden `folderId`. Explicit
`ref.item` coordinates always name the complete existing item title and do not
receive the prefix.
`ref.item` coordinates name either the complete existing item title or, in
0.21+, its exact item UUID. Neither form receives the prefix.

The Bitwarden provider supports every Password Manager item type. When an item
type is selected through `BITWARDEN_DEFAULT_TYPE` or `?type=`, it filters reads
Expand Down Expand Up @@ -375,8 +375,15 @@ unintended partial match. `field = "notes"` addresses a Secure Note's body.

<VersionCompatibility version="0.18" />

Resolved item titles are matched **in full, case-insensitively** — `test database` finds
`Test Database`, but `API_KEY` never matches `API_KEY_OLD`. The `bw` CLI itself
<VersionCompatibility version="0.21" kind="changed">

`ref.item` also accepts an exact Bitwarden item UUID.

</VersionCompatibility>

An exact item UUID selects that item directly. Otherwise, resolved item titles
are matched **in full, case-insensitively** — `test database` finds `Test
Database`, but `API_KEY` never matches `API_KEY_OLD`. The `bw` CLI itself
accepts a substring here, which works well interactively because it prints the
candidates and lets you choose; a name in `secretspec.toml` is resolved with
nobody watching, so a partial match would quietly read — or overwrite — a
Expand All @@ -387,8 +394,10 @@ SecretSpec refuses the address and lists the colliding IDs rather than picking
one. Rename the items so the selected name is unique, or use `?type=` when the
collisions have different item types.

Adding `?type=` narrows the match to that item type, on both reads and writes.
That is how a Card and a Login of the same name stay separately addressable:
Adding `?type=` narrows a name match to that item type, on both reads and
writes. An exact item UUID takes precedence because it already identifies one
item. That is how a Card and a Login of the same name stay separately
addressable:

```bash
$ secretspec get API_KEY --provider "bw://?type=card"
Expand All @@ -405,7 +414,8 @@ item title, or when a specific field is required:
DATABASE_URL = { description = "Application database", ref = { item = "MyApp Database", field = "password" }, providers = ["bw"] }
```

`ref.item` is matched against the Bitwarden item name, not its item ID.
`ref.item` accepts either an exact Bitwarden item UUID or a complete,
case-insensitive item title.

### Migrating bare item names {/* #migrating-bare-item-names-020 */}

Expand Down
31 changes: 20 additions & 11 deletions docs/src/content/docs/reference/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -513,21 +513,30 @@ the collection's actual organization. Naming it is optional when the collection
name is unambiguous. Addresses that resolve to nothing fail with the
organizations or collections that do exist.

Item names match the same way — **in full and case-insensitively** (0.18+), so
`API_KEY` never resolves `API_KEY_OLD`. Names are not unique in Bitwarden, and a
name matching several items is refused with their ids rather than resolved to an
arbitrary one; address a single item by using its id as the `item`. `?type=`
narrows both reads and writes to that item type, keeping a Card and a same-named
Login separately addressable. An unsupported `?type=`, or an unknown query
parameter, is rejected when the address is parsed rather than ignored.
<VersionCompatibility version="0.21" kind="changed">

`ref.item` also accepts an exact Bitwarden item UUID.

</VersionCompatibility>

An exact item UUID selects that item directly. Otherwise, item names match the
same way — **in full and case-insensitively** (0.18+), so `API_KEY` never
resolves `API_KEY_OLD`. Names are not unique in Bitwarden, and a name matching
several items is refused with their ids rather than resolved to an arbitrary
one; address a single item by using its id as the `item`. `?type=` narrows name
matches on both reads and writes, keeping a Card and a same-named Login
separately addressable. An exact item UUID takes precedence because it already
identifies one item. An unsupported `?type=`, or an unknown query parameter, is
rejected when the address is parsed rather than ignored.

SecretSpec 0.20+ convention items use the title
`secretspec/{project}/{profile}/{key}`. `?folder=` replaces the prefix before
the key; it is an item-title namespace, not a Bitwarden folder. Explicit
`ref.item` values remain complete, unprefixed item titles. Releases through
0.19 wrote bare convention titles, which must be renamed to the 0.20 layout or
kept with an explicit `ref = { item = "OLD_TITLE" }`; there is no automatic
bare-name fallback because a bare item carries no project/profile ownership.
`ref.item` values remain complete, unprefixed item titles or, in 0.21+, exact
item UUIDs. Releases through 0.19 wrote bare convention titles, which must be
renamed to the 0.20 layout or kept with an explicit
`ref = { item = "OLD_TITLE" }`; there is no automatic bare-name fallback
because a bare item carries no project/profile ownership.

`?server=` does not configure the CLI. The `bw` CLI takes its server only from
`bw config server`, which must be run while logged out, so self-hosted users
Expand Down
142 changes: 128 additions & 14 deletions secretspec/src/provider/bw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,11 +1014,12 @@ fn is_note_body_field(field: &str) -> bool {
field.eq_ignore_ascii_case("notes")
}

/// Finds the one item `item_name` addresses, for both reads and writes.
/// Finds the one item `item_reference` addresses, for both reads and writes.
///
/// Narrows the way `bw get item` itself does — by name, then by type, then
/// refusing what is still ambiguous — with one deliberate difference: the name
/// has to match in full.
/// An exact item ID selects that item directly. Otherwise, narrows the way
/// `bw get item` itself does — by name, then by type, then refusing what is
/// still ambiguous — with one deliberate difference: the name has to match in
/// full.
///
/// `bw`'s own lookup accepts a substring (`searchCiphersBasic` splits the query
/// and matches parts across name, username and URIs) because it is an
Expand All @@ -1036,13 +1037,18 @@ fn is_note_body_field(field: &str) -> bool {
/// `Überblick` unreachable as `überblick`.
///
/// `require_type` is `Some` only when the address named a type; see
/// [`BitwardenConfig::default_item_type`].
/// [`BitwardenConfig::default_item_type`]. It narrows name matching, while an
/// item ID is already an exact, unique address.
fn find_addressed_item<'a>(
items: &'a [BitwardenItem],
item_name: &str,
item_reference: &str,
require_type: Option<BitwardenItemType>,
) -> Result<Option<&'a BitwardenItem>> {
let wanted = item_name.to_lowercase();
if let Some(item) = items.iter().find(|item| item.id == item_reference) {
return Ok(Some(item));
}

let wanted = item_reference.to_lowercase();
let by_name: Vec<&BitwardenItem> = items
.iter()
.filter(|item| item.name.to_lowercase() == wanted)
Expand All @@ -1067,7 +1073,7 @@ fn find_addressed_item<'a>(
[] => Ok(None),
[only] => Ok(Some(only)),
several => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} Bitwarden items are named '{item_name}'. Rename them, or point the \
"{} Bitwarden items are named '{item_reference}'. Rename them, or point the \
secret at one of these ids with ref = {{ item = \"<id>\" }}:\n{}",
several.len(),
several
Expand Down Expand Up @@ -1609,8 +1615,12 @@ impl BitwardenProvider {
));
}

// `--search` narrows server-side, which is worth having on a large
// vault, but it is bw's own fuzzy matcher and not the lookup: it
// An item UUID bypasses `--search`: bw's fuzzy matcher does not search
// item IDs, so an unrelated searchable field could otherwise produce
// a non-empty candidate set that omits the addressed item.
//
// For names, `--search` narrows server-side, which is worth having on a
// large vault, but it is bw's own fuzzy matcher and not the lookup: it
// decides on its own terms which items are even considered.
//
// Those terms have been wrong. Before bitwarden/clients e1aa943b
Expand All @@ -1624,10 +1634,15 @@ impl BitwardenProvider {
// secret is absent", and the fall back re-lists unfiltered. `set` has
// always listed unfiltered, so this also makes reads and writes
// consider the same set of items.
let mut items = self.list_items(Some(item_name))?;
if items.is_empty() {
items = self.list_items(None)?;
}
let items = if uuid::Uuid::parse_str(item_name).is_ok() {
self.list_items(None)?
} else {
let mut items = self.list_items(Some(item_name))?;
if items.is_empty() {
items = self.list_items(None)?;
}
items
};

if let Some(item) = find_addressed_item(&items, item_name, self.resolved_item_type()?)? {
return self.extract_value_from_item(item, field_hint);
Expand Down Expand Up @@ -4119,6 +4134,28 @@ mod tests {
assert!(msg.contains("ref = { item ="), "{msg}");
}

#[test]
fn an_item_id_selects_one_of_multiple_same_named_items() {
let items = [
named_item(
"11111111-1111-1111-1111-111111111111",
"API_KEY",
BitwardenItemType::Login,
),
named_item(
"22222222-2222-2222-2222-222222222222",
"API_KEY",
BitwardenItemType::Login,
),
];

let hit = find_addressed_item(&items, "22222222-2222-2222-2222-222222222222", None)
.expect("an exact item ID is not ambiguous")
.expect("the addressed item exists");

assert_eq!(hit.id, "22222222-2222-2222-2222-222222222222");
}

#[test]
fn an_addressed_type_selects_between_same_named_items_on_write() {
// The `set` half of the type filter: with only a Login present, an
Expand Down Expand Up @@ -5065,6 +5102,40 @@ mod tests {
});
}

#[cfg(unix)]
#[test]
fn get_accepts_an_exact_item_id() {
let fake = FakeBw::new().with_items(json!([
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "22222222-2222-2222-2222-222222222222",
"type": 1,
"login": {"password": "search-decoy"}
},
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "Vault",
"type": 1,
"login": {"password": "second"}
}
]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let value = provider
.get_from_password_manager("22222222-2222-2222-2222-222222222222", None)
.unwrap();
assert_eq!(
value.map(|secret| secret.expose_secret().to_string()),
Some("second".to_string())
);
let log = fake.invocations();
assert!(
!log.contains("<--search>"),
"an item UUID must bypass bw's non-ID search: {log}"
);
});
}

#[cfg(unix)]
#[test]
fn get_fails_closed_when_not_authenticated() {
Expand Down Expand Up @@ -5201,6 +5272,49 @@ mod tests {
assert_eq!(sent["login"]["username"], "alice");
}

#[cfg(unix)]
#[test]
fn set_accepts_an_exact_item_id() {
let fake = FakeBw::new().with_items(json!([
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Vault",
"type": 1,
"login": {"password": "first"}
},
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "Vault",
"type": 1,
"login": {"password": "old"}
}
]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.set_to_password_manager(
"22222222-2222-2222-2222-222222222222",
None,
&SecretString::new("new".into()),
)
.unwrap();
});

let log = fake.invocations();
assert!(
log.contains(
"argv: <--nointeraction> <edit> <item> <22222222-2222-2222-2222-222222222222>"
),
"{log}"
);
assert!(
!log.contains("<create>"),
"must not create a new item: {log}"
);
let sent = decode_stdin_line(&fake, "edit");
assert_eq!(sent["login"]["password"], "new");
}

#[cfg(unix)]
#[test]
fn set_creates_a_new_item_when_none_matches() {
Expand Down