import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { decryptJson, encryptBuffer, decryptBuffer } from './crypto.js' import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js' import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' import { syncLogbook } from './sync.js' import type { SyncQueueItem } from './db.js' export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const export const BACKUP_VERSION = 1 as const export interface LogbookBackupFile { format: typeof BACKUP_FORMAT version: typeof BACKUP_VERSION exportedAt: string logbook: { id: string encryptedTitle: string updatedAt: string isDemo?: boolean } logbookKey: { ciphertext: string iv: string tag: string } payloads: { yacht: { encryptedData: string iv: string tag: string updatedAt: string } | null deviation: { encryptedData: string iv: string tag: string updatedAt: string } | null crews: Array<{ payloadId: string encryptedData: string iv: string tag: string updatedAt: string }> entries: Array<{ payloadId: string encryptedData: string iv: string tag: string updatedAt: string }> photos: Array<{ payloadId: string entryId: string encryptedData: string iv: string tag: string updatedAt: string }> gpsTracks: Array<{ entryId: string encryptedData: string iv: string tag: string updatedAt: string }> } counts: { entries: number photos: number crews: number gpsTracks: number hasYacht: boolean hasDeviation: boolean } } export interface LogbookBackupPreview { title: string exportedAt: string sourceLogbookId: string counts: LogbookBackupFile['counts'] } async function deriveBackupPassphraseKey(passphrase: string): Promise { const encoder = new TextEncoder() const passphraseBytes = encoder.encode(passphrase.trim()) const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') const baseKey = await window.crypto.subtle.importKey( 'raw', passphraseBytes, { name: 'PBKDF2' }, false, ['deriveKey'] ) return window.crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 100_000, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ) } async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) { const key = await deriveBackupPassphraseKey(passphrase) return encryptBuffer(logbookKey, key) } async function unwrapLogbookKey( wrapped: LogbookBackupFile['logbookKey'], passphrase: string ): Promise { const key = await deriveBackupPassphraseKey(passphrase) return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) } function isBackupFile(value: unknown): value is LogbookBackupFile { if (!value || typeof value !== 'object') return false const obj = value as Partial return ( obj.format === BACKUP_FORMAT && obj.version === BACKUP_VERSION && typeof obj.exportedAt === 'string' && !!obj.logbook?.id && !!obj.logbook?.encryptedTitle && !!obj.logbookKey?.ciphertext && !!obj.payloads ) } function encryptedPayloadData( encryptedData: string, iv: string, tag: string, extra?: Record ): string { return JSON.stringify({ ciphertext: encryptedData, iv, tag, ...extra }) } async function collectLogbookPayloads(logbookId: string): Promise { const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([ db.yachts.get(logbookId), db.deviations.get(logbookId), db.crews.where({ logbookId }).toArray(), db.entries.where({ logbookId }).toArray(), db.photos.where({ logbookId }).toArray(), db.gpsTracks.where({ logbookId }).toArray() ]) return { yacht: yacht ? { encryptedData: yacht.encryptedData, iv: yacht.iv, tag: yacht.tag, updatedAt: yacht.updatedAt } : null, deviation: deviation ? { encryptedData: deviation.encryptedData, iv: deviation.iv, tag: deviation.tag, updatedAt: deviation.updatedAt } : null, crews: crews.map((c) => ({ payloadId: c.payloadId, encryptedData: c.encryptedData, iv: c.iv, tag: c.tag, updatedAt: c.updatedAt })), entries: entries.map((e) => ({ payloadId: e.payloadId, encryptedData: e.encryptedData, iv: e.iv, tag: e.tag, updatedAt: e.updatedAt })), photos: photos.map((p) => ({ payloadId: p.payloadId, entryId: p.entryId, encryptedData: p.encryptedData, iv: p.iv, tag: p.tag, updatedAt: p.updatedAt })), gpsTracks: gpsTracks.map((t) => ({ entryId: t.entryId, encryptedData: t.encryptedData, iv: t.iv, tag: t.tag, updatedAt: t.updatedAt })) } } function remapBackup( backup: LogbookBackupFile, newLogbookId: string ): LogbookBackupFile { return { ...backup, logbook: { ...backup.logbook, id: newLogbookId }, payloads: { ...backup.payloads, yacht: backup.payloads.yacht ? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt } : null, deviation: backup.payloads.deviation ? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt } : null, crews: backup.payloads.crews.map((c) => ({ ...c })), entries: backup.payloads.entries.map((e) => ({ ...e })), photos: backup.payloads.photos.map((p) => ({ ...p })), gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t })) } } } async function queueRestoredLogbookForSync( logbookId: string, encryptedTitle: string, logbookKey: ArrayBuffer, payloads: LogbookBackupFile['payloads'] ): Promise { const masterKey = getActiveMasterKey() if (!masterKey) throw new Error('Master key not found') const aesMasterKey = await window.crypto.subtle.importKey( 'raw', masterKey, { name: 'AES-GCM' }, false, ['encrypt'] ) const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey) const now = new Date().toISOString() const items: Omit[] = [ { action: 'create', type: 'logbook', payloadId: logbookId, logbookId, data: JSON.stringify({ encryptedTitle, encryptedKey: encryptedKey.ciphertext, iv: encryptedKey.iv, tag: encryptedKey.tag }), updatedAt: now } ] if (payloads.yacht) { items.push({ action: 'update', type: 'yacht', payloadId: logbookId, logbookId, data: encryptedPayloadData( payloads.yacht.encryptedData, payloads.yacht.iv, payloads.yacht.tag ), updatedAt: payloads.yacht.updatedAt }) } if (payloads.deviation) { items.push({ action: 'update', type: 'deviation', payloadId: logbookId, logbookId, data: encryptedPayloadData( payloads.deviation.encryptedData, payloads.deviation.iv, payloads.deviation.tag ), updatedAt: payloads.deviation.updatedAt }) } for (const crew of payloads.crews) { items.push({ action: 'create', type: 'crew', payloadId: crew.payloadId, logbookId, data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), updatedAt: crew.updatedAt }) } for (const entry of payloads.entries) { items.push({ action: 'create', type: 'entry', payloadId: entry.payloadId, logbookId, data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), updatedAt: entry.updatedAt }) } for (const photo of payloads.photos) { items.push({ action: 'create', type: 'photo', payloadId: photo.payloadId, logbookId, data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { entryId: photo.entryId }), updatedAt: photo.updatedAt }) } for (const track of payloads.gpsTracks) { items.push({ action: 'create', type: 'gpsTrack', payloadId: track.entryId, logbookId, data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), updatedAt: track.updatedAt }) } await db.syncQueue.bulkPut(items) } async function writeBackupToDexie( logbookId: string, backup: LogbookBackupFile, logbookKey: ArrayBuffer ): Promise { const { logbook, payloads } = backup await db.logbooks.put({ id: logbookId, encryptedTitle: logbook.encryptedTitle, updatedAt: logbook.updatedAt, isSynced: 0, isShared: 0, isDemo: logbook.isDemo ? 1 : 0 }) await saveLogbookKey(logbookId, logbookKey) if (payloads.yacht) { await db.yachts.put({ logbookId, encryptedData: payloads.yacht.encryptedData, iv: payloads.yacht.iv, tag: payloads.yacht.tag, updatedAt: payloads.yacht.updatedAt }) } if (payloads.deviation) { await db.deviations.put({ logbookId, encryptedData: payloads.deviation.encryptedData, iv: payloads.deviation.iv, tag: payloads.deviation.tag, updatedAt: payloads.deviation.updatedAt }) } if (payloads.crews.length > 0) { await db.crews.bulkPut( payloads.crews.map((c) => ({ payloadId: c.payloadId, logbookId, encryptedData: c.encryptedData, iv: c.iv, tag: c.tag, updatedAt: c.updatedAt })) ) } if (payloads.entries.length > 0) { await db.entries.bulkPut( payloads.entries.map((e) => ({ payloadId: e.payloadId, logbookId, encryptedData: e.encryptedData, iv: e.iv, tag: e.tag, updatedAt: e.updatedAt })) ) } if (payloads.photos.length > 0) { await db.photos.bulkPut( payloads.photos.map((p) => ({ payloadId: p.payloadId, entryId: p.entryId, logbookId, encryptedData: p.encryptedData, iv: p.iv, tag: p.tag, caption: '', updatedAt: p.updatedAt })) ) } if (payloads.gpsTracks.length > 0) { await db.gpsTracks.bulkPut( payloads.gpsTracks.map((t) => ({ entryId: t.entryId, logbookId, encryptedData: t.encryptedData, iv: t.iv, tag: t.tag, updatedAt: t.updatedAt })) ) } } export async function exportLogbookBackup( logbookId: string, passphrase: string ): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { if (!passphrase.trim() || passphrase.length < 8) { throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') } const logbook = await db.logbooks.get(logbookId) if (!logbook || logbook.isShared === 1) { throw new Error('BACKUP_NOT_OWNER') } if (navigator.onLine) { await syncLogbook(logbookId).catch((err) => { console.warn('Pre-backup sync failed, exporting local data:', err) }) } const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) const payloads = await collectLogbookPayloads(logbookId) const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) const backup: LogbookBackupFile = { format: BACKUP_FORMAT, version: BACKUP_VERSION, exportedAt: new Date().toISOString(), logbook: { id: logbook.id, encryptedTitle: logbook.encryptedTitle, updatedAt: logbook.updatedAt, isDemo: logbook.isDemo === 1 }, logbookKey: wrappedKey, payloads, counts: { entries: payloads.entries.length, photos: payloads.photos.length, crews: payloads.crews.length, gpsTracks: payloads.gpsTracks.length, hasYacht: !!payloads.yacht, hasDeviation: !!payloads.deviation } } const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle) const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const datePart = new Date().toISOString().slice(0, 10) const filename = `${safeTitle}-${datePart}.daagbok.json` const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }) return { blob, filename, backup } } export async function parseLogbookBackupFile(file: File): Promise { const text = await file.text() let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('BACKUP_INVALID_JSON') } if (!isBackupFile(parsed)) { throw new Error('BACKUP_INVALID_FORMAT') } return parsed } export async function previewLogbookBackup( backup: LogbookBackupFile, passphrase: string ): Promise { const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const parsed = JSON.parse(backup.logbook.encryptedTitle) const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) return { title, exportedAt: backup.exportedAt, sourceLogbookId: backup.logbook.id, counts: backup.counts } } export interface RestoreLogbookOptions { overwrite?: boolean assignNewId?: boolean } export async function restoreLogbookBackup( backup: LogbookBackupFile, passphrase: string, options: RestoreLogbookOptions = {} ): Promise<{ logbookId: string; title: string }> { if (!getActiveMasterKey()) { throw new Error('BACKUP_NOT_AUTHENTICATED') } const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const parsedTitle = JSON.parse(backup.logbook.encryptedTitle) const title = await decryptJson( parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey ) let targetId = backup.logbook.id const existing = await db.logbooks.get(targetId) if (existing && !options.overwrite && !options.assignNewId) { throw new Error('BACKUP_ID_CONFLICT') } if (existing && options.overwrite) { await deleteLocalLogbookCache(targetId) } if (options.assignNewId || (existing && !options.overwrite)) { targetId = crypto.randomUUID() } const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId) await writeBackupToDexie(targetId, prepared, logbookKey) await queueRestoredLogbookForSync( targetId, prepared.logbook.encryptedTitle, logbookKey, prepared.payloads ) if (navigator.onLine) { await syncLogbook(targetId).catch((err) => { console.warn('Post-restore sync failed, data saved locally:', err) }) } return { logbookId: targetId, title } } export function downloadBackupBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) }