[PM-41268] Withhold gated cipher secrets behind a type-checked witness - #8115
Draft
Hinton wants to merge 1 commit into
Draft
[PM-41268] Withhold gated cipher secrets behind a type-checked witness#8115Hinton wants to merge 1 commit into
Hinton wants to merge 1 commit into
Conversation
Member
Author
|
We need to filter out partial ciphers and ensure they are only sent to compatible clients. For now that is the web vault only. |
Emitting a cipher's full secret data now requires a FullCipherAccess witness, and the default response shape is partial. A path that forgets to obtain a witness returns partial data - a visible bug - rather than leaking secrets. - PartialCipherData.Strip reshapes the plaintext JSON envelope, keeping the encrypted name (and, for logins, the encrypted URIs) and dropping every other encrypted field. Nothing is decrypted; retained values stay individually-encrypted EncStrings. - FullCipherAccess is the witness. Its factories are internal, so only the leasing gate can mint one; the Full* response ctors call Require() per element, keeping bulk lists fail-closed rather than only single reads. - Each of the 4 cipher response types gains a Full* subclass deriving from its partial counterpart, so ListResponseModel holds a polymorphic mix and the wire contract is unchanged (verified: the generated OpenAPI spec adds and removes no schemas). All secret setters are now protected. - Attachment metadata is withheld from partial responses because it carries each attachment's encryption key, and GetAttachmentData is gated: a download URL grants the encrypted attachment, which the caller could decrypt with an org key they already hold. - ICipherLeaseGate is the decision point, with NoopCipherLeaseGate as the OSS default. The interface deliberately has zero PAM-domain dependencies, so this lands independently of the leasing domain; the commercial gate overrides the registration later in startup. Only the web vault understands the partial shape, so gated ciphers are omitted entirely for every other client rather than sent partial. An older client would render an item with no credentials as though it were empty, and saving it back would overwrite the withheld fields with the blanks the client holds; dropping the item is the lesser harm, and it stays visible in the web vault where the user can request access. PartialCipherSupport is the single predicate, and it fails safe - an absent or unrecognized device type is treated as unable to render the shape. In sync this mirrors the existing FilterUnsupportedCipherTypes pattern; on single reads a gated cipher is a 404. Behavior is unchanged: the no-op gate authorizes everything, so every existing response is still full and nothing is ever filtered. The bulk read paths use the gate's self-loading overload rather than eagerly querying collections, so nothing extra is queried while the feature is off. Adds a reflection-based fitness guard asserting the invariants that make this fail closed - no public setter on any secret property, no way for application code to mint a witness, and every Full* ctor requiring one - so a future refactor that reopens one of those holes fails a test.
Hinton
force-pushed
the
pam/cipher-partial-data-witness
branch
from
July 31, 2026 14:13
3c9b84b to
ff6c1cf
Compare
|
|
||
| var userService = sutProvider.GetDependency<IUserService>(); | ||
| userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user); | ||
| userService.HasPremiumFromOrganization(user).Returns(false); |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## pam/cipher-partial-data #8115 +/- ##
===========================================================
+ Coverage 62.87% 62.91% +0.04%
===========================================================
Files 2301 2305 +4
Lines 100213 100379 +166
Branches 9019 9042 +23
===========================================================
+ Hits 63008 63154 +146
- Misses 35021 35036 +15
- Partials 2184 2189 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-41268
Stacked on #8114 (which declares the
PartialDatafield).📔 Objective
Emitting a cipher's full secret data now requires a
FullCipherAccesswitness, andthe default response shape is partial. A path that forgets to obtain a witness
returns partial data — a visible bug — rather than leaking secrets.
PartialCipherData.Stripreshapes the plaintext JSON envelope, keeping theencrypted name (and, for logins, the encrypted URIs) and dropping every other
encrypted field. Nothing is decrypted; retained values stay individually-encrypted
EncStrings.FullCipherAccessis the witness. Its factories areinternal, so only theleasing gate can mint one, and the
Full*ctors callRequire()per element —keeping bulk lists fail-closed, not just single reads.
Full*subclass deriving from itspartial counterpart, so
ListResponseModel<T>holds a polymorphic mix and the wirecontract is unchanged. Every existing public ctor stays, now delegating to a
protectedoverload withpartial: true— so existing call sites keep compiling butemit no secrets. All secret setters became
protected.attachment's encryption key), and
GetAttachmentDatais gated: a download URLgrants the encrypted attachment, which the caller could decrypt with an org key they
already hold.
GetAttachmentDataAdminis deliberately not gated.ICipherLeaseGateis the decision point, withNoopCipherLeaseGateas the OSSdefault. The interface has zero PAM-domain dependencies, which is what lets this
land independently of the leasing domain; the commercial gate overrides the
registration later in startup (last-registration-wins, commented at the call site).
Only compatible clients receive the partial shape
Only the web vault understands it today, so for every other client gated ciphers are omitted
entirely rather than sent partial. An older client would render an item with no credentials as
though it were empty — and saving it back would overwrite the withheld fields with the blanks the
client holds. Dropping the item is the lesser harm, and it stays visible in the web vault where the
user can request access.
PartialCipherSupportis the single predicate and it fails safe: an absent or unrecognizeddevice type maps to
ClientType.All, notWeb, so an unidentified caller is treated as unable torender the shape. Browser extension, desktop, mobile and CLI are all distinct client types.
FilterUnsupportedCipherTypespattern with a sibling filter — sameidea, applied to a response shape rather than a cipher type.
be discarded.
The web vault ships with the server, so there's no version skew to handle. Other clients will need a
minimum-version check when they gain support; the predicate takes the request's device type, so that
can be added without touching call sites.
One note for reviewers: the guard also covers write-returns, since they share the single-cipher
helpers. It can't fire there in practice — once write-path gating lands a gated cipher can't be
mutated at all, and today nothing is gated — so no successful mutation will 404.
Behavior is unchanged
The no-op gate authorizes everything, so every existing response is still full. Verified
by regenerating the OpenAPI spec: no schemas added or removed, and
data+partialDatacoexist on all four models (datais blob encryption,partialDataisPAM — unrelated fields).
The bulk read paths use the gate's self-loading overload rather than eagerly querying
collections, so nothing extra is queried while the feature is off.
Fitness guard
CipherLeaseFilterEnforcementTestsasserts, by reflection, the invariants that make thisfail closed: no public setter on any of the 14 secret properties, no way for application
code to mint a witness, each
Full*derives from its partial counterpart, and everyFull*public ctor requires a witness. A future refactor that reopens one of those holesfails a test rather than silently leaking.
One behavior change worth calling out
OrganizationExportResponseModelfilters gated ciphers out rather than emittingpartial ones — a partially-stripped export is not a usable backup. Under the no-op gate
nothing is gated, so this is inert today, but it is a deliberate semantic difference from
the read paths.
Verification
dotnet build bitwarden-server.slnxclean; Api.Test 2016 passed, Core.Test 6128passed. Client-compatibility coverage: the predicate across every client type (including the
fail-safe paths), and end-to-end through sync,
GetAlland single reads for both a web-vault and anincompatible caller. Pre-existing leasing-agnostic controller tests pass unchanged via a new
AutoFixture customization that stubs the gate unrestricted.
Out of scope (follow-ups)
CipherLeaseGate— needsCollection.AccessRuleIdand its persistence, soit lands with the leasing domain. Until then no cipher is ever partial.
CipherServicewrite-path gating (EnsureCanMutate*) — mutation refusal is a separateconcern and is inert under the no-op gate.
🚨 Breaking Changes
None. The wire contract is unchanged and behavior is identical under the no-op gate.
Note for future callers: constructing a bare
CipherMiniResponseModel(or its siblings)now yields a partial response — use the
Full*variant with a witness to emitsecret data.