E2EE Technical Implementation - Security Whitepaper | PopaDex

E2EE Technical Implementation - Security Whitepaper

E2EE Technical Implementation - Security Whitepaper

E2EE Technical Implementation - Security Whitepaper

Target Audience: Security engineers, CTOs, compliance officers, security researchers

Purpose: Comprehensive technical documentation of PopaDex’s end-to-end encryption implementation

Last Security Audit: September 2025 (annual penetration testing)


Architecture Overview

PopaDex implements a client-side encryption model with zero-knowledge server architecture. All sensitive financial data is encrypted on the client device before transmission to servers, using keys derived from the user’s password that never leave the client.

Core Principles

  1. Zero-Knowledge: Servers store only encrypted data and cannot decrypt it
  2. Client-Side Key Derivation: Encryption keys derived from passwords on user devices
  3. Defense in Depth: Multiple layers of security (encryption, authentication, transport security)
  4. Transparency: Open-source encryption implementation available for audit

System Components

┌─────────────┐         ┌──────────────┐         ┌─────────────┐
│   Client    │  HTTPS  │    Server    │         │  Database   │
│  (Browser)  │◄───────►│  (Rails API) │◄───────►│ (Postgres)  │
└─────────────┘         └──────────────┘         └─────────────┘
      │                         │                        │
      │ Encryption              │ Encrypted Blob         │ Encrypted Blob
      │ Decryption              │ Storage Only           │ Storage Only
      │ Key Management          │ No Keys                │ No Keys

Data Flow:

  1. User enters password → Master Key derived (client-side)
  2. DEK (Data Encryption Key) unwrapped with Master Key (client-side)
  3. Data encrypted with DEK (client-side)
  4. Encrypted data + wrapped DEK sent to server
  5. Server stores encrypted data in database (cannot decrypt)

Threat Model

Protected against:

  • ✅ Server compromise (encrypted data useless without keys)
  • ✅ Database breach (encrypted data, no keys stored)
  • ✅ Man-in-the-middle attacks (HTTPS + HSTS + certificate pinning)
  • ✅ Offline brute force (high KDF iterations)
  • ✅ Rainbow table attacks (unique salt per user)
  • ✅ Replay attacks (nonces in AES-GCM, CSRF tokens)

NOT protected against:

  • ❌ Client-side malware (keylogger, memory scraper)
  • ❌ Compromised user device
  • ❌ Weak user passwords
  • ❌ Phishing attacks (user gives away password)
  • ❌ Rubber-hose cryptanalysis (physical coercion)

Assumption: User’s device and browser are trusted and uncompromised.


Cryptographic Primitives

All algorithms are NIST-approved and battle-tested in production systems.

Symmetric Encryption: AES-256-GCM

Algorithm: Advanced Encryption Standard, 256-bit key, Galois/Counter Mode

Why AES-256-GCM:

  • Industry standard for symmetric encryption
  • NIST approved (FIPS 197)
  • Hardware acceleration on modern CPUs (AES-NI)
  • Authenticated encryption (prevents tampering)
  • IV/nonce prevents identical plaintexts from producing identical ciphertexts

Implementation:

// WebCrypto API
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

const encrypted = await crypto.subtle.encrypt(
  {
    name: "AES-GCM",
    iv: crypto.getRandomValues(new Uint8Array(12)), // 96-bit nonce
    tagLength: 128 // 128-bit authentication tag
  },
  key,
  data
);

Parameters:

  • Key size: 256 bits
  • IV size: 96 bits (12 bytes)
  • Tag length: 128 bits (authentication)
  • Block size: 128 bits

Security: 2^256 possible keys makes brute force computationally infeasible with current technology.

Key Derivation: PBKDF2-SHA256

Algorithm: Password-Based Key Derivation Function 2 with SHA-256

Why PBKDF2:

  • NIST approved (NIST SP 800-132)
  • Widely implemented and audited
  • Configurable iteration count
  • Simple and proven design

Parameters:

  • Hash function: SHA-256
  • Iterations: 600,000 (2025 OWASP recommendation)
  • Salt size: 256 bits (32 bytes, unique per user)
  • Output key: 256 bits (32 bytes)

Implementation:

const salt = crypto.getRandomValues(new Uint8Array(32));

