Skip to content

[PM-41268] Withhold gated cipher secrets behind a type-checked witness - #8115

Draft
Hinton wants to merge 1 commit into
pam/cipher-partial-datafrom
pam/cipher-partial-data-witness
Draft

[PM-41268] Withhold gated cipher secrets behind a type-checked witness#8115
Hinton wants to merge 1 commit into
pam/cipher-partial-datafrom
pam/cipher-partial-data-witness

Conversation

@Hinton

@Hinton Hinton commented Jul 31, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

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

Stacked on #8114 (which declares the PartialData field).

📔 Objective

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, and the Full* ctors call Require() per element
    keeping bulk lists fail-closed, not just single reads.
  • Each of the 4 cipher response types gains a Full* subclass deriving from its
    partial counterpart
    , so ListResponseModel<T> holds a polymorphic mix and the wire
    contract is unchanged. Every existing public ctor stays, now delegating to a
    protected overload with partial: true — so existing call sites keep compiling but
    emit no secrets. All secret setters became protected.
  • Attachment metadata is withheld from partial responses (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. GetAttachmentDataAdmin is deliberately not gated.
  • ICipherLeaseGate is the decision point, with NoopCipherLeaseGate as the OSS
    default. 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.

PartialCipherSupport is the single predicate and it fails safe: an absent or unrecognized
device type maps to ClientType.All, not Web, so an unidentified caller is treated as unable to
render the shape. Browser extension, desktop, mobile and CLI are all distinct client types.

  • Sync mirrors the existing FilterUnsupportedCipherTypes pattern with a sibling filter — same
    idea, applied to a response shape rather than a cipher type.
  • List endpoints filter before building, so the strip work isn't done for a response that would
    be discarded.
  • Single reads return 404 for a gated cipher.

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 +
partialData coexist on all four models (data is blob encryption, partialData is
PAM — 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

CipherLeaseFilterEnforcementTests asserts, by reflection, the invariants that make this
fail 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 every
Full* public ctor requires a witness. A future refactor that reopens one of those holes
fails a test rather than silently leaking.

One behavior change worth calling out

OrganizationExportResponseModel filters gated ciphers out rather than emitting
partial 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.slnx clean; Api.Test 2016 passed, Core.Test 6128
passed
. Client-compatibility coverage: the predicate across every client type (including the
fail-safe paths), and end-to-end through sync, GetAll and single reads for both a web-vault and an
incompatible caller. Pre-existing leasing-agnostic controller tests pass unchanged via a new
AutoFixture customization that stubs the gate unrestricted.

Out of scope (follow-ups)

  • The real CipherLeaseGate — needs Collection.AccessRuleId and its persistence, so
    it lands with the leasing domain. Until then no cipher is ever partial.
  • CipherService write-path gating (EnsureCanMutate*) — mutation refusal is a separate
    concern 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 emit
secret data.

@Hinton Hinton changed the title feat(pam): withhold gated cipher secrets behind a type-checked witness [PM-41259] Withhold gated cipher secrets behind a type-checked witness Jul 31, 2026
@Hinton

Hinton commented Jul 31, 2026

Copy link
Copy Markdown
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.

@Hinton Hinton changed the title [PM-41259] Withhold gated cipher secrets behind a type-checked witness [PM-41268] Withhold gated cipher secrets behind a type-checked witness Jul 31, 2026
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
Hinton force-pushed the pam/cipher-partial-data-witness branch from 3c9b84b to ff6c1cf Compare July 31, 2026 14:13

var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user);
userService.HasPremiumFromOrganization(user).Returns(false);
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.40602% with 100 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.91%. Comparing base (bdab4c6) to head (ff6c1cf).

Files with missing lines Patch % Lines
src/Api/Vault/Controllers/CiphersController.cs 48.59% 52 Missing and 3 partials ⚠️
...c/Api/Vault/Models/Response/CipherResponseModel.cs 62.65% 31 Missing ⚠️
.../Tools/Controllers/OrganizationExportController.cs 0.00% 6 Missing ⚠️
...Models/Response/OrganizationExportResponseModel.cs 0.00% 4 Missing ⚠️
src/Core/Pam/Services/NoopCipherLeaseGate.cs 25.00% 3 Missing ⚠️
src/Core/Vault/Authorization/FullCipherAccess.cs 93.75% 1 Missing ⚠️
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.
📢 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant