72 "reason": "decryptAes256 now returns Utils.fromArrayToByteString, which maps each byte to its own character with no UTF-8 decoding, so multi-byte UTF-8 characters in decrypted LastPass fields come out garbled."
90 "reason": "decryptAes256 now decodes the raw plaintext bytes from the DANGEROUS_* helpers with Utils.fromArrayToByteString (one byte per character, Latin-1) instead of UTF-8, so multi-byte characters in LastPass import data come out garbled."
108 "reason": "decryptAes256 now calls Utils.fromArrayToByteString, which maps each byte to one character (Latin-1) instead of decoding UTF-8, so any multi-byte character in a decrypted LastPass field gets corrupted."
126 "reason": "decryptAes256 now returns Utils.fromArrayToByteString of the decrypted bytes. That maps each byte to one char (Latin-1) instead of decoding UTF-8, so any multi-byte character in imported fields is corrupted."
131 "reason": "The missing await in the *WithDefault helpers was already there, and the claimed regression doesn't hold. Before, bad-padding ECB output still went through forge's output.toString() (UTF-8 decode, which throws on garbage bytes), and the Node crypto path threw outright, so the per-field fallback was already ineffective. This PR doesn't turn a working fallback into an aborted import."
154 "reason": "The PR replaced Utils.fromArrayToUtf8 with Utils.fromArrayToByteString, which maps each byte to one character instead of decoding UTF-8, so multi-byte characters in decrypted fields come out garbled."
159 "reason": "The old ECB path (WebCrypto aesDecryptFast) ignored decipher.finish(), but DANGEROUS_aesEcbDecryptLastpassImport now throws on failure, and the *WithDefault helpers return the promise without awaiting it, so the rejection escapes their catch."
164 "reason": "The PR changes only crypto-utils.ts and an index export and adds no tests, and no spec covers CryptoUtils or the LastPass parser (the only LastPass spec is the CSV importer)."
169 "reason": "The zero-IV default existed before this PR and WebCrypto never forced callers to pass an IV, and every current CBC caller passes one, so the PR introduces no defect here."
187 "reason": "The PR swapped Utils.fromArrayToUtf8 for Utils.fromArrayToByteString, which maps each byte to its own character instead of decoding UTF-8, so multi-byte characters in decrypted LastPass fields come out garbled."
192 "reason": "The PR adds no tests for CryptoUtils.decryptAes256 or the new CBC helper; the only spec covers ECB with ASCII text, so the UTF-8 regression goes undetected."
197 "reason": "The *WithDefault wrappers return the promise without awaiting it inside try, so a rejected decryption escapes the catch and the default is never returned; this predates the PR, but the defect does exist in the code."
215 "reason": "GetManyByOrganizationIdAsync runs before RestoreAsync while users are still Revoked (an exempt status), so the Contains check never matches and bulk restore never creates collections."
220 "reason": "The single-user path checks requirement.State, which is Enabled if any of the user's orgs enforces the policy, rather than the org-scoped RequiresDefaultCollectionOnConfirm(organizationId)."
225 "reason": "The logic is duplicated inline and the policy query is evaluated before the cheaper flag and name checks; the bulk query runs on every call no matter what the flag says."
235 "reason": "The restore-only DefaultUserCollectionName property was added to the shared OrganizationUserBulkRequestModel, which many unrelated endpoints use."
253 "reason": "The org-wide enforced-user query runs before the loop, while the target users are still Revoked (an exempt status), so the Contains check at line 244 can never be true and bulk restore never creates default collections."
258 "reason": "GetAsync(userId) sets State to Enabled when any of the user's orgs enforces the policy, and the check is not scoped to organizationUser.OrganizationId (unlike RequiresDefaultCollectionOnConfirm), so a policy in another org can trigger collection creation in the restoring org."
263 "reason": "The eligibility logic really is duplicated inline and the two copies disagree (user-wide State vs. org-scoped ID list), and the bulk path runs the policy query every time and calls CreateDefaultCollectionsAsync once per user instead of one batched call."
268 "reason": "The new tests are wrapped in #region/#endregion at lines 1177/1367 and 1369/1544, and .claude/CLAUDE.md line 13 explicitly says to never use code regions."
286 "reason": "The org-scoped policy query runs before RestoreAsync while users are still Revoked, which is an exempt status, so their IDs are filtered out and the Contains check never matches."
291 "reason": "GetAsync(userId).State is Enabled if any of the user's organizations enforces the policy (policyDetails.Any()), so a collection can be created in an organization that doesn't enforce it, unlike RequiresDefaultCollectionOnConfirm(orgId)."
296 "reason": "The diff removes both `organizationUser.Status = status;` lines, so after restore the in-memory entity still says Revoked when it is logged, returned, and reused by callers."
319 "reason": "GetManyByOrganizationIdAsync filters with factory.Enforce, which exempts Revoked status, and the users are still Revoked when it is called, so the Contains check never matches."
324 "reason": "State is Enabled whenever any enforced policy detail exists across all of the user's orgs (policyDetails.Any()), so the check is not scoped to organizationUser.OrganizationId, unlike RequiresDefaultCollectionOnConfirm(orgId)."
329 "reason": "The diff removes `organizationUser.Status = status` in both paths, so callers like the SCIM PatchUserCommand (which checks orgUser.Status) and the event logging get an entity whose status is still Revoked."
334 "reason": "The policy queries run before the cheap flag, name and status checks, and the bulk query runs on every call, so they add DB load even when the feature flag is off."
339 "reason": "CreateDefaultCollectionsAsync accepts a list but is called once per user inside the loop, which means N round trips instead of one batched call."
344 "reason": "The tests stub GetManyByOrganizationIdAsync to return Revoked users' IDs and stub State without org scoping, which hides the defects in findings 1 and 2."
360 "location": "RestoreOrganizationUserCommand.cs:202-204 (used at :244)",
361 "verdict": "real",
362 "reason": "GetManyByOrganizationIdAsync filters through factory.Enforce, which drops users with Revoked status; the list is built before any RestoreAsync call, so the Contains check never matches a user being restored."
367 "reason": "GetAsync(userId) builds the requirement from every org the user belongs to, and the factory sets State=Enabled if any enforced policy exists; the check never uses organizationUser.OrganizationId, so a policy in another org can trigger collection creation in this one."
372 "reason": "The in-memory Status is now stale, but no current consumer reads it: SCIM re-fetches the user, the bulk response only uses Id and the error string, and event logging doesn't use Status, so nothing observable breaks."
377 "reason": "This needs a hypothetical infrastructure failure, and any other repository or push call in the same non-transactional flow can fail the same way; it's general robustness speculation, not a concrete defect from this change."
382 "reason": "The single-user path runs the GetAsync policy query before the feature-flag and name checks, and the bulk path always runs GetManyByOrganizationIdAsync, adding needless DB queries to every restore even when the flag is off or no name is sent."
387 "reason": "This is an API-design preference; the other bulk endpoints just ignore the extra optional field, and the command checks the feature flag before using it, so no incorrect behavior results."
405 "reason": "The policy query runs while the users are still Revoked, which is an exempt status, so they are filtered out; the Contains check is never true and bulk restore never creates a default collection."
410 "reason": "GetAsync(userId).State is Enabled when any of the user's organizations enforces Data Ownership, so a restore into an organization without the policy can still create a default collection there."
415 "reason": "The diff removes `organizationUser.Status = status` in both paths, so the in-memory entity stays Revoked; SCIM PatchUserCommand reuses that same orgUser across operations and branches on its Status."
420 "reason": "The new CreateDefaultCollectionsAsync call runs after the restore has been committed but before the event and push, and the bulk path only catches BadRequestException, so a failure there leaves the user restored with no event or push."
425 "reason": "The policy queries run before the cheap feature-flag and name checks, and in the bulk path they run unconditionally, so every restore pays for those lookups."
430 "reason": "The bulk tests stub GetManyByOrganizationIdAsync to return revoked users' IDs, which the real query filters out, and no test covers the cross-organization policy case or the post-restore status."
448 "reason": "B64::as_bytes() returns the decoded 16 raw bytes rather than the UTF-8 bytes of the base64 string that was used before, so the KDF salt no longer matches the salt importers read from the file."
466 "reason": "B64::as_bytes() returns the decoded 16 raw bytes, not the base64 text bytes the old String passed, so the KDF salt changed and other clients can't derive the same key."
484 "reason": "B64::as_bytes() returns the decoded 16 raw bytes rather than the base64 text bytes used before, while the file still stores the base64 string, so importers derive a different key."
502 "reason": "`salt` is now a `B64`, so `salt.as_bytes()` gives the 16 decoded bytes instead of the UTF-8 bytes of the base64 string, but the export's `salt` field still holds the base64 string, so other clients' importers derive a different key."
507 "reason": "The decoder really is more lenient now (it strips padding and skips trailing-bit checks), but `B64Url` uses a deliberately permissive decoder (`BASE64URL_PERMISSIVE`), and accepting padded or non-canonical input breaks nothing; the review itself says it probably doesn't matter, so this is an intended behaviour change, not a defect."
525 "reason": "B64::as_bytes() returns the decoded 16 raw bytes, not the UTF-8 bytes of the base64 string the old code used, so the export key is now derived from a different salt than other clients use."
530 "reason": "Accepting padding and ignoring trailing bits is the documented, intended behaviour of B64Url, and the review shows no concrete case where it breaks anything; the string-vs-bytes comparison problem is speculation."
553 "reason": "B64::as_bytes() returns the 16 decoded raw bytes rather than the 24-byte base64 string, so PinKey::derive now uses a different salt from the old code and from the salt string other clients read from the export."
558 "reason": "The decoding is indeed more lenient now, but the B64/B64Url types are documented to ignore padding, so this is intended. Accepting padded or non-canonical forms of the same bytes does not cause any concrete wrong result or security failure on these paths."
576 "reason": "When both sources are empty, syncNewPolicies returns early and never calls newPolicyService.replace, so policies removed since the last sync stay in the new policy state. The legacy syncPolicies always calls replace, so it does clear them."
594 "reason": "syncNewPolicies returns early without calling newPolicyService.replace when both policiesNew and policies are empty, unlike syncPolicies which always replaces, so stale policies are never cleared."
612 "reason": "syncNewPolicies returns before calling newPolicyService.replace when both policiesNew and policies are empty, so stale policies stay in POLICIES_NEW, unlike syncPolicies, which always replaces."
630 "reason": "syncNewPolicies returns before calling newPolicyService.replace when both sources are empty, so POLICIES_NEW keeps stale policies while the legacy store is cleared."
635 "reason": "The PR's new upsert correctly targets the notification's userId; the active-account behaviour of syncPolicy is pre-existing legacy code that this PR doesn't introduce."
640 "reason": "The cited lines don't exist (the file is 26 lines), and an upsert-only notification path matches the legacy service by design; the only real removal problem is the one already covered by Finding 1."
645 "reason": "Replacing with the login-time policies mirrors the existing legacy policyService.replace call on the line before it and is intentional pre-sync seeding, not a defect."
650 "reason": "This rests on a hypothetical server change; falling back to policies when policiesNew is empty is documented intended behaviour, and the review shows no concrete wrong result."
668 "reason": "syncNewPolicies returns before calling replace when both arrays are empty, so stale policiesNew state is never cleared, unlike legacy syncPolicies, which always replaces."
673 "reason": "Falling back when policiesNew is empty is a documented design choice, and since policiesNew is a superset of policies, the divergence it describes is speculative rather than a concrete defect."
678 "reason": "The new upsert correctly uses the notification's userId; the active-account behavior of syncPolicy is pre-existing legacy code, not a defect this PR introduced."
683 "reason": "Policy notifications send updated policy data (including enabled=false), which upsert overwrites correctly; the legacy service has no delete path either, so no new defect is introduced."
688 "reason": "This deliberately mirrors the existing legacy replace-on-login behavior, and the follow-up sync is expected to replace it; the only residual problem is the separate early-return bug in Finding 1."
706 "reason": "syncNewPolicies returns before calling newPolicyService.replace when both arrays are empty or absent, so stale policiesNew state is never cleared, unlike syncPolicies, which always calls replace."
711 "reason": "Falling back on an empty array is intended and documented in the code comment, and since policiesNew (accepted and confirmed) covers at least what policies (confirmed) does, an empty policiesNew means policies is empty too, so the fallback adds no wrong data; the real harm comes only from the early return in Finding 1."
716 "reason": "policyService.syncPolicy writes for the active account, while the new newPolicyService.upsert writes for the notification's userId, so in multi-user notification handling the two stores can be updated for different users."
734 "reason": "The explicit startup logout check was removed. checkSessionTimeouts only fires for the active user when hasPassedSessionTimeout is true, which never happens for .onAppRestart, or when the account has no unlock method. So a 'log out on restart' user who has a master password is only locked."
739 "reason": "canBeLocked(userId:) gets its biometric flag from getBiometricUnlockStatus(), which reads stateService.getBiometricAuthenticationEnabled() without a userId. Now that the PR uses account.canBeLocked to decide logouts, inactive accounts are judged using the active user's biometric setting."
757 "reason": "The explicit logout-action startup check was replaced with checkSessionTimeouts. hasPassedSessionTimeout returns false for .onAppRestart, and the no-unlock-method branch requires !canBeLocked, so an account with a master password and the logout action is only routed to vaultUnlock on restart instead of being logged out."
762 "reason": "canBeLocked(userId:) gets its biometric part from getBiometricUnlockStatus(), which calls stateService.getBiometricAuthenticationEnabled(), and that reads the active user's flag. This PR now uses that value to decide logout for inactive accounts, so they can be logged out wrongly or missed."
767 "reason": "The four negative tests never set isAuthenticated. The mock defaults it to false, so isLoggedOut is true, and the !isLoggedOut clause by itself prevents the logout, which means the targeted clauses are never exercised."
785 "reason": "The explicit startup check for sessionTimeoutAction == .logout was replaced with a callback that only fires when hasPassedSessionTimeout is true (never for .onAppRestart) or when the account has no unlock method, so master-password, PIN or biometric users who chose 'log out on app restart' are now only locked."
790 "reason": "canBeLocked(userId:) and sessionTimeoutAction(userId:) both get biometric status from getBiometricAuthenticationEnabled(), which reads the active account, so an inactive biometric-only account can wrongly match the new no-unlock-method condition and be logged out."
795 "reason": "checkSessionTimeouts also runs on every willEnterForeground and in the profile switcher, so an authenticated, locked TDE account that is still mid-login and has no unlock method meets the new condition and gets sent through .didTimeout, which forces logout."
813 "reason": "The explicit logout-action check was removed, and hasPassedSessionTimeout returns false for .onAppRestart and for intervals that haven't elapsed, so an MP user set to log out on restart only gets the unlock screen."
818 "reason": "canBeLocked calls getBiometricUnlockStatus(), which reads the biometric-enabled flag of the active user, so the new per-account logout decision is wrong for inactive accounts."
823 "reason": "A biometry lockout makes getBiometricUnlockStatus throw (caught by try?) or return .notAvailable, so canBeLocked is false and a biometric-only account is force-logged out because of a temporary OS state."
828 "reason": "preparedStartRoute now runs checkSessionTimeouts in extensions, where every account defaults to locked in memory, so an inactive account with no unlock method that is unlocked in the main app gets logged out in shared storage."
833 "reason": "A throw from sessionTimeoutAction or logout for one account (for example a no-MP account during biometry lockout) aborts the whole loop, so the active user's startup timeout can be skipped, which the old per-active-user try? check never allowed."
838 "reason": "Applying normal time-based timeouts to inactive accounts at startup is consistent with existing checkSessionTimeouts behavior, and the reviewer calls it 'probably fine'; this is a scope and latency observation, not a concrete defect."
856 "reason": "The explicit startup check for logout with a non-never timeout was removed; hasPassedSessionTimeout returns false for .onAppRestart (VaultTimeoutService.swift:163-167), so a master-password user who chose 'log out on app restart' is no longer routed to didTimeout and is only shown the unlock screen."
861 "reason": "canBeLocked(userId:) uses biometricsRepository.getBiometricUnlockStatus(), which reads the active user's biometric flag, and the PR now uses that value to destructively log out inactive accounts."
866 "reason": "checkSessionTimeouts also runs on every willEnterForeground (AppProcessor) and from the profile switcher, and the new no-unlock-method condition has no restart gating, so a locked authenticated account with no MP/PIN/biometrics (or one whose biometry is locked out, which try? turns into false) is sent through didTimeout and logged out."
871 "reason": "The whole per-account loop sits in one do/catch, so a throw for one account skips the rest; now that the function runs at startup, a later active user's timeout may never be handled."
889 "reason": "The explicit `.logout`-action/non-`.never` startup check was replaced by `checkSessionTimeouts`, whose `hasPassedSessionTimeout` returns false for `.onAppRestart` (and for unexpired timers), so users with a master password and the \"Log out\" action are no longer logged out on restart; the updated test only forces the mock flag."
890 },
891 {
892 "location": "BitwardenShared/Core/Auth/Repositories/AuthRepository.swift:583-597 (canBeLocked at 553-563)",
893 "verdict": "real",
894 "reason": "`canBeLocked(userId:)` calls `biometricsRepository.getBiometricUnlockStatus()`, which reads `getBiometricAuthenticationEnabled()` for the active user only, so the new logout rule evaluates inactive accounts using the wrong user's biometrics setting."
899 "reason": "The no-unlock-method condition is not limited to startup; `checkSessionTimeouts` also runs on every `willEnterForeground`, so a locked active account with no MP/PIN/biometrics (e.g. a TDE user waiting for device approval) gets `.didTimeout` and is logged out when the app returns to the foreground."