feat: implement master key decryption for letters and add UI warning for image decryption failures

This commit is contained in:
ramvignesh-b
2026-04-15 17:50:38 +05:30
parent a72464bfbb
commit 198f9c32dd
4 changed files with 192 additions and 40 deletions
+111 -15
View File
@@ -1,6 +1,16 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import pino from "pino";
import { AuthHelper } from "./utils/auth"; import { AuthHelper } from "./utils/auth";
const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});
test.describe("Letter Drafting (Real Backend)", () => { test.describe("Letter Drafting (Real Backend)", () => {
const password = "Password123!"; const password = "Password123!";
@@ -11,10 +21,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
await AuthHelper.registerAndLogin(page, email, name, password); 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(); 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 // Wait for the recipient input to be present in the DOM
const recipientInput = page.locator("#recipient"); const recipientInput = page.locator("#recipient");
@@ -23,15 +33,19 @@ test.describe("Letter Drafting (Real Backend)", () => {
const recipientName = "Dear Friend"; const recipientName = "Dear Friend";
await recipientInput.fill(recipientName); await recipientInput.fill(recipientName);
// Type into the Fabric.js canvas // Initial load: verify textarea value (populated by Fabric when focused)
console.log(">> [Draft] Typing into canvas...");
const canvasInput = page.getByLabel("Canvas text input"); const canvasInput = page.getByLabel("Canvas text input");
await canvasInput.waitFor({ state: "visible" }); await canvasInput.waitFor({ state: "attached" });
await canvasInput.focus(); 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 // Draft a letter
console.log(">> [Draft] Clicking Store..."); 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(); await page.getByRole("button", { name: /store/i }).click();
// Verify Success Modal/Alert // Verify Success Modal/Alert
@@ -40,10 +54,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
// Verify URL updated with a UUID // Verify URL updated with a UUID
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/); await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
const savedUrl = page.url(); const savedUrl = page.url();
console.log(`>> [Draft] Saved URL: ${savedUrl}`); logger.info(`>> [Draft] Saved URL: ${savedUrl}`);
// Reload and verify persistence // Reload and verify persistence
console.log(">> [Draft] Reloading to verify persistence..."); logger.info(">> [Draft] Reloading to verify persistence...");
await page.goto(savedUrl); await page.goto(savedUrl);
// Wait for initial load overlay to disappear // Wait for initial load overlay to disappear
@@ -53,7 +67,13 @@ test.describe("Letter Drafting (Real Backend)", () => {
await expect(page.locator("#recipient")).toHaveValue(recipientName); await expect(page.locator("#recipient")).toHaveValue(recipientName);
// Check canvas content // 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 }) => { 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); 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(); await page.getByRole("button", { name: /write something/i }).click();
const recipientInput = page.locator("#recipient"); 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."); await canvasInput.fill("This letter will be sealed and shared.");
// Click Seal // Click Seal
console.log(">> [Seal] Clicking Seal..."); logger.info(">> [Seal] Clicking Seal...");
await page.getByRole("button", { name: /seal/i }).click(); await page.getByRole("button", { name: /seal/i }).click();
// Verify "Sealed & Ready" modal // Verify "Sealed & Ready" modal
console.log(">> [Seal] Verifying sharing modal..."); logger.info(">> [Seal] Verifying sharing modal...");
await expect(page.getByText(/sealed & ready/i)).toBeVisible(); await expect(page.getByText(/sealed & ready/i)).toBeVisible();
// Verify sharing link contains a hash (the key) // 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("/read/");
expect(linkValue).toContain("#"); expect(linkValue).toContain("#");
console.log(`>> [Seal] Sharing link generated: ${linkValue}`); logger.info(`>> [Seal] Sharing link generated: ${linkValue}`);
// Verify "Copy" button works // Verify "Copy" button works
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible(); 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 page.getByRole("button", { name: /close/i }).click();
await expect(page.getByText(/sealed & ready/i)).toBeHidden(); 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();
});
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export function DrawerSection({
className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${ className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
isOpen isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5" ? "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} {children}
+1 -1
View File
@@ -11,7 +11,7 @@ import { useLetters } from "../hooks/useLetters";
export default function Drawer() { export default function Drawer() {
const { user, logout, unlock } = useAuth(); const { user, logout, unlock } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(); const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
+79 -23
View File
@@ -8,8 +8,12 @@ import {
ComposeCanvas, ComposeCanvas,
} from "../components/ui/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; import {
decryptCanvasImages,
decryptCanvasImagesWithSharingKey,
} from "../utils/letterLogic";
interface LetterMetadata { interface LetterMetadata {
recipient?: string; recipient?: string;
@@ -24,13 +28,21 @@ export default function Reader() {
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<{
message: string;
log: string;
} | null>(null);
const [metadata, setMetadata] = useState<LetterMetadata | null>(null); const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] = const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null); useState<CanvasJSON | null>(null);
const { masterKey } = useKeyStore();
useEffect(() => { useEffect(() => {
if (!sharingKey) { if (!(sharingKey || masterKey)) {
setError("No sharing key provided. Please check the link."); setError(
"No sharing key provided. Please check the link or log in if you are the author.",
);
setIsDecrypting(false); setIsDecrypting(false);
return; return;
} }
@@ -38,33 +50,69 @@ export default function Reader() {
const loadAndDecrypt = async () => { const loadAndDecrypt = async () => {
try { try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`); 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 cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey;
const decryptedMetadata = if (isShared && !encrypted_content) throw new Error("Content missing");
await cryptoUtils.decryptMetadataWithSharingKey( const isDecryptionKeyAvailable = encrypted_dek && masterKey;
encrypted_metadata, if (!(isShared || isDecryptionKeyAvailable))
sharingKey, 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); setMetadata(decryptedMetadata as LetterMetadata);
const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey( // Decrypt Content
encrypted_content, const decryptedContent = isShared
sharingKey, ? await cryptoUtils.decryptLetterWithSharingKey(
); encrypted_content,
const json = JSON.parse(decryptedContent) as CanvasJSON; sharingKey,
)
: await cryptoUtils.decryptLetter(
{ encrypted_content, encrypted_dek },
masterKey,
);
if (images && images.length > 0) { const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptCanvasImagesWithSharingKey(
json, try {
images, // Decrypt Images
sharingKey, if (images?.length > 0) {
cryptoUtils, 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) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"; const message = err instanceof Error ? err.message : "Unknown error";
setError(`Failed to load letter: ${message}`); setError(`Failed to load letter: ${message}`);
@@ -74,7 +122,7 @@ export default function Reader() {
}; };
loadAndDecrypt(); loadAndDecrypt();
}, [public_id, sharingKey]); }, [public_id, sharingKey, masterKey]);
useEffect(() => { useEffect(() => {
if (!isDecrypting && decryptedCanvasData && canvasRef.current) { if (!isDecrypting && decryptedCanvasData && canvasRef.current) {
@@ -111,6 +159,14 @@ export default function Reader() {
return ( return (
<section className="min-h-screen w-full bg-base-200 px-4 py-8"> <section className="min-h-screen w-full bg-base-200 px-4 py-8">
{warning && (
<div className="alert alert-warning">
<div className="flex-1">
<p>{warning.message}</p>
<p className="text-xs opacity-70">{warning.log}</p>
</div>
</div>
)}
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>