feat(ext/sw): upload_attachment + download_attachment router handlers
Both popup-only. upload_attachment encrypts via WASM, putBlobs via GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef on the item + manifest summaries. Duplicate uploads (same content = same id from sha256) return the existing ref without a re-upload. download_attachment reads + decrypts and returns plaintext bytes for the popup to wrap in a Blob. 4 new router tests (accept × 2, reject × 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -732,3 +732,49 @@ describe('get_vault_settings / update_vault_settings', () => {
|
|||||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||||
|
|
||||||
import type { PopupMessage, Response } from '../../shared/messages';
|
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 { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||||
import type { GitHost } from '../git-host';
|
import type { GitHost } from '../git-host';
|
||||||
import { createGitHost, base64ToUint8Array } 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));
|
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||||||
return { ok: true };
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||||||
DeviceSettings, GeneratorRequest, VaultSettings,
|
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Messages a popup (or setup page) may send ---
|
// --- Messages a popup (or setup page) may send ---
|
||||||
@@ -28,7 +28,9 @@ export type PopupMessage =
|
|||||||
| { type: 'get_vault_settings' }
|
| { type: 'get_vault_settings' }
|
||||||
| { type: 'update_vault_settings'; settings: VaultSettings }
|
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||||
| { type: 'get_blacklist' }
|
| { 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 ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -95,6 +97,14 @@ export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
|||||||
data: { settings: VaultSettings };
|
data: { settings: VaultSettings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadAttachmentResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { attachment: AttachmentRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadAttachmentResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { bytes: ArrayBuffer; filename: string; mimeType: string };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Capability sets (consumed by the router) ---
|
// --- Capability sets (consumed by the router) ---
|
||||||
|
|
||||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||||
@@ -104,7 +114,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'fill_credentials',
|
'fill_credentials',
|
||||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||||
'remove_blacklist',
|
'remove_blacklist', 'upload_attachment', 'download_attachment',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user