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

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