Status: Security Specification Updated: April 2026
Complete security architecture, key management, and privacy compliance documentation.
LibreLog operates on a zero-knowledge model:
- Personal data (meals, weight, goals) stored locally only
- No backend servers receive or process personal data
- API keys (BYOK) encrypted; never sent to third parties
- External APIs (OFF, USDA) receive only search queries, never user profiles
- Users own all their data
- Data deletion is permanent (soft-deleted after 30 days)
- Export available in multiple formats
- No account required; app works offline-first
- Vanilla JS: No framework backdoors
- Capacitor: Official Ionic plugin ecosystem
- All external dependencies audited before use
- AGPL-3.0 license ensures transparency
Threat Model:
- Browser clear cache → data loss (mitigated by auto-backup)
- Malicious scripts via XSS → data access
- Forensic analysis of device → data exposure
Mitigations:
// src/utils/storage.js
export class SecureStorage {
// IndexedDB has no built-in encryption
// Mitigate via: content-security-policy, input validation, sanitization
async storeIndexedDB(storeName, data) {
// Data stored plaintext in IndexedDB (browser limitation)
// Privacy depends on device security + CSP
await db.put(storeName, data);
}
}Recommendations for Users:
- Enable device encryption (iOS: standard; Android: Settings → Security → Encrypt)
- Use app lock on Capacitor (biometric support)
- Disable browser history on web version
- Log out if device shared
BYOK API keys must be encrypted at rest.
// src/utils/storage.js
export class EncryptedStorage {
// Use Web Crypto API for encryption
// Master password = sha256(user password)
async setEncryptedKey(name, value, userPassword) {
try {
// 1. Derive key from password
const masterKey = await this._deriveKey(userPassword);
// 2. Encrypt value
const encoded = new TextEncoder().encode(value);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
masterKey,
encoded
);
// 3. Store IV + ciphertext
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
const b64 = btoa(String.fromCharCode(...combined));
localStorage.setItem(`enc_${name}`, b64);
return true;
} catch (error) {
console.error('Encryption failed:', error);
return false;
}
}
async getEncryptedKey(name, userPassword) {
try {
const b64 = localStorage.getItem(`enc_${name}`);
if (!b64) return null;
// 1. Decode base64
const combined = new Uint8Array(
atob(b64).split('').map(c => c.charCodeAt(0))
);
// 2. Extract IV (first 12 bytes)
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
// 3. Derive key from password
const masterKey = await this._deriveKey(userPassword);
// 4. Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
masterKey,
ciphertext
);
return new TextDecoder().decode(decrypted);
} catch (error) {
console.error('Decryption failed:', error);
return null;
}
}
async _deriveKey(password) {
// PBKDF2 for key derivation
const salt = new TextEncoder().encode('librelog-salt'); // Fixed salt ok for this use case
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
}// src/data/state.js
export const NON_SENSITIVE_DATA = [
'lastSync',
'theme',
'language',
'appVersion'
];
export function storeInLocalStorage(key, value) {
if (!NON_SENSITIVE_DATA.includes(key)) {
console.warn(`Non-sensitive data only. Use EncryptedStorage for: ${key}`);
return;
}
localStorage.setItem(key, JSON.stringify(value));
}// src/app.js
if (location.protocol !== 'https:' && !isLocalhost()) {
// Redirect to HTTPS
window.location.protocol = 'https:';
}
function isLocalhost() {
return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
}<!-- index.html -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' https: data:;
font-src 'self';
connect-src 'self' https://world.openfoodfacts.org https://fdc.nal.usda.gov https://api.openai.com https://api.anthropic.com;
frame-ancestors 'none';
form-action 'self'
">// src/utils/sanitize.js
export function sanitizeInput(input, type = 'text') {
if (!input) return '';
// Remove HTML/script tags
let sanitized = input.replace(/<[^>]*>/g, '');
// Type-specific validation
switch (type) {
case 'number':
return parseFloat(sanitized) || 0;
case 'email':
return sanitized.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)?.[0] || '';
case 'url':
try {
new URL(sanitized);
return sanitized;
} catch {
return '';
}
default:
// Allow alphanumeric, spaces, basic punctuation
return sanitized.replace(/[^a-zA-Z0-9\s\-_().,]/g, '');
}
}
export function sanitizeHTML(html) {
const div = document.createElement('div');
div.textContent = html;
return div.innerHTML;
}
// Usage in meal logging
const mealName = sanitizeInput(userInput, 'text');
const quantity = sanitizeInput(userQuantity, 'number');// src/utils/dom.js
export function createSafeElement(tag, content, className) {
const element = document.createElement(tag);
// Never use innerHTML with user data
if (typeof content === 'string') {
element.textContent = content; // Safe: no HTML parsing
} else if (content instanceof HTMLElement) {
element.appendChild(content);
}
if (className) {
element.className = className;
}
return element;
}
// Safe meal card rendering
function renderMealCard(meal) {
const card = createSafeElement('div', '', 'meal-card');
const name = createSafeElement('h3', meal.name);
const kcal = createSafeElement('p', `${meal.totals.kcal} kcal`);
card.appendChild(name);
card.appendChild(kcal);
return card;
}Security Model:
- Keys encrypted with user password
- Never logged or cached unencrypted
- Each API call validates key format
- Costs tracked but responses never logged with keys
// src/integrations/aiClient.js
export class AIClient {
constructor(provider, encryptedKeyName) {
this.provider = provider;
this.encryptedKeyName = encryptedKeyName; // e.g., 'byok_openai_key'
this.apiKey = null; // Loaded on demand
}
async getAPIKey(userPassword) {
// Decrypt key on-demand
this.apiKey = await EncryptedStorage.getEncryptedKey(
this.encryptedKeyName,
userPassword
);
if (!this.apiKey) {
throw new Error('API key not configured or password incorrect');
}
return this.apiKey;
}
async request(userPassword, message) {
const apiKey = await this.getAPIKey(userPassword);
try {
// Validate key format before use
if (!this._isValidKeyFormat(apiKey)) {
throw new Error('Invalid API key format');
}
const response = await fetch(this._getEndpoint(), {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
});
// Never log response with key
return response.json();
} finally {
// Clear key from memory
this.apiKey = null;
}
}
_isValidKeyFormat(key) {
// Basic format validation per provider
switch (this.provider) {
case 'openai':
return key.startsWith('sk-') && key.length > 20;
case 'anthropic':
return key.startsWith('sk-ant-') && key.length > 20;
default:
return false;
}
}
}// src/integrations/rateLimiter.js
export class RateLimiter {
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = [];
}
async execute(fn) {
const now = Date.now();
// Clean old requests
this.requests = this.requests.filter(t => t > now - this.windowMs);
if (this.requests.length >= this.maxRequests) {
throw new Error('Rate limit exceeded');
}
this.requests.push(now);
try {
return await fn();
} catch (error) {
// Remove failed request from count (retry doesn't count against limit)
this.requests.pop();
throw error;
}
}
}
// Per-API limiters
const openAILimiter = new RateLimiter(3, 60000); // 3 per minute
const offLimiter = new RateLimiter(60, 60000); // 60 per minuteWhat LibreLog collects:
- Meals, foods, nutrition data
- Weight & body measurements
- User goals & preferences
What LibreLog does NOT collect:
- Location (unless user explicitly adds)
- Contact information
- Device identifiers
- Browsing history
- IP addresses (except in server logs)
User Rights:
// src/pages/settings.js
export class PrivacyControls {
async exportAllData() {
// Right to data portability
const backup = {
meals: await db.getAll('meals'),
foods: await db.getAll('foods'),
recipes: await db.getAll('recipes'),
measurements: await db.getAll('measurements'),
exportedAt: new Date().toISOString()
};
return JSON.stringify(backup, null, 2);
}
async deleteAllData() {
// Right to be forgotten
// Show confirmation: "This cannot be undone"
const stores = ['meals', 'foods', 'recipes', 'measurements',
'aiConversations', 'syncLog', 'apiCache'];
for (const store of stores) {
await db.clear(store);
}
// Clear encrypted keys
localStorage.clear();
// Clear IndexedDB
const dbs = await indexedDB.databases();
for (const db of dbs) {
indexedDB.deleteDatabase(db.name);
}
}
async requestDataDeletion() {
// 30-day grace period before hard-delete
await db.put('_config', {
id: 'deletionRequested',
value: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
});
// Soft-delete all records
const stores = ['meals', 'foods', 'recipes', 'measurements'];
for (const store of stores) {
const records = await db.getAll(store);
for (const record of records) {
record.deletedAt = new Date().toISOString();
await db.put(store, record);
}
}
}
}Web version uses minimal cookies:
// src/utils/cookies.js
export function setupCookies() {
// Session cookie only (not persistent)
// No third-party tracking cookies
// CSP prevents cookie theft via XSS
const sessionId = crypto.randomUUID();
document.cookie = `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict`;
}# LibreLog Privacy Policy
## Data Storage
All personal data (meals, weight, goals) is stored on your device only. LibreLog has no backend servers.
## External Integrations
- Open Food Facts: Search queries only, no personal data
- USDA FDC: Search queries only, no personal data
- AI Services (optional, BYOK): You control data sent; we don't store responses
## Your Rights
- Export data anytime (JSON format)
- Delete data anytime (permanent)
- Disable analytics at any time
- No account required; no email collection
## Changes
We may update this policy. Changes take effect on next app update.
| Threat | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Browser clear cache | High | Total data loss | Auto-backup to WebDAV/Gist |
| XSS via user input | Medium | Data theft | Input sanitization + CSP |
| Man-in-the-middle | Medium | API key theft | HTTPS enforcement |
| Weak password | High | BYOK key theft | Educate users, entropy check |
| Device theft | Low | Full data access | Recommend device encryption |
| Supply chain attack | Low | Malicious code | Minimize dependencies, audit |
| API provider breach | Low | API credentials leaked | User's key, not stored with us |
| Bug in crypto.subtle | Very low | Key compromise | Browser updates |
Before production release:
- All API calls over HTTPS
- CSP headers enforced
- XSS vulnerabilities scanned (npm audit)
- Input validation on all forms
- BYOK keys encrypted at rest
- No sensitive data in logs
- Service worker secure (no cache of PII)
- Barcode scanning requests permissions
- Camera access scoped properly
- Third-party dependencies < 5 direct deps
- AGPL-3.0 license prominent
- Privacy policy visible in app
- Data export tested end-to-end
- Data deletion removes all traces
- Offline mode tested without backend
- API rate limiting prevents abuse
- Assess scope — Which records, which users?
- Contain — Disable affected API key, rotate credentials
- Notify users — Post-incident, transparent communication
- Fix root cause — Security patch + version bump
- Document — Post-mortem analysis
// Emergency response in settings
export async function revokeCompromisedKey(provider) {
console.error(`SECURITY: Revoking compromised ${provider} key`);
// 1. Clear encrypted key
localStorage.removeItem(`enc_byok_${provider}_key`);
// 2. Disable AI features
const settings = await db.get('users', 'user-1');
settings.integrations.byokEnabled = false;
await db.put('users', settings);
// 3. Notify user
showAlert(`Your ${provider} key was revoked. Please re-enter it in Settings.`, 'error');
// 4. Log incident (for future audit)
console.log(`Incident: Revoked ${provider} key at ${new Date().toISOString()}`);
}# Run security audits
npm audit # Check dependencies
npm run lint # Code quality
npm run security-scan # Static analysis
# Manual security testing
# 1. Try XSS payloads: <img src=x onerror="alert('xss')">
# 2. Try SQL injection: ' OR '1'='1
# 3. Test BYOK: Enter invalid keys
# 4. Test offline: Disable network, use app
# 5. Test sync conflict: Edit same meal on 2 devicesThis security guide provides:
- Zero-knowledge architecture
- Encryption for sensitive keys
- XSS + injection protection
- GDPR compliance framework
- Threat modeling
- Incident response procedures
- Security audit checklist