Files
kapteins-daagbok/client/src/services/logbookKeys.ts
T
elpatron caf85ad9eb Fix shared logbook access for crew after AI summary sync.
Correct owner detection while the logbook loads, preserve AI summaries on
live-log saves, skip corrupt entry decrypts, and never regenerate keys for
shared logbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:48:45 +02:00

207 lines
6.1 KiB
TypeScript

import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { encryptBuffer, decryptBuffer, generateMasterKey, decryptJson } 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.')
}
try {
// 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
} catch (err) {
console.warn(`Failed to decrypt logbook key for ${logbookId}, returning null to allow fallback:`, err)
return null
}
}
/**
* 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()
}
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId)
// Self-healing migration for legacy logbooks that got a wrong key generated
if (key && encryptedTitle && masterKey) {
try {
const parsed = JSON.parse(encryptedTitle)
await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, key)
// Key works, return it
return key
} catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try {
const parsed = JSON.parse(encryptedTitle)
await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey)
// Decryption succeeded with master key! Update logbook key to master key.
console.info('Legacy logbook detected. Repairing logbook key to match master key...')
key = masterKey
await saveLogbookKey(logbookId, key)
// Sync the repaired key to the server
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(key, aesMasterKey)
const payloadData = {
encryptedTitle,
encryptedKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
}
await db.syncQueue.put({
action: 'create',
type: 'logbook',
payloadId: logbookId,
logbookId,
data: JSON.stringify(payloadData),
updatedAt: new Date().toISOString()
})
return key
} catch (masterErr) {
console.error('Neither logbook key nor master key can decrypt title.', masterErr)
}
}
}
// If no logbook key exists yet
if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) {
try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
const parsed = JSON.parse(encryptedTitle)
await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey)
// Yes, legacy logbook! Re-use masterKey as the logbook key so existing data is decryptable.
console.info('Using master key for legacy logbook key...')
key = masterKey
} catch (err) {
// Not decryptable with masterKey, generate new random key
key = generateLogbookKey()
}
} else {
key = generateLogbookKey()
}
await saveLogbookKey(logbookId, key)
if (masterKey) {
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(key, aesMasterKey)
const payloadData = {
encryptedTitle,
encryptedKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
}
await db.syncQueue.put({
action: 'create',
type: 'logbook',
payloadId: logbookId,
logbookId,
data: JSON.stringify(payloadData),
updatedAt: new Date().toISOString()
})
}
}
return key
}