added encryption
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
|
||||
@@ -15,11 +15,25 @@ import {
|
||||
} from 'firebase/auth'
|
||||
import { auth, googleProvider } from '../lib/firebase'
|
||||
import { registerUser, getUserByEmail } from '../lib/api'
|
||||
import {
|
||||
deriveSecretKey,
|
||||
generateDeviceKey,
|
||||
generateSalt,
|
||||
getSalt,
|
||||
saveSalt,
|
||||
getDeviceKey,
|
||||
saveDeviceKey,
|
||||
encryptSecretKey,
|
||||
decryptSecretKey,
|
||||
saveEncryptedSecretKey,
|
||||
getEncryptedSecretKey,
|
||||
} from '../lib/crypto'
|
||||
|
||||
type AuthContextValue = {
|
||||
user: User | null
|
||||
userId: string | null
|
||||
loading: boolean
|
||||
secretKey: Uint8Array | null
|
||||
signInWithGoogle: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
@@ -29,17 +43,78 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [userId, setUserId] = useState<string | null>(null)
|
||||
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Initialize encryption keys on login
|
||||
async function initializeEncryption(authUser: User, token: string) {
|
||||
try {
|
||||
const firebaseUID = authUser.uid
|
||||
const firebaseIDToken = token
|
||||
|
||||
// Get or create salt
|
||||
let salt = getSalt()
|
||||
if (!salt) {
|
||||
salt = generateSalt()
|
||||
saveSalt(salt)
|
||||
}
|
||||
|
||||
// Derive master key from Firebase credentials
|
||||
const derivedKey = await deriveSecretKey(firebaseUID, firebaseIDToken, salt)
|
||||
|
||||
// Check if device key exists
|
||||
let deviceKey = await getDeviceKey()
|
||||
if (!deviceKey) {
|
||||
// First login on this device: generate device key
|
||||
deviceKey = await generateDeviceKey()
|
||||
await saveDeviceKey(deviceKey)
|
||||
}
|
||||
|
||||
// Check if encrypted key exists in IndexedDB
|
||||
const cachedEncrypted = await getEncryptedSecretKey()
|
||||
if (!cachedEncrypted) {
|
||||
// First login (or IndexedDB cleared): encrypt and cache the key
|
||||
const encrypted = await encryptSecretKey(derivedKey, deviceKey)
|
||||
await saveEncryptedSecretKey(encrypted.ciphertext, encrypted.nonce)
|
||||
} else {
|
||||
// Subsequent login on same device: verify we can decrypt
|
||||
// (This ensures device key is correct)
|
||||
try {
|
||||
await decryptSecretKey(
|
||||
cachedEncrypted.ciphertext,
|
||||
cachedEncrypted.nonce,
|
||||
deviceKey
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Device key mismatch, regenerating...', error)
|
||||
// Device key doesn't match - regenerate
|
||||
deviceKey = await generateDeviceKey()
|
||||
await saveDeviceKey(deviceKey)
|
||||
const encrypted = await encryptSecretKey(derivedKey, deviceKey)
|
||||
await saveEncryptedSecretKey(encrypted.ciphertext, encrypted.nonce)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep secret key in memory for session
|
||||
setSecretKey(derivedKey)
|
||||
} catch (error) {
|
||||
console.error('Error initializing encryption:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Register or fetch user from MongoDB
|
||||
async function syncUserWithDatabase(authUser: User) {
|
||||
try {
|
||||
const token = await authUser.getIdToken()
|
||||
const email = authUser.email!
|
||||
|
||||
// Initialize encryption before syncing user
|
||||
await initializeEncryption(authUser, token)
|
||||
|
||||
// Try to get existing user
|
||||
try {
|
||||
const existingUser = await getUserByEmail(email, token)
|
||||
const existingUser = await getUserByEmail(email, token) as { id: string }
|
||||
setUserId(existingUser.id)
|
||||
} catch (error) {
|
||||
// User doesn't exist, register them
|
||||
@@ -50,11 +125,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
photoURL: authUser.photoURL || undefined,
|
||||
},
|
||||
token
|
||||
)
|
||||
) as { id: string }
|
||||
setUserId(newUser.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing user with database:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +138,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (u) => {
|
||||
setUser(u)
|
||||
if (u) {
|
||||
await syncUserWithDatabase(u)
|
||||
try {
|
||||
await syncUserWithDatabase(u)
|
||||
} catch (error) {
|
||||
console.error('Auth sync failed:', error)
|
||||
}
|
||||
} else {
|
||||
setUserId(null)
|
||||
setSecretKey(null)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -77,6 +158,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
// Clear secret key from memory
|
||||
setSecretKey(null)
|
||||
// Keep device key and encrypted key for next login
|
||||
// Do NOT clear localStorage or IndexedDB
|
||||
await firebaseSignOut(auth)
|
||||
setUserId(null)
|
||||
}
|
||||
@@ -84,6 +169,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
userId,
|
||||
secretKey,
|
||||
loading,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
|
||||
@@ -84,12 +84,20 @@ export async function updateUserProfile(
|
||||
// ENTRY ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
export interface EncryptionMetadata {
|
||||
encrypted: boolean
|
||||
ciphertext?: string // Base64-encoded encrypted content
|
||||
nonce?: string // Base64-encoded nonce
|
||||
algorithm?: string // e.g., "XSalsa20-Poly1305"
|
||||
}
|
||||
|
||||
export interface JournalEntryCreate {
|
||||
title: string
|
||||
content: string
|
||||
title?: string // Optional if encrypted
|
||||
content?: string // Optional if encrypted
|
||||
mood?: string
|
||||
tags?: string[]
|
||||
isPublic?: boolean
|
||||
encryption?: EncryptionMetadata
|
||||
}
|
||||
|
||||
export interface JournalEntry extends JournalEntryCreate {
|
||||
@@ -97,6 +105,8 @@ export interface JournalEntry extends JournalEntryCreate {
|
||||
userId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
entryDate?: string
|
||||
encryption?: EncryptionMetadata
|
||||
}
|
||||
|
||||
export async function createEntry(
|
||||
|
||||
271
src/lib/crypto.ts
Normal file
271
src/lib/crypto.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Client-side encryption utilities
|
||||
*
|
||||
* Zero-knowledge privacy flow:
|
||||
* 1. KDF derives master key from firebaseUID + firebaseIDToken
|
||||
* 2. Device key stored in localStorage
|
||||
* 3. Master key encrypted with device key → stored in IndexedDB
|
||||
* 4. Journal entries encrypted with master key
|
||||
* 5. Only ciphertext sent to server
|
||||
*/
|
||||
|
||||
import { getSodium } from '../utils/sodium'
|
||||
|
||||
/**
|
||||
* Derive master encryption key from Firebase credentials using PBKDF2
|
||||
*
|
||||
* Flow:
|
||||
* - Input: firebaseUID + firebaseIDToken + constant salt
|
||||
* - Output: 32-byte key for encryption
|
||||
*/
|
||||
export async function deriveSecretKey(
|
||||
firebaseUID: string,
|
||||
firebaseIDToken: string,
|
||||
salt: string
|
||||
): Promise<Uint8Array> {
|
||||
// Use native Web Crypto API for key derivation (PBKDF2)
|
||||
// This is more reliable than libsodium's Argon2i
|
||||
const password = `${firebaseUID}:${firebaseIDToken}`
|
||||
const encoding = new TextEncoder()
|
||||
const passwordBuffer = encoding.encode(password)
|
||||
const saltBuffer = encoding.encode(salt)
|
||||
|
||||
// Import the password as a key
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordBuffer,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits']
|
||||
)
|
||||
|
||||
// Derive key using PBKDF2-SHA256
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBuffer,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
baseKey,
|
||||
256 // 256 bits = 32 bytes
|
||||
)
|
||||
|
||||
return new Uint8Array(derivedBits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate device key (256 bits) for encrypting the master key
|
||||
* Stored in localStorage, persists across sessions on same device
|
||||
*/
|
||||
export async function generateDeviceKey(): Promise<Uint8Array> {
|
||||
// Use native crypto.getRandomValues for device key generation
|
||||
// This is safe because device key doesn't need libsodium
|
||||
const deviceKey = new Uint8Array(32) // 256 bits
|
||||
crypto.getRandomValues(deviceKey)
|
||||
return deviceKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt master key with device key for storage
|
||||
* Result stored in IndexedDB
|
||||
*/
|
||||
export async function encryptSecretKey(
|
||||
secretKey: Uint8Array,
|
||||
deviceKey: Uint8Array
|
||||
): Promise<{
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
}> {
|
||||
const sodium = await getSodium()
|
||||
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
|
||||
const ciphertext = sodium.crypto_secretbox_easy(secretKey, nonce, deviceKey)
|
||||
|
||||
return {
|
||||
ciphertext: sodium.to_base64(ciphertext),
|
||||
nonce: sodium.to_base64(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt master key using device key
|
||||
* Retrieves encrypted key from IndexedDB and decrypts with device key
|
||||
*/
|
||||
export async function decryptSecretKey(
|
||||
ciphertext: string,
|
||||
nonce: string,
|
||||
deviceKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const sodium = await getSodium()
|
||||
|
||||
const ciphertextBytes = sodium.from_base64(ciphertext)
|
||||
const nonceBytes = sodium.from_base64(nonce)
|
||||
|
||||
try {
|
||||
return sodium.crypto_secretbox_open_easy(ciphertextBytes, nonceBytes, deviceKey)
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt secret key - device key mismatch or corrupted data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt journal entry content
|
||||
* Used before sending to server
|
||||
* Converts string content to Uint8Array before encryption
|
||||
*/
|
||||
export async function encryptEntry(
|
||||
entryContent: string,
|
||||
secretKey: Uint8Array
|
||||
): Promise<{
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
}> {
|
||||
const sodium = await getSodium()
|
||||
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
|
||||
const contentBytes = sodium.from_string(entryContent)
|
||||
const ciphertext = sodium.crypto_secretbox_easy(contentBytes, nonce, secretKey)
|
||||
|
||||
return {
|
||||
ciphertext: sodium.to_base64(ciphertext),
|
||||
nonce: sodium.to_base64(nonce),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt journal entry content
|
||||
* Used when fetching from server
|
||||
*/
|
||||
export async function decryptEntry(
|
||||
ciphertext: string,
|
||||
nonce: string,
|
||||
secretKey: Uint8Array
|
||||
): Promise<string> {
|
||||
const sodium = await getSodium()
|
||||
|
||||
const ciphertextBytes = sodium.from_base64(ciphertext)
|
||||
const nonceBytes = sodium.from_base64(nonce)
|
||||
|
||||
try {
|
||||
const plaintext = sodium.crypto_secretbox_open_easy(ciphertextBytes, nonceBytes, secretKey)
|
||||
return sodium.to_string(plaintext)
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt entry - corrupted data or wrong key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB operations for storing encrypted secret key
|
||||
*/
|
||||
const DB_NAME = 'GratefulJournal'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'encryption'
|
||||
|
||||
export async function initializeIndexedDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveEncryptedSecretKey(
|
||||
ciphertext: string,
|
||||
nonce: string
|
||||
): Promise<void> {
|
||||
const db = await initializeIndexedDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.put(
|
||||
{ ciphertext, nonce },
|
||||
'secretKey'
|
||||
)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getEncryptedSecretKey(): Promise<{
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
} | null> {
|
||||
const db = await initializeIndexedDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.get('secretKey')
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearEncryptedSecretKey(): Promise<void> {
|
||||
const db = await initializeIndexedDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.delete('secretKey')
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage operations for device key
|
||||
*/
|
||||
const DEVICE_KEY_STORAGE_KEY = 'gj_device_key'
|
||||
const KDF_SALT_STORAGE_KEY = 'gj_kdf_salt'
|
||||
|
||||
export async function saveDeviceKey(deviceKey: Uint8Array): Promise<void> {
|
||||
const sodium = await getSodium()
|
||||
const base64Key = sodium.to_base64(deviceKey)
|
||||
localStorage.setItem(DEVICE_KEY_STORAGE_KEY, base64Key)
|
||||
}
|
||||
|
||||
export async function getDeviceKey(): Promise<Uint8Array | null> {
|
||||
const sodium = await getSodium()
|
||||
const stored = localStorage.getItem(DEVICE_KEY_STORAGE_KEY)
|
||||
if (!stored) return null
|
||||
try {
|
||||
return sodium.from_base64(stored)
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve device key:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceKey(): void {
|
||||
localStorage.removeItem(DEVICE_KEY_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function saveSalt(salt: string): void {
|
||||
localStorage.setItem(KDF_SALT_STORAGE_KEY, salt)
|
||||
}
|
||||
|
||||
export function getSalt(): string | null {
|
||||
return localStorage.getItem(KDF_SALT_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function generateSalt(): string {
|
||||
// Use a constant salt for deterministic KDF
|
||||
// This is safe because the password already includes firebase credentials
|
||||
return 'grateful-journal-v1'
|
||||
}
|
||||
80
src/lib/libsodium.d.ts
vendored
Normal file
80
src/lib/libsodium.d.ts
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
declare module 'libsodium-wrappers' {
|
||||
interface SodiumPlus {
|
||||
ready: Promise<void>
|
||||
|
||||
// Random bytes
|
||||
randombytes_buf(length: number): Uint8Array
|
||||
|
||||
// Secret-box (XSalsa20-Poly1305) — "_easy" variants
|
||||
crypto_secretbox_easy(
|
||||
message: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array
|
||||
): Uint8Array
|
||||
/** Throws on failure (wrong key / corrupted ciphertext) */
|
||||
crypto_secretbox_open_easy(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array
|
||||
): Uint8Array
|
||||
crypto_secretbox_keygen(): Uint8Array
|
||||
|
||||
// Box (X25519 + XSalsa20-Poly1305)
|
||||
crypto_box_easy(
|
||||
message: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
secretKey: Uint8Array
|
||||
): Uint8Array
|
||||
crypto_box_open_easy(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
secretKey: Uint8Array
|
||||
): Uint8Array
|
||||
crypto_box_keypair(): { publicKey: Uint8Array; privateKey: Uint8Array; keyType: string }
|
||||
|
||||
// Password hashing
|
||||
crypto_pwhash(
|
||||
outlen: number,
|
||||
passwd: string,
|
||||
salt: Uint8Array,
|
||||
opslimit: number,
|
||||
memlimit: number,
|
||||
alg: number
|
||||
): Uint8Array
|
||||
|
||||
// Encoding helpers
|
||||
to_base64(data: Uint8Array, variant?: number): string
|
||||
from_base64(data: string, variant?: number): Uint8Array
|
||||
to_string(data: Uint8Array): string
|
||||
from_string(data: string): Uint8Array
|
||||
to_hex(data: Uint8Array): string
|
||||
from_hex(data: string): Uint8Array
|
||||
|
||||
// Base64 variant constants
|
||||
base64_variants: {
|
||||
ORIGINAL: number
|
||||
ORIGINAL_NO_PADDING: number
|
||||
URLSAFE: number
|
||||
URLSAFE_NO_PADDING: number
|
||||
}
|
||||
|
||||
// Constants
|
||||
crypto_pwhash_SALTBYTES: number
|
||||
crypto_pwhash_OPSLIMIT_SENSITIVE: number
|
||||
crypto_pwhash_MEMLIMIT_SENSITIVE: number
|
||||
crypto_pwhash_OPSLIMIT_MODERATE: number
|
||||
crypto_pwhash_MEMLIMIT_MODERATE: number
|
||||
crypto_pwhash_ALG_DEFAULT: number
|
||||
crypto_secretbox_NONCEBYTES: number
|
||||
crypto_secretbox_KEYBYTES: number
|
||||
crypto_secretbox_MACBYTES: number
|
||||
crypto_box_NONCEBYTES: number
|
||||
crypto_box_PUBLICKEYBYTES: number
|
||||
crypto_box_SECRETKEYBYTES: number
|
||||
}
|
||||
|
||||
const sodium: SodiumPlus
|
||||
export default sodium
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { getUserEntries, type JournalEntry } from '../lib/api'
|
||||
import { formatIST, formatISTDateOnly, getISTDateComponents } from '../lib/timezone'
|
||||
import { decryptEntry } from '../lib/crypto'
|
||||
import { formatIST, getISTDateComponents } from '../lib/timezone'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
|
||||
interface DecryptedEntry extends JournalEntry {
|
||||
decryptedTitle?: string
|
||||
decryptedContent?: string
|
||||
decryptError?: string
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { user, userId, loading } = useAuth()
|
||||
const { user, userId, secretKey, loading } = useAuth()
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||||
const [entries, setEntries] = useState<DecryptedEntry[]>([])
|
||||
const [loadingEntries, setLoadingEntries] = useState(false)
|
||||
|
||||
// Fetch entries on mount and when userId changes
|
||||
@@ -20,7 +27,57 @@ export default function HistoryPage() {
|
||||
try {
|
||||
const token = await user.getIdToken()
|
||||
const response = await getUserEntries(userId, token, 100, 0)
|
||||
setEntries(response.entries)
|
||||
|
||||
// Decrypt entries if they are encrypted
|
||||
const decryptedEntries: DecryptedEntry[] = await Promise.all(
|
||||
response.entries.map(async (entry) => {
|
||||
if (entry.encryption?.encrypted && entry.encryption?.ciphertext && entry.encryption?.nonce) {
|
||||
// Entry is encrypted, try to decrypt
|
||||
if (!secretKey) {
|
||||
return {
|
||||
...entry,
|
||||
decryptError: 'Encryption key not available',
|
||||
decryptedTitle: '[Encrypted]',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptEntry(
|
||||
entry.encryption.ciphertext,
|
||||
entry.encryption.nonce,
|
||||
secretKey
|
||||
)
|
||||
|
||||
// Split decrypted content: first line is title, rest is content
|
||||
const lines = decrypted.split('\n\n')
|
||||
const decryptedTitle = lines[0]
|
||||
const decryptedContent = lines.slice(1).join('\n\n')
|
||||
|
||||
return {
|
||||
...entry,
|
||||
decryptedTitle,
|
||||
decryptedContent,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to decrypt entry ${entry.id}:`, error)
|
||||
return {
|
||||
...entry,
|
||||
decryptError: 'Failed to decrypt entry',
|
||||
decryptedTitle: '[Decryption Failed]',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Entry is not encrypted, use plaintext
|
||||
return {
|
||||
...entry,
|
||||
decryptedTitle: entry.title || '[Untitled]',
|
||||
decryptedContent: entry.content || '',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setEntries(decryptedEntries)
|
||||
} catch (error) {
|
||||
console.error('Error fetching entries:', error)
|
||||
} finally {
|
||||
@@ -29,7 +86,7 @@ export default function HistoryPage() {
|
||||
}
|
||||
|
||||
fetchEntries()
|
||||
}, [user, userId])
|
||||
}, [user, userId, secretKey])
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
@@ -208,7 +265,7 @@ export default function HistoryPage() {
|
||||
<span className="entry-date">{formatDate(entry.createdAt)}</span>
|
||||
<span className="entry-time">{formatTime(entry.createdAt)}</span>
|
||||
</div>
|
||||
<h4 className="entry-title">{entry.title}</h4>
|
||||
<h4 className="entry-title">{entry.decryptedTitle || entry.title || '[Untitled]'}</h4>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { createEntry } from '../lib/api'
|
||||
import { encryptEntry } from '../lib/crypto'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, userId, loading, signOut } = useAuth()
|
||||
const { user, userId, secretKey, loading } = useAuth()
|
||||
const [entry, setEntry] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -41,22 +42,45 @@ export default function HomePage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
setMessage({ type: 'error', text: 'Encryption key not available. Please log in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const token = await user.getIdToken()
|
||||
|
||||
// Combine title and content for encryption
|
||||
const contentToEncrypt = `${title.trim()}\n\n${entry.trim()}`
|
||||
|
||||
// Encrypt the entry with master key
|
||||
const { ciphertext, nonce } = await encryptEntry(
|
||||
contentToEncrypt,
|
||||
secretKey
|
||||
)
|
||||
|
||||
// Send encrypted data to backend
|
||||
// Note: title and content are null for encrypted entries
|
||||
await createEntry(
|
||||
userId,
|
||||
{
|
||||
title: title.trim(),
|
||||
content: entry.trim(),
|
||||
title: undefined,
|
||||
content: undefined,
|
||||
isPublic: false,
|
||||
encryption: {
|
||||
encrypted: true,
|
||||
ciphertext,
|
||||
nonce,
|
||||
algorithm: 'XSalsa20-Poly1305',
|
||||
},
|
||||
},
|
||||
token
|
||||
)
|
||||
|
||||
setMessage({ type: 'success', text: 'Entry saved successfully!' })
|
||||
setMessage({ type: 'success', text: 'Entry saved securely!' })
|
||||
setTitle('')
|
||||
setEntry('')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { updateUserProfile } from '../lib/api'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
@@ -213,7 +213,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Clear Data */}
|
||||
<button type="button" className="settings-clear-btn" onClick={handleClearData}>
|
||||
<button type="button" className="settings-clear-btn" onClick={handleClearData} disabled>
|
||||
<span>Clear Local Data</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
|
||||
39
src/utils/sodium.ts
Normal file
39
src/utils/sodium.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Singleton initialization for libsodium-wrappers
|
||||
*
|
||||
* Ensures libsodium.wasm is loaded exactly once and provides
|
||||
* safe async access to the initialized instance.
|
||||
*/
|
||||
|
||||
import sodium from 'libsodium-wrappers'
|
||||
|
||||
let sodiumInstance: typeof sodium | null = null
|
||||
|
||||
/**
|
||||
* Get initialized sodium instance
|
||||
* Safe to call multiple times - initialization happens only once
|
||||
*
|
||||
* @returns Promise that resolves to initialized sodium
|
||||
* @throws Error if sodium initialization fails
|
||||
*/
|
||||
export async function getSodium() {
|
||||
if (!sodiumInstance) {
|
||||
await sodium.ready
|
||||
sodiumInstance = sodium
|
||||
|
||||
if (!sodiumInstance.to_base64) {
|
||||
throw new Error(
|
||||
'Libsodium initialization failed: wasm functions missing'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return sodiumInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous check if sodium is ready (after first getSodium call)
|
||||
*/
|
||||
export function isSodiumReady(): boolean {
|
||||
return sodiumInstance !== null
|
||||
}
|
||||
Reference in New Issue
Block a user