From 198f9c32dd63674098b00a119bae718b49f6a09f Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Wed, 15 Apr 2026 17:50:38 +0530 Subject: [PATCH] feat: implement master key decryption for letters and add UI warning for image decryption failures --- frontend/e2e/letter.spec.ts | 126 ++++++++++++++++--- frontend/src/components/ui/DrawerSection.tsx | 2 +- frontend/src/pages/Drawer.tsx | 2 +- frontend/src/pages/Reader.tsx | 102 +++++++++++---- 4 files changed, 192 insertions(+), 40 deletions(-) diff --git a/frontend/e2e/letter.spec.ts b/frontend/e2e/letter.spec.ts index 75bf1f6..304ab1e 100644 --- a/frontend/e2e/letter.spec.ts +++ b/frontend/e2e/letter.spec.ts @@ -1,6 +1,16 @@ import { expect, test } from "@playwright/test"; +import pino from "pino"; import { AuthHelper } from "./utils/auth"; +const logger = pino({ + transport: { + target: "pino-pretty", + options: { + colorize: true, + }, + }, +}); + test.describe("Letter Drafting (Real Backend)", () => { const password = "Password123!"; @@ -11,10 +21,10 @@ test.describe("Letter Drafting (Real Backend)", () => { await AuthHelper.registerAndLogin(page, email, name, password); - console.log(">> [Draft] Navigating to Editor via UI..."); + logger.info(">> [Draft] Navigating to Editor via UI..."); await page.getByRole("button", { name: /write something/i }).click(); - console.log(`>> [Draft] Current URL after click: ${page.url()}`); + logger.info(`>> [Draft] Current URL after click: ${page.url()}`); // Wait for the recipient input to be present in the DOM const recipientInput = page.locator("#recipient"); @@ -23,15 +33,19 @@ test.describe("Letter Drafting (Real Backend)", () => { const recipientName = "Dear Friend"; await recipientInput.fill(recipientName); - // Type into the Fabric.js canvas - console.log(">> [Draft] Typing into canvas..."); + // Initial load: verify textarea value (populated by Fabric when focused) const canvasInput = page.getByLabel("Canvas text input"); - await canvasInput.waitFor({ state: "visible" }); + await canvasInput.waitFor({ state: "attached" }); await canvasInput.focus(); - await canvasInput.fill("This is a secret draft created by E2E testing."); + await expect(canvasInput).toHaveValue(/Take a deep breath/i); - // Store as draft - console.log(">> [Draft] Clicking Store..."); + // Draft a letter + logger.info(">> [Draft] Typing content..."); + await canvasInput.focus(); + await page.keyboard.type("This is a secret draft"); + await page.keyboard.press("Enter"); + await page.keyboard.type("It should persist."); + logger.info(">> [Draft] Clicking Store..."); await page.getByRole("button", { name: /store/i }).click(); // Verify Success Modal/Alert @@ -40,10 +54,10 @@ test.describe("Letter Drafting (Real Backend)", () => { // Verify URL updated with a UUID await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/); const savedUrl = page.url(); - console.log(`>> [Draft] Saved URL: ${savedUrl}`); + logger.info(`>> [Draft] Saved URL: ${savedUrl}`); // Reload and verify persistence - console.log(">> [Draft] Reloading to verify persistence..."); + logger.info(">> [Draft] Reloading to verify persistence..."); await page.goto(savedUrl); // Wait for initial load overlay to disappear @@ -53,7 +67,13 @@ test.describe("Letter Drafting (Real Backend)", () => { await expect(page.locator("#recipient")).toHaveValue(recipientName); // Check canvas content - await expect(canvasInput).toHaveValue(/This is a secret draft/); + // We wait for the content to appear in the textarea. + // toHaveValue will poll until it matches or timeouts. + await canvasInput.focus(); + await expect(canvasInput).toHaveValue(/This is a secret draft/i, { + timeout: 10000, + }); + await expect(canvasInput).toHaveValue(/It should persist/i); }); test("should seal a letter and show sharing link", async ({ page }) => { @@ -63,7 +83,7 @@ test.describe("Letter Drafting (Real Backend)", () => { await AuthHelper.registerAndLogin(page, email, name, password); - console.log(">> [Seal] Navigating to Editor via UI..."); + logger.info(">> [Seal] Navigating to Editor via UI..."); await page.getByRole("button", { name: /write something/i }).click(); const recipientInput = page.locator("#recipient"); @@ -75,11 +95,11 @@ test.describe("Letter Drafting (Real Backend)", () => { await canvasInput.fill("This letter will be sealed and shared."); // Click Seal - console.log(">> [Seal] Clicking Seal..."); + logger.info(">> [Seal] Clicking Seal..."); await page.getByRole("button", { name: /seal/i }).click(); // Verify "Sealed & Ready" modal - console.log(">> [Seal] Verifying sharing modal..."); + logger.info(">> [Seal] Verifying sharing modal..."); await expect(page.getByText(/sealed & ready/i)).toBeVisible(); // Verify sharing link contains a hash (the key) @@ -89,7 +109,7 @@ test.describe("Letter Drafting (Real Backend)", () => { expect(linkValue).toContain("/read/"); expect(linkValue).toContain("#"); - console.log(`>> [Seal] Sharing link generated: ${linkValue}`); + logger.info(`>> [Seal] Sharing link generated: ${linkValue}`); // Verify "Copy" button works await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); @@ -98,4 +118,80 @@ test.describe("Letter Drafting (Real Backend)", () => { await page.getByRole("button", { name: /close/i }).click(); await expect(page.getByText(/sealed & ready/i)).toBeHidden(); }); + + test("should allow author to access sealed letter from drawer without sharing key", async ({ + page, + }) => { + const timestamp = Date.now() + Math.random(); + const email = `drawer-${timestamp}@example.com`; + const name = `Drawer Author ${timestamp}`; + const recipientName = "Drawer Test Recipient"; + const letterContent = "This is a sealed letter accessed via the drawer."; + + await AuthHelper.registerAndLogin(page, email, name, password); + + logger.info(">> [Drawer] Creating and sealing a letter..."); + await page.getByRole("button", { name: /write something/i }).click(); + + const recipientInput = page.locator("#recipient"); + await recipientInput.waitFor({ state: "visible" }); + await recipientInput.fill(recipientName); + + const canvasInput = page.getByLabel("Canvas text input"); + await canvasInput.focus(); + await canvasInput.fill(letterContent); + + // Click Seal + await page.getByRole("button", { name: /seal/i }).click(); + await expect(page.getByText(/sealed & ready/i)).toBeVisible(); + + // Close modal + await page.getByRole("button", { name: /close/i }).click(); + + // Navigate to Drawer - use ID or precise label + logger.info(">> [Drawer] Navigating to Drawer..."); + await page.locator("button[aria-label='Open Drawer']").click(); + + // Open "Kept" section - search for the section with id='kept' and click its toggle button + logger.info(">> [Drawer] Opening Kept section..."); + const keptSection = page.locator("#kept"); + await keptSection.getByRole("button", { name: /kept/i }).click(); + + // Find the sealed letter in the drawer by recipient name and click it + logger.info(">> [Drawer] Clicking sealed letter in drawer..."); + const sealedItem = page + .getByRole("button", { name: new RegExp(recipientName, "i") }) + .first(); + await sealedItem.click(); + + // Verify it opens the Reader without a hash + logger.info(">> [Drawer] Verifying Reader page..."); + // Give it a bit more time for decryption + await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash + + // Check decrypted content in Reader + await expect(page.getByText(/decrypting/i)).toBeHidden({ + timeout: 10000, + }); + await expect( + page.getByText(new RegExp(`A sealed message for ${recipientName}`, "i")), + ).toBeVisible(); + + // Verify content is decrypted (using author's masterKey automatically) + await expect(page.getByText(/decrypting/i)).toBeHidden(); + // In the Reader, we check if the recipient name is visible in the Reader header. + await expect(page.getByText(/Drawer Test Recipient/i)).toBeVisible(); + + // Also check if we are redirected to the Reader if we manually go to the Editor URL + const readerUrl = page.url(); + const quillUrl = readerUrl.replace("/read/", "/quill/"); + logger.info( + `>> [Drawer] Navigating to Editor URL (expecting redirect): ${quillUrl}`, + ); + await page.goto(quillUrl); + + // It should redirect back to the reader + await expect(page).toHaveURL(readerUrl); + await expect(page.getByText(/Drawer Test Recipient/i)).toBeVisible(); + }); }); diff --git a/frontend/src/components/ui/DrawerSection.tsx b/frontend/src/components/ui/DrawerSection.tsx index daabf18..ab02460 100644 --- a/frontend/src/components/ui/DrawerSection.tsx +++ b/frontend/src/components/ui/DrawerSection.tsx @@ -24,7 +24,7 @@ export function DrawerSection({ className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${ isOpen ? "max-h-125 opacity-100 py-3 border-b border-base-content/5" - : "max-h-0 opacity-0" + : "max-h-0 opacity-0 pointer-events-none" }`} > {children} diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 5fc3fdf..01a3b8f 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -11,7 +11,7 @@ import { useLetters } from "../hooks/useLetters"; export default function Drawer() { const { user, logout, unlock } = useAuth(); - const [openSection, setOpenSection] = useState(); + const [openSection, setOpenSection] = useState(null); const navigate = useNavigate(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 6ffc9b0..4f7337f 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -8,8 +8,12 @@ import { ComposeCanvas, } from "../components/ui/ComposeCanvas"; import { endpoints } from "../config/endpoints"; +import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; -import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; +import { + decryptCanvasImages, + decryptCanvasImagesWithSharingKey, +} from "../utils/letterLogic"; interface LetterMetadata { recipient?: string; @@ -24,13 +28,21 @@ export default function Reader() { const [isDecrypting, setIsDecrypting] = useState(true); const [error, setError] = useState(null); + const [warning, setWarning] = useState<{ + message: string; + log: string; + } | null>(null); const [metadata, setMetadata] = useState(null); const [decryptedCanvasData, setDecryptedCanvasData] = useState(null); + const { masterKey } = useKeyStore(); + useEffect(() => { - if (!sharingKey) { - setError("No sharing key provided. Please check the link."); + if (!(sharingKey || masterKey)) { + setError( + "No sharing key provided. Please check the link or log in if you are the author.", + ); setIsDecrypting(false); return; } @@ -38,33 +50,69 @@ export default function Reader() { const loadAndDecrypt = async () => { try { const response = await api.get(`${endpoints.LETTERS}${public_id}/`); - const { encrypted_content, encrypted_metadata, images } = response.data; + const { encrypted_content, encrypted_metadata, encrypted_dek, images } = + response.data; const cryptoUtils = new CryptoUtils(); + const isShared = !!sharingKey; - const decryptedMetadata = - await cryptoUtils.decryptMetadataWithSharingKey( - encrypted_metadata, - sharingKey, - ); + if (isShared && !encrypted_content) throw new Error("Content missing"); + const isDecryptionKeyAvailable = encrypted_dek && masterKey; + if (!(isShared || isDecryptionKeyAvailable)) + throw new Error("Auth required"); + + // Decrypt Metadata + const decryptedMetadata = isShared + ? await cryptoUtils.decryptMetadataWithSharingKey( + encrypted_metadata, + sharingKey, + ) + : await cryptoUtils.decryptMetadata( + { encrypted_content: encrypted_metadata, encrypted_dek }, + masterKey, + ); setMetadata(decryptedMetadata as LetterMetadata); - const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey( - encrypted_content, - sharingKey, - ); - const json = JSON.parse(decryptedContent) as CanvasJSON; + // Decrypt Content + const decryptedContent = isShared + ? await cryptoUtils.decryptLetterWithSharingKey( + encrypted_content, + sharingKey, + ) + : await cryptoUtils.decryptLetter( + { encrypted_content, encrypted_dek }, + masterKey, + ); - if (images && images.length > 0) { - await decryptCanvasImagesWithSharingKey( - json, - images, - sharingKey, - cryptoUtils, - ); + const canvasData: CanvasJSON = JSON.parse(decryptedContent); + + try { + // Decrypt Images + if (images?.length > 0) { + isShared + ? await decryptCanvasImagesWithSharingKey( + canvasData, + images, + sharingKey, + cryptoUtils, + ) + : await decryptCanvasImages( + canvasData, + images, + encrypted_dek, + masterKey, + cryptoUtils, + ); + } + } catch (err) { + setWarning({ + message: + "Failed to decrypt elements. Images might not render in the letter as intended.", + log: err, + }); } - setDecryptedCanvasData(json); + setDecryptedCanvasData(canvasData); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; setError(`Failed to load letter: ${message}`); @@ -74,7 +122,7 @@ export default function Reader() { }; loadAndDecrypt(); - }, [public_id, sharingKey]); + }, [public_id, sharingKey, masterKey]); useEffect(() => { if (!isDecrypting && decryptedCanvasData && canvasRef.current) { @@ -111,6 +159,14 @@ export default function Reader() { return (
+ {warning && ( +
+
+

{warning.message}

+

{warning.log}

+
+
+ )}