added encryption

This commit is contained in:
2026-03-09 10:54:07 +05:30
parent 6e184dc590
commit 6720e28d08
27 changed files with 2093 additions and 709 deletions

View File

@@ -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'

View File

@@ -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,

View File

@@ -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
View 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
View 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
}

View File

@@ -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>
))
)}

View File

@@ -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('')

View File

@@ -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
View 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
}