engage
This commit is contained in:
commit
09139f0681
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
79
README.md
Normal file
79
README.md
Normal 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
283
index.html
Normal 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
68
server.js
Normal 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}`));
|
||||||
Loading…
x
Reference in New Issue
Block a user