const masterKey = await crypto.subtle.deriveKey(
  {
    name: "PBKDF2",
    salt: salt,
    iterations: 600000,
    hash: "SHA-256"
  },
  passwordKey,
  { name: "AES-GCM", length: 256 },
  false,
  ["wrapKey", "unwrapKey"]
);

Iteration count rationale:

  • OWASP 2023: Minimum 600,000 iterations for PBKDF2-SHA256
  • Balances security vs. performance (~500ms on modern hardware)
  • Increased annually to account for hardware improvements
  • Configurable per-user for future-proofing

Brute force resistance:

  • 600,000 iterations = 600,000× slower than single hash
  • Attacker needs ~600,000 hashes per password attempt
  • GPU-accelerated: ~100-1,000 attempts/second (vs millions without KDF)

Key Wrapping: AES-KW (RFC 3394)

Algorithm: AES Key Wrap

Why AES-KW:

  • RFC 3394 standard specifically designed for wrapping encryption keys
  • Deterministic (same input = same output, for key verification)
  • Compact (minimal overhead)
  • Integrity check built-in

Implementation:

const wrappedDEK = await crypto.subtle.wrapKey(
  "raw",
  dek,
  masterKey,
  { name: "AES-KW" }
);

// Server stores: wrappedDEK + salt + KDF params
// Client can unwrap:
const dek = await crypto.subtle.unwrapKey(
  "raw",
  wrappedDEK,
  masterKey,
  { name: "AES-KW" },
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

Why wrap keys:

  • DEK is random 256-bit key (high entropy)
  • Master Key derived from password (lower entropy)
  • Wrapping adds layer of protection
  • Allows Master Key rotation without re-encrypting all data

Random Number Generation: CSPRNG

Implementation: WebCrypto API crypto.getRandomValues()

Why WebCrypto:

  • Cryptographically secure pseudo-random number generator (CSPRNG)
  • Platform-native implementation (system entropy)
  • No predictable patterns
  • Suitable for cryptographic keys, IVs, salts

Usage:

// Generate salt
const salt = crypto.getRandomValues(new Uint8Array(32));

// Generate IV/nonce
const iv = crypto.getRandomValues(new Uint8Array(12));

// Generate DEK
const dek = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

Entropy sources: OS-level (varies by platform)

  • Linux: /dev/urandom
  • macOS: SecRandomCopyBytes
  • Windows: BCryptGenRandom
  • Browser: Platform-specific CSPRNG

Hashing: SHA-256

Algorithm: Secure Hash Algorithm 256-bit

Usage in PopaDex:

  • Integrity checks
  • PBKDF2 hash function
  • HMAC for authentication tokens
  • Data deduplication (hashes, not plaintext)

Why SHA-256:

  • NIST approved (FIPS 180-4)
  • 256-bit output (2^256 collision resistance)
  • Fast and widely supported
  • No known practical attacks

Key Management

PopaDex uses a hierarchical key system with three key types:

Password
   │
   ├─PBKDF2──► Master Key (256-bit)
   │                │
   │                ├─AES-KW──► Data Encryption Key (DEK, 256-bit)
   │                │                │
   │                │                └──AES-GCM──► Encrypted Data
   │                │
   │                └─Recovery Key (256-bit)
   │                       │
   │                       └─AES-KW──► Recovery-Wrapped DEK

Master Key Derivation

Process:

User Password (variable length string)
    + Salt (32 bytes random, unique per user)
    ↓ PBKDF2-SHA256 (600,000 iterations)
Master Key (256 bits)

Master Key properties:

  • Derived deterministically from password + salt
  • Never stored on server
  • Never transmitted over network
  • Stored temporarily in browser memory during session
  • Cleared on logout or session timeout

Storage:

// Master Key stored in sessionStorage (temporary)
// Cleared after 15 minutes of inactivity or manual logout
sessionStorage.setItem('mk', masterKeyB64);

// Salt stored in database (plain text, non-sensitive)
// Needed to re-derive Master Key on each login

Server-side storage:

-- users table
email: text
password_hash: text (Devise bcrypt, for authentication only)
salt: text (hex-encoded, used for PBKDF2)
wrapped_dek: text (base64-encoded)
kdf_iterations: integer (600000, configurable)

Data Encryption Key (DEK)

Generation:

const dek = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

DEK properties:

  • 256 random bits (maximum entropy)
  • Generated once per user during E2EE setup
  • Used to encrypt/decrypt all user data
  • Never stored in plain text
  • Wrapped with Master Key before storage

Why separate DEK:

  • Password-derived Master Key has lower entropy (limited by password strength)
  • DEK has full 256-bit entropy
  • Allows password change without re-encrypting all data (re-wrap DEK with new Master Key)
  • Key rotation possible (generate new DEK, re-encrypt data, wrap with Master Key)

DEK lifecycle:

Setup:
  1. Generate random 256-bit DEK
  2. Wrap DEK with Master Key → Wrapped DEK
  3. Store Wrapped DEK on server

Login:
  1. Fetch Wrapped DEK from server
  2. Derive Master Key from password + salt
  3. Unwrap DEK using Master Key
  4. Store DEK in memory (sessionStorage)

Session:
  5. Encrypt data with DEK before sending to server
  6. Decrypt data from server with DEK

Logout:
  7. Clear DEK from memory

Recovery Key

Purpose: Backup access if user forgets password

Generation:

const recoveryKey = crypto.getRandomValues(new Uint8Array(32));
const recoveryKeyB64 = btoa(String.fromCharCode(...recoveryKey));

// Derive Recovery Master Key (similar to password-based Master Key)
const recoveryMasterKey = await crypto.subtle.deriveKey(
  {
    name: "PBKDF2",
    salt: recoverySalt, // Separate salt
    iterations: 600000,
    hash: "SHA-256"
  },
  recoveryKey,
  { name: "AES-GCM", length: 256 },
  false,
  ["wrapKey", "unwrapKey"]
);

// Wrap DEK with Recovery Master Key
const recoveryWrappedDEK = await crypto.subtle.wrapKey(
  "raw",
  dek,
  recoveryMasterKey,
  { name: "AES-KW" }
);

Recovery Key properties:

  • 256 random bits (full entropy, like DEK)
  • Base64-encoded for human transmission
  • One-time download during E2EE setup
  • User responsible for secure storage
  • Can be regenerated (invalidates old key)

Server storage:

recovery_salt: text (hex-encoded)
recovery_wrapped_dek: text (base64-encoded)

Recovery process:

  1. User requests password reset with recovery key
  2. System derives Recovery Master Key from recovery key + salt
  3. Unwraps DEK using Recovery Master Key
  4. User sets new password
  5. New Master Key derived from new password
  6. DEK re-wrapped with new Master Key
  7. Old password invalidated, recovery key remains valid

Data Encryption Flow

Login: Key Unwrapping

User Login
    ↓
Enter Password
    ↓
[Client] Fetch Salt from Server
    ↓
[Client] Derive Master Key (PBKDF2: password + salt → Master Key)
    ↓
[Client] Fetch Wrapped DEK from Server
    ↓
[Client] Unwrap DEK (AES-KW: Master Key + Wrapped DEK → DEK)
    ↓
[Client] Store DEK in sessionStorage
    ↓
[Client] Store temporary password in sessionStorage (max 60s, for re-unlock)
    ↓
Session Active (DEK in memory)

Automatic unlock: For 60 seconds after login, password stored in sessionStorage allows automatic re-unlock if session times out. After 60 seconds, password cleared and user must manually re-enter.

Save Data: Encryption

User Updates Financial Data
    ↓
[Client] Serialize data to JSON
    ↓
[Client] Generate random 12-byte IV
    ↓
[Client] Encrypt JSON with AES-GCM (DEK + IV + data → ciphertext + auth tag)
    ↓
[Client] Package: { ciphertext, iv, tag }
    ↓
[HTTPS] Send encrypted blob to server
    ↓
[Server] Store encrypted blob in database (cannot decrypt)

Example encrypted record:

{
  "id": "acct_123",
  "encrypted_data": "A8fG3k9...ciphertext...Zx2pQ==",
  "iv": "7Kf2nM4pQ1xZ",
  "auth_tag": "8Hg3jK9mN..."
}

Load Data: Decryption

User Views Dashboard
    ↓
[HTTPS] Client requests data from server
    ↓
[Server] Returns encrypted blobs { ciphertext, iv, tag }
    ↓
[Client] Retrieve DEK from sessionStorage
    ↓
[Client] Decrypt with AES-GCM (DEK + IV + ciphertext + tag → JSON)
    ↓
[Client] Parse JSON
    ↓
[Client] Display decrypted data in UI

Decryption verification: AES-GCM auth tag automatically verifies integrity. If tampered, decryption fails.

Session Timeout: Key Clearing

15 Minutes of Inactivity
    ↓
[Client] E2EE Session Timeout Triggered
    ↓
[Client] Clear DEK from sessionStorage
    ↓
[Client] Clear any decrypted data from memory
    ↓
User sees "Locked" state (balances show "••••••")
    ↓
Re-enter password to unlock:
    ↓
[Client] Retrieve temporary password from sessionStorage (if < 60s)
    OR User manually re-enters password
    ↓
[Client] Re-derive Master Key, unwrap DEK
    ↓
Session Active Again

Full logout (30 minutes): Devise session expires, user redirected to login page.


Session Management

PopaDex uses a two-tier timeout system to balance security and convenience.

Timeout Levels

Level 1: E2EE Session Timeout (15 minutes)

  • Clears encryption keys (DEK) from browser memory
  • User sees balances as “••••••” (data still on server, just can’t decrypt)
  • Re-enter password to unlock (< 5 seconds)
  • Prevents data exposure if user walks away from device

Level 2: Full Logout (30 minutes)

  • Complete Devise (Rails authentication) session expires
  • User redirected to login page
  • Must sign in fully (email + password)
  • More secure for shared/public computers

Automatic Re-unlock During Login

Problem: User logs in, then E2EE session times out 15 minutes later - annoying to re-enter password.

Solution: Temporary password storage (60 seconds max)

Login
  ↓
Store password in sessionStorage (encrypted with session-specific key)
  ↓
Derive Master Key, unwrap DEK
  ↓
Start 60-second timer
  ↓
After 60 seconds: Clear password from memory
  ↓
If E2EE timeout occurs within 60s: Auto-unlock with stored password
If E2EE timeout occurs after 60s: User must manually re-enter password

Security trade-off:

  • Risk: Password in memory for 60 seconds
  • Mitigation: sessionStorage cleared on tab close, not accessible to other sites
  • Benefit: Seamless login experience without repeated password entry

Implementation:

// Stimulus controller
connect() {
  const password = sessionStorage.getItem('temp_pwd');
  if (password && this.isE2EELocked()) {
    this.unlockE2EE(password);
  }
  
  setTimeout(() => {
    sessionStorage.removeItem('temp_pwd');
  }, 60000); // Clear after 60 seconds
}

Attack Resistance

Offline Brute Force

Attack: Attacker obtains database dump, attempts to crack passwords offline.

Defense: High KDF Iterations

  • 600,000 PBKDF2 iterations makes each password attempt ~500ms
  • GPU-accelerated: ~100-1,000 attempts/second (vs millions/second without KDF)
  • 8-character alphanumeric password (62^8 = 218 trillion combinations)
  • At 1,000 attempts/second: ~7 million years to crack

Additional protection:

  • Wrapped DEK (even if password cracked, must also crack DEK wrapping)
  • Unique salt per user (no rainbow tables)
  • Encrypted data (even with DEK, must decrypt all records)

Server Compromise

Attack: Attacker gains full access to PopaDex servers and database.

What attacker gets:

  • Encrypted data blobs (useless without DEK)
  • Wrapped DEKs (useless without Master Keys)
  • Salts (public knowledge, not secret)
  • KDF parameters (public knowledge)
  • User emails and metadata

What attacker DOESN’T get:

  • Plaintext financial data (encrypted)
  • DEKs (wrapped with Master Keys never stored)
  • Master Keys (derived from passwords, never stored)
  • Passwords (only bcrypt hashes for authentication, useless for decryption)

Result: Even with complete server access, user data remains confidential.

Man-in-the-Middle (MITM)

Attack: Attacker intercepts network traffic between client and server.

Defense:

  • HTTPS/TLS 1.3: All traffic encrypted in transit
  • HSTS: HTTP Strict Transport Security forces HTTPS
  • Certificate Pinning: Prevents rogue certificates (mobile apps)
  • Data already encrypted: Even if HTTPS broken, attacker sees E2EE ciphertext

Additional protection:

  • CSRF tokens prevent cross-site request forgery
  • SameSite cookies prevent CSRF
  • Content Security Policy (CSP) prevents XSS

XSS (Cross-Site Scripting)

Attack: Malicious JavaScript injected into PopaDex pages.

Defense:

  • CSP headers: Restrict script sources
  • Input sanitization: All user inputs escaped/sanitized
  • Short-lived keys: DEK in memory only during active session
  • Session timeouts: Keys cleared after 15 minutes inactivity

Risk acceptance: If attacker achieves XSS, they can extract DEK from memory. However, this requires active session (user logged in) and breaks on logout.

Replay Attacks

Attack: Attacker captures encrypted request, replays it later.

Defense:

  • AES-GCM nonces: Each encryption uses unique IV, prevents replay
  • CSRF tokens: Server validates request authenticity
  • Timestamp validation: Reject old requests
  • Session tokens: JWT expires after timeout

Rainbow Tables

Attack: Pre-computed password hashes for fast cracking.

Defense:

  • Unique salt per user: Rainbow table must be computed per user
  • High iteration count: 600,000 iterations makes pre-computation impractical
  • Combined cost: Attacker must compute 600k-iteration hashes for each password per user

Result: Rainbow tables offer no advantage over online brute force.


Security Audits & Testing

Internal Security Reviews

  • Frequency: Quarterly
  • Scope: Code review, architecture review, threat modeling
  • Team: Security engineer + external consultant

Automated Vulnerability Scanning

  • Tools: Dependabot, npm audit, bundle-audit
  • Frequency: Continuous (every commit)
  • Action: Auto-patch low-risk vulnerabilities, manual review for high-risk

Penetration Testing

  • Frequency: Annual
  • Vendor: [Third-party security firm - NDA]
  • Scope: Full-stack testing (web app, API, infrastructure)
  • Last test: September 2025
  • Results: No critical vulnerabilities, 2 medium-severity findings (patched within 48 hours)

Responsible Disclosure

We welcome security researchers to report vulnerabilities.

Process:

  1. Email [email protected] with details
  2. Do not publicly disclose until we’ve had time to patch (90 days)
  3. We acknowledge receipt within 48 hours
  4. We provide fix timeline within 7 days
  5. We credit researchers in hall of fame (optional)

Bug Bounty Program: Coming 2026


Compliance & Standards

GDPR Compliance

  • Data minimization: Collect only necessary data
  • Right to erasure: Account deletion purges all data within 48 hours
  • Data portability: Export functionality (CSV/JSON)
  • Encryption: E2EE exceeds GDPR security requirements

NIST Guidelines

  • NIST SP 800-132: PBKDF2 implementation follows guidelines
  • NIST SP 800-57: Key management practices
  • FIPS 197: AES encryption
  • FIPS 180-4: SHA-256 hashing

OWASP Best Practices

  • OWASP Top 10: Addressed in architecture
  • Password storage: PBKDF2 with high iterations
  • Session management: Secure session handling
  • Input validation: All inputs validated/sanitized

Future Improvements

Planned Enhancements

Q1 2026:

  • Argon2id: Migrate from PBKDF2 to Argon2id (more memory-hard)
  • Hardware security keys: WebAuthn/U2F support
  • Biometric unlock: Face ID/Touch ID for mobile apps

Q2 2026:

  • Key rotation: Automated DEK rotation
  • Multi-device sync: End-to-end encrypted sync between devices
  • Encrypted backups: Zero-knowledge cloud backups

Q3 2026:

  • Post-quantum cryptography: Transition to quantum-resistant algorithms
  • Formal verification: Mathematical proof of security properties

References

Standards:

Implementation:

Security:


Contact

Security Questions: [email protected]

General Support: [email protected]

Vulnerability Reports: [email protected] (PGP key available)


Last updated: October 6, 2025 Version: 2025.10 Next security audit: Q3 2026

Start Using PopaDex

Improve your Net Worth Tracking and Personal Finance Management

Sign up to our newsletter

To stay up to date with the roadmap progress, announcements and exclusive discounts, make sure to sign up with your email below.