caf85ad9eb
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>
207 lines
6.1 KiB
TypeScript
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
|
|
}
|