added encryption
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user