Skip to content

[PM-41576] PAM partial filtered data - #1359

Open
Hinton wants to merge 9 commits into
mainfrom
pam/cipher-partial-data
Open

[PM-41576] PAM partial filtered data#1359
Hinton wants to merge 9 commits into
mainfrom
pam/cipher-partial-data

Conversation

@Hinton

@Hinton Hinton commented Aug 6, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-41576

📔 Objective

When a cipher is PAM-gated, the server withholds its secret fields and returns a reduced partial_data envelope in their place — the encrypted name and, for logins, the encrypted URIs. This PR adds support to for this field in the SDK.

  • Cipher.partial_data: Option<String> — the raw JSON envelope (defaulted + skip-if-none, so existing ciphers round-trip byte-identically).
  • CipherView.partial / CipherListView.partial: bool — the flag clients read instead of inferring gating from a string.
  • RestrictedCipherData — a private deserialize struct mirroring the server's PartialCipherData.Strip output (a purpose-built camelCase envelope). This is the single authoritative allowlist for what a gated view may expose: name + login URIs, nothing else.
  • Restricted decrypt branch (decrypt_restricted_cipher_view / _list_view) — short-circuits ahead of try_parse_blob in both the lenient and StrictDecrypt impls, decrypts only the allowlisted fields, marks the view partial = true, and never reads login/card/… So a gated Login/Card/BankAccount no longer fails with MissingField, in either decrypt mode.

Wire shape

partial_data is delivered in camelCase carrying the same LoginUri fields (uri/uriChecksum/match) the SDK already consumes on a full login, so the restricted path reuses LoginUri directly. Paired with the server change in bitwarden/server#8115, which emits a purpose-built camelCase envelope (rather than round-tripping the legacy PascalCase storage DTO). Clients pass partialData through as an opaque string, so this is a two-party server↔SDK contract with no clients impact.

In the future this can be replaced by a client encrypted blob of the partial fields.

Fail-closed

A malformed envelope or an undecryptable field degrades to empty rather than un-gating — the view is always returned partial = true. The CipherView path still runs remove_invalid_checksums, mirroring the full paths' guard against a tampering server changing URIs.

⏰ Reminders before review

Most of the diff outside cipher.rs is the mechanical one-line partial_data: None / partial: false added to existing Cipher/CipherView/CipherListView construction sites (the two new fields). The substance is in crates/bitwarden-vault/src/cipher/cipher.rs.

Add a `partial_data` field to `Cipher` and a `partial` flag to
`CipherView`/`CipherListView`, plus a dedicated decrypt branch for
restricted ciphers.

When the server withholds a gated cipher's secret fields it sends a
reduced `partial_data` envelope in their place: the encrypted name and,
for logins, the encrypted URIs. The new branch parses that envelope
(mirroring the server's `PartialCipherData.Strip` shape via
`RestrictedCipherData`), decrypts only those allowlisted fields, and
returns a view marked `partial = true` — it never reads the cipher's
secret payloads, so an over-sharing server blob can't leak a password or
TOTP onto a gated view. The allowlist lives once, in Rust.

The branch runs ahead of both the lenient and strict decrypt paths and
is independent of the `PM-34500-strict_cipher_decryption` flag, so a
restricted Login/Card/BankAccount no longer fails with `MissingField`.
Malformed envelopes fail closed: the row stays partial with an empty
name rather than un-gating. The `CipherView` path still runs
`remove_invalid_checksums`, mirroring the full paths' guard against a
tampering server.

This lets clients route gated ciphers through the SDK like any other
cipher, instead of filtering them out and hand-decrypting them.
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🔍 SDK Breaking Change Detection

SDK Version: pam/cipher-partial-data (b19f4d4)

⚠️ If breaking changes are detected, a corresponding pull request addressing them must be ready for merge in the affected client repository.

Client Status Details
typescript ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/clients. - View Details
android ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/android. - View Details

Breaking change detection uses the build of the SDK from this branch, including any incompatibities pre-existing on or merged into this branch. Check the workflow logs to confirm.
Results update as workflows complete.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.92671% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.37%. Comparing base (3781725) to head (b10e1c0).

Files with missing lines Patch % Lines
crates/bitwarden-vault/src/cipher/cipher.rs 96.57% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1359      +/-   ##
==========================================
+ Coverage   86.29%   86.37%   +0.08%     
==========================================
  Files         500      500              
  Lines       73448    73871     +423     
