DEV Community

Cover image for How I Built an End-to-End Encrypted File Sharing App with Flask and ChaCha20 in 30 Days
Aayush
Aayush

Posted on

How I Built an End-to-End Encrypted File Sharing App with Flask and ChaCha20 in 30 Days

OneTimeShare Demo

TL;DR: I built a self-destructing file sharing app in 30 days. Files are encrypted with ChaCha20-Poly1305, passwords are hashed with Argon2id, and everything deletes after one download. On launch day, I found a race condition that would have let attackers bypass my password limit. Here's the full story.

GitHub logo Aayushbankar / onetimeshare

Secure, one-time file sharing web app

πŸš€ One-Time Secure File/Text Sharing App

30-Day Build Challenge

Status CI Language License Deployment

πŸ”΄ LIVE DEMO: https://onetimeshare.onrender.com


🧐 The Problem

Sharing sensitive information (API keys, passwords, configuration files) via email, Discord, or WhatsApp is insecure. Third-party services like Pastebin or WeTransfer store your data on their servers, creating a privacy risk.

πŸ’‘ The Solution

Build a lightweight, containerized Python web application that allows you to:

  1. Upload a file or text snippet.
  2. Generate a unique, secure link.
  3. Permanently delete the data from the server immediately after it is viewed once (or after a short timer expires).

Project Overview

Goal: Build a secure, one-time file sharing application within 30 days. Timeline: December 24, 2025 β†’ January 24, 2026 Tech Stack:

  • Backend: Python / Flask
  • Storage: Dockerized Redis (Ephemeral storage)
  • Containerization: Docker
  • UI: Bootstrap
  • CI/CD: GitHub Actions

πŸš€ Quick Start (Docker - Recommended)

The fastest way to run…


The Problem

Every developer has done it.

Shared an API key in Slack. Sent a .env file over WhatsApp. Pasted credentials into a Discord DM.

These messages live forever. They're indexed. They're searchable. They're a breach waiting to happen.

