feat(collab): E2E-compliant crew invitations and link-sharing collaboration
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
base64ToBuffer,
|
||||
bufferToBase64
|
||||
} from './crypto.js'
|
||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
@@ -261,6 +262,7 @@ export async function completeLoginWithRecovery(
|
||||
|
||||
export function logoutUser() {
|
||||
setActiveMasterKey(null)
|
||||
clearLogbookKeysCache()
|
||||
localStorage.removeItem('active_username')
|
||||
localStorage.removeItem('active_userid')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
@@ -12,9 +13,9 @@ function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
}
|
||||
|
||||
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
|
||||
@@ -61,6 +61,13 @@ export interface LocalGpsTrack {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookKey {
|
||||
logbookId: string
|
||||
encryptedKey: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
@@ -79,6 +86,7 @@ class DaagboxDatabase extends Dexie {
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
constructor() {
|
||||
@@ -101,6 +109,17 @@ class DaagboxDatabase extends Dexie {
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt'
|
||||
})
|
||||
this.version(3).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
|
||||
@@ -20,14 +21,15 @@ export interface SavedGpsTrack {
|
||||
|
||||
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
|
||||
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. Please log in.')
|
||||
}
|
||||
|
||||
const record = await db.gpsTracks.get(entryId)
|
||||
if (!record) return null
|
||||
|
||||
const logbookId = record.logbookId
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (Array.isArray(decrypted)) {
|
||||
@@ -55,8 +57,8 @@ export async function saveUploadedGpsTrack(
|
||||
filename: string,
|
||||
fileType: string
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found. Please log in.')
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const trackData: SavedGpsTrack = {
|
||||
waypoints,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db, type LocalLogbook } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||
|
||||
const API_BASE = '/api/logbooks'
|
||||
|
||||
@@ -11,8 +12,8 @@ export interface DecryptedLogbook {
|
||||
isSynced: boolean
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active master key
|
||||
export async function decryptLogbookTitle(encryptedTitle: string): Promise<string> {
|
||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||
export async function decryptLogbookTitle(logbookId: string, encryptedTitle: string): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
@@ -20,7 +21,8 @@ export async function decryptLogbookTitle(encryptedTitle: string): Promise<strin
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedTitle)
|
||||
const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey)
|
||||
const key = await getLogbookKey(logbookId) || masterKey
|
||||
const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, key)
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt logbook title:', error)
|
||||
@@ -53,6 +55,29 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
if (response.ok) {
|
||||
const serverLogbooks = await response.json()
|
||||
|
||||
// Decrypt and save logbook keys locally if they exist
|
||||
for (const lb of serverLogbooks) {
|
||||
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey)
|
||||
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv)
|
||||
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
|
||||
|
||||
if (encryptedKeyStr && ivStr && tagStr) {
|
||||
try {
|
||||
const aesKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
const decryptedKey = await decryptBuffer(encryptedKeyStr, ivStr, tagStr, aesKey)
|
||||
await saveLogbookKey(lb.id, decryptedKey)
|
||||
} catch (err) {
|
||||
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update Dexie database cache
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
@@ -62,7 +87,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
}))
|
||||
|
||||
// Clear existing cache for this user and insert new ones
|
||||
// Note: Currently Dexie schema doesn't store userId on logbook table, but we can bulkPut.
|
||||
await db.logbooks.bulkPut(localLogbooks)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -76,7 +100,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
// Decrypt titles
|
||||
const decrypted: DecryptedLogbook[] = []
|
||||
for (const lb of cachedLogbooks) {
|
||||
const title = await decryptLogbookTitle(lb.encryptedTitle)
|
||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||
decrypted.push({
|
||||
id: lb.id,
|
||||
title,
|
||||
@@ -100,12 +124,34 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. E2E Encrypt title
|
||||
const encrypted = await encryptJson(title, masterKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
const localId = window.crypto.randomUUID()
|
||||
// 1. Generate Logbook Key and save it locally
|
||||
const logbookKey = generateLogbookKey()
|
||||
await saveLogbookKey(localIdForCreate(), logbookKey) // Generate temporary ID to bind to key
|
||||
|
||||
const localId = tempUUID
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 2. Encrypt logbook key with user's master key
|
||||
const aesMasterKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
|
||||
// 3. E2E Encrypt title using the Logbook Key
|
||||
const encrypted = await encryptJson(title, logbookKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
|
||||
const payloadData = {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
encryptedKey: encryptedKey.ciphertext,
|
||||
iv: encryptedKey.iv,
|
||||
tag: encryptedKey.tag
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
@@ -116,7 +162,7 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: localId,
|
||||
encryptedTitle: encryptedTitleStr
|
||||
...payloadData
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,7 +200,7 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
type: 'logbook',
|
||||
payloadId: localId,
|
||||
logbookId: localId,
|
||||
data: JSON.stringify({ encryptedTitle: encryptedTitleStr }),
|
||||
data: JSON.stringify(payloadData),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
@@ -166,6 +212,13 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary UUID helpers to preserve single localId assignment across generation
|
||||
let tempUUID = ''
|
||||
function localIdForCreate(): string {
|
||||
tempUUID = window.crypto.randomUUID()
|
||||
return tempUUID
|
||||
}
|
||||
|
||||
// Delete a logbook and all associated payloads locally and on server
|
||||
export async function deleteLogbook(id: string): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { encryptBuffer, decryptBuffer, generateMasterKey } from './crypto.js'
|
||||
|
||||
// In-memory cache of decrypted logbook keys (ArrayBuffer)
|
||||
const keyCache = new Map<string, ArrayBuffer>()
|
||||
|
||||
/**
|
||||
* Retrieves the logbook-specific key for a given logbookId.
|
||||
* Falls back to the user's master key if no logbook-specific key exists (legacy logbooks).
|
||||
*/
|
||||
export async function getLogbookKey(logbookId: string): Promise<ArrayBuffer | null> {
|
||||
if (keyCache.has(logbookId)) {
|
||||
return keyCache.get(logbookId)!
|
||||
}
|
||||
|
||||
const record = await db.logbookKeys.get(logbookId)
|
||||
if (!record) {
|
||||
return null // Caller will fall back to getActiveMasterKey()
|
||||
}
|
||||
|
||||
const masterKeyBytes = getActiveMasterKey()
|
||||
if (!masterKeyBytes) {
|
||||
throw new Error('Master key not found. Please log in.')
|
||||
}
|
||||
|
||||
// Derive CryptoKey from user master key
|
||||
const aesKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKeyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
|
||||
// Decrypt logbook key using User Master Key
|
||||
const decrypted = await decryptBuffer(record.encryptedKey, record.iv, record.tag, aesKey)
|
||||
keyCache.set(logbookId, decrypted)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and stores a logbook-specific key in the local IndexedDB.
|
||||
*/
|
||||
export async function saveLogbookKey(logbookId: string, logbookKeyBuffer: ArrayBuffer): Promise<void> {
|
||||
const masterKeyBytes = getActiveMasterKey()
|
||||
if (!masterKeyBytes) {
|
||||
throw new Error('Master key not found. Please log in.')
|
||||
}
|
||||
|
||||
// Derive CryptoKey from user master key
|
||||
const aesKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKeyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
|
||||
const encrypted = await encryptBuffer(logbookKeyBuffer, aesKey)
|
||||
|
||||
await db.logbookKeys.put({
|
||||
logbookId,
|
||||
encryptedKey: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag
|
||||
})
|
||||
|
||||
keyCache.set(logbookId, logbookKeyBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random 256-bit logbook key.
|
||||
*/
|
||||
export function generateLogbookKey(): ArrayBuffer {
|
||||
return generateMasterKey() // 32 random bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the in-memory logbook key cache (called on logout).
|
||||
*/
|
||||
export function clearLogbookKeysCache() {
|
||||
keyCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a logbook-specific key exists for a given logbookId.
|
||||
* If not, it generates a key, encrypts it with the user's master key, saves it locally and in the sync queue.
|
||||
*/
|
||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
let key = await getLogbookKey(logbookId)
|
||||
if (key) return key
|
||||
|
||||
// Generate new key
|
||||
key = generateLogbookKey()
|
||||
await saveLogbookKey(logbookId, key)
|
||||
|
||||
// Encrypt it with user master key
|
||||
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 encrypted = await encryptBuffer(key, aesMasterKey)
|
||||
|
||||
// Retrieve local logbook details to preserve encryptedTitle
|
||||
const localLb = await db.logbooks.get(logbookId)
|
||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||
|
||||
const payloadData = {
|
||||
encryptedTitle,
|
||||
encryptedKey: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag
|
||||
}
|
||||
|
||||
// Put in sync queue to update the server logbook record with the key
|
||||
await db.syncQueue.put({
|
||||
action: 'create', // Server sync treats create as upsert
|
||||
type: 'logbook',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(payloadData),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
return key
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise<jsPDF> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. Please log in.')
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
|
||||
Reference in New Issue
Block a user