diff --git a/src/components/EncryptionProgress.tsx b/src/components/EncryptionProgress.tsx new file mode 100644 index 0000000..52fc0af --- /dev/null +++ b/src/components/EncryptionProgress.tsx @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Trans } from "@lingui/macro"; +import { Text } from "./text"; + +export interface EncryptionProgressProps { + /** + * Map of filename to encryption progress (0-100) + */ + progress: Map; + + /** + * Whether encryption is currently in progress + */ + isEncrypting: boolean; +} + +/** + * Displays encryption progress for multiple files + * + * Shows individual file progress bars with percentage indicators + * + * @example + * ```tsx + * const progress = new Map([['file1.pdf', 45], ['file2.jpg', 78]]); + * + * ``` + */ +export function EncryptionProgress({ + progress, + isEncrypting, +}: EncryptionProgressProps) { + if (!isEncrypting && progress.size === 0) { + return null; + } + + const files = Array.from(progress.entries()); + + return ( +
+ + Encrypting files... + + + {files.length > 0 && ( +
+ {files.map(([filename, percentage]) => ( +
+
+ + {filename} + + + {percentage}% + +
+
+
+
+
+ ))} +
+ )} + + {files.length === 0 && ( +
+
+
+ )} +
+ ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 70ec433..9225224 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -80,14 +80,21 @@ export interface FileMetadata { */ export interface FileQueueEntry { id: string; // UUID - file: Blob; // Actual file data + file: Blob; // Actual file data (encrypted) metadata: FileMetadata; - uploadState: "pending" | "uploading" | "failed" | "completed"; + uploadState: + | "pending" + | "encrypting" + | "encrypted" + | "uploading" + | "failed" + | "completed"; secretId?: string; // Target Secret (if known) retryCount: number; error?: string; createdAt: Date; lastAttemptAt?: Date; + checksum?: string; // SHA-256 checksum of encrypted file } /** diff --git a/src/pages/ShareTarget.test.tsx b/src/pages/ShareTarget.test.tsx index b3a9d87..2808968 100644 --- a/src/pages/ShareTarget.test.tsx +++ b/src/pages/ShareTarget.test.tsx @@ -3,18 +3,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Mock } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; import { ShareTarget } from "./ShareTarget"; import { handleShareTargetMessage } from "./ShareTarget.utils"; +import { db } from "../lib/db"; // Mock secretApi to prevent real API calls vi.mock("../services/secretApi", () => ({ fetchSecrets: vi.fn().mockResolvedValue([]), + getSecretMasterKey: vi.fn(), })); +import { fetchSecrets, getSecretMasterKey } from "../services/secretApi"; + describe("ShareTarget Component", () => { // Helper function to set window.location with search params const setLocationSearch = (search: string) => { @@ -834,3 +838,391 @@ describe("handleShareTargetMessage (unit tests)", () => { expect(setErrorsSpy).not.toHaveBeenCalled(); }); }); + +/** + * Phase 2: File Encryption Integration Tests + * Testing encryption before IndexedDB storage + */ +describe("ShareTarget - File Encryption Integration (Phase 2)", () => { + // Helper function to render with proper context + const renderComponentWithContext = () => { + return render( + + + + + + ); + }; + + beforeEach(async () => { + // Setup i18n + i18n.load("en", {}); + i18n.activate("en"); + + // Clear sessionStorage + sessionStorage.clear(); + + // Reset window.location + Object.defineProperty(window, "location", { + value: { + pathname: "/share", + search: "", + hash: "", + href: "http://localhost:5173/share", + }, + writable: true, + configurable: true, + }); + + // Clear IndexedDB + await db.fileQueue.clear(); + + // Mock fetchSecrets to return test secrets + vi.mocked(fetchSecrets).mockResolvedValue([ + { + id: "secret-123", + title: "Test Secret", + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }, + ]); + + // Mock getSecretMasterKey to return a valid CryptoKey + const mockKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + vi.mocked(getSecretMasterKey).mockResolvedValue(mockKey); + }); + + it("should encrypt files before adding to IndexedDB queue", async () => { + // Setup: Create test file + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + // Render component + renderComponentWithContext(); + + // Wait for secrets to load + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + // Select secret + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + // Click upload button + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Wait for encryption to complete + await waitFor( + () => { + const queueEntries = db.fileQueue.toArray(); + return queueEntries.then((entries) => { + expect(entries.length).toBeGreaterThan(0); + const entry = entries[0]; + expect(entry?.uploadState).toBe("encrypted"); + }); + }, + { timeout: 5000 } + ); + }); + + it("should show encryption progress indicator", async () => { + // Setup: Create test file + const testFile = { + name: "large-file.pdf", + type: "application/pdf", + size: 5 * 1024 * 1024, // 5MB + dataUrl: "data:application/pdf;base64," + "A".repeat(1000), // Larger file + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + // Render component + renderComponentWithContext(); + + // Wait for secrets to load + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + // Select secret + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + // Click upload button + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Check for encryption progress indicator + await waitFor(() => { + expect(screen.getByText(/Encrypting files/i)).toBeInTheDocument(); + }); + }); + + it("should handle encryption errors gracefully", async () => { + // Mock getSecretMasterKey to throw error + vi.mocked(getSecretMasterKey).mockRejectedValue( + new Error("Failed to get master key") + ); + + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Wait for error message + await waitFor(() => { + expect(screen.getByText(/Failed to get master key/i)).toBeInTheDocument(); + }); + }); + + it("should not expose encryption keys in console/errors", async () => { + const consoleSpy = vi.spyOn(console, "error"); + + // Mock error during encryption + vi.mocked(getSecretMasterKey).mockRejectedValue( + new Error("Encryption failed") + ); + + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + await waitFor(() => { + expect(screen.getByText(/Encryption failed/i)).toBeInTheDocument(); + }); + + // Check that console.error was called but doesn't contain sensitive data + const errorCalls = consoleSpy.mock.calls; + errorCalls.forEach((call) => { + const message = String(call[0]); + // Ensure no CryptoKey objects or key material in logs + expect(message).not.toMatch(/CryptoKey/i); + expect(message).not.toMatch(/[A-Za-z0-9+/]{43,44}={0,2}/); // No Base64 256-bit keys + }); + + consoleSpy.mockRestore(); + }); + + it("should update uploadState to 'encrypted' after encryption", async () => { + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Wait for encryption state update + await waitFor( + async () => { + const entries = await db.fileQueue.toArray(); + const entry = entries.find((e) => e.metadata.name === "test.pdf"); + expect(entry?.uploadState).toBe("encrypted"); + }, + { timeout: 5000 } + ); + }); + + it("should store encrypted blob in IndexedDB", async () => { + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Wait for file to be added to IndexedDB (regardless of final state) + await waitFor( + async () => { + const entries = await db.fileQueue.toArray(); + expect(entries.length).toBeGreaterThan(0); + + const entry = entries.find((e) => e.metadata.name === "test.pdf"); + expect(entry).toBeDefined(); + // Verify that entry has been processed (either encrypted or completed) + expect(["encrypted", "completed", "uploading"]).toContain( + entry?.uploadState + ); + }, + { timeout: 5000 } + ); + }); + + it("should calculate checksums correctly", async () => { + const testFile = { + name: "test.pdf", + type: "application/pdf", + size: 1024, + dataUrl: "data:application/pdf;base64,JVBERi0xLjQK", + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([testFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + const secretSelect = screen.getByLabelText(/Select Secret/i); + fireEvent.change(secretSelect, { target: { value: "secret-123" } }); + + const uploadButton = screen.getByRole("button", { + name: /Save to Secret/i, + }); + fireEvent.click(uploadButton); + + // Wait for checksum to be calculated + await waitFor( + async () => { + const entries = await db.fileQueue.toArray(); + const entry = entries.find((e) => e.metadata.name === "test.pdf"); + expect(entry?.checksum).toBeDefined(); + expect(entry?.checksum).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex (64 chars) + }, + { timeout: 5000 } + ); + }); + + it("should reject files larger than 10MB", async () => { + // Mock successful key fetch + const mockKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + vi.mocked(getSecretMasterKey).mockResolvedValue(mockKey); + + vi.mocked(fetchSecrets).mockResolvedValue([ + { + id: "secret-123", + title: "Test Secret", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]); + + // Create a file larger than 10MB (11MB = 11 * 1024 * 1024 bytes) + const largeFileSize = 11 * 1024 * 1024; + const largeFile = { + name: "large-file.jpg", + type: "image/jpeg", + size: largeFileSize, // 11MB + dataUrl: "data:image/jpeg;base64,/9j/4AAQ", // Minimal JPEG header + }; + + sessionStorage.setItem("share-target-files", JSON.stringify([largeFile])); + + renderComponentWithContext(); + + await waitFor(() => { + expect(fetchSecrets).toHaveBeenCalled(); + }); + + // Should show error about file size + await waitFor(() => { + expect( + screen.getByText(/File too large.*large-file\.jpg.*Maximum 10MB/i) + ).toBeInTheDocument(); + }); + }); + + it("should handle file parsing errors gracefully", () => { + // Set invalid JSON in sessionStorage + sessionStorage.setItem("share-target-files", "{invalid json"); + + vi.mocked(fetchSecrets).mockResolvedValue([]); + + renderComponentWithContext(); + + // Should show error message + expect( + screen.getByText(/Failed to load shared files/i) + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/ShareTarget.tsx b/src/pages/ShareTarget.tsx index 321667c..7d0b459 100644 --- a/src/pages/ShareTarget.tsx +++ b/src/pages/ShareTarget.tsx @@ -7,9 +7,17 @@ import { Heading } from "../components/heading"; import { Text } from "../components/text"; import { Button } from "../components/button"; import { Badge } from "../components/badge"; +import { EncryptionProgress } from "../components/EncryptionProgress"; import { handleShareTargetMessage } from "./ShareTarget.utils"; -import { fetchSecrets, type Secret } from "../services/secretApi"; +import { + fetchSecrets, + getSecretMasterKey, + type Secret, +} from "../services/secretApi"; import { addFileToQueue, processFileQueue } from "../lib/fileQueue"; +import { encryptFile, deriveFileKey } from "../lib/crypto/encryption"; +import { calculateChecksum } from "../lib/crypto/checksum"; +import { db } from "../lib/db"; interface SharedFile { name: string; @@ -223,6 +231,12 @@ export function ShareTarget() { const [secretsLoadError, setSecretsLoadError] = useState(null); const clearTimeoutRef = useRef(null); + // Encryption state + const [encryptionProgress, setEncryptionProgress] = useState< + Map + >(new Map()); + const [isEncrypting, setIsEncrypting] = useState(false); + // Load secrets on mount useEffect(() => { const loadSecrets = async () => { @@ -376,38 +390,98 @@ export function ShareTarget() { if (!selectedSecretId || !sharedData?.files) return; setUploading(true); + setIsEncrypting(true); setUploadError(null); setUploadSuccess(false); + setEncryptionProgress(new Map()); try { - // Queue all files for upload + // Get master key for the secret + const masterKey = await getSecretMasterKey(selectedSecretId); + + // Encrypt and queue all files for (const file of sharedData.files) { - // Validate dataUrl - if (!file.dataUrl) { - throw new Error(`File ${file.name} has no data URL`); - } + try { + // Validate dataUrl + if (!file.dataUrl) { + throw new Error(`File ${file.name} has no data URL`); + } + + // Update encryption progress (0%) + setEncryptionProgress((prev) => new Map(prev).set(file.name, 0)); - // Fetch file data from dataUrl - const response = await fetch(file.dataUrl); - if (!response.ok) { - throw new Error(`Failed to fetch file data for ${file.name}`); + // Fetch file data from dataUrl + const response = await fetch(file.dataUrl); + if (!response.ok) { + throw new Error(`Failed to fetch file data for ${file.name}`); + } + const blob = await response.blob(); + const plaintext = new Uint8Array(await blob.arrayBuffer()); + + // Update encryption progress (25%) + setEncryptionProgress((prev) => new Map(prev).set(file.name, 25)); + + // Derive file-specific key + const fileKey = await deriveFileKey(masterKey, file.name); + + // Update encryption progress (50%) + setEncryptionProgress((prev) => new Map(prev).set(file.name, 50)); + + // Encrypt file + const encryptedFile = await encryptFile(plaintext, fileKey); + + // Update encryption progress (75%) + setEncryptionProgress((prev) => new Map(prev).set(file.name, 75)); + + // Combine IV + authTag + ciphertext into single blob + // Ensure type compatibility for Blob constructor + const ivBuffer = new Uint8Array(encryptedFile.iv); + const authTagBuffer = new Uint8Array(encryptedFile.authTag); + const ciphertextBuffer = new Uint8Array(encryptedFile.ciphertext); + + const encryptedBlob = new Blob([ + ivBuffer, + authTagBuffer, + ciphertextBuffer, + ]); + + // Calculate checksum of encrypted data + const encryptedArrayBuffer = await encryptedBlob.arrayBuffer(); + const encryptedData = new Uint8Array(encryptedArrayBuffer); + const checksum = await calculateChecksum(encryptedData); + + // Update encryption progress (100%) + setEncryptionProgress((prev) => new Map(prev).set(file.name, 100)); + + // Add encrypted file to queue + const queueId = await addFileToQueue( + encryptedBlob, + { + name: file.name, + type: file.type, + size: file.size, // Original plaintext size + timestamp: Date.now(), + }, + selectedSecretId + ); + + // Update queue entry with encryption state and checksum + await db.fileQueue.update(queueId, { + uploadState: "encrypted", + checksum, + }); + } catch (error) { + console.error(`Encryption failed for ${file.name}:`, error); + throw new Error( + `Encryption failed for ${file.name}: ${error instanceof Error ? error.message : "Unknown error"}` + ); } - const blob = await response.blob(); - - // Add to queue (use actual blob size for consistency) - await addFileToQueue( - blob, - { - name: file.name, - type: file.type, - size: blob.size, - timestamp: Date.now(), - }, - selectedSecretId - ); } - // Process the queue + // All files encrypted successfully + setIsEncrypting(false); + + // Process the queue (upload encrypted files) const stats = await processFileQueue(); if (stats.failed > 0) { @@ -430,8 +504,11 @@ export function ShareTarget() { } } catch (error) { console.error("Upload failed:", error); + setIsEncrypting(false); setUploadError( - error instanceof Error ? error.message : "Failed to upload files" + error instanceof Error + ? error.message + : "Failed to encrypt/upload files" ); } finally { setUploading(false); @@ -586,7 +663,14 @@ export function ShareTarget() {
- {uploading && ( + {isEncrypting && ( + + )} + + {uploading && !isEncrypting && (
( ); describe("ShareTarget - Upload Functionality", () => { - beforeEach(() => { + beforeEach(async () => { // Reset mocks vi.clearAllMocks(); @@ -40,6 +40,14 @@ describe("ShareTarget - Upload Functionality", () => { }, ]); + // Mock getSecretMasterKey to return a valid CryptoKey + const mockKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + vi.mocked(secretApi.getSecretMasterKey).mockResolvedValue(mockKey); + // Mock addFileToQueue vi.mocked(fileQueue.addFileToQueue).mockResolvedValue("file-123"); @@ -130,24 +138,25 @@ describe("ShareTarget - Upload Functionality", () => { }); await user.click(uploadBtn); - // Verify files were queued - await waitFor(() => { - expect(fileQueue.addFileToQueue).toHaveBeenCalledWith( - expect.objectContaining({ - size: 3, - type: "image/jpeg", - }), - expect.objectContaining({ - name: "test.jpg", - type: "image/jpeg", - size: 3, // Now uses blob.size, not original file.size - }), - "secret-1" - ); - }); + // Wait for encryption to complete and upload to be processed + await waitFor( + () => { + expect(fileQueue.addFileToQueue).toHaveBeenCalled(); + expect(fileQueue.processFileQueue).toHaveBeenCalled(); + }, + { timeout: 5000 } + ); - // Verify upload was processed - expect(fileQueue.processFileQueue).toHaveBeenCalled(); + // Verify the call was made with encrypted blob + expect(fileQueue.addFileToQueue).toHaveBeenCalledWith( + expect.any(Blob), // Encrypted blob + expect.objectContaining({ + name: "test.jpg", + type: "image/jpeg", + size: 1024, // Original plaintext size + }), + "secret-1" + ); }); it("should show upload progress during upload", async () => { diff --git a/src/services/secretApi.test.ts b/src/services/secretApi.test.ts index 72a87a6..62e6ef9 100644 --- a/src/services/secretApi.test.ts +++ b/src/services/secretApi.test.ts @@ -7,6 +7,7 @@ import { uploadAttachment, listAttachments, deleteAttachment, + getSecretMasterKey, ApiError, type Secret, type SecretAttachment, @@ -253,4 +254,170 @@ describe("Secret API", () => { ); }); }); + + describe("getSecretMasterKey", () => { + it("should fetch and import master key successfully", async () => { + // Generate a real 256-bit key and export it to Base64 + const testKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + const exportedKey = await crypto.subtle.exportKey("raw", testKey); + const keyBytes = new Uint8Array(exportedKey); + + // Convert to Base64 + const base64Key = btoa(String.fromCharCode(...keyBytes)); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + id: "secret-123", + title: "Test Secret", + master_key: base64Key, + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + }), + }); + + const masterKey = await getSecretMasterKey("secret-123"); + + expect(masterKey).toBeInstanceOf(CryptoKey); + expect(masterKey.type).toBe("secret"); + expect(masterKey.algorithm.name).toBe("AES-GCM"); + expect((masterKey.algorithm as AesKeyAlgorithm).length).toBe(256); + expect(masterKey.usages).toContain("encrypt"); + expect(masterKey.usages).toContain("decrypt"); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiConfig.baseUrl}/api/v1/secrets/secret-123`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }) + ); + }); + + it("should reject empty secretId", async () => { + await expect(getSecretMasterKey("")).rejects.toThrow( + "secretId is required" + ); + await expect(getSecretMasterKey(" ")).rejects.toThrow( + "secretId is required" + ); + }); + + it("should throw ApiError when API request fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: "Secret not found" }), + }); + + await expect(getSecretMasterKey("invalid-id")).rejects.toThrow(ApiError); + await expect(getSecretMasterKey("invalid-id")).rejects.toThrow( + "Secret not found" + ); + }); + + it("should handle API errors without json body", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => { + throw new Error("Invalid JSON"); + }, + }); + + await expect(getSecretMasterKey("secret-123")).rejects.toThrow(ApiError); + }); + + it("should throw error when master_key is missing", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + id: "secret-123", + title: "Test Secret", + // master_key missing + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + }), + }); + + await expect(getSecretMasterKey("secret-123")).rejects.toThrow( + "Secret has no master key" + ); + }); + + it("should correctly decode Base64 master key", async () => { + // Use a known 256-bit key (32 bytes of 0x42) + const testKeyBytes = new Uint8Array(32).fill(0x42); + const base64Key = btoa(String.fromCharCode(...testKeyBytes)); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + id: "secret-123", + title: "Test Secret", + master_key: base64Key, + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + }), + }); + + const masterKey = await getSecretMasterKey("secret-123"); + + // Verify key was imported correctly + expect(masterKey).toBeInstanceOf(CryptoKey); + expect(masterKey.type).toBe("secret"); + expect(masterKey.extractable).toBe(true); // Needed for deriveFileKey + + // Verify we can use the key for encryption + const testData = new Uint8Array([1, 2, 3, 4, 5]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + masterKey, + testData + ); + expect(encrypted).toBeInstanceOf(Object); + expect(encrypted.byteLength).toBeGreaterThan(0); + }); + + it("should create extractable CryptoKey (required for HKDF)", async () => { + const testKeyBytes = new Uint8Array(32).fill(0x01); + const base64Key = btoa(String.fromCharCode(...testKeyBytes)); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + id: "secret-123", + title: "Test Secret", + master_key: base64Key, + created_at: "2025-01-01", + updated_at: "2025-01-01", + }, + }), + }); + + const masterKey = await getSecretMasterKey("secret-123"); + + expect(masterKey.extractable).toBe(true); + + // Should be able to export it (needed for deriveFileKey) + const exported = await crypto.subtle.exportKey("raw", masterKey); + expect(exported).toBeInstanceOf(Object); + expect(exported.byteLength).toBe(32); // 256 bits + }); + }); }); diff --git a/src/services/secretApi.ts b/src/services/secretApi.ts index 09c1c79..08ffc8e 100644 --- a/src/services/secretApi.ts +++ b/src/services/secretApi.ts @@ -203,3 +203,63 @@ export async function deleteAttachment(attachmentId: string): Promise { throw new ApiError(error.message, response.status, error.errors); } } + +/** + * Get the master key for a secret + * + * @param secretId - Secret ID + * @returns CryptoKey for encryption/decryption + * @throws ApiError if request fails + * + * @example + * ```ts + * const masterKey = await getSecretMasterKey('secret-123'); + * // Use masterKey for file encryption + * ``` + */ +export async function getSecretMasterKey(secretId: string): Promise { + if (!secretId || secretId.trim() === "") { + throw new Error("secretId is required"); + } + + const response = await fetch( + `${apiConfig.baseUrl}/api/v1/secrets/${secretId}`, + { + method: "GET", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + const error: ApiErrorResponse = await response + .json() + .catch(() => ({ message: response.statusText })); + throw new ApiError(error.message, response.status, error.errors); + } + + const result: ApiResponse = + await response.json(); + + // The master_key is Base64-encoded + const masterKeyBase64 = result.data.master_key; + if (!masterKeyBase64) { + throw new Error("Secret has no master key"); + } + + // Decode Base64 to ArrayBuffer + const bytes = Uint8Array.from(atob(masterKeyBase64), (c) => c.charCodeAt(0)); + + // Import as CryptoKey (extractable needed for deriveFileKey) + const key = await crypto.subtle.importKey( + "raw", + bytes, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + return key; +} diff --git a/tests/setup.ts b/tests/setup.ts index 490ab53..e1cb019 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,3 +9,21 @@ import "fake-indexeddb/auto"; // Initialize i18n for tests i18n.load("en", enMessages); i18n.activate("en"); + +// Polyfill for Blob.arrayBuffer() in test environment (JSDOM doesn't have it) +if (typeof Blob.prototype.arrayBuffer === "undefined") { + Blob.prototype.arrayBuffer = function (): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + resolve(reader.result); + } else { + reject(new Error("Failed to read Blob as ArrayBuffer")); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(this); + }); + }; +}