diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 03848de..7da418c 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -732,3 +732,49 @@ describe('get_vault_settings / update_vault_settings', () => { expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); + +// --- upload_attachment / download_attachment (γ₁ Task 6) --- + +describe('upload_attachment / download_attachment', () => { + it('upload_attachment accepted from popup', async () => { + const state = makeState(); + const result = await route( + { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, + state, + makePopupSender(), + ); + // The handler may return ok: false (vault_locked) since session is not primed, + // but the router MUST reach the handler — i.e. not return unauthorized_sender. + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('upload_attachment rejected from content script', async () => { + const state = makeState(); + const result = await route( + { type: 'upload_attachment', itemId: 'abc', filename: 'f.pdf', mimeType: 'application/pdf', bytes: new ArrayBuffer(10) }, + state, + makeContentSender(), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('download_attachment accepted from popup', async () => { + const state = makeState(); + const result = await route( + { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, + state, + makePopupSender(), + ); + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('download_attachment rejected from content script', async () => { + const state = makeState(); + const result = await route( + { type: 'download_attachment', itemId: 'abc', attachmentId: 'aid' }, + state, + makeContentSender(), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 58aa66c..01cfef2 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -4,7 +4,7 @@ /// via sender.url === popup.html (or setup.html for save_setup). import type { PopupMessage, Response } from '../../shared/messages'; -import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig } from '../../shared/types'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types'; import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; import type { GitHost } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host'; @@ -201,6 +201,91 @@ export async function handle( await saveBlacklist(bl.filter((h) => h !== msg.hostname)); return { ok: true }; } + + case 'upload_attachment': { + try { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + + // Look up the per-attachment size cap from vault settings (if any). + let maxBytes = BigInt(Number.MAX_SAFE_INTEGER); + try { + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + const cap = settings.attachment_caps?.per_attachment_max_bytes; + if (cap !== undefined) maxBytes = BigInt(cap); + } catch { + // If settings are unavailable, proceed uncapped. + } + + const plaintext = new Uint8Array(msg.bytes); + const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes); + // encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array) + const aid: string = encrypted.aid; + const encBytes: Uint8Array = encrypted.bytes; + + // Duplicate-id check: same content hash → same id → no-op re-upload. + let existingRef: AttachmentRef | undefined; + try { + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId); + existingRef = item.attachments.find((a) => a.id === aid); + } catch { + // Item not yet persisted or not found — will fail at addAttachmentToItem below. + } + if (existingRef) { + return { ok: true, data: { attachment: existingRef } }; + } + + // Store the encrypted blob. + const blobPath = `attachments/${aid}.bin`; + await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`); + + // Build the AttachmentRef and append it to the item + manifest. + const ref: AttachmentRef = { + id: aid, + filename: msg.filename, + mime_type: msg.mimeType, + size: plaintext.length, + created: Math.floor(Date.now() / 1000), + }; + await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref); + + return { ok: true, data: { attachment: ref } }; + } catch (e) { + console.error('[relicario] upload_attachment failed', e); + return { ok: false, error: 'upload_failed' }; + } + } + + case 'download_attachment': { + try { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + + // Fetch the item to recover the filename + mime type from the AttachmentRef. + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId); + const ref = item.attachments.find((a) => a.id === msg.attachmentId); + if (!ref) return { ok: false, error: 'attachment_not_found' }; + + const blobPath = `attachments/${msg.attachmentId}.bin`; + const encBytes = await state.gitHost.getBlob(blobPath); + const decrypted = state.wasm.attachment_decrypt(handle, encBytes); + + return { + ok: true, + data: { + bytes: decrypted.buffer.slice( + decrypted.byteOffset, + decrypted.byteOffset + decrypted.byteLength, + ) as ArrayBuffer, + filename: ref.filename, + mimeType: ref.mime_type, + }, + }; + } catch (e) { + console.error('[relicario] download_attachment failed', e); + return { ok: false, error: 'download_failed' }; + } + } } } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 2654bca..069b56d 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,6 +1,6 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, - DeviceSettings, GeneratorRequest, VaultSettings, + DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, } from './types'; // --- Messages a popup (or setup page) may send --- @@ -28,7 +28,9 @@ export type PopupMessage = | { type: 'get_vault_settings' } | { type: 'update_vault_settings'; settings: VaultSettings } | { type: 'get_blacklist' } - | { type: 'remove_blacklist'; hostname: string }; + | { type: 'remove_blacklist'; hostname: string } + | { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer } + | { type: 'download_attachment'; itemId: string; attachmentId: string }; // --- Messages a content script may send --- @@ -95,6 +97,14 @@ export interface VaultSettingsResponse extends Extract { data: { settings: VaultSettings }; } +export interface UploadAttachmentResponse extends Extract { + data: { attachment: AttachmentRef }; +} + +export interface DownloadAttachmentResponse extends Extract { + data: { bytes: ArrayBuffer; filename: string; mimeType: string }; +} + // --- Capability sets (consumed by the router) --- export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ @@ -104,7 +114,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'fill_credentials', 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_vault_settings', 'update_vault_settings', 'get_blacklist', - 'remove_blacklist', + 'remove_blacklist', 'upload_attachment', 'download_attachment', ] as PopupMessage['type'][]); export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([