From ff6c1cfa10131e1d3d3c3c8af340e9ba8ec5ecb3 Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 31 Jul 2026 15:52:37 +0200 Subject: [PATCH 1/3] feat(pam): withhold gated cipher secrets behind a type-checked witness 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. --- .../Controllers/EmergencyAccessController.cs | 9 +- .../Response/EmergencyAccessResponseModel.cs | 11 +- .../OrganizationExportController.cs | 13 +- .../OrganizationExportResponseModel.cs | 9 +- .../Vault/Controllers/CiphersController.cs | 231 +++++++++++++----- src/Api/Vault/Controllers/SyncController.cs | 34 ++- .../Models/Response/CipherResponseModel.cs | 231 ++++++++++++++++-- .../Models/Response/SyncResponseModel.cs | 18 +- src/Core/Pam/Services/ICipherLeaseGate.cs | 48 ++++ src/Core/Pam/Services/NoopCipherLeaseGate.cs | 30 +++ .../Vault/Authorization/FullCipherAccess.cs | 51 ++++ .../Authorization/PartialCipherSupport.cs | 29 +++ .../Vault/Models/Data/PartialCipherData.cs | 57 +++++ .../Utilities/ServiceCollectionExtensions.cs | 4 + .../CipherLeaseGateBypassCustomization.cs | 40 +++ .../Controllers/CiphersControllerTests.cs | 142 ++++++++++- .../Vault/Controllers/SyncControllerTests.cs | 103 ++++++++ .../CipherLeaseFilterEnforcementTests.cs | 89 +++++++ .../Response/CipherResponseModelTests.cs | 138 +++++++++-- .../PartialCipherSupportTests.cs | 53 ++++ .../Models/Data/PartialCipherDataTests.cs | 137 +++++++++++ 21 files changed, 1338 insertions(+), 139 deletions(-) create mode 100644 src/Core/Pam/Services/ICipherLeaseGate.cs create mode 100644 src/Core/Pam/Services/NoopCipherLeaseGate.cs create mode 100644 src/Core/Vault/Authorization/FullCipherAccess.cs create mode 100644 src/Core/Vault/Authorization/PartialCipherSupport.cs create mode 100644 src/Core/Vault/Models/Data/PartialCipherData.cs create mode 100644 test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs create mode 100644 test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs create mode 100644 test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs create mode 100644 test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 4e9f9bf96851..e5dd6013762f 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -12,6 +12,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -25,17 +26,20 @@ public class EmergencyAccessController : Controller private readonly IEmergencyAccessRepository _emergencyAccessRepository; private readonly IEmergencyAccessService _emergencyAccessService; private readonly IGlobalSettings _globalSettings; + private readonly ICipherLeaseGate _cipherLeaseGate; public EmergencyAccessController( IUserService userService, IEmergencyAccessRepository emergencyAccessRepository, IEmergencyAccessService emergencyAccessService, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _emergencyAccessRepository = emergencyAccessRepository; _emergencyAccessService = emergencyAccessService; _globalSettings = globalSettings; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("trusted")] @@ -203,7 +207,8 @@ public async Task ViewCiphers(Guid id) { var user = await _userService.GetUserByPrincipalAsync(User); var viewResult = await _emergencyAccessService.ViewAsync(id, user); - return new EmergencyAccessViewResponseModel(_globalSettings, viewResult.EmergencyAccess, viewResult.Ciphers, user); + return new EmergencyAccessViewResponseModel(_globalSettings, viewResult.EmergencyAccess, viewResult.Ciphers, user, + _cipherLeaseGate.Unrestricted()); } [HttpGet("{id}/{cipherId}/attachment/{attachmentId}")] diff --git a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs index aa21b18bef91..eee91cfddfeb 100644 --- a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs +++ b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs @@ -9,6 +9,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Auth.Models.Response; @@ -129,15 +130,19 @@ public EmergencyAccessViewResponseModel( IGlobalSettings globalSettings, EmergencyAccess emergencyAccess, IEnumerable ciphers, - User user) + User user, + FullCipherAccess fullCipherAccess) : base("emergencyAccessView") { KeyEncrypted = emergencyAccess.KeyEncrypted; + // Emergency access only retrieves personal ciphers, which are never leasing-gated, so full data + // is released (organizationAbility is not needed for personal ciphers). Ciphers = ciphers.Select(cipher => - new CipherResponseModel( + new FullCipherResponseModel( + fullCipherAccess, cipher, user, - null, // Emergency access only retrieves personal ciphers so organizationAbility is not needed + null, globalSettings)); } diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index ff0bff1150d7..944a2e9f63b2 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -6,6 +6,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Queries; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,6 +21,7 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; + private readonly ICipherLeaseGate _cipherLeaseGate; public OrganizationExportController( IUserService userService, @@ -27,13 +29,15 @@ public OrganizationExportController( IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, ICollectionRepository collectionRepository, - IFeatureService featureService) + IFeatureService featureService, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _globalSettings = globalSettings; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("export")] @@ -50,8 +54,9 @@ public async Task Export(Guid organizationId) .GetManySharedCollectionsByOrganizationIdAsync(organizationId); await Task.WhenAll(ciphersTask, collectionsTask); + // Whole-vault export is authorized through org-wide permissions, so nothing is leasing-gated. return Ok(new OrganizationExportResponseModel(ciphersTask.Result, collectionsTask.Result, - _globalSettings)); + _globalSettings, _cipherLeaseGate.Unrestricted())); } var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), @@ -68,7 +73,9 @@ public async Task Export(Guid organizationId) var managedCiphers = await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, managedOrgCollections.Select(c => c.Id)); - return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); + // Leasing-gated ciphers the exporter holds no valid lease for are excluded from the export. + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, managedCiphers); + return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings, fullAccess)); } // Unauthorized diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 208c7f7aefde..0469aa319252 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -7,6 +7,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Tools.Models.Response; @@ -18,9 +19,13 @@ public OrganizationExportResponseModel() : base("organizationExport") } public OrganizationExportResponseModel(IEnumerable ciphers, - IEnumerable collections, GlobalSettings globalSettings) : this() + IEnumerable collections, GlobalSettings globalSettings, FullCipherAccess fullCipherAccess) : this() { - Ciphers = ciphers.Select(c => new CipherMiniDetailsResponseModel(c, globalSettings)); + // Under PAM credential leasing, a leasing-gated cipher the exporter cannot fully access is + // excluded from the export entirely — a partially-stripped export is not a usable backup. + Ciphers = ciphers + .Where(c => fullCipherAccess.Authorizes(c.Id)) + .Select(c => new FullCipherMiniDetailsResponseModel(fullCipherAccess, c, globalSettings)); Collections = collections.Select(c => new CollectionResponseModel(c)); } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 7436ee993aa6..28d881868eed 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -15,11 +15,13 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Authorization.Permissions; using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; @@ -27,6 +29,7 @@ using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -51,6 +54,7 @@ public class CiphersController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IArchiveCiphersCommand _archiveCiphersCommand; private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; + private readonly ICipherLeaseGate _cipherLeaseGate; public CiphersController( ICipherRepository cipherRepository, @@ -65,7 +69,8 @@ public CiphersController( IOrganizationAbilityCacheService organizationAbilityCacheService, ICollectionRepository collectionRepository, IArchiveCiphersCommand archiveCiphersCommand, - IUnarchiveCiphersCommand unarchiveCiphersCommand) + IUnarchiveCiphersCommand unarchiveCiphersCommand, + ICipherLeaseGate cipherLeaseGate) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -80,6 +85,76 @@ public CiphersController( _collectionRepository = collectionRepository; _archiveCiphersCommand = archiveCiphersCommand; _unarchiveCiphersCommand = unarchiveCiphersCommand; + _cipherLeaseGate = cipherLeaseGate; + } + + /// + /// Whether the calling client understands the reduced partial shape. When it does not, a leasing-gated + /// cipher is withheld entirely rather than sent partial — see . + /// + private bool ClientSupportsPartialCiphers => + PartialCipherSupport.IsSupportedBy(_currentContext.DeviceType); + + /// + /// Resolves single-cipher read access under credential leasing. Returns null when the caller gets the + /// partial shape, or throws when the cipher is gated and the caller's client cannot render it — such a + /// client must not receive the cipher at all. + /// + private async Task AuthorizeReadOrThrowAsync(Guid userId, Cipher cipher) + { + var access = await _cipherLeaseGate.AuthorizeReadAsync(userId, cipher); + if (access is null && !ClientSupportsPartialCiphers) + { + throw new NotFoundException(); + } + + return access; + } + + /// + /// Builds a cipher response for a personal/member read or write-return, honouring credential leasing: + /// a leasing-gated cipher with no valid active lease yields the partial shape, otherwise the full one. + /// + private async Task BuildCipherResponseAsync(CipherDetails cipher, User user) + { + var organizationAbility = await GetOrganizationAbilityAsync(cipher); + var access = await AuthorizeReadOrThrowAsync(user.Id, cipher); + return access is null + ? new CipherResponseModel(cipher, user, organizationAbility, _globalSettings) + : new FullCipherResponseModel(access, cipher, user, organizationAbility, _globalSettings); + } + + /// Details variant of . + private async Task BuildCipherDetailsResponseAsync( + CipherDetails cipher, User user, IEnumerable collectionCiphers) + { + var organizationAbility = await GetOrganizationAbilityAsync(cipher); + var access = await AuthorizeReadOrThrowAsync(user.Id, cipher); + return access is null + ? new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphers) + : new FullCipherDetailsResponseModel(access, cipher, user, organizationAbility, _globalSettings, collectionCiphers); + } + + /// + /// Bulk variant for write-returns over a set of ciphers (archive/unarchive). Leasing-gated ciphers + /// the caller cannot fully access fall back to the partial shape, and are dropped entirely for a + /// client that cannot render it; the gated set is resolved once. + /// + private async Task> BuildCipherResponsesAsync( + ICollection ciphers, User user, + IDictionary organizationAbilities) + { + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers); + var supportsPartial = ClientSupportsPartialCiphers; + return ciphers + .Where(cipher => supportsPartial || fullAccess.Authorizes(cipher.Id)) + .Select(cipher => + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullAccess.Authorizes(cipher.Id) + ? new FullCipherResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings) + : new CipherResponseModel(cipher, user, organizationAbility, _globalSettings); + }); } [HttpGet("{id}")] @@ -92,9 +167,7 @@ public async Task Get(Guid id) throw new NotFoundException(); } - var organizationAbility = await GetOrganizationAbilityAsync(cipher); - - return new CipherResponseModel(cipher, user, organizationAbility, _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpGet("{id}/admin")] @@ -110,7 +183,10 @@ public async Task GetAdmin(string id) var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value); var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); - return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); + // Admin/org-wide path: authorized through org permissions with no collection-membership path, so + // the cipher is never leasing-gated for this caller and full data is released. + return new FullCipherMiniDetailsResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, + collectionCiphersGroupDict, cipher.OrganizationUseTotp); } [HttpGet("{id}/details")] @@ -123,9 +199,8 @@ public async Task GetDetails(Guid id) throw new NotFoundException(); } - var organizationAbility = await GetOrganizationAbilityAsync(cipher); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); - return new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphers); + return await BuildCipherDetailsResponseAsync(cipher, user, collectionCiphers); } [HttpGet("{id}/full-details")] @@ -149,12 +224,21 @@ public async Task> GetAll() collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); } var organizationAbilities = await GetOrganizationAbilitiesAsync(ciphers); - var responses = ciphers.Select(cipher => new CipherDetailsResponseModel( - cipher, - user, - GetOrganizationAbility(cipher, organizationAbilities), - _globalSettings, - collectionCiphersGroupDict)).ToArray(); + + // Bulk reads strip every leasing-gated cipher regardless of lease state; secrets are only ever + // released through the single-cipher GET above. The self-loading overload is used deliberately + // so nothing extra is queried while the flag is off. + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers); + var supportsPartial = ClientSupportsPartialCiphers; + var responses = ciphers + .Where(cipher => supportsPartial || fullAccess.Authorizes(cipher.Id)) + .Select(cipher => + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings, collectionCiphersGroupDict) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings, collectionCiphersGroupDict); + }).ToArray(); return new ListResponseModel(responses); } @@ -181,8 +265,7 @@ public async Task Post([FromBody] CipherRequestModel model) } await _cipherService.SaveDetailsAsync(cipher, user.Id, model.LastKnownRevisionDate, null, cipher.OrganizationId.HasValue); - var response = new CipherResponseModel(cipher, user, await GetOrganizationAbilityAsync(cipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("create")] @@ -235,8 +318,8 @@ public async Task PostAdmin([FromBody] CipherCreateRequ await _cipherService.SaveAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, true, false); - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); - return response; + // Admin create through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, false); } [HttpPut("{id}")] @@ -272,8 +355,7 @@ public async Task Put(Guid id, [FromBody] CipherRequestMode await _cipherService.SaveDetailsAsync(model.ToCipherDetails(cipher), user.Id, model.LastKnownRevisionDate, collectionIds); - var response = new CipherResponseModel(cipher, user, await GetOrganizationAbilityAsync(cipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("{id}")] @@ -312,8 +394,8 @@ public async Task PutAdmin(Guid id, [FromBody] CipherRe var cipherClone = model.ToCipher(cipher).Clone(); await _cipherService.SaveAsync(cipherClone, userId, model.LastKnownRevisionDate, collectionIds, true, false); - var response = new CipherMiniResponseModel(cipherClone, _globalSettings, cipher.OrganizationUseTotp); - return response; + // Admin edit through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipherClone, _globalSettings, cipher.OrganizationUseTotp); } [HttpPost("{id}/admin")] @@ -337,9 +419,12 @@ await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCol : await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + // Reaching this endpoint requires can-access-all-ciphers (org-wide) permission, so these ciphers + // are not leasing-gated for this caller and full data is released. + var fullAccess = _cipherLeaseGate.Unrestricted(); var allOrganizationCipherResponses = allOrganizationCiphers.Select(c => - new CipherMiniDetailsResponseModel(c, _globalSettings, c.OrganizationUseTotp) + new FullCipherMiniDetailsResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) ); return new ListResponseModel(allOrganizationCipherResponses); @@ -366,10 +451,22 @@ public async Task> GetAssignedOrga })); } + var cipherList = ciphers.ToList(); var user = await _userService.GetUserByPrincipalAsync(User); var organizationAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId); - var responses = ciphers.Select(cipher => - new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings)); + + // Member read: leasing-gated ciphers (reachable only through leasing-enabled collections) are + // delivered partial here too. Secrets are only released through the single-cipher GET. The + // self-loading overload is used deliberately so nothing extra is queried while the flag is off. + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, cipherList); + + var supportsPartial = ClientSupportsPartialCiphers; + var responses = cipherList + .Where(cipher => supportsPartial || fullAccess.Authorizes(cipher.Id)) + .Select(cipher => + fullAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullAccess, cipher, user, organizationAbility, _globalSettings) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, _globalSettings)); return new ListResponseModel(responses); } @@ -709,8 +806,7 @@ public async Task PutPartial(Guid id, [FromBody] CipherPart await _cipherRepository.UpdatePartialAsync(id, user.Id, folderId, model.Favorite); var updatedCipher = await GetByIdAsync(id, user.Id); - var response = new CipherResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(updatedCipher, user); } [HttpPost("{id}/partial")] @@ -748,8 +844,7 @@ public async Task PutShare(Guid id, [FromBody] CipherShareR model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); - var response = new CipherResponseModel(sharedCipher, user, await GetOrganizationAbilityAsync(sharedCipher), _globalSettings); - return response; + return await BuildCipherResponseAsync(sharedCipher, user); } [HttpPost("{id}/share")] @@ -776,7 +871,7 @@ await _cipherService.SaveCollectionsAsync(cipher, var updatedCipher = await GetByIdAsync(id, user.Id); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id); - return new CipherDetailsResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings, collectionCiphers); + return await BuildCipherDetailsResponseAsync(updatedCipher, user, collectionCiphers); } [HttpPost("{id}/collections")] @@ -809,7 +904,7 @@ await _cipherService.SaveCollectionsAsync(cipher, Unavailable = updatedCipher is null, Cipher = updatedCipher is null ? null - : new CipherDetailsResponseModel(updatedCipher, user, await GetOrganizationAbilityAsync(updatedCipher), _globalSettings, collectionCiphers) + : await BuildCipherDetailsResponseAsync(updatedCipher, user, collectionCiphers) }; return response; } @@ -847,7 +942,10 @@ public async Task PutCollectionsAdmin(string id, var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value); var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); - return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); + // Admin/org-wide path: authorized through org permissions with no collection-membership path, so + // the cipher is never leasing-gated for this caller and full data is released. + return new FullCipherMiniDetailsResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, + collectionCiphersGroupDict, cipher.OrganizationUseTotp); } [HttpPost("{id}/collections-admin")] @@ -892,7 +990,8 @@ public async Task PutArchive(Guid id) } var archivedCipher = archivedCipherOrganizationDetails.First(); - return new CipherResponseModel(archivedCipher, await _userService.GetUserByPrincipalAsync(User), await GetOrganizationAbilityAsync(archivedCipher), _globalSettings); + var user = await _userService.GetUserByPrincipalAsync(User); + return await BuildCipherResponseAsync(archivedCipher, user); } [HttpPut("archive")] @@ -916,8 +1015,7 @@ public async Task> PutArchiveMany([FromBo } var organizationAbilities = await GetOrganizationAbilitiesAsync(archivedCiphers); - var responses = archivedCiphers.Select(cipher => - new CipherResponseModel(cipher, user, GetOrganizationAbility(cipher, organizationAbilities), _globalSettings)).ToArray(); + var responses = (await BuildCipherResponsesAsync(archivedCiphers, user, organizationAbilities)).ToArray(); return new ListResponseModel(responses); } @@ -1092,11 +1190,8 @@ public async Task PutUnarchive(Guid id) } var unarchivedCipher = unarchivedCipherDetails.First(); - return new CipherResponseModel(unarchivedCipher, - await _userService.GetUserByPrincipalAsync(User), - await GetOrganizationAbilityAsync(unarchivedCipher), - _globalSettings - ); + var user = await _userService.GetUserByPrincipalAsync(User); + return await BuildCipherResponseAsync(unarchivedCipher, user); } [HttpPut("unarchive")] @@ -1120,8 +1215,7 @@ public async Task> PutUnarchiveMany([From } var organizationAbilities = await GetOrganizationAbilitiesAsync(unarchivedCipherOrganizationDetails); - var responses = unarchivedCipherOrganizationDetails.Select(cipher => - new CipherResponseModel(cipher, user, GetOrganizationAbility(cipher, organizationAbilities), _globalSettings)).ToArray(); + var responses = (await BuildCipherResponsesAsync(unarchivedCipherOrganizationDetails, user, organizationAbilities)).ToArray(); return new ListResponseModel(responses); } @@ -1137,11 +1231,7 @@ public async Task PutRestore(Guid id) } await _cipherService.RestoreAsync(cipher, user.Id); - return new CipherResponseModel( - cipher, - user, - await GetOrganizationAbilityAsync(cipher), - _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpPut("{id}/restore-admin")] @@ -1156,7 +1246,8 @@ public async Task PutRestoreAdmin(Guid id) } await _cipherService.RestoreAsync(new CipherDetails(cipher), userId, true); - return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); + // Admin restore through org-wide permissions; the cipher is not leasing-gated for this caller. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp); } [HttpPut("restore")] @@ -1171,7 +1262,13 @@ public async Task> PutRestoreMany([Fr var cipherIdsToRestore = new HashSet(model.Ids.Select(i => new Guid(i))); var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId); - var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, restoredCiphers); + var supportsPartial = ClientSupportsPartialCiphers; + var responses = restoredCiphers + .Where(c => supportsPartial || fullAccess.Authorizes(c.Id)) + .Select(c => fullAccess.Authorizes(c.Id) + ? new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) + : new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(responses); } @@ -1198,7 +1295,9 @@ public async Task> PutRestoreManyAdmi var userId = _userService.GetProperUserId(User).Value; var restoredCiphers = await _cipherService.RestoreManyAsync(cipherIdsToRestore, userId, model.OrganizationId, true); - var responses = restoredCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + // Admin restore through org-wide permissions; these ciphers are not leasing-gated for this caller. + var fullAccess = _cipherLeaseGate.Unrestricted(); + var responses = restoredCiphers.Select(c => new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(responses); } @@ -1266,7 +1365,13 @@ public async Task> PutShareMany([From userId ); - var response = updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var fullAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(userId, updated); + var supportsPartial = ClientSupportsPartialCiphers; + var response = updated + .Where(c => supportsPartial || fullAccess.Authorizes(c.Id)) + .Select(c => fullAccess.Authorizes(c.Id) + ? new FullCipherMiniResponseModel(fullAccess, c, _globalSettings, c.OrganizationUseTotp) + : new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); return new ListResponseModel(response); } @@ -1340,12 +1445,10 @@ await _cipherRepository.GetOrganizationDetailsByIdAsync(id) : AttachmentId = attachmentId, Url = uploadUrl, FileUploadType = _attachmentStorageService.FileUploadType, - CipherResponse = request.AdminRequest ? null : new CipherResponseModel( - cipherDetails, - user, - await GetOrganizationAbilityAsync(cipherDetails), - _globalSettings), - CipherMiniResponse = request.AdminRequest ? new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp) : null, + CipherResponse = request.AdminRequest ? null : await BuildCipherResponseAsync(cipherDetails, user), + CipherMiniResponse = request.AdminRequest + ? new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp) + : null, }; } @@ -1433,11 +1536,7 @@ await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, Request.ContentLength.GetValueOrDefault(0), user.Id, false, lastKnownRevisionDate); }); - return new CipherResponseModel( - cipher, - user, - await GetOrganizationAbilityAsync(cipher), - _globalSettings); + return await BuildCipherResponseAsync(cipher, user); } [HttpPost("{id}/attachment-admin")] @@ -1465,7 +1564,8 @@ await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, Request.ContentLength.GetValueOrDefault(0), userId, true, lastKnownRevisionDate); }); - return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); + // Admin attachment upload through org-wide permissions; the cipher is not leasing-gated here. + return new FullCipherMiniResponseModel(_cipherLeaseGate.Unrestricted(), cipher, _globalSettings, cipher.OrganizationUseTotp); } [HttpGet("{id}/attachment/{attachmentId}/admin")] @@ -1492,6 +1592,13 @@ public async Task GetAttachmentData(Guid id, string att throw new NotFoundException(); } + // A leasing-gated cipher with no valid active lease must not receive a download URL: the URL + // grants the encrypted attachment, decryptable with the org key the caller already holds. + if (await _cipherLeaseGate.AuthorizeReadAsync(userId, cipher) is null) + { + throw new NotFoundException(); + } + var result = await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); return new AttachmentResponseModel(result); } diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index cff0536a3039..87edefe3dee2 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -21,8 +21,10 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -50,6 +52,7 @@ public class SyncController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly ICipherLeaseGate _cipherLeaseGate; public SyncController( IUserService userService, @@ -67,7 +70,8 @@ public SyncController( IOrganizationAbilityCacheService organizationAbilityCacheService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IWebAuthnCredentialRepository webAuthnCredentialRepository, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + ICipherLeaseGate cipherLeaseGate) { _userService = userService; _folderRepository = folderRepository; @@ -85,6 +89,7 @@ public SyncController( _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _webAuthnCredentialRepository = webAuthnCredentialRepository; _userAccountKeysQuery = userAccountKeysQuery; + _cipherLeaseGate = cipherLeaseGate; } [HttpGet("")] @@ -121,6 +126,12 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); } + // PAM credential leasing: ciphers reachable only through leasing-enabled collections are delivered + // with reduced data during the passive sync. The active GET /ciphers/{id} path is unchanged. The + // witness authorizes the non-gated subset; gated ciphers fall through to the partial shape. + var fullCipherAccess = await _cipherLeaseGate.AuthorizeReadManyAsync(user.Id, ciphers, collections, collectionCiphersGroupDict); + ciphers = FilterGatedCiphersForUnsupportedClients(ciphers, fullCipherAccess); + var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); @@ -142,7 +153,7 @@ await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials, - policiesNew, organizationUserDetailsNew); + policiesNew, organizationUserDetailsNew, fullCipherAccess); return response; } @@ -164,6 +175,25 @@ private async Task> GetOrganizationAbilit return organizationAbilities; } + /// + /// Drops leasing-gated ciphers for clients that cannot render the partial shape. Sibling of + /// : same idea, applied to a response shape rather than a + /// cipher type. Sending a partial cipher to a client that does not understand it would show an item + /// with no credentials as though it were empty, and saving it back would overwrite the withheld + /// fields. Omitting it is the lesser harm — the item stays visible in the web vault, where the user + /// can request access. + /// + private ICollection FilterGatedCiphersForUnsupportedClients( + ICollection ciphers, FullCipherAccess fullCipherAccess) + { + if (PartialCipherSupport.IsSupportedBy(_currentContext.DeviceType)) + { + return ciphers; + } + + return ciphers.Where(c => fullCipherAccess.Authorizes(c.Id)).ToList(); + } + private ICollection FilterUnsupportedCipherTypes(ICollection ciphers) { var unsupportedTypes = new List(); diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 159f4368bd24..5b4548c50511 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -6,6 +6,7 @@ using Bit.Core.Models.Api; using Bit.Core.Models.Data.Organizations; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; @@ -16,7 +17,19 @@ namespace Bit.Api.Vault.Models.Response; public class CipherMiniResponseModel : ResponseModel { + // PARTIAL/safe constructor. Under PAM credential leasing the secret Data blob is never emitted from + // here — only the reduced PartialData. Any path that uses this type without a FullCipherAccess + // witness therefore fails closed: a missed migration returns partial data (a visible bug), never a + // leak. public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMini") + : this(cipher, globalSettings, orgUseTotp, obj, partial: true) + { + } + + // Shared construction. When partial is false the secret Data is left null for a derived Full* type + // to populate via PopulateFullData; this constructor never emits secret data on its own. + protected CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bool orgUseTotp, + string obj, bool partial) : base(obj) { if (cipher == null) @@ -26,16 +39,39 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo Id = cipher.Id; Type = cipher.Type; - Data = cipher.Data; RevisionDate = cipher.RevisionDate; OrganizationId = cipher.OrganizationId; - Attachments = AttachmentResponseModel.FromCipher(cipher, globalSettings); + // Attachment metadata (including each attachment's encryption Key) is withheld from the partial + // shape: it is only ever a leasing-gated cipher's response, and the gate also blocks the + // attachment download, so nothing is decryptable. + Attachments = partial ? null : AttachmentResponseModel.FromCipher(cipher, globalSettings); OrganizationUseTotp = orgUseTotp; CreationDate = cipher.CreationDate; DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; + if (partial && !cipher.IsDataBlobEncrypted()) + { + // The reduced blob signals the cipher is leasing-gated; the client decrypts PartialData + // itself. An opaque (SDK-encrypted) blob can't be reshaped without decrypting, so nothing + // is returned for one. + PartialData = PartialCipherData.Strip(cipher.Type, cipher.Data); + } + } + + /// + /// Populates the full secret data blob (and the obsolete typed fields) for a Full* response. + /// Requires a witness authorizing this cipher, so full secret data + /// cannot be emitted without first passing through the leasing gate that mints the witness. + /// + protected void PopulateFullData(FullCipherAccess access, Cipher cipher) + { + ArgumentNullException.ThrowIfNull(access); + access.Require(cipher.Id); + + Data = cipher.Data; + if (cipher.IsDataBlobEncrypted()) { return; @@ -97,57 +133,55 @@ public CipherMiniResponseModel(Cipher cipher, IGlobalSettings globalSettings, bo public Guid Id { get; set; } public Guid? OrganizationId { get; set; } public CipherType Type { get; set; } - public string Data { get; set; } + + // Setter is locked so the secret blob can only ever be populated through the witness-gated + // PopulateFullData path, never via a public constructor or object initializer. + public string Data { get; protected set; } /// /// The reduced data blob returned in place of when the caller can only reach this /// cipher through leasing-enabled collections (PAM credential leasing). Contains the encrypted title /// and, for logins, the encrypted URIs — never the dropped secrets. Null for full responses. /// - /// - /// Declared ahead of the behavior that populates it, so the wire contract and the generated client - /// bindings exist first. Nothing sets it yet: every response is still full, and because the property - /// is omitted when null the serialized output is unchanged. - /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string PartialData { get; set; } [Obsolete("Use Data instead.")] - public string Name { get; set; } + public string Name { get; protected set; } [Obsolete("Use Data instead.")] - public string Notes { get; set; } + public string Notes { get; protected set; } [Obsolete("Use Data instead.")] - public CipherLoginModel Login { get; set; } + public CipherLoginModel Login { get; protected set; } [Obsolete("Use Data instead.")] - public CipherCardModel Card { get; set; } + public CipherCardModel Card { get; protected set; } [Obsolete("Use Data instead.")] - public CipherIdentityModel Identity { get; set; } + public CipherIdentityModel Identity { get; protected set; } [Obsolete("Use Data instead.")] - public CipherSecureNoteModel SecureNote { get; set; } + public CipherSecureNoteModel SecureNote { get; protected set; } [Obsolete("Use Data instead.")] - public CipherSSHKeyModel SSHKey { get; set; } + public CipherSSHKeyModel SSHKey { get; protected set; } [Obsolete("Use Data instead.")] - public CipherBankAccountModel BankAccount { get; set; } + public CipherBankAccountModel BankAccount { get; protected set; } [Obsolete("Use Data instead.")] - public CipherDriversLicenseModel DriversLicense { get; set; } + public CipherDriversLicenseModel DriversLicense { get; protected set; } [Obsolete("Use Data instead.")] - public CipherPassportModel Passport { get; set; } + public CipherPassportModel Passport { get; protected set; } [Obsolete("Use Data instead.")] - public IEnumerable Fields { get; set; } + public IEnumerable Fields { get; protected set; } [Obsolete("Use Data instead.")] - public IEnumerable PasswordHistory { get; set; } - public IEnumerable Attachments { get; set; } + public IEnumerable PasswordHistory { get; protected set; } + public IEnumerable Attachments { get; protected set; } public bool OrganizationUseTotp { get; set; } public DateTime RevisionDate { get; set; } public DateTime CreationDate { get; set; } @@ -164,7 +198,18 @@ public CipherResponseModel( OrganizationAbility? organizationAbility, IGlobalSettings globalSettings, string obj = "cipher") - : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj) + : this(cipher, user, organizationAbility, globalSettings, obj, partial: true) + { + } + + protected CipherResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + IGlobalSettings globalSettings, + string obj, + bool partial) + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, partial) { FolderId = cipher.FolderId; Favorite = cipher.Favorite; @@ -190,7 +235,19 @@ public CipherDetailsResponseModel( OrganizationAbility? organizationAbility, GlobalSettings globalSettings, IDictionary> collectionCiphers, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + : this(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IDictionary> collectionCiphers, + string obj, + bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { @@ -208,7 +265,19 @@ public CipherDetailsResponseModel( OrganizationAbility? organizationAbility, GlobalSettings globalSettings, IEnumerable collectionCiphers, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + : this(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetails cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + IEnumerable collectionCiphers, + string obj, + bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? []; } @@ -219,7 +288,18 @@ public CipherDetailsResponseModel( OrganizationAbility? organizationAbility, GlobalSettings globalSettings, string obj = "cipherDetails") - : base(cipher, user, organizationAbility, globalSettings, obj) + : this(cipher, user, organizationAbility, globalSettings, obj, partial: true) + { + } + + protected CipherDetailsResponseModel( + CipherDetailsWithCollections cipher, + User user, + OrganizationAbility? organizationAbility, + GlobalSettings globalSettings, + string obj, + bool partial) + : base(cipher, user, organizationAbility, globalSettings, obj, partial) { CollectionIds = cipher.CollectionIds ?? []; } @@ -231,7 +311,14 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel { public CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettings, IDictionary> collectionCiphers, bool orgUseTotp, string obj = "cipherMiniDetails") - : base(cipher, globalSettings, orgUseTotp, obj) + : this(cipher, globalSettings, collectionCiphers, orgUseTotp, obj, partial: true) + { + } + + protected CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettings, + IDictionary> collectionCiphers, bool orgUseTotp, + string obj, bool partial) + : base(cipher, globalSettings, orgUseTotp, obj, partial) { if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { @@ -245,7 +332,13 @@ public CipherMiniDetailsResponseModel(Cipher cipher, GlobalSettings globalSettin public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMiniDetails") - : base(cipher, globalSettings, orgUseTotp, obj) + : this(cipher, globalSettings, orgUseTotp, obj, partial: true) + { + } + + protected CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, + GlobalSettings globalSettings, bool orgUseTotp, string obj, bool partial) + : base(cipher, globalSettings, orgUseTotp, obj, partial) { CollectionIds = cipher.CollectionIds ?? []; } @@ -259,3 +352,87 @@ public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections c public IEnumerable CollectionIds { get; set; } } + +/// +/// The full-data counterpart of . Constructing one requires a +/// witness authorizing the cipher, so secret data can only be emitted by +/// a path that has passed through the leasing gate. +/// +public class FullCipherMiniResponseModel : CipherMiniResponseModel +{ + public FullCipherMiniResponseModel(FullCipherAccess access, Cipher cipher, + IGlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMini") + : base(cipher, globalSettings, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + +/// The full-data counterpart of . +public class FullCipherResponseModel : CipherResponseModel +{ + public FullCipherResponseModel(FullCipherAccess access, CipherDetails cipher, User user, + OrganizationAbility? organizationAbility, IGlobalSettings globalSettings, string obj = "cipher") + : base(cipher, user, organizationAbility, globalSettings, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + +/// The full-data counterpart of . +public class FullCipherDetailsResponseModel : CipherDetailsResponseModel +{ + public FullCipherDetailsResponseModel(FullCipherAccess access, CipherDetails cipher, User user, + OrganizationAbility? organizationAbility, GlobalSettings globalSettings, + IDictionary> collectionCiphers, + string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherDetailsResponseModel(FullCipherAccess access, CipherDetails cipher, User user, + OrganizationAbility? organizationAbility, GlobalSettings globalSettings, + IEnumerable collectionCiphers, string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, collectionCiphers, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherDetailsResponseModel(FullCipherAccess access, CipherDetailsWithCollections cipher, + User user, OrganizationAbility? organizationAbility, GlobalSettings globalSettings, + string obj = "cipherDetails") + : base(cipher, user, organizationAbility, globalSettings, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} + +/// The full-data counterpart of . +public class FullCipherMiniDetailsResponseModel : CipherMiniDetailsResponseModel +{ + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, Cipher cipher, + GlobalSettings globalSettings, + IDictionary> collectionCiphers, bool orgUseTotp, + string obj = "cipherMiniDetails") + : base(cipher, globalSettings, collectionCiphers, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, + CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, + bool orgUseTotp, string obj = "cipherMiniDetails") + : base(cipher, globalSettings, orgUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } + + public FullCipherMiniDetailsResponseModel(FullCipherAccess access, + CipherOrganizationDetailsWithCollections cipher, GlobalSettings globalSettings, + string obj = "cipherMiniDetails") + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj, partial: false) + { + PopulateFullData(access, cipher); + } +} diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index de0b4ebe3e1e..fd8992fa49f8 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -19,6 +19,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -46,20 +47,23 @@ public SyncResponseModel( IEnumerable sends, IEnumerable webAuthnCredentials, IEnumerable policiesNew = null, - IEnumerable organizationUserDetailsNew = null) + IEnumerable organizationUserDetailsNew = null, + FullCipherAccess fullCipherAccess = null) : this() { Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser, organizationUserDetailsNew); Folders = folders.Select(f => new FolderResponseModel(f)); + // A leasing-gated cipher (one the witness does not authorize) is delivered partial; when no + // witness is supplied every cipher falls back to the partial shape, keeping sync fail-closed. Ciphers = ciphers.Select(cipher => - new CipherDetailsResponseModel( - cipher, - user, - GetOrganizationAbility(cipher, organizationAbilities), - globalSettings, - collectionCiphersDict)); + { + var organizationAbility = GetOrganizationAbility(cipher, organizationAbilities); + return fullCipherAccess is not null && fullCipherAccess.Authorizes(cipher.Id) + ? new FullCipherDetailsResponseModel(fullCipherAccess, cipher, user, organizationAbility, globalSettings, collectionCiphersDict) + : new CipherDetailsResponseModel(cipher, user, organizationAbility, globalSettings, collectionCiphersDict); + }); Collections = collections?.Select( c => new CollectionDetailsResponseModel(c)) ?? new List(); Domains = excludeDomains ? null : new DomainsResponseModel(user, false); diff --git a/src/Core/Pam/Services/ICipherLeaseGate.cs b/src/Core/Pam/Services/ICipherLeaseGate.cs new file mode 100644 index 000000000000..00c33ff78879 --- /dev/null +++ b/src/Core/Pam/Services/ICipherLeaseGate.cs @@ -0,0 +1,48 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; + +namespace Bit.Pam.Services; + +/// +/// The read decision point for PAM credential leasing in Vault code. A cipher reachable only through +/// leasing-enabled collections is "leasing-gated": its secrets are withheld (partial data) unless the +/// caller holds a valid active lease. Every method is "unrestricted" when the Pam feature flag is +/// off, so flag-off behaviour is unchanged. +/// +public interface ICipherLeaseGate +{ + /// + /// Per-cipher read decision. Returns a witness authorizing full data + /// when the caller may see it (not gated, or gated with a valid active lease), or null when + /// the caller is blocked and must receive the partial shape. + /// + Task AuthorizeReadAsync(Guid userId, Cipher cipher); + + /// + /// Bulk read decision. Returns a single witness authorizing full data for the non-gated subset of + /// , computed in-memory from the supplied collections and mappings (no + /// per-cipher queries). Bulk reads strip every gated cipher regardless of lease state — + /// secrets are only ever released through . + /// + Task AuthorizeReadManyAsync( + Guid userId, + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher); + + /// + /// Self-loading variant of the bulk decision, for callers that have not already loaded the caller's + /// collections and mappings. Loads them once — but only when the flag is on, so the flag-off path + /// stays query-free. + /// + Task AuthorizeReadManyAsync(Guid userId, IEnumerable ciphers); + + /// + /// Mints an unrestricted witness for a context that has already been authorized out-of-band — org + /// admins acting through org-wide permissions, personal vaults, and export flows the controller has + /// already gated. Use deliberately; it authorizes full data for any cipher. + /// + FullCipherAccess Unrestricted(); +} diff --git a/src/Core/Pam/Services/NoopCipherLeaseGate.cs b/src/Core/Pam/Services/NoopCipherLeaseGate.cs new file mode 100644 index 000000000000..8b00e6b33391 --- /dev/null +++ b/src/Core/Pam/Services/NoopCipherLeaseGate.cs @@ -0,0 +1,30 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; + +namespace Bit.Pam.Services; + +/// +/// Open-source fallback for . PAM credential leasing is a commercial +/// feature, so in builds without the commercial implementation the gate never gates: every cipher is +/// fully accessible, matching the behaviour when the PAM feature flag is off. The real gating logic +/// lives in the commercial Pam library. +/// +public class NoopCipherLeaseGate : ICipherLeaseGate +{ + public Task AuthorizeReadAsync(Guid userId, Cipher cipher) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public Task AuthorizeReadManyAsync( + Guid userId, + IEnumerable ciphers, + IEnumerable? collections, + IDictionary>? collectionCiphersByCipher) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public Task AuthorizeReadManyAsync(Guid userId, IEnumerable ciphers) + => Task.FromResult(FullCipherAccess.Unrestricted()); + + public FullCipherAccess Unrestricted() => FullCipherAccess.Unrestricted(); +} diff --git a/src/Core/Vault/Authorization/FullCipherAccess.cs b/src/Core/Vault/Authorization/FullCipherAccess.cs new file mode 100644 index 000000000000..8f103c429b17 --- /dev/null +++ b/src/Core/Vault/Authorization/FullCipherAccess.cs @@ -0,0 +1,51 @@ +namespace Bit.Core.Vault.Authorization; + +/// +/// A capability that authorizes returning a cipher's full secret data under PAM credential +/// leasing. It is minted only by the leasing gate (ICipherLeaseGate) — application code cannot +/// fabricate one — and is required by the constructors of the Full* cipher response models. This +/// makes emitting full secret data a deliberate, type-checked act: the default (partial) response +/// shapes need no witness, so a path that forgets to obtain one fails closed. +/// +public sealed class FullCipherAccess +{ + private readonly bool _unrestricted; + private readonly HashSet _authorizedCipherIds; + + private FullCipherAccess(bool unrestricted, HashSet? authorizedCipherIds) + { + _unrestricted = unrestricted; + _authorizedCipherIds = authorizedCipherIds ?? new HashSet(); + } + + /// + /// Authorizes full data for any cipher. Minted by the gate for contexts that have already been + /// authorized out-of-band (org admins, personal vaults, the flag-off no-op path). + /// + internal static FullCipherAccess Unrestricted() => new(unrestricted: true, authorizedCipherIds: null); + + /// Authorizes full data for exactly the given cipher. + internal static FullCipherAccess ForCipher(Guid cipherId) => new(unrestricted: false, [cipherId]); + + /// Authorizes full data for exactly the given set of ciphers. + internal static FullCipherAccess ForCiphers(IEnumerable cipherIds) => + new(unrestricted: false, cipherIds.ToHashSet()); + + /// Whether this witness authorizes full data for . + public bool Authorizes(Guid cipherId) => _unrestricted || _authorizedCipherIds.Contains(cipherId); + + /// + /// Throws when this witness does not authorize . Called by the + /// Full* response model constructors so a full response cannot be built for a cipher the + /// witness does not cover — keeping bulk lists fail-closed per element, not just at single reads. + /// + public void Require(Guid cipherId) + { + if (!Authorizes(cipherId)) + { + throw new InvalidOperationException( + "A full cipher response was constructed for a cipher the caller is not authorized to " + + "read in full. This indicates a credential-leasing filtering bug."); + } + } +} diff --git a/src/Core/Vault/Authorization/PartialCipherSupport.cs b/src/Core/Vault/Authorization/PartialCipherSupport.cs new file mode 100644 index 000000000000..7de98c6aee07 --- /dev/null +++ b/src/Core/Vault/Authorization/PartialCipherSupport.cs @@ -0,0 +1,29 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Vault.Authorization; + +/// +/// Which clients understand the reduced partial-data cipher shape emitted for leasing-gated ciphers +/// (see ). +/// +/// +/// A client that does not understand the shape must have gated ciphers omitted entirely rather +/// than be sent a partial one: it 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 — the user sees it in the web vault, where they can request access. +/// +public static class PartialCipherSupport +{ + /// + /// Whether the calling client can be sent partial ciphers. Only the web vault can today. + /// + /// + /// Fails safe: an absent or unrecognized device type maps to , which is + /// not , so an unknown caller is treated as unable to handle the shape. + /// The web vault is served from the same deployment as the server, so there is no version skew to + /// account for; other clients will need a minimum-version check when they gain support. + /// + public static bool IsSupportedBy(DeviceType? deviceType) => + DeviceTypes.ToClientType(deviceType) == ClientType.Web; +} diff --git a/src/Core/Vault/Models/Data/PartialCipherData.cs b/src/Core/Vault/Models/Data/PartialCipherData.cs new file mode 100644 index 000000000000..a251906a59ac --- /dev/null +++ b/src/Core/Vault/Models/Data/PartialCipherData.cs @@ -0,0 +1,57 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; +using Bit.Core.Utilities; +using Bit.Core.Vault.Enums; + +namespace Bit.Core.Vault.Models.Data; + +/// +/// Produces a "partial" version of a cipher's encrypted Data blob for PAM credential leasing. +/// When a user can only reach a cipher through leasing-enabled collections, they receive this reduced +/// blob instead of the full one. +/// +/// +/// Zero-knowledge is preserved: nothing is ever decrypted. This only reshapes the plaintext JSON +/// envelope, keeping the encrypted title (and, for logins, the encrypted URIs) and dropping every other +/// encrypted field (username, password, TOTP, notes, custom fields, etc.). The retained values remain +/// individually-encrypted EncStrings. +/// +public static class PartialCipherData +{ + /// + /// Reduces a cipher's JSON Data blob to the fields allowed under credential leasing. + /// Logins keep Name and Uris; all other types keep only Name. + /// + /// The cipher's type. + /// The full, encrypted JSON data blob. Must be JSON (not an SDK-encrypted blob). + /// A reduced JSON data blob, or the input unchanged when it is null/empty. + public static string Strip(CipherType type, string data) + { + if (string.IsNullOrWhiteSpace(data)) + { + return data; + } + + if (type == CipherType.Login) + { + var login = JsonSerializer.Deserialize(data); + var partial = new CipherLoginData + { + Name = login.Name, + Uris = login.Uris, + }; + return JsonSerializer.Serialize(partial, JsonHelpers.IgnoreWritingNull); + } + + var nameOnly = JsonSerializer.Deserialize(data); + return JsonSerializer.Serialize( + new NameOnlyData { Name = nameOnly.Name }, JsonHelpers.IgnoreWritingNull); + } + + private class NameOnlyData + { + public string Name { get; set; } + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 551a7c946cf3..ad7d57c84383 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -58,6 +58,7 @@ using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; +using Bit.Pam.Services; using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; @@ -156,6 +157,9 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett { services.AddScoped(); services.TryAddScoped(); + // PAM credential leasing is commercial; OSS builds never gate. The commercial Pam library + // registers the real gate later in startup, and last registration wins. + services.TryAddScoped(); services.AddUserServices(globalSettings); services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); diff --git a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs new file mode 100644 index 000000000000..558fcbde8ad8 --- /dev/null +++ b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs @@ -0,0 +1,40 @@ +using AutoFixture; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Entities; +using Bit.Pam.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; + +namespace Bit.Api.Test.Vault.AutoFixture; + +/// +/// Injects an substitute pre-configured to authorize full data for every +/// cipher — the flag-off / not-gated behaviour. This lets leasing-agnostic controller tests assert their +/// existing full-data expectations without each one having to stub the gate. Tests that exercise gating +/// re-stub the dependency (e.g. make AuthorizeReadAsync return null) after building the SUT. +/// +public class CipherLeaseGateBypassCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + var gate = Substitute.For(); + var unrestricted = FullCipherAccess.Unrestricted(); + + gate.Unrestricted().Returns(unrestricted); + gate.AuthorizeReadAsync(Arg.Any(), Arg.Any()).Returns(unrestricted); + gate.AuthorizeReadManyAsync(Arg.Any(), Arg.Any>()).Returns(unrestricted); + gate.AuthorizeReadManyAsync(Arg.Any(), Arg.Any>(), + Arg.Any>(), + Arg.Any>>()) + .Returns(unrestricted); + + fixture.Inject(gate); + } +} + +public class CipherLeaseGateBypassCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new CipherLeaseGateBypassCustomization(); +} diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 926180400dd7..f91a6c627089 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Utilities; +using Bit.Api.Test.Vault.AutoFixture; using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; @@ -15,9 +16,11 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -33,6 +36,9 @@ namespace Bit.Api.Test.Controllers; [ControllerCustomize(typeof(CiphersController))] [SutProviderCustomize] +// Bypasses PAM credential leasing so these leasing-agnostic tests keep asserting full-data responses; +// the leasing tests re-stub the gate after building the SUT. +[CipherLeaseGateBypassCustomize] public class CiphersControllerTests { [Theory, BitAutoData] @@ -1181,7 +1187,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithManagePermission_Restores var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1250,7 +1256,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithAccessToUnassignedCipher_ var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1279,7 +1285,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithAccessToAllCollectionItem var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1303,7 +1309,7 @@ public async Task PutRestoreAdmin_WithCustomUser_WithEditAnyCollectionTrue_Resto var result = await sutProvider.Sut.PutRestoreAdmin(cipherOrgDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -1343,7 +1349,7 @@ public async Task PutRestoreAdmin_WithOwnerOrAdmin_WithEditPermission_LimitItemD var result = await sutProvider.Sut.PutRestoreAdmin(cipherDetails.Id); - Assert.IsType(result); + Assert.IsAssignableFrom(result); await sutProvider.GetDependency().Received(1).RestoreAsync(Arg.Is( (cd) => cd.OrganizationId.Equals(cipherOrgDetails.OrganizationId)), userId, true); } @@ -2356,6 +2362,132 @@ public async Task PostFileForExistingAttachment_WithInvalidContentType_ThrowsBad () => sutProvider.Sut.PostFileForExistingAttachment(cipherId, attachmentId)); Assert.Equal("Invalid content.", exception.Message); } + [Theory, BitAutoData] + public async Task GetAttachmentData_LeasingGatedCipher_ThrowsNotFoundAndDoesNotIssueUrl( + Guid cipherId, string attachmentId, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)userId); + var cipherDetails = new CipherDetails { Id = cipherId, UserId = userId, Type = CipherType.Login, Data = "{}" }; + sutProvider.GetDependency().GetByIdAsync(cipherId, userId) + .Returns(Task.FromResult(cipherDetails)); + // Gated with no active lease. + sutProvider.GetDependency() + .AuthorizeReadAsync(userId, Arg.Any()) + .Returns((FullCipherAccess)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetAttachmentData(cipherId, attachmentId)); + + // The URL grants the encrypted attachment, so it must never be minted for a gated cipher. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetAttachmentDownloadDataAsync(default, default); + } + + [Theory, BitAutoData] + public async Task Get_LeasingGatedCipher_UnsupportedClient_ThrowsNotFound( + Guid cipherId, Guid userId, User user, + SutProvider sutProvider) + { + user.Id = userId; + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + var cipherDetails = new CipherDetails { Id = cipherId, UserId = userId, Type = CipherType.Login, Data = "{}" }; + sutProvider.GetDependency().GetByIdAsync(cipherId, userId) + .Returns(Task.FromResult(cipherDetails)); + sutProvider.GetDependency() + .AuthorizeReadAsync(userId, Arg.Any()) + .Returns((FullCipherAccess)null); + // A mobile client cannot render the partial shape. + sutProvider.GetDependency().DeviceType.Returns(DeviceType.Android); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Get(cipherId)); + } + + [Theory, BitAutoData] + public async Task Get_LeasingGatedCipher_WebVault_ReturnsPartialShape( + Guid cipherId, Guid userId, User user, + SutProvider sutProvider) + { + user.Id = userId; + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + var cipherDetails = new CipherDetails + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = """{"Name":"2.name|encrypted","Password":"2.password|encrypted"}""", + }; + sutProvider.GetDependency().GetByIdAsync(cipherId, userId) + .Returns(Task.FromResult(cipherDetails)); + sutProvider.GetDependency() + .AuthorizeReadAsync(userId, Arg.Any()) + .Returns((FullCipherAccess)null); + sutProvider.GetDependency().DeviceType.Returns(DeviceType.ChromeBrowser); + + var result = await sutProvider.Sut.Get(cipherId); + + Assert.Null(result.Data); + Assert.NotNull(result.PartialData); + Assert.DoesNotContain("2.password|encrypted", result.PartialData); + } + + [Theory, BitAutoData] + public async Task GetAll_UnsupportedClient_OmitsGatedCiphers( + Guid userId, User user, SutProvider sutProvider) + { + user.Id = userId; + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var visible = new CipherDetails { Id = Guid.NewGuid(), UserId = userId, Type = CipherType.Login, Data = "{}" }; + var gated = new CipherDetails { Id = Guid.NewGuid(), UserId = userId, Type = CipherType.Login, Data = "{}" }; + sutProvider.GetDependency().Organizations + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByUserIdAsync(userId, Arg.Any()) + .Returns(Task.FromResult>([visible, gated])); + + // Authorize only the non-gated cipher. + sutProvider.GetDependency() + .AuthorizeReadManyAsync(userId, Arg.Any>()) + .Returns(FullCipherAccess.ForCipher(visible.Id)); + sutProvider.GetDependency().DeviceType.Returns(DeviceType.Android); + + var result = await sutProvider.Sut.GetAll(); + + // The gated cipher is dropped rather than sent partial: this client would render it as an empty + // item, and saving it back would clobber the withheld fields. + Assert.Single(result.Data); + Assert.Equal(visible.Id, result.Data.First().Id); + } + + [Theory, BitAutoData] + public async Task GetAll_WebVault_KeepsGatedCiphersAsPartial( + Guid userId, User user, SutProvider sutProvider) + { + user.Id = userId; + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var visible = new CipherDetails { Id = Guid.NewGuid(), UserId = userId, Type = CipherType.Login, Data = "{}" }; + var gated = new CipherDetails { Id = Guid.NewGuid(), UserId = userId, Type = CipherType.Login, Data = "{}" }; + sutProvider.GetDependency().Organizations + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByUserIdAsync(userId, Arg.Any()) + .Returns(Task.FromResult>([visible, gated])); + + sutProvider.GetDependency() + .AuthorizeReadManyAsync(userId, Arg.Any>()) + .Returns(FullCipherAccess.ForCipher(visible.Id)); + sutProvider.GetDependency().DeviceType.Returns(DeviceType.ChromeBrowser); + + var result = await sutProvider.Sut.GetAll(); + + Assert.Equal(2, result.Data.Count()); + Assert.Null(result.Data.Single(c => c.Id == gated.Id).Data); + Assert.NotNull(result.Data.Single(c => c.Id == visible.Id).Data); + } + [Theory, BitAutoData] public async Task GetAttachmentData_CipherNotFound_ThrowsNotFoundException( Guid cipherId, string attachmentId, Guid userId, diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index 97212e3f2e69..c9a476622925 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -25,8 +25,11 @@ using Bit.Core.Tools.Repositories; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; +using Bit.Api.Test.Vault.AutoFixture; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -37,6 +40,9 @@ namespace Bit.Api.Test.Controllers; [ControllerCustomize(typeof(SyncController))] [SutProviderCustomize] +// Bypasses PAM credential leasing so these leasing-agnostic tests keep asserting full-data responses; +// the leasing tests re-stub the gate after building the SUT. +[CipherLeaseGateBypassCustomize] public class SyncControllerTests { [Theory] @@ -448,6 +454,103 @@ public async Task Get_BankAccountCiphers_ReturnedWhenFlagEnabledAndClientVersion Assert.Contains(result.Ciphers, c => c.Type == CipherType.BankAccount); } + [Theory] + [BitAutoData] + public async Task Get_GatedCiphers_FilteredForClientsThatCannotRenderThem( + User user, SutProvider sutProvider) + { + var (gated, visible) = SetupLeasingSync(user, sutProvider); + // A mobile client would show a partial cipher as an empty item, and saving it back would + // clobber the withheld fields — so it must not receive it at all. + sutProvider.GetDependency().DeviceType.Returns(DeviceType.Android); + + var result = await sutProvider.Sut.Get(); + + Assert.DoesNotContain(result.Ciphers, c => c.Id == gated.Id); + Assert.Contains(result.Ciphers, c => c.Id == visible.Id); + } + + [Theory] + [BitAutoData] + public async Task Get_GatedCiphers_DeliveredPartialToTheWebVault( + User user, SutProvider sutProvider) + { + var (gated, visible) = SetupLeasingSync(user, sutProvider); + sutProvider.GetDependency().DeviceType.Returns(DeviceType.ChromeBrowser); + + var result = await sutProvider.Sut.Get(); + + var gatedResponse = Assert.Single(result.Ciphers, c => c.Id == gated.Id); + Assert.Null(gatedResponse.Data); + Assert.NotNull(gatedResponse.PartialData); + Assert.NotNull(Assert.Single(result.Ciphers, c => c.Id == visible.Id).Data); + } + + [Theory] + [BitAutoData] + public async Task Get_NoDeviceType_FiltersGatedCiphers( + User user, SutProvider sutProvider) + { + var (gated, visible) = SetupLeasingSync(user, sutProvider); + // Fails safe: an unidentified caller is treated as unable to render the shape. + sutProvider.GetDependency().DeviceType.Returns((DeviceType?)null); + + var result = await sutProvider.Sut.Get(); + + Assert.DoesNotContain(result.Ciphers, c => c.Id == gated.Id); + Assert.Contains(result.Ciphers, c => c.Id == visible.Id); + } + + /// + /// Arranges a sync over two ciphers where only visible is authorized for full data, i.e. + /// gated is leasing-gated. Returns (gated, visible). + /// + private static (CipherDetails Gated, CipherDetails Visible) SetupLeasingSync( + User user, SutProvider sutProvider) + { + user.EquivalentDomains = null; + user.ExcludedGlobalEquivalentDomains = null; + + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(Arg.Any()).ReturnsForAnyArgs(user); + userService.HasPremiumFromOrganization(user).Returns(false); + + sutProvider.GetDependency().Run(user).Returns(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(), + SignatureKeyPairData = null, + }); + + var gated = new CipherDetails + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = """{"Name":"2.name|encrypted","Password":"2.password|encrypted"}""", + UserId = user.Id, + }; + var visible = new CipherDetails + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = """{"Name":"2.name|encrypted"}""", + UserId = user.Id, + }; + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id, Arg.Any()) + .Returns(new List { gated, visible }); + + sutProvider.GetDependency() + .AuthorizeReadManyAsync(user.Id, Arg.Any>(), + Arg.Any>(), + Arg.Any>>()) + .Returns(FullCipherAccess.ForCipher(visible.Id)); + + sutProvider.GetDependency().TwoFactorIsEnabledAsync(user).Returns(false); + + return (gated, visible); + } + [Theory] [BitAutoData] public async Task Get_BankAccountCiphers_FilteredWhenFlagDisabled( diff --git a/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs new file mode 100644 index 000000000000..90d317e88864 --- /dev/null +++ b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using Bit.Api.Vault.Models.Response; +using Bit.Core.Vault.Authorization; +using Xunit; + +namespace Bit.Api.Test.Vault.Models.Response; + +/// +/// Fitness tests guarding the structural invariants that make PAM credential-leasing filtering +/// fail closed. These assert on shape rather than behaviour, so a future refactor that reopens one +/// of these holes fails here rather than silently leaking secret cipher data. +/// +public class CipherLeaseFilterEnforcementTests +{ + /// + /// Every property on the cipher response models that carries secret cipher data. These may only be + /// written through the witness-gated PopulateFullData path, so none may have a public setter. + /// + [Theory] + [InlineData(nameof(CipherMiniResponseModel.Data))] + [InlineData(nameof(CipherMiniResponseModel.Name))] + [InlineData(nameof(CipherMiniResponseModel.Notes))] + [InlineData(nameof(CipherMiniResponseModel.Login))] + [InlineData(nameof(CipherMiniResponseModel.Card))] + [InlineData(nameof(CipherMiniResponseModel.Identity))] + [InlineData(nameof(CipherMiniResponseModel.SecureNote))] + [InlineData(nameof(CipherMiniResponseModel.SSHKey))] + [InlineData(nameof(CipherMiniResponseModel.BankAccount))] + [InlineData(nameof(CipherMiniResponseModel.DriversLicense))] + [InlineData(nameof(CipherMiniResponseModel.Passport))] + [InlineData(nameof(CipherMiniResponseModel.Fields))] + [InlineData(nameof(CipherMiniResponseModel.PasswordHistory))] + [InlineData(nameof(CipherMiniResponseModel.Attachments))] + public void SecretProperties_HaveNoPublicSetter(string propertyName) + { + var property = typeof(CipherMiniResponseModel).GetProperty(propertyName); + + Assert.NotNull(property); + Assert.True( + property.SetMethod is null || !property.SetMethod.IsPublic, + $"{propertyName} must not have a public setter: secret cipher data may only be written " + + "through the FullCipherAccess-gated PopulateFullData path."); + } + + [Fact] + public void FullCipherAccess_CannotBeMintedByApplicationCode() + { + var type = typeof(FullCipherAccess); + + Assert.Empty(type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); + Assert.Empty(type + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.ReturnType == typeof(FullCipherAccess))); + } + + /// + /// Each Full* model must derive from its partial counterpart, so a list typed to the partial + /// type can hold a polymorphic mix and the wire contract stays unchanged. + /// + [Theory] + [InlineData(typeof(CipherMiniResponseModel), typeof(FullCipherMiniResponseModel))] + [InlineData(typeof(CipherResponseModel), typeof(FullCipherResponseModel))] + [InlineData(typeof(CipherDetailsResponseModel), typeof(FullCipherDetailsResponseModel))] + [InlineData(typeof(CipherMiniDetailsResponseModel), typeof(FullCipherMiniDetailsResponseModel))] + public void FullModel_DerivesFromItsPartialCounterpart(Type partialType, Type fullType) + { + Assert.True( + partialType.IsAssignableFrom(fullType), + $"{fullType.Name} must derive from {partialType.Name}."); + } + + /// + /// Every public constructor of a Full* model must take a , so + /// full secret data cannot be emitted without one. + /// + [Theory] + [InlineData(typeof(FullCipherMiniResponseModel))] + [InlineData(typeof(FullCipherResponseModel))] + [InlineData(typeof(FullCipherDetailsResponseModel))] + [InlineData(typeof(FullCipherMiniDetailsResponseModel))] + public void FullModel_EveryPublicConstructor_RequiresAWitness(Type fullType) + { + var constructors = fullType.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + Assert.NotEmpty(constructors); + Assert.All(constructors, ctor => + Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(FullCipherAccess))); + } +} diff --git a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs index 721cedaff1dc..146c3e65fa94 100644 --- a/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs +++ b/test/Api.Test/Vault/Models/Response/CipherResponseModelTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Api.Vault.Models.Response; using Bit.Core.Settings; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; @@ -46,7 +47,7 @@ public void Constructor_DriversLicense_DeserializesAllFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.DriversLicense, response.Type); Assert.Equal("2.name|encrypted", response.Name); @@ -80,7 +81,7 @@ public void Constructor_DriversLicense_WithMinimalData_DeserializesSuccessfully( CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.DriversLicense, response.Type); Assert.NotNull(response.DriversLicense); @@ -118,7 +119,7 @@ public void Constructor_Passport_DeserializesAllFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.Passport, response.Type); Assert.Equal("2.name|encrypted", response.Name); @@ -154,7 +155,7 @@ public void Constructor_Passport_WithMinimalData_DeserializesSuccessfully() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(CipherType.Passport, response.Type); Assert.NotNull(response.Passport); @@ -186,7 +187,7 @@ public void Constructor_DriversLicense_WithCustomFields_IncludesFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.NotNull(response.Fields); Assert.Single(response.Fields); @@ -215,7 +216,7 @@ public void Constructor_Passport_WithCustomFields_IncludesFields() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.NotNull(response.Fields); Assert.Single(response.Fields); @@ -241,7 +242,7 @@ public void Constructor_DriversLicense_PreservesRawDataField() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(serializedData, response.Data); } @@ -265,7 +266,7 @@ public void Constructor_Passport_PreservesRawDataField() CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(serializedData, response.Data); } @@ -291,7 +292,7 @@ public void Constructor_BlobEncryptedData_DoesNotThrowAndSkipsLegacyFields(Ciphe CreationDate = DateTime.UtcNow, }; - var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); Assert.Equal(type, response.Type); Assert.Equal(blob, response.Data); @@ -309,41 +310,126 @@ public void Constructor_BlobEncryptedData_DoesNotThrowAndSkipsLegacyFields(Ciphe Assert.Null(response.PasswordHistory); } + private static Cipher LoginCipher(string data) => new() + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Data = data, + RevisionDate = DateTime.UtcNow, + CreationDate = DateTime.UtcNow, + }; + [Fact] - public void Constructor_DoesNotSetPartialData() + public void Constructor_Partial_Login_EmitsOnlyPartialDataAndWithholdsSecrets() + { + var cipher = LoginCipher(JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Username = "2.username|encrypted", + Password = "2.password|encrypted", + Totp = "2.totp|encrypted", + Notes = "2.notes|encrypted", + Uris = [new CipherLoginData.CipherLoginUriData { Uri = "2.uri|encrypted" }], + })); + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + + Assert.Null(response.Data); + Assert.NotNull(response.PartialData); + Assert.Contains("2.name|encrypted", response.PartialData); + Assert.Contains("2.uri|encrypted", response.PartialData); + + // The whole serialized model must be free of every withheld secret, not just the typed fields. + var json = JsonSerializer.Serialize(response); + Assert.DoesNotContain("2.username|encrypted", json); + Assert.DoesNotContain("2.password|encrypted", json); + Assert.DoesNotContain("2.totp|encrypted", json); + Assert.DoesNotContain("2.notes|encrypted", json); + + // The obsolete typed fields are only populated on the witness-gated path. + Assert.Null(response.Name); + Assert.Null(response.Notes); + Assert.Null(response.Login); + } + + [Theory] + [InlineData(CipherType.SecureNote)] + [InlineData(CipherType.Card)] + [InlineData(CipherType.Identity)] + [InlineData(CipherType.SSHKey)] + [InlineData(CipherType.BankAccount)] + [InlineData(CipherType.DriversLicense)] + [InlineData(CipherType.Passport)] + public void Constructor_Partial_NonLogin_KeepsOnlyTheName(CipherType type) { var cipher = new Cipher { Id = Guid.NewGuid(), - Type = CipherType.Login, - Data = JsonSerializer.Serialize(new CipherLoginData { Name = "2.name|encrypted" }), + Type = type, + Data = """{"Name":"2.name|encrypted","Notes":"2.notes|encrypted"}""", RevisionDate = DateTime.UtcNow, CreationDate = DateTime.UtcNow, }; var response = new CipherMiniResponseModel(cipher, _globalSettings, false); - // PartialData is the declared wire contract for PAM credential leasing; nothing populates it - // yet, so every response is still full. + Assert.Null(response.Data); + Assert.Contains("2.name|encrypted", response.PartialData); + Assert.DoesNotContain("2.notes|encrypted", response.PartialData); + } + + [Fact] + public void Constructor_Partial_BlobEncrypted_EmitsNeitherDataNorPartialData() + { + // An opaque SDK-encrypted blob can't be reshaped without decrypting, so nothing is returned. + var cipher = LoginCipher("""{"format_version":1,"wrapped_cek":"abc","envelope":"def"}"""); + + var response = new CipherMiniResponseModel(cipher, _globalSettings, false); + + Assert.Null(response.Data); Assert.Null(response.PartialData); - Assert.Equal(cipher.Data, response.Data); } [Fact] - public void Serialize_NullPartialData_OmitsTheProperty() + public void Constructor_Partial_OmitsAttachments() { - var cipher = new Cipher + var cipher = LoginCipher(JsonSerializer.Serialize(new CipherLoginData { Name = "2.name|encrypted" })); + cipher.Attachments = """{"id":{"Key":"2.attachmentkey|encrypted","FileName":"2.f|encrypted","Size":"1"}}"""; + + var partial = new CipherMiniResponseModel(cipher, _globalSettings, false); + var full = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); + + // Attachment metadata carries each attachment's encryption key, so it is withheld too. + Assert.Null(partial.Attachments); + Assert.NotNull(full.Attachments); + } + + [Fact] + public void Constructor_Full_PreservesEverythingAndSetsNoPartialData() + { + var data = JsonSerializer.Serialize(new CipherLoginData { - Id = Guid.NewGuid(), - Type = CipherType.Login, - Data = JsonSerializer.Serialize(new CipherLoginData { Name = "2.name|encrypted" }), - RevisionDate = DateTime.UtcNow, - CreationDate = DateTime.UtcNow, - }; + Name = "2.name|encrypted", + Password = "2.password|encrypted", + }); + var cipher = LoginCipher(data); - var json = JsonSerializer.Serialize(new CipherMiniResponseModel(cipher, _globalSettings, false)); + var response = new FullCipherMiniResponseModel(FullCipherAccess.Unrestricted(), cipher, _globalSettings, false); - // Null-suppressed, so adding the property leaves existing responses byte-identical. - Assert.DoesNotContain("partialData", json, StringComparison.OrdinalIgnoreCase); + Assert.Equal(data, response.Data); + Assert.Null(response.PartialData); + Assert.Equal("2.password|encrypted", response.Login.Password); + } + + [Fact] + public void Constructor_Full_WithoutAuthorizationForTheCipher_Throws() + { + var cipher = LoginCipher(JsonSerializer.Serialize(new CipherLoginData { Name = "2.name|encrypted" })); + var accessForSomeoneElse = FullCipherAccess.ForCipher(Guid.NewGuid()); + + // Fail closed: a witness that does not cover this cipher must not yield a full response. + Assert.Throws(() => + new FullCipherMiniResponseModel(accessForSomeoneElse, cipher, _globalSettings, false)); } } + diff --git a/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs b/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs new file mode 100644 index 000000000000..05feb1f182d2 --- /dev/null +++ b/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.Enums; +using Bit.Core.Vault.Authorization; +using Xunit; + +namespace Bit.Core.Test.Vault.Authorization; + +public class PartialCipherSupportTests +{ + [Theory] + [InlineData(DeviceType.ChromeBrowser)] + [InlineData(DeviceType.FirefoxBrowser)] + [InlineData(DeviceType.SafariBrowser)] + [InlineData(DeviceType.EdgeBrowser)] + [InlineData(DeviceType.UnknownBrowser)] + public void IsSupportedBy_WebVault_IsSupported(DeviceType deviceType) + { + Assert.True(PartialCipherSupport.IsSupportedBy(deviceType)); + } + + [Theory] + // Browser extensions are a distinct client from the web vault and do not understand the shape. + [InlineData(DeviceType.ChromeExtension)] + [InlineData(DeviceType.FirefoxExtension)] + [InlineData(DeviceType.SafariExtension)] + // Desktop + [InlineData(DeviceType.WindowsDesktop)] + [InlineData(DeviceType.MacOsDesktop)] + [InlineData(DeviceType.LinuxDesktop)] + // Mobile + [InlineData(DeviceType.Android)] + [InlineData(DeviceType.iOS)] + // CLI + [InlineData(DeviceType.WindowsCLI)] + [InlineData(DeviceType.MacOsCLI)] + [InlineData(DeviceType.LinuxCLI)] + public void IsSupportedBy_OtherClients_IsNotSupported(DeviceType deviceType) + { + Assert.False(PartialCipherSupport.IsSupportedBy(deviceType)); + } + + [Fact] + public void IsSupportedBy_NoDeviceType_IsNotSupported() + { + // Fails safe: an unidentified caller must not be sent a shape it may not understand. + Assert.False(PartialCipherSupport.IsSupportedBy(null)); + } + + [Fact] + public void IsSupportedBy_UnrecognizedDeviceType_IsNotSupported() + { + Assert.False(PartialCipherSupport.IsSupportedBy((DeviceType)byte.MaxValue)); + } +} diff --git a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs new file mode 100644 index 000000000000..8523121a6024 --- /dev/null +++ b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Bit.Core.Enums; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Models.Data; +using Xunit; + +namespace Bit.Core.Test.Vault.Models.Data; + +public class PartialCipherDataTests +{ + // Any occurrence of this marker in the stripped output means a field leaked through. + private const string Sentinel = "2.SENTINEL|encrypted"; + + [Fact] + public void Strip_Login_KeepsNameAndUris() + { + var data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Uris = + [ + new CipherLoginData.CipherLoginUriData + { + Uri = "2.uri|encrypted", + UriChecksum = "2.checksum|encrypted", + Match = UriMatchType.Host, + }, + ], + }); + + var stripped = PartialCipherData.Strip(CipherType.Login, data); + var result = JsonSerializer.Deserialize(stripped); + + Assert.Equal("2.name|encrypted", result.Name); + Assert.Single(result.Uris); + Assert.Equal("2.uri|encrypted", result.Uris.First().Uri); + Assert.Equal("2.checksum|encrypted", result.Uris.First().UriChecksum); + Assert.Equal(UriMatchType.Host, result.Uris.First().Match); + } + + [Fact] + public void Strip_Login_DropsEverySecretField() + { + var data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Username = Sentinel, + Password = Sentinel, + PasswordRevisionDate = DateTime.UtcNow, + Totp = Sentinel, + AutofillOnPageLoad = true, + Notes = Sentinel, + Fields = [new CipherFieldData { Name = Sentinel, Value = Sentinel, Type = FieldType.Text }], + PasswordHistory = [new CipherPasswordHistoryData { Password = Sentinel }], + }); + + var stripped = PartialCipherData.Strip(CipherType.Login, data); + + // Assert on the raw string as well as the deserialized shape: a field that survives under an + // unexpected key would still be a leak. + Assert.DoesNotContain("SENTINEL", stripped); + + var result = JsonSerializer.Deserialize(stripped); + Assert.Null(result.Username); + Assert.Null(result.Password); + Assert.Null(result.PasswordRevisionDate); + Assert.Null(result.Totp); + Assert.Null(result.AutofillOnPageLoad); + Assert.Null(result.Notes); + Assert.Null(result.Fields); + Assert.Null(result.PasswordHistory); + } + + [Theory] + [InlineData(CipherType.SecureNote)] + [InlineData(CipherType.Card)] + [InlineData(CipherType.Identity)] + [InlineData(CipherType.SSHKey)] + [InlineData(CipherType.BankAccount)] + [InlineData(CipherType.DriversLicense)] + [InlineData(CipherType.Passport)] + public void Strip_NonLogin_KeepsOnlyName(CipherType type) + { + // A deliberately over-broad blob: whatever the type, only Name may survive. + var data = $$""" + { + "Name": "2.name|encrypted", + "Notes": "{{Sentinel}}", + "Number": "{{Sentinel}}", + "Code": "{{Sentinel}}", + "PrivateKey": "{{Sentinel}}", + "RoutingNumber": "{{Sentinel}}", + "LicenseNumber": "{{Sentinel}}", + "PassportNumber": "{{Sentinel}}", + "Fields": [{ "Name": "{{Sentinel}}", "Value": "{{Sentinel}}", "Type": 0 }] + } + """; + + var stripped = PartialCipherData.Strip(type, data); + + Assert.DoesNotContain("SENTINEL", stripped); + Assert.Contains("2.name|encrypted", stripped); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Strip_NullOrWhitespace_ReturnsInputUnchanged(string? data) + { + Assert.Equal(data, PartialCipherData.Strip(CipherType.Login, data)); + } + + [Fact] + public void Strip_MissingName_DoesNotThrow() + { + var stripped = PartialCipherData.Strip(CipherType.SecureNote, """{"Notes":"2.notes|encrypted"}"""); + + Assert.DoesNotContain("2.notes|encrypted", stripped); + } + + [Fact] + public void Strip_IsIdempotent() + { + var data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Password = Sentinel, + Uris = [new CipherLoginData.CipherLoginUriData { Uri = "2.uri|encrypted" }], + }); + + var once = PartialCipherData.Strip(CipherType.Login, data); + var twice = PartialCipherData.Strip(CipherType.Login, once); + + Assert.Equal(once, twice); + } +} From bc550aa01475f1936b03b6a08390f1f10d6233aa Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 3 Aug 2026 18:08:14 +0200 Subject: [PATCH 2/3] style: apply dotnet format --- src/Api/Vault/Controllers/CiphersController.cs | 1 - src/Core/Pam/Services/ICipherLeaseGate.cs | 2 +- src/Core/Pam/Services/NoopCipherLeaseGate.cs | 2 +- src/Core/Vault/Authorization/FullCipherAccess.cs | 2 +- src/Core/Vault/Authorization/PartialCipherSupport.cs | 2 +- src/Core/Vault/Models/Data/PartialCipherData.cs | 2 +- .../Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs | 2 +- test/Api.Test/Vault/Controllers/CiphersControllerTests.cs | 4 ++-- test/Api.Test/Vault/Controllers/SyncControllerTests.cs | 4 ++-- .../Models/Response/CipherLeaseFilterEnforcementTests.cs | 2 +- .../Vault/Authorization/PartialCipherSupportTests.cs | 2 +- test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs | 2 +- 12 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 28d881868eed..66f65e6a585f 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -15,7 +15,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Core/Pam/Services/ICipherLeaseGate.cs b/src/Core/Pam/Services/ICipherLeaseGate.cs index 00c33ff78879..ede9327c4559 100644 --- a/src/Core/Pam/Services/ICipherLeaseGate.cs +++ b/src/Core/Pam/Services/ICipherLeaseGate.cs @@ -1,4 +1,4 @@ -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; diff --git a/src/Core/Pam/Services/NoopCipherLeaseGate.cs b/src/Core/Pam/Services/NoopCipherLeaseGate.cs index 8b00e6b33391..c1208755079b 100644 --- a/src/Core/Pam/Services/NoopCipherLeaseGate.cs +++ b/src/Core/Pam/Services/NoopCipherLeaseGate.cs @@ -1,4 +1,4 @@ -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; diff --git a/src/Core/Vault/Authorization/FullCipherAccess.cs b/src/Core/Vault/Authorization/FullCipherAccess.cs index 8f103c429b17..52a188847c04 100644 --- a/src/Core/Vault/Authorization/FullCipherAccess.cs +++ b/src/Core/Vault/Authorization/FullCipherAccess.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Vault.Authorization; +namespace Bit.Core.Vault.Authorization; /// /// A capability that authorizes returning a cipher's full secret data under PAM credential diff --git a/src/Core/Vault/Authorization/PartialCipherSupport.cs b/src/Core/Vault/Authorization/PartialCipherSupport.cs index 7de98c6aee07..903e534d2883 100644 --- a/src/Core/Vault/Authorization/PartialCipherSupport.cs +++ b/src/Core/Vault/Authorization/PartialCipherSupport.cs @@ -1,4 +1,4 @@ -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Utilities; namespace Bit.Core.Vault.Authorization; diff --git a/src/Core/Vault/Models/Data/PartialCipherData.cs b/src/Core/Vault/Models/Data/PartialCipherData.cs index a251906a59ac..4f29812b8ef9 100644 --- a/src/Core/Vault/Models/Data/PartialCipherData.cs +++ b/src/Core/Vault/Models/Data/PartialCipherData.cs @@ -1,4 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below +// FIXME: Update this file to be null safe and then delete the line below #nullable disable using System.Text.Json; diff --git a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs index 558fcbde8ad8..26531a257d63 100644 --- a/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs +++ b/test/Api.Test/Vault/AutoFixture/CipherLeaseGateBypassCustomization.cs @@ -1,4 +1,4 @@ -using AutoFixture; +using AutoFixture; using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Vault.Authorization; diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index f91a6c627089..5847382196aa 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -2,8 +2,8 @@ using System.Text; using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Utilities; using Bit.Api.Test.Vault.AutoFixture; +using Bit.Api.Utilities; using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; @@ -20,8 +20,8 @@ using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; -using Bit.Pam.Services; using Bit.Core.Vault.Services; +using Bit.Pam.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index c9a476622925..095f3ca9f9f4 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using AutoFixture; +using Bit.Api.Test.Vault.AutoFixture; using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models.Response; using Bit.Core; @@ -23,10 +24,9 @@ using Bit.Core.Test.Billing.Mocks; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; -using Bit.Api.Test.Vault.AutoFixture; -using Bit.Core.Vault.Authorization; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Pam.Services; diff --git a/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs index 90d317e88864..e0e62fcb6315 100644 --- a/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs +++ b/test/Api.Test/Vault/Models/Response/CipherLeaseFilterEnforcementTests.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using Bit.Api.Vault.Models.Response; using Bit.Core.Vault.Authorization; using Xunit; diff --git a/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs b/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs index 05feb1f182d2..b68213576ce3 100644 --- a/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs +++ b/test/Core.Test/Vault/Authorization/PartialCipherSupportTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Vault.Authorization; using Xunit; diff --git a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs index 8523121a6024..6f2ad71a65cd 100644 --- a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs +++ b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; From bb44da69f2cd0d3475cf216e1c5f9846ea8d898d Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 7 Aug 2026 10:07:03 +0200 Subject: [PATCH 3/3] refactor(pam): emit partial cipher data as a camelCase envelope `PartialCipherData.Strip` now serializes a purpose-built DTO with `IgnoreWritingNullAndCamelCase` instead of round-tripping `CipherLoginData`. This matches the shape the SDK's restricted decrypt path consumes (the same `LoginUri` fields as a full login) and drops the redundant singular `Uri` the legacy computed getter leaked. Input is parsed case-insensitively so the stored PascalCase blob and an already-stripped camelCase blob both round-trip. Adds a JsonDocument shape test pinning the camelCase wire contract. --- .../Vault/Models/Data/PartialCipherData.cs | 26 ++++++++++--- .../Models/Data/PartialCipherDataTests.cs | 38 ++++++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/Core/Vault/Models/Data/PartialCipherData.cs b/src/Core/Vault/Models/Data/PartialCipherData.cs index 4f29812b8ef9..2009b64d9c68 100644 --- a/src/Core/Vault/Models/Data/PartialCipherData.cs +++ b/src/Core/Vault/Models/Data/PartialCipherData.cs @@ -26,7 +26,13 @@ public static class PartialCipherData /// /// The cipher's type. /// The full, encrypted JSON data blob. Must be JSON (not an SDK-encrypted blob). - /// A reduced JSON data blob, or the input unchanged when it is null/empty. + /// + /// A reduced JSON data blob, or the input unchanged when it is null/empty. The output is a + /// purpose-built camelCase envelope (name, and for logins uris: uri, + /// uriChecksum, match) — the shape the SDK's restricted decrypt path consumes, + /// matching how it deserializes a full login's URIs. Input is parsed case-insensitively so the + /// stored PascalCase blob and an already-stripped camelCase blob both round-trip (idempotent). + /// public static string Strip(CipherType type, string data) { if (string.IsNullOrWhiteSpace(data)) @@ -36,22 +42,30 @@ public static string Strip(CipherType type, string data) if (type == CipherType.Login) { - var login = JsonSerializer.Deserialize(data); - var partial = new CipherLoginData + var login = JsonSerializer.Deserialize(data, JsonHelpers.IgnoreCase); + // A dedicated DTO — not a reduced CipherLoginData — so the legacy computed `Uri` + // getter and the base-class fields never leak into the envelope. + var partial = new PartialLoginData { Name = login.Name, Uris = login.Uris, }; - return JsonSerializer.Serialize(partial, JsonHelpers.IgnoreWritingNull); + return JsonSerializer.Serialize(partial, JsonHelpers.IgnoreWritingNullAndCamelCase); } - var nameOnly = JsonSerializer.Deserialize(data); + var nameOnly = JsonSerializer.Deserialize(data, JsonHelpers.IgnoreCase); return JsonSerializer.Serialize( - new NameOnlyData { Name = nameOnly.Name }, JsonHelpers.IgnoreWritingNull); + new NameOnlyData { Name = nameOnly.Name }, JsonHelpers.IgnoreWritingNullAndCamelCase); } private class NameOnlyData { public string Name { get; set; } } + + private class PartialLoginData + { + public string Name { get; set; } + public IEnumerable Uris { get; set; } + } } diff --git a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs index 6f2ad71a65cd..f245b4cab77b 100644 --- a/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs +++ b/test/Core.Test/Vault/Models/Data/PartialCipherDataTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; using Xunit; @@ -29,7 +30,7 @@ public void Strip_Login_KeepsNameAndUris() }); var stripped = PartialCipherData.Strip(CipherType.Login, data); - var result = JsonSerializer.Deserialize(stripped); + var result = JsonSerializer.Deserialize(stripped, JsonHelpers.IgnoreCase); Assert.Equal("2.name|encrypted", result.Name); Assert.Single(result.Uris); @@ -38,6 +39,39 @@ public void Strip_Login_KeepsNameAndUris() Assert.Equal(UriMatchType.Host, result.Uris.First().Match); } + [Fact] + public void Strip_Login_EmitsCamelCaseEnvelope() + { + // The stripped output is the SDK's restricted-decrypt contract: a purpose-built camelCase + // envelope of name + uris only. Assert the wire shape directly — a casing change or the + // legacy singular `Uri` getter leaking back in would silently break SDK deserialization. + var data = JsonSerializer.Serialize(new CipherLoginData + { + Name = "2.name|encrypted", + Uris = + [ + new CipherLoginData.CipherLoginUriData + { + Uri = "2.uri|encrypted", + UriChecksum = "2.checksum|encrypted", + Match = UriMatchType.Host, + }, + ], + }); + + var stripped = PartialCipherData.Strip(CipherType.Login, data); + + using var doc = JsonDocument.Parse(stripped); + var root = doc.RootElement; + // Top-level allowlist: exactly name + uris — no singular `uri`, no secret fields. + Assert.Equal(new[] { "name", "uris" }, root.EnumerateObject().Select(p => p.Name).ToArray()); + + var uri = root.GetProperty("uris")[0]; + Assert.Equal( + new[] { "uri", "uriChecksum", "match" }, + uri.EnumerateObject().Select(p => p.Name).ToArray()); + } + [Fact] public void Strip_Login_DropsEverySecretField() { @@ -60,7 +94,7 @@ public void Strip_Login_DropsEverySecretField() // unexpected key would still be a leak. Assert.DoesNotContain("SENTINEL", stripped); - var result = JsonSerializer.Deserialize(stripped); + var result = JsonSerializer.Deserialize(stripped, JsonHelpers.IgnoreCase); Assert.Null(result.Username); Assert.Null(result.Password); Assert.Null(result.PasswordRevisionDate);