I wanted a tool where:

  1. Files are encrypted at rest (the server can't read them)
  2. Files delete themselves after one download
  3. Password protection is zero-knowledge (the key never touches the server)

Existing tools either store data indefinitely, require accounts, or cost enterprise money.

So I built my own. In 30 days. In public.


The Stack

Component Choice Why
Backend Flask (Python 3.13) Lightweight, flexible, I know it
Database Redis TTL auto-expiry = perfect for ephemeral data
Encryption ChaCha20-Poly1305 Constant-time, used by Signal, no AES-NI needed
KDF Argon2id Memory-hard, GPU-resistant
Deployment Docker + Render Free tier, Gunicorn-ready

Why ChaCha20 over AES-GCM?

AES-GCM performance varies based on hardware (AES-NI). ChaCha20-Poly1305 is software-optimized and constant-time everywhere β€” critical for containerized deployments where you don't control the CPU.


The Build: Week by Week

Week 1: Foundation (Days 1-7)

Day 1: Flask application factory, Dockerfile, docker-compose with Redis.

Day 2: Core upload endpoint. UUID filenames (path traversal prevention), extension validation, 20MB limit.

Day 3: The first "graveyard" moment.

I built an entire RedisService class. Flask ignored it completely.

The bug: Missing __init__.py.

Two hours. Zero bytes of actual code.

Day 6: Atomic self-destruct with Redis WATCH/MULTI/EXEC:

pipeline.watch(token)
metadata = redis_client.hgetall(token)
if metadata:
    pipeline.multi()
    pipeline.delete(token)
    pipeline.execute()
    return metadata
Enter fullscreen mode Exit fullscreen mode

Week 1 Bug Count: 29


Week 2: The Security Core (Days 8-14)

This is where 80% of the bugs lived.

Day 10: The HTTP Statelessness Disaster

I implemented password retry limits:

# THE BUG
attempts = 0
if attempts > 5:
    block_user()
attempts += 1
Enter fullscreen mode Exit fullscreen mode

26 bugs in one day.

All from the same root cause: I forgot that HTTP is stateless. Python variables don't persist across requests. The retry counter had to live in Redis.

The lesson: Every persistent state must be externalized.

Day 14: The DoS I Built

My "security" feature: delete files after 5 wrong passwords.

The attack I didn't consider: anyone could delete anyone's file by sending 5 wrong passwords.

The fix: Lock files, don't delete them. Deletion = DoS vector.


Week 3: Encryption and Hardening (Days 15-21)

Day 16: Streaming Encryption

The naive approach: read entire file into memory, encrypt, write back.

The problem: 500MB file = 500MB RAM = OOM kill.

The solution: 64KB streaming chunks:

def encrypt_file_chunked(input_path, output_path, key):
    base_nonce = os.urandom(12)  # Unique per file
    cipher = ChaCha20Poly1305(key)

    with open(input_path, 'rb') as infile:
        with open(output_path, 'wb') as outfile:
            chunk_num = 0
            while chunk := infile.read(64 * 1024):
                chunk_nonce = increment_nonce(base_nonce, chunk_num)
                encrypted = cipher.encrypt(chunk_nonce, chunk, None)
                outfile.write(len(encrypted).to_bytes(4, 'big'))
                outfile.write(encrypted)
                chunk_num += 1
    return base_nonce
Enter fullscreen mode Exit fullscreen mode

Critical detail: Nonce = base_nonce + chunk_number using arithmetic addition. XOR would cause collisions.

Day 20: Load Testing

Ran Locust across 3 tiers:

  • Tier 1 (30 users): 174 RPS, 355ms p95
  • Tier 2 (100 users): 116 RPS, 1.3s p95
  • Tier 3 (300 users): 97 RPS, 3.5s p95

Result: Grade A, 0% error rate. Production-ready.


Week 4: Launch (Days 22-30)

Days 22-25: CI/CD (GitHub Actions), Playwright E2E tests, security headers (CSP, HSTS, X-Frame-Options).

Day 29: Deep security audit. Fixed nonce edge cases, added constant-time comparison.

Day 30: The audit that almost didn't happen.


The Day 30 Crisis

9 PM. Launch day. Everything ready.

I almost shipped.

Then I ran one more pass.

Critical Bug #1: Race Condition

# THE BUG
attempts = int(metadata.get('attempt_to_unlock', 0))
attempts += 1
metadata['attempt_to_unlock'] = str(attempts)
redis_service.store_file_metadata(token, metadata)
Enter fullscreen mode Exit fullscreen mode

The attack: 50 parallel requests hit this simultaneously.

  1. All 50 read attempts = 0
  2. All 50 compute attempts = 1
  3. All 50 write attempts = 1

Result: 50 password guesses for the price of 1. My "5 attempt limit" was fiction.

The fix: Atomic increment:

# THE FIX
def increment_file_attempt(self, token: str) -> int:
    return self.redis_client.hincrby(token, "attempt_to_unlock", 1)
Enter fullscreen mode Exit fullscreen mode

HINCRBY is atomic. Redis serializes operations. No race condition possible.

Critical Bug #2: Missing CSRF

Admin login had no CSRF token.

The attack: Login CSRF. Attacker forces victim to log into attacker's account.

The fix: Flask-WTF. 15 minutes.


The Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     CLIENT                           β”‚
β”‚  Browser β†’ Drag/Drop β†’ Password (Optional)          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 FLASK + GUNICORN                     β”‚
β”‚  Routes (HTTP) β†’ Services (Logic) β†’ Utils (Crypto)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β–Ό                             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       REDIS          β”‚    β”‚    ENCRYPTED DISK       β”‚
β”‚  Metadata (TTL: 5h)  β”‚    β”‚  ChaCha20-Poly1305      β”‚
β”‚  Atomic Counters     β”‚    β”‚  UUID Filenames         β”‚
β”‚  Rate Limit State    β”‚    β”‚  64KB Chunks            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Final Stats

Metric Value
Days 30
Commits 60
Bugs Fixed 100+
Critical Vulns (Day 30) 6
Lines of Code ~5,000

Key Lessons

1. Read-Modify-Write = Race Condition

If you're incrementing anything in a distributed system, use atomic operations. Always.

2. Security is Logic, Not Libraries

I had ChaCha20. I had Argon2. I still had a brute-force bypass in the application logic.

3. The Final Audit is Non-Negotiable

If I'd shipped on Day 29, I'd be writing an incident report.

4. Build in Public Works

The pressure of knowing people were watching made me do the extra audit. That audit caught the race condition.


Future Roadmap

  • [ ] S3 integration for files > 20MB
  • [ ] Public API for CLI access
  • [ ] Mobile-optimized UI

Try It

Live Demo: onetimeshare.onrender.com

Source Code: github.com/Aayushbankar/onetimeshare


If you learned something, I'd appreciate a follow. I build in public and document everything β€” including the failures.

What's the scariest bug you've found on launch day? πŸ‘‡

Top comments (0)