diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index cb858930650..e3b7c69e163 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -1457,6 +1457,15 @@
"attachmentSaved": {
"message": "Attachment saved"
},
+ "fixEncryption": {
+ "message": "Fix encryption"
+ },
+ "fixEncryptionTooltip": {
+ "message": "This file is using an outdated encryption method."
+ },
+ "attachmentUpdated": {
+ "message": "Attachment updated"
+ },
"file": {
"message": "File"
},
diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts
index 871163ac80b..1da2d352c14 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts
@@ -1,4 +1,4 @@
-import { Component, Input } from "@angular/core";
+import { Component, input, ChangeDetectionStrategy } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router";
@@ -25,31 +25,23 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach
import { AttachmentsV2Component } from "./attachments-v2.component";
-// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
-// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-header",
template: ``,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @Input() pageTitle: string;
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @Input() backAction: () => void;
+ readonly pageTitle = input();
+ readonly backAction = input<() => void>();
}
-// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
-// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-footer",
template: ``,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupFooterComponent {
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @Input() pageTitle: string;
+ readonly pageTitle = input();
}
describe("AttachmentsV2Component", () => {
@@ -120,7 +112,7 @@ describe("AttachmentsV2Component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
.componentInstance;
- expect(cipherAttachment.submitBtn).toEqual(submitBtn);
+ expect(cipherAttachment.submitBtn()).toEqual(submitBtn);
});
it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => {
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 33582c857aa..92e350fab90 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -708,6 +708,15 @@
"addAttachment": {
"message": "Add attachment"
},
+ "fixEncryption": {
+ "message": "Fix encryption"
+ },
+ "fixEncryptionTooltip": {
+ "message": "This file is using an outdated encryption method."
+ },
+ "attachmentUpdated": {
+ "message": "Attachment updated"
+ },
"maxFileSizeSansPunctuation": {
"message": "Maximum file size is 500 MB"
},
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 3d5303c8e82..4be70b102d1 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5173,6 +5173,15 @@
"message": "Fix",
"description": "This is a verb. ex. 'Fix The Car'"
},
+ "fixEncryption": {
+ "message": "Fix encryption"
+ },
+ "fixEncryptionTooltip": {
+ "message": "This file is using an outdated encryption method."
+ },
+ "attachmentUpdated": {
+ "message": "Attachment updated"
+ },
"oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
},
diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts
index b95d9023a7c..8472a359c51 100644
--- a/libs/common/src/vault/abstractions/cipher.service.ts
+++ b/libs/common/src/vault/abstractions/cipher.service.ts
@@ -161,6 +161,17 @@ export abstract class CipherService implements UserKeyRotationDataProvider;
+ /**
+ * Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
+ * @param cipher - The cipher with old attachments to upgrade
+ * @param userId - The user ID
+ * @param attachmentId - If provided, only upgrade the attachment with this ID
+ */
+ abstract upgradeOldCipherAttachments(
+ cipher: CipherView,
+ userId: UserId,
+ attachmentId?: string,
+ ): Promise;
/**
* Save the collections for a cipher with the server
*
@@ -274,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider;
+ ): Promise;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index 7eebe960a7f..402b8ed1030 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -1656,12 +1656,14 @@ export class CipherService implements CipherServiceAbstraction {
const key =
attachment.key != null
? attachment.key
- : await firstValueFrom(
- this.keyService.orgKeys$(userId).pipe(
- filterOutNullish(),
- map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
- ),
- );
+ : cipherDomain.organizationId
+ ? await firstValueFrom(
+ this.keyService.orgKeys$(userId).pipe(
+ filterOutNullish(),
+ map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
+ ),
+ )
+ : await firstValueFrom(this.keyService.userKey$(userId).pipe(filterOutNullish()));
return await this.encryptService.decryptFileData(encBuf, key);
}
@@ -1829,6 +1831,95 @@ export class CipherService implements CipherServiceAbstraction {
}
}
+ /**
+ * Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
+ * @param cipher
+ * @param userId
+ * @param attachmentId Optional specific attachment ID to upgrade. If not provided, all old attachments will be upgraded.
+ */
+ async upgradeOldCipherAttachments(
+ cipher: CipherView,
+ userId: UserId,
+ attachmentId?: string,
+ ): Promise {
+ if (!cipher.hasOldAttachments) {
+ return cipher;
+ }
+
+ let cipherDomain = await this.get(cipher.id, userId);
+
+ for (const attachmentView of cipher.attachments) {
+ if (
+ attachmentView.key != null ||
+ (attachmentId != null && attachmentView.id !== attachmentId)
+ ) {
+ continue;
+ }
+
+ try {
+ // 1. Get download URL
+ const downloadUrl = await this.getAttachmentDownloadUrl(cipher.id, attachmentView);
+
+ // 2. Download attachment data
+ const dataResponse = await this.apiService.nativeFetch(
+ new Request(downloadUrl, { cache: "no-store" }),
+ );
+
+ if (dataResponse.status !== 200) {
+ throw new Error(`Failed to download attachment. Status: ${dataResponse.status}`);
+ }
+
+ // 3. Decrypt the attachment
+ const decryptedBuffer = await this.getDecryptedAttachmentBuffer(
+ cipher.id as CipherId,
+ attachmentView,
+ dataResponse,
+ userId,
+ );
+
+ // 4. Re-upload with attachment key
+ cipherDomain = await this.saveAttachmentRawWithServer(
+ cipherDomain,
+ attachmentView.fileName,
+ decryptedBuffer,
+ userId,
+ );
+
+ // 5. Delete the old attachment
+ const cipherData = await this.deleteAttachmentWithServer(
+ cipher.id,
+ attachmentView.id,
+ userId,
+ );
+ cipherDomain = new Cipher(cipherData);
+ } catch (e) {
+ this.logService.error(`Failed to upgrade attachment ${attachmentView.id}`, e);
+ throw e;
+ }
+ }
+
+ return await this.decrypt(cipherDomain, userId);
+ }
+
+ private async getAttachmentDownloadUrl(
+ cipherId: string,
+ attachmentView: AttachmentView,
+ ): Promise {
+ try {
+ const attachmentResponse = await this.apiService.getAttachmentData(
+ cipherId,
+ attachmentView.id,
+ );
+ return attachmentResponse.url;
+ } catch (e) {
+ // Fall back to the attachment's stored URL
+ if (e instanceof ErrorResponse && e.statusCode === 404 && attachmentView.url) {
+ return attachmentView.url;
+ }
+ throw new Error(`Failed to get download URL for attachment ${attachmentView.id}`);
+ }
+ }
+
private async encryptObjProperty(
model: V,
obj: D,
diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html
index 83e5956a067..855c37ecab5 100644
--- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html
+++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html
@@ -1,32 +1,57 @@
{{ "attachments" | i18n }}
-
- -
-
-
- {{ attachment.fileName }}
- {{ attachment.sizeName }}
-
-
-
-
-
-
-
-
-
-
-
-
+@if (cipher()?.attachments; as attachments) {
+
+ @for (attachment of attachments; track attachment.id) {
+ -
+
+
+ {{
+ attachment.fileName
+ }}
+ {{ attachment.sizeName }}
+
+
+
+
+
+ @if (attachment.key != null) {
+
+ } @else {
+
+ }
+
+
+
+
+
+
+
+ }
+
+}