This commit is contained in:
Marius Pana 2026-01-15 13:38:02 +02:00
commit 09139f0681
4 changed files with 431 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
data/

79
README.md Normal file
View File

@ -0,0 +1,79 @@
# 🔥 SecureBurn
A ultra-minimalist, zero-dependency Node.js "PrivateBin" clone. It allows users to share encrypted secrets that self-destruct (burn) immediately after the first access.
# 🛡️ Security Model: Zero-Knowledge
Encryption: AES-256-GCM encryption happens entirely in the sender's browser.
Privacy: The decryption key is stored in the URL after the # symbol (the fragment identifier).
Blind Storage: Browsers do not send the URL fragment to the server. Therefore, the server only ever sees and stores encrypted binary data. It has no way to read your secrets.
Self-Destruction: The server deletes the encrypted file from the disk the moment it is streamed to a recipient.
# 🚀 Getting Started
## Prerequisites
Node.js (v16.0.0 or higher recommended)
No package manager (npm/yarn) is required.
## Installation & Running
Clone or copy the three files (server.js, index.html, auth.json) into a directory.
Start the server:
```Bash
node server.js
```
Access the UI: Open http://localhost:3000 in your browser.
# 🛠️ Configuration
The application is designed to be plug-and-play. You can modify the constants at the top of server.js:
PORT: The port the server listens on (default: 3000).
DATA_DIR: Where encrypted blobs are stored (default: ./data).
MAX_SIZE_BYTES: Maximum secret size (default: 10MB).
# 📡 API Usage
You can create burn codes programmatically without using the web UI.
Create a Secret
Endpoint: POST /api/secret
Body: Raw binary data (the encrypted payload).
Example using curl:
```Bash
curl -X POST --data-binary "@encrypted_file.bin" http://localhost:3000/api/secret
Response:
JSON
{ "id": "550e8400-e29b-41d4-a716-446655440000" }
```
# 📋 Features
Zero Dependencies: Uses only native Node.js modules (http, fs, crypto, path).
10MB Capacity: Handles large text blocks or small files.
One-Click Copy: Generated links are automatically copied to the clipboard.
Mobile Friendly: Clean, responsive "Chat-style" UI.
Secure Erasure: Uses fs.unlink to ensure the file is removed from the filesystem after one read.
# ⚠️ Important Notes
Persistence: Since it uses the file system, secrets will survive a server restart until they are burned.
HTTPS: To use the Web Crypto API (window.crypto), this app must be served over HTTPS in production (except for localhost).
# Todo
- tested only on macos using safari (not sure if works on other platforms)
- add some method to delete secrets that have not been accessed within a certain time frame.
- add some method to expiry secrets (e.g. when creating, maybe define how long they should be valid for)

283
index.html Normal file
View File

@ -0,0 +1,283 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PrivateBin Clone</title>
<style>
:root {
--bg: #1e1e1e;
--surface: #2d2d2d;
--primary: #10a37f;
--text: #ececec;
}
body {
background: var(--bg);
color: var(--text);
font-family: sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 90%;
max-width: 600px;
background: var(--surface);
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
textarea {
width: 100%;
height: 250px;
background: #111;
border: 1px solid #444;
color: #fff;
padding: 1rem;
border-radius: 8px;
font-size: 16px;
resize: none;
box-sizing: border-box;
outline: none;
}
textarea:focus {
border-color: var(--primary);
}
.btn {
width: 100%;
background: var(--primary);
color: white;
border: none;
padding: 15px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
margin-top: 1rem;
}
.btn:active {
transform: scale(0.98);
}
.url-box {
background: #111;
padding: 15px;
border-radius: 8px;
border: 1px dashed var(--primary);
margin-top: 1rem;
cursor: pointer;
word-break: break-all;
text-align: center;
}
.hidden {
display: none;
}
.status {
font-size: 12px;
color: #888;
margin-top: 8px;
text-align: center;
}
</style>
</head>
<body>
<div class="card" id="main">
<div id="input-view">
<textarea
id="text"
placeholder="Type or paste your secret here..."
></textarea>
<button class="btn" onclick="burnIt()">Create Burn Link</button>
<div class="status">
Maximum size: 10MB. Content is encrypted in-browser.
</div>
</div>
<div id="result-view" class="hidden">
<h2 style="margin-top: 0">Link Generated!</h2>
<p style="color: #aaa">
Copy the link below. It will be deleted forever after the
first person opens it.
</p>
<div
class="url-box"
id="url-display"
onclick="copyToClipboard()"
></div>
<button
class="btn"
style="background: #444"
onclick="location.reload()"
>
Create Another
</button>
</div>
<div id="read-view" class="hidden">
<h2 id="read-status" style="margin-bottom: 0">Decrypting...</h2>
<div id="content-container" class="hidden">
<div
id="content"
style="
white-space: pre-wrap;
background: #111;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
border: 1px solid #333;
max-height: 400px;
overflow-y: auto;
"
></div>
<div style="display: flex; gap: 10px; margin-top: 1rem">
<button
class="btn"
onclick="copyDecryptedText()"
style="flex: 1"
>
📋 Copy Secret
</button>
<button
class="btn secondary"
onclick="location.href = '/'"
style="flex: 1; background: #444"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
<script>
// Check for existing link in URL on load
window.onload = () => {
if (window.location.hash.includes("id=")) initDecryption();
};
async function burnIt() {
const text = document.getElementById("text").value;
if (!text) return;
// 1. Generate Key
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
// 2. Encrypt
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(text),
);
// 3. Upload Blob (IV + Ciphertext)
const blob = new Blob([iv, encrypted]);
const res = await fetch("/api/secret", {
method: "POST",
body: blob,
});
const { id } = await res.json();
// 4. Create Hash-Link (Key stays in hash, never sent to server)
const jwk = await crypto.subtle.exportKey("jwk", key);
const keyStr = btoa(JSON.stringify(jwk));
const fullUrl = `${window.location.origin}/#id=${id}&key=${keyStr}`;
document.getElementById("input-view").classList.add("hidden");
document
.getElementById("result-view")
.classList.remove("hidden");
document.getElementById("url-display").innerText = fullUrl;
navigator.clipboard.writeText(fullUrl);
}
async function initDecryption() {
document.getElementById("input-view").classList.add("hidden");
document.getElementById("read-view").classList.remove("hidden");
const statusEl = document.getElementById("read-status");
const contentContainer =
document.getElementById("content-container");
try {
const params = new URLSearchParams(
window.location.hash.substring(1),
);
const id = params.get("id");
const keyStr = params.get("key");
if (!id || !keyStr)
throw new Error("Invalid or malformed link.");
const res = await fetch(`/api/secret/${id}`);
if (!res.ok)
throw new Error(
"This secret was already burned or never existed.",
);
const buf = await res.arrayBuffer();
const iv = buf.slice(0, 12);
const data = buf.slice(12);
const jwk = JSON.parse(atob(keyStr));
const key = await window.crypto.subtle.importKey(
"jwk",
jwk,
"AES-GCM",
true,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
data,
);
const text = new TextDecoder().decode(decrypted);
// Update Header with Warning
statusEl.innerHTML = `
<span style="color: var(--primary)">🔥 Secret Decrypted & Burned</span>
<div style="font-size: 0.9rem; color: #ffab00; margin-top: 12px; font-weight: normal; line-height: 1.4; border-left: 3px solid #ffab00; padding-left: 10px;">
<strong>Attention:</strong> This is the only time and place you can view this secret.
It has been deleted from the server. Copy it now before closing this tab.
</div>
`;
document.getElementById("content").innerText = text;
contentContainer.classList.remove("hidden");
// Wipe hash from URL immediately for safety
window.history.replaceState(null, null, " ");
} catch (e) {
statusEl.innerText = "❌ Error";
statusEl.style.color = "#ff4444";
const content = document.getElementById("content");
contentContainer.classList.remove("hidden");
content.innerText = e.message;
content.style.color = "#ff8888";
}
}
function copyDecryptedText() {
const text = document.getElementById("content").innerText;
navigator.clipboard.writeText(text);
const copyBtn = event.target;
const originalText = copyBtn.innerText;
copyBtn.innerText = "✅ Copied!";
setTimeout(() => (copyBtn.innerText = originalText), 2000);
}
function copyToClipboard() {
const text = document.getElementById("url-display").innerText;
navigator.clipboard.writeText(text);
alert("Copied!");
}
</script>
</body>
</html>

68
server.js Normal file
View File

@ -0,0 +1,68 @@
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const PORT = process.env.PORT || 3000;
const DATA_DIR = path.join(__dirname, 'data');
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
const sendJSON = (res, status, data) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
const server = http.createServer((req, res) => {
const { method, url } = req;
// Serve Frontend
if (method === 'GET' && (url === '/' || url.startsWith('/view'))) {
res.writeHead(200, { 'Content-Type': 'text/html' });
fs.createReadStream(path.join(__dirname, 'index.html')).pipe(res);
return;
}
// CREATE Secret (Public POST)
if (method === 'POST' && url === '/api/secret') {
const contentLength = parseInt(req.headers['content-length'], 10);
if (contentLength > MAX_SIZE_BYTES) {
res.writeHead(413); return res.end('Too large');
}
const id = crypto.randomUUID(); // Unique and Random URL component
const filePath = path.join(DATA_DIR, id);
const writeStream = fs.createWriteStream(filePath);
req.pipe(writeStream);
req.on('end', () => sendJSON(res, 201, { id }));
writeStream.on('error', () => { res.writeHead(500); res.end(); });
return;
}
// READ & BURN Secret
if (method === 'GET' && url.startsWith('/api/secret/')) {
const id = url.split('/').pop();
if (!/^[a-z0-9-]+$/i.test(id)) { res.writeHead(400); return res.end(); }
const filePath = path.join(DATA_DIR, id);
if (!fs.existsSync(filePath)) {
res.writeHead(404); return res.end('Burned or Not Found');
}
const readStream = fs.createReadStream(filePath);
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
readStream.pipe(res);
// Delete immediately after stream ends
readStream.on('end', () => {
fs.unlink(filePath, (err) => { if (err) console.error("Burn failed", err); });
});
return;
}
res.writeHead(404); res.end();
});
server.listen(PORT, () => console.log(`Public Burn Server: http://localhost:${PORT}`));