==========================================
+ Hits        63380    63805     +425     
+ Misses      10068    10066       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Hinton added a commit to bitwarden/clients that referenced this pull request Aug 6, 2026
…pivot)

Pivots the PAM cipher-gating client work: instead of hiding gated rows
from the SDK and hand-decrypting them, gated rows now flow through the
SDK, which decrypts the reduced envelope into a `partial` view
(bitwarden/sdk-internal#1359).

- Revert the SdkRecordMapper `shouldInclude` filter and the
  `decryptPartialCiphers` path (the last caller of the deprecated
  `Cipher.decrypt`). Gated rows go through `decryptMany*` like any cipher.
- CipherResponse passes `partialData` through verbatim (no client lift);
  `Cipher.toSdkCipher`/`fromSdkCipher` map it both ways so it round-trips
  losslessly (re-attach hack removed). The decrypted view's gating marker
  is the SDK's `partial` boolean.
- Vault seams read `view.partial`; `CipherOpenVerdict` gains a `handled`
  case so the open gate can block the open and surface the "Privileged
  Controls license required" dialog (unlicensed-user design).
- Move the "Privileged" badge into a dedicated "Controlled access" column,
  shown only when a PAM-enabled org (`Organization.usePam`) is in view and
  the badge seam is provided.

Depends on sdk-internal#1359 (adds Cipher.partial_data + the `partial`
view flag + the restricted decrypt path); the `@bitwarden/sdk-internal`
bump lands once that publishes. Supersedes #22168, #22169, #22170.
Hinton added a commit to bitwarden/clients that referenced this pull request Aug 6, 2026
…pivot)

Pivots the PAM cipher-gating client work: instead of hiding gated rows
from the SDK and hand-decrypting them, gated rows now flow through the
SDK, which decrypts the reduced envelope into a `partial` view
(bitwarden/sdk-internal#1359).

- Revert the SdkRecordMapper `shouldInclude` filter and the
  `decryptPartialCiphers` path (the last caller of the deprecated
  `Cipher.decrypt`). Gated rows go through `decryptMany*` like any cipher.
- CipherResponse passes `partialData` through verbatim (no client lift);
  `Cipher.toSdkCipher`/`fromSdkCipher` map it both ways so it round-trips
  losslessly (re-attach hack removed). The decrypted view's gating marker
  is the SDK's `partial` boolean.
- Vault seams read `view.partial`; `CipherOpenVerdict` gains a `handled`
  case so the open gate can block the open and surface the "Privileged
  Controls license required" dialog (unlicensed-user design).
- Move the "Privileged" badge into a dedicated "Controlled access" column,
  shown only when a PAM-enabled org (`Organization.usePam`) is in view and
  the badge seam is provided.

Depends on sdk-internal#1359 (adds Cipher.partial_data + the `partial`
view flag + the restricted decrypt path); the `@bitwarden/sdk-internal`
bump lands once that publishes. Supersedes #22168, #22169, #22170.
The server now emits the PAM `partial_data` envelope as a purpose-built camelCase shape carrying the same `LoginUri` fields the full decrypt path uses. Deserialize the URIs straight into `LoginUri` and drop the parallel PascalCase `RestrictedLoginUri` + its `From` impl. `RestrictedCipherData` stays the permissive top-level allowlist (no `deny_unknown_fields`) — the fail-closed boundary that drops over-shared secret fields.
@Hinton Hinton changed the title feat(vault): decrypt server-restricted (PAM-gated) partial ciphers [PM-41576] PAM partial filtered data Aug 7, 2026
@Hinton
Hinton marked this pull request as ready for review August 7, 2026 08:45
@Hinton
Hinton requested review from a team as code owners August 7, 2026 08:45
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the PAM-gated partial cipher support: the new Cipher::partial_data envelope, the partial flag on CipherView/CipherListView, and the restricted decrypt branches that short-circuit ahead of try_parse_blob in both the lenient and StrictDecrypt impls. The RestrictedCipherData allowlist, the fail-closed unwrap_or_default() parse, the checksum guard mirroring the full view path, and the type-discriminant-only list view all look correct, and the remaining diff is the mechanical field additions at existing construction sites. Key rotation is unaffected since partial_data is never populated by the CipherDetailsResponseModel conversions used by sync_current_account_data. One hardening finding on the encrypt side.

