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:
adlee-was-taken
2026-04-25 16:04:06 -04:00
parent 559c881dca
commit 5217d04034
3 changed files with 145 additions and 4 deletions

View File

@@ -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' });
});
});

View File

@@ -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' };
}
}
}
}

View File

@@ -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<Response, { ok: true }> {
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) ---
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',
'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<ContentMessage['type']> = new Set([