[PM-41576] PAM partial filtered data - #1359
Conversation
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.
🔍 SDK Breaking Change DetectionSDK Version:
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. |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…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.
…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.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the PAM-gated partial cipher support: the new Code Review Details
|
…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
left a comment
There was a problem hiding this comment.
Tools-owned changes (bitwarden-exporters) look good
quexten
left a comment
There was a problem hiding this comment.
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
| // 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( |
There was a problem hiding this comment.
@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:
- always keep the partial format with the name / uri separated out for partial ciphers?
- move to a (small) data blob
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Partials comes from the sync.
- Partials define their own DTO which has a separate type from the cipher blobs.
- 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 { |
There was a problem hiding this comment.
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.
| deleted_date: cipher.deleted_date, | ||
| revision_date: cipher.revision_date, | ||
| archived_date: cipher.archived_date, | ||
| partial: true, |
There was a problem hiding this comment.
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.
shane-melton
left a comment
There was a problem hiding this comment.
I want to second Bernd's findings above and note a few of my own
| /// 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, |
There was a problem hiding this comment.
💭 : 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Is this a guarantee? Are we never allowing import/export of partials?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
That won't work. First the web clients to not currently use
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.
Agreed, not sure how to do that across the stack though.
No, per the first section of this comment. |
- 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.
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.
|
Neat, thank you for the test vectors! |
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.
🎟️ 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_dataenvelope 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'sPartialCipherData.Stripoutput (a purpose-built camelCase envelope). This is the single authoritative allowlist for what a gated view may expose: name + login URIs, nothing else.decrypt_restricted_cipher_view/_list_view) — short-circuits ahead oftry_parse_blobin both the lenient andStrictDecryptimpls, decrypts only the allowlisted fields, marks the viewpartial = true, and never readslogin/card/… So a gatedLogin/Card/BankAccountno longer fails withMissingField, in either decrypt mode.Wire shape
partial_datais delivered in camelCase carrying the sameLoginUrifields (uri/uriChecksum/match) the SDK already consumes on a full login, so the restricted path reusesLoginUridirectly. 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 passpartialDatathrough 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. TheCipherViewpath still runsremove_invalid_checksums, mirroring the full paths' guard against a tampering server changing URIs.⏰ Reminders before review
Most of the diff outside
cipher.rsis the mechanical one-linepartial_data: None/partial: falseadded to existingCipher/CipherView/CipherListViewconstruction sites (the two new fields). The substance is incrates/bitwarden-vault/src/cipher/cipher.rs.