Code Review Details
  • ⚠️ : Re-encrypting a partial: true view silently strips every secret field; no guard on the encrypt paths
    • crates/bitwarden-vault/src/cipher/cipher.rs:680

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
…iews

A CipherView produced from a server-restricted (PAM-gated) cipher has
`partial = true` and all secret fields stripped to None. Nothing on the
write path read `partial`, so feeding such a view back through any encrypt
path (edit/share_cipher/move_to_organization via EncryptMode, or key
rotation via encrypt_blob_cipher) silently overwrote the item's secrets
with empty values server-side.

Add a fail-closed guard at the two lowest-level encrypt functions
(encrypt_legacy_field_encryption and encrypt_blob_cipher_with_wrapping_key),
returning the new CryptoError::EncryptRestrictedView instead. This covers
both client write APIs and rotation.
harr1424
harr1424 previously approved these changes Aug 7, 2026

@harr1424 harr1424 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tools-owned changes (bitwarden-exporters) look good

@quexten quexten left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, my main two concerns are:

  • The API here is brittle and will easily lead to bugs where a reduced cipher is updated in place of a full cipher
    • If we cannot force callers to behave right (we can, but it's more effort), then at least we should make the API intuitive, and there should be a function or field "allow_re_encryption" that maps to the condition "partial data present"
  • We should add test vectors to ensure permanent decryptability

Comment thread crates/bitwarden-exporters/src/models.rs
Comment thread crates/bitwarden-user-crypto-management/src/key_rotation/data.rs
// Fail closed: a restricted (partial) view has all secret fields stripped; re-encrypting it
// would overwrite the item's secrets with empty values. See `decrypt_restricted_cipher_view`.
if view.partial {
return Err(BlobEncryptionError::Crypto(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Hinton where does the partial view come from? Assuming that - as asked above - only org ciphers have partial views, then blob ciphers won't immediately be a problem, but become a problem as soon as we undertake organizations moving to blob ciphers.

Is the plan to:

  1. always keep the partial format with the name / uri separated out for partial ciphers?
  2. move to a (small) data blob

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this path is actually always correct. You wouldn't want to re-encrypt the partial data. Updates should be done on the full cipher.

Blobs will impact conversion to PAM and PAM updates, though, since the server can no longer separate our the partial data from the full data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partials comes from the sync.

  1. Partials define their own DTO which has a separate type from the cipher blobs.
  2. Yes. We initially wanted blobs but it was deemed not viable at this stage.

ctx: &mut KeyStoreContext<KeySlotIds>,
key: SymmetricKeySlotId,
) -> Result<CipherView, CryptoError> {
if let Some(raw) = &self.0.partial_data {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we make it easy to represent illegal states here. With f.e errors in rust the caller is forced to handle it correctly. They don't here.

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
deleted_date: cipher.deleted_date,
revision_date: cipher.revision_date,
archived_date: cipher.archived_date,
partial: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please add a new field on the cipher view "allow re-decryption". If a cipher is a partial cipher, this should be set to false. This makes it way clearer that this cipher should never be used for key rotation.

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated

@shane-melton shane-melton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to second Bernd's findings above and note a few of my own

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
/// as the `CipherView` produced will not have all fields populated (e.g. `collection_ids`).
pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
CipherView {
partial: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 : This seems dangerous. The CipherEditRequest doesn't carry a partial flag, so if a caller mistakenly attempts to edit a partial cipher, the edit.rs flow will happily strip the secrets (they aren't present to copy) and the new CipherView will have partial: false. Meaning the guard at the encryption level that throws CryptoError::EncryptRestrictedView never triggers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the encryption error, comment, editing a cipher should be blocked behind a complete data pull requirement. It WOULD be nice for the type system to enforce this, though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite agree, adding partial to CipherEditRequest makes it a valid type state to have a partial view which will always throw. You always have to make an explicit choice to convert something into the cipher edit request.

@MGibson1 MGibson1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, the approach to implement by tweaking the Cipherview is having a lot of knock on effects where we need to assert whether or not something is partial. It feels like the better approach is to use a partial view for all lists, which would then convert all of the discussion around updates to blocked by the type system.

};

Self {
partial: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a guarantee? Are we never allowing import/export of partials?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, partials is not import/exportable.

// Fail closed: a restricted (partial) view has all secret fields stripped; re-encrypting it
// would overwrite the item's secrets with empty values. See `decrypt_restricted_cipher_view`.
if view.partial {
return Err(BlobEncryptionError::Crypto(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this path is actually always correct. You wouldn't want to re-encrypt the partial data. Updates should be done on the full cipher.

Blobs will impact conversion to PAM and PAM updates, though, since the server can no longer separate our the partial data from the full data.

/// as the `CipherView` produced will not have all fields populated (e.g. `collection_ids`).
pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
CipherView {
partial: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the encryption error, comment, editing a cipher should be blocked behind a complete data pull requirement. It WOULD be nice for the type system to enforce this, though.

/// [`bitwarden_crypto::CryptoError::EncryptRestrictedView`] rather than silently stripping
/// secrets.
#[serde(default)]
pub partial: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My expertise in the SDK is out of date here, but we have this CipherView and the CipherListView below. Is that not the definition of the difference between pam complete data and pam partial data? What is the need for inclusion on both of these data types?

Partials on the full CipherView object is the reason for most of the complexity and risk in this PR, can we limit it to the list view?

@Hinton

Hinton commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Overall, the approach to implement by tweaking the Cipherview is having a lot of knock on effects where we need to assert whether or not something is partial. It feels like the better approach is to use a partial view for all lists, which would then convert all of the discussion around updates to blocked by the type system.

That won't work. First the web clients to not currently use CipherListView. This should change but is far out of the scope for the pam team. Secondly we plan to use partials in places that requires the cipher view. We could possibly implement a generic above CipherView. but that would again require extensive changes.

Blobs will impact conversion to PAM and PAM updates, though, since the server can no longer separate our the partial data from the full data.

There is currently no timeline for org blobs, our original proposal leaned into the blob model but we had to pivot since it would not be available. Once organizations starts using blobs we will use a partial blob, it should be done in the same migration.

As for the encryption error, comment, editing a cipher should be blocked behind a complete data pull requirement. It WOULD be nice for the type system to enforce this, though.

Agreed, not sure how to do that across the stack though.

My expertise in the SDK is out of date here, but we have this CipherView and the CipherListView below. Is that not the definition of the difference between pam complete data and pam partial data? What is the need for inclusion on both of these data types?

Partials on the full CipherView object is the reason for most of the complexity and risk in this PR, can we limit it to the list view?

No, per the first section of this comment.

@Hinton
Hinton requested a review from MGibson1 August 13, 2026 08:00
- Fail closed: partial_data is only decrypted for organization ciphers,
  across the lenient and strict paths for both CipherView and
  CipherListView (new CryptoError::RestrictedCipherRequiresOrganization).
- Restricted views no longer carry the wrapped cipher key (they are never
  re-encrypted); the icon-checksum gate now reads the source cipher's key.
- Preserve partial_data across merge_with_cipher so a partial_edit
  (favorite/folder toggle) no longer silently ungates a cipher.
- Pin restricted partial_data envelopes as permanent const test vectors
  (org-keyed, decrypt-only) to guard against a format break.
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
decrypt_restricted_cipher_view/_list_view were unconditionally lenient,
so StrictDecrypt<Cipher> silently stopped being strict for partials. A
field that is present but fails to decrypt now propagates as an error
under strict mode instead of degrading to empty. Absent fields (a
partial legitimately lacks most) and malformed envelopes stay lenient
in both modes, preserving the fail-closed-never-ungate contract.
@Hinton
Hinton requested a review from quexten August 13, 2026 11:35
quexten
quexten previously approved these changes Aug 14, 2026
@quexten

quexten commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Neat, thank you for the test vectors!

Hinton and others added 2 commits August 14, 2026 10:40
The generated API models now carry `partialData`, so resolve the TODOs
in the cipher merge paths: take `partial_data` from the server response
(`self`) rather than preserving the stale local value.

- `TryFrom<CipherDetailsResponseModel>` reads `partial_data` from the
  response (also fixes the sync conversion, which uses it directly).
- The four `merge_with_cipher` impls now source `partial_data` from the
  response, so a full response un-gates a locally-restricted cipher and a
  restricted response gates it — the server is authoritative for gating.

Add a test covering both directions across all four merge impls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants