diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 1f1e63a..ad6958a 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -383,8 +383,40 @@ export async function handle( return { ok: true }; } - case 'export_backup': - return { ok: false, error: 'export_backup not yet implemented' }; + case 'export_backup': { + if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + try { + const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest); + + let reference_jpg: string | null = null; + if (msg.includeImage) { + const stored = await chrome.storage.local.get('imageBase64'); + const b64 = stored.imageBase64 as string | undefined; + if (!b64) return { ok: false, error: 'no reference image stored locally' }; + reference_jpg = b64; + } + + const inputJson = JSON.stringify({ + salt: blob.salt_b64, + params_json: blob.params_json, + devices_json: blob.devices_json, + manifest_enc: blob.manifest_enc_b64, + settings_enc: blob.settings_enc_b64, + items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })), + attachments: blob.attachments.map(a => ({ + item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64 + })), + reference_jpg, + git_archive: null, // Extension never bundles git history. + }); + + const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase); + return { ok: true, data: { bytes: bytes.buffer } }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } case 'restore_backup': return { ok: false, error: 'restore_backup not yet implemented' }; diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 2b8c3b2..a252554 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -3,6 +3,7 @@ import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; +import { uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,6 +40,85 @@ export async function fetchAndDecryptManifest( return w.manifest_decrypt(handle, ciphertext) as Manifest; } +/** + * Read every byte the .relbak envelope needs from the remote vault repo. + * Returns base64 strings for binary blobs (matching the WASM JSON shape). + * + * Translates the extension's flat `attachments/.bin` layout to the + * canonical `/` envelope-key form by walking the decrypted + * manifest. Attachments referenced by the manifest but missing on-disk + * are skipped with a console warning (the user already lost them; the + * backup just records what's there). + */ +export async function fetchVaultStateForBackup( + gitHost: GitHost, + manifest: Manifest, +): Promise<{ + salt_b64: string; + params_json: string; + devices_json: string; + manifest_enc_b64: string; + settings_enc_b64: string; + items: Array<{ id: string; ciphertext_b64: string }>; + attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }>; +}> { + const meta = await fetchVaultMeta(gitHost); + const devicesBytes = await gitHost.readFile('.relicario/devices.json'); + const devicesText = new TextDecoder().decode(devicesBytes); + const manifestEnc = await gitHost.readFile('manifest.enc'); + const settingsEnc = await gitHost.readFile('settings.enc'); + + // Items: items/.enc, flat directory. + const itemNames = await gitHost.listDir('items'); + const items = await Promise.all(itemNames + .filter((name) => name.endsWith('.enc')) + .map(async (name) => { + const id = name.replace(/\.enc$/, ''); + const ct = await gitHost.readFile(`items/${name}`); + return { id, ciphertext_b64: uint8ArrayToBase64(ct) }; + })); + + // Attachments live at `attachments/.bin`. Map aid -> item_id via the + // manifest's attachment_summaries. + const aidToItem: Record = {}; + for (const [itemId, entry] of Object.entries(manifest.items)) { + for (const summary of entry.attachment_summaries ?? []) { + aidToItem[summary.id] = itemId; + } + } + + let attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }> = []; + try { + const blobNames = await gitHost.listDir('attachments'); + for (const name of blobNames.filter((n) => n.endsWith('.bin'))) { + const aid = name.replace(/\.bin$/, ''); + const item_id = aidToItem[aid]; + if (!item_id) { + console.warn('[relicario] backup: attachment', aid, 'is orphan (no manifest entry); skipping'); + continue; + } + const ct = await gitHost.getBlob(`attachments/${name}`); + attachments.push({ + item_id, + attachment_id: aid, + ciphertext_b64: uint8ArrayToBase64(ct), + }); + } + } catch { + // attachments/ may not exist yet — fine. + } + + return { + salt_b64: uint8ArrayToBase64(meta.salt), + params_json: meta.paramsJson, + devices_json: devicesText, + manifest_enc_b64: uint8ArrayToBase64(manifestEnc), + settings_enc_b64: uint8ArrayToBase64(settingsEnc), + items, + attachments, + }; +} + export async function encryptAndWriteManifest( git: GitHost, handle: SessionHandle,