Build a minimal, client-side encrypted paste app where the server never sees plaintext
Welcome to another official UVF IT build guide. Today, we are creating a minimal working clone of Nullsec, a server-blind encrypted paste service. The core security model here is simple but powerful: all encryption happens in the browser using Web Crypto, and the server only ever stores opaque ciphertext blobs.
We will use Python with Flask and SQLite for the backend, and a single HTML/JS file for the frontend. This stack is free, runs locally in under 45 minutes, and lets you verify the 'server-blind' property by inspecting the database directly.
npx serve or just opening the HTML file directly if CORS is handled).Create a project directory and initialize a virtual environment to keep your dependencies isolated from your system Python. This ensures that installing Flask and its extensions doesn't interfere with other projects on your machine.
Install Flask for the API and Flask-Cors to allow the browser to make cross-origin requests when running the frontend separately. We will also install cryptography if needed, but for this specific clone, we are relying on the browser's native Web Crypto API, so the server-side crypto dependencies are minimal.
mkdir nullsec-clone
cd nullsec-clone
python3 -m venv venv
source venv/bin/activate
pip install flask flask-corsCreate app.py. The server stores only the ciphertext and a random ID. It never touches the plaintext or the key. This is the core of the 'server-blind' architecture: the server is just a dumb storage bucket for encrypted blobs.
We use SQLite for persistence. The schema is minimal: id (text, primary key) and data (text, the base64-encoded ciphertext). We initialize the database table on startup if it doesn't exist.
The API has two endpoints: POST /paste to create a new paste and GET /paste/<id> to retrieve it. The POST endpoint accepts a JSON body with the ciphertext, generates a UUID for the paste, and inserts it into the database. The GET endpoint retrieves the ciphertext by ID. If the ID doesn't exist, it returns a 404.
app.py
import sqlite3
import uuid
from flask import Flask, request, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
DB_NAME = 'paste.db'
def init_db():
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS pastes (
id TEXT PRIMARY KEY,
data TEXT NOT NULL
)
''')
conn.commit()
conn.close()
init_db()
@app.route('/paste', methods=['POST'])
def create_paste():
data = request.get_json()
if not data or 'ciphertext' not in data:
return jsonify({'error': 'Missing ciphertext'}), 400
paste_id = str(uuid.uuid4())
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute('INSERT INTO pastes (id, data) VALUES (?, ?)', (paste_id, data['ciphertext']))
conn.commit()
conn.close()
return jsonify({'id': paste_id}), 201
@app.route('/paste/<paste_id>', methods=['GET'])
def get_paste(paste_id):
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute('SELECT data FROM pastes WHERE id = ?', (paste_id,))
result = cursor.fetchone()
conn.close()
if result is None:
return jsonify({'error': 'Paste not found'}), 404
return jsonify({'ciphertext': result[0]}), 200
if __name__ == '__main__':
app.run(debug=True, port=5000)Create static/index.html. This single file handles UI, encryption, and API calls. It contains the HTML structure, CSS for styling, and the JavaScript logic for Web Crypto operations.
We use the Web Crypto API to generate a random AES-GCM key from a user-provided password. The key is never sent to the server. We use a simple PBKDF2 derivation to turn the password into a cryptographic key, which is a standard practice for password-based encryption.
The plaintext is encrypted in the browser, then base64-encoded and sent to the Flask API. The server stores this opaque string. To decrypt, the user enters the same password, the browser regenerates the key, and decrypts the ciphertext locally.
static/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nullsec Clone</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
.section { margin-bottom: 30px; padding: 20px; border: 1px solid #ccc; border-radius: 8px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, textarea { width: 100%; padding: 10px; margin-bottom: 10px; box-sizing: border-box; }
button { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background-color: #0056b3; }
#result { margin-top: 20px; padding: 10px; background-color: #f0f0f0; border-radius: 4px; word-break: break-all; }
</style>
</head>
<body>
<h1>Server-Blind Encrypted Paste</h1>
<div class="section">
<h2>Create Paste</h2>
<label for="plaintext">Plaintext:</label>
<textarea id="plaintext" rows="4" placeholder="Enter your secret text..."></textarea>
<label for="password">Password:</label>
<input type="password" id="password" placeholder="Enter a password">
<button onclick="createPaste()">Create Paste</button>
</div>
<div class="section">
<h2>Decrypt Paste</h2>
<label for="pasteId">Paste ID:</label>
<input type="text" id="pasteId" placeholder="Enter paste ID">
<label for="decryptPassword">Password:</label>
<input type="password" id="decryptPassword" placeholder="Enter the same password">
<button onclick="decryptPaste()">Decrypt & View</button>
</div>
<div id="result"></div>
<script>
const API_BASE = 'http://localhost:5000';
// Helper to convert ArrayBuffer to Base64
function bufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// Helper to convert Base64 to ArrayBuffer
function base64ToBuffer(base64) {
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// Derive key from password using PBKDF2
async function deriveKey(password) {
const encoder = new TextEncoder();
const salt = encoder.encode('nullsec-clone-salt'); // Fixed salt for simplicity
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{
name: 'AES-GCM',
length: 256
},
false,
['encrypt', 'decrypt']
);
}
// Encrypt plaintext
async function encrypt(plaintext, password) {
const key = await deriveKey(password);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes for AES-GCM
const encoder = new TextEncoder();
const plaintextBuffer = encoder.encode(plaintext);
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
plaintextBuffer
);
// Combine IV and ciphertext, then base64 encode
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedBuffer), iv.length);
return bufferToBase64(combined.buffer);
}
// Decrypt ciphertext
async function decrypt(ciphertextBase64, password) {
const key = await deriveKey(password);
const buffer = base64ToBuffer(ciphertextBase64);
const bytes = new Uint8Array(buffer);
const iv = bytes.slice(0, 12);
const ciphertext = bytes.slice(12);
try {
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
ciphertext.buffer
);
const decoder = new TextDecoder();
return decoder.decode(decryptedBuffer);
} catch (e) {
throw new Error('Decryption failed. Wrong password?');
}
}
async function createPaste() {
const plaintext = document.getElementById('plaintext').value;
const password = document.getElementById('password').value;
const resultDiv = document.getElementById('result');
if (!plaintext || !password) {
alert('Please enter both plaintext and password.');
return;
}
try {
const ciphertext = await encrypt(plaintext, password);
const response = await fetch(`${API_BASE}/paste`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ciphertext: ciphertext })
});
if (!response.ok) {
throw new Error('Failed to create paste');
}
const data = await response.json();
resultDiv.textContent = `Paste created! ID: ${data.id}`;
} catch (error) {
resultDiv.textContent = `Error: ${error.message}`;
}
}
async function decryptPaste() {
const pasteId = document.getElementById('pasteId').value;
const password = document.getElementById('decryptPassword').value;
const resultDiv = document.getElementById('result');
if (!pasteId || !password) {
alert('Please enter both paste ID and password.');
return;
}
try {
const response = await fetch(`${API_BASE}/paste/${pasteId}`);
if (!response.ok) {
throw new Error('Paste not found');
}
const data = await response.json();
const plaintext = await decrypt(data.ciphertext, password);
resultDiv.textContent = `Decrypted Text: ${plaintext}`;
} catch (error) {
resultDiv.textContent = `Error: ${error.message}`;
}
}
</script>
</body>
</html>Now that the backend and frontend are ready, it is time to bring them to life. You will need two separate processes running simultaneously: the Python Flask server to handle API requests and store data, and your web browser to execute the JavaScript encryption logic.
Start the Flask application in your terminal. This will listen on port 5000 by default. Keep this terminal window open, as it is the heart of your service. If you see 'Running on http://127.0.0.1:5000', you are good to go.
For the frontend, you have two options. The simplest is to open static/index.html directly in your browser. However, because we are making API calls to http://localhost:5000, you must ensure that CORS is handled. Since we installed flask-cors in Phase 1, the server will allow these cross-origin requests even if the HTML file is loaded from the file:// protocol in most modern browsers. If your browser blocks file:// requests, you can use a simple static server like npx serve static in a second terminal.
python app.pyopen static/index.html
# On Linux, use: xdg-open static/index.html
# On Windows, use: start static/index.htmlThe most critical part of this build is proving that the server is truly 'blind'. We need to demonstrate that the data stored in the database is indistinguishable from random noise to anyone who does not possess the encryption key.
First, create a paste in your browser. Enter a very distinct plaintext, such as 'Hello UVF IT', and set a password like 'secret'. Click 'Create Paste' and note the UUID that appears in the alert box.
Now, switch to your terminal. We will query the SQLite database directly. If the server were storing plaintext, you would see 'Hello UVF IT' in the output. Instead, you should see a long, base64-encoded string. This string is the ciphertext, which is useless without the key derived from the password.
This step is your proof of concept. It confirms that even if an attacker gains full read access to your paste.db file, they cannot read the contents of your pastes without the password.
sqlite3 paste.db "SELECT data FROM pastes LIMIT 1;"| Check | Command / Why it matters |
|---|---|
| Backend is running | Run curl http://localhost:5000/paste/test and expect a 404 JSON response, not a connection error. |
| Paste creation works | In the browser, enter text and password, click 'Create Paste', and see an alert with a UUID. |
| Server-blindness verified | Run sqlite3 paste.db "SELECT data FROM pastes LIMIT 1;" and confirm the output is base64, not your plaintext. |
| Decryption works | In the browser, enter the paste ID and correct password, click 'Decrypt & View', and see the original plaintext. |
| Wrong password fails | Enter the paste ID and an incorrect password, click 'Decrypt & View', and see 'Decryption failed. Wrong password?' |
You have built a minimal server-blind encrypted paste service. To extend it, consider adding: 1) Paste expiration (store a timestamp and delete old entries), 2) Burn-after-read (delete the paste after first successful decryption), 3) More robust key derivation (use a random salt stored with the ciphertext instead of deriving it from the password each time). Always verify that your server never logs or stores plaintext.