summaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-06-20 01:07:45 +0000
committerGab Virebent <gabriel1@virebent.art>2026-06-20 01:07:45 +0000
commitaa459e3b84cc4c5d908f3781c9253848c18640c3 (patch)
tree8454b956cc0fa027034c8291f39d58ece469e10c /static
downloadnymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.tar.gz
nymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.tar.xz
nymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.zip
Initial public release: Nym-native anonymous submission system
End-to-end verified pipeline (browser -> HTTP relay -> Nym mixnet -> reader). Client-side X25519+HKDF+AES-GCM-256, no-log blind relay, AGPL-3.0.
Diffstat (limited to 'static')
-rw-r--r--static/crypto.js111
-rw-r--r--static/index.html61
-rw-r--r--static/style.css179
3 files changed, 351 insertions, 0 deletions
diff --git a/static/crypto.js b/static/crypto.js
new file mode 100644
index 0000000..63b395f
--- /dev/null
+++ b/static/crypto.js
@@ -0,0 +1,111 @@
+// NymDrop — client-side encryption via WebCrypto API.
+// No external libraries. Plaintext never leaves the browser.
+
+// Server's X25519 public key — replaced at deploy time.
+const NYMDROP_PUBKEY_HEX = "6aa99a672c48cb8eee65ddd5c5e5f8947a8d52d39a8b8eb4e11b15c87fe49038";
+
+async function hexToBytes(hex) {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < hex.length; i += 2)
+ bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
+ return bytes;
+}
+
+async function encryptPayload(plaintext) {
+ const serverPubBytes = await hexToBytes(NYMDROP_PUBKEY_HEX);
+
+ // Generate ephemeral X25519 keypair for this submission.
+ const ephemeral = await crypto.subtle.generateKey(
+ { name: "ECDH", namedCurve: "X25519" },
+ true,
+ ["deriveKey", "deriveBits"]
+ );
+
+ // Import server's X25519 public key.
+ const serverKey = await crypto.subtle.importKey(
+ "raw",
+ serverPubBytes,
+ { name: "ECDH", namedCurve: "X25519" },
+ false,
+ []
+ );
+
+ // ECDH key agreement → 32-byte shared secret.
+ const sharedBits = await crypto.subtle.deriveBits(
+ { name: "ECDH", public: serverKey },
+ ephemeral.privateKey,
+ 256
+ );
+
+ // Derive AES-GCM-256 key via HKDF-SHA-256.
+ const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
+ const aesKey = await crypto.subtle.deriveKey(
+ {
+ name: "HKDF",
+ hash: "SHA-256",
+ salt: new Uint8Array(32),
+ info: new TextEncoder().encode("nymdrop-v1")
+ },
+ hkdfKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt"]
+ );
+
+ // Encrypt plaintext.
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ aesKey,
+ new TextEncoder().encode(plaintext)
+ );
+
+ // Export ephemeral X25519 public key (32 bytes raw).
+ const ephemeralPubRaw = await crypto.subtle.exportKey("raw", ephemeral.publicKey);
+
+ // Packet layout: ephemeral_pub(32) + iv(12) + ciphertext
+ const packet = new Uint8Array(32 + 12 + ciphertext.byteLength);
+ packet.set(new Uint8Array(ephemeralPubRaw), 0);
+ packet.set(iv, 32);
+ packet.set(new Uint8Array(ciphertext), 44);
+
+ return packet;
+}
+
+document.getElementById("drop-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const status = document.getElementById("status");
+ status.className = "";
+ status.textContent = "Encrypting...";
+
+ try {
+ let plaintext = document.getElementById("message").value;
+
+ const fileInput = document.getElementById("file");
+ if (fileInput.files.length > 0) {
+ const fileBytes = await fileInput.files[0].arrayBuffer();
+ const fileB64 = btoa(String.fromCharCode(...new Uint8Array(fileBytes)));
+ plaintext += "\n---FILE:" + fileInput.files[0].name + "---\n" + fileB64;
+ }
+
+ const encrypted = await encryptPayload(plaintext);
+
+ const resp = await fetch("/submit", {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream" },
+ body: encrypted,
+ });
+
+ if (resp.ok) {
+ document.getElementById("drop-form").reset();
+ document.getElementById("filename").textContent = "";
+ status.textContent = "Delivered. No record of this submission exists on the server.";
+ } else {
+ status.className = "error";
+ status.textContent = "Delivery failed. Try again.";
+ }
+ } catch (err) {
+ status.className = "error";
+ status.textContent = "Encryption failed: " + err.message;
+ }
+});
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..c957a01
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="referrer" content="no-referrer">
+ <title>NymDrop — Secure Submission</title>
+ <link rel="stylesheet" href="/style.css">
+</head>
+<body>
+<div class="card">
+
+ <div class="logo">
+ <div class="logo-icon">⬡</div>
+ <h1>NYMDROP</h1>
+ </div>
+ <p class="tagline">Anonymous document submission — encrypted in your browser, delivered over Nym mixnet.</p>
+
+ <div class="badges">
+ <span class="badge badge-green">END-TO-END ENCRYPTED</span>
+ <span class="badge badge-blue">NYM MIXNET</span>
+ <span class="badge badge-gray">NO LOGS</span>
+ <span class="badge badge-gray">NO METADATA</span>
+ </div>
+
+ <form id="drop-form">
+ <label for="message">Message</label>
+ <textarea id="message" placeholder="Write your message here. It will be encrypted before leaving your browser."></textarea>
+
+ <div class="file-section">
+ <label>Attachment (optional)</label>
+ <label class="file-label" for="file">&#128206; Choose file</label>
+ <input type="file" id="file">
+ <span id="filename"></span>
+ </div>
+
+ <button type="submit" class="submit-btn">&#128274; Submit securely</button>
+ </form>
+
+ <div id="status"></div>
+
+ <hr class="divider">
+
+ <p class="notice">
+ <span>Your submission is encrypted in this browser before being sent.</span>
+ The server never sees the content and retains no logs.
+ Once delivered over the Nym mixnet, no record of this submission exists on this server.
+ For maximum anonymity, submit from a public network.
+ </p>
+
+</div>
+<div class="footer">NYMDROP &nbsp;·&nbsp; ZERO KNOWLEDGE &nbsp;·&nbsp; ZERO LOGS</div>
+<script src="/crypto.js"></script>
+<script>
+ document.getElementById('file').addEventListener('change', function() {
+ const name = this.files.length ? this.files[0].name : '';
+ document.getElementById('filename').textContent = name;
+ });
+</script>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..3b077d7
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,179 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ background: #0d1117;
+ color: #c9d1d9;
+ font-family: 'Courier New', Courier, monospace;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+}
+
+.card {
+ background: #161b22;
+ border: 1px solid #21262d;
+ border-radius: 8px;
+ padding: 2.5rem 2rem;
+ width: 100%;
+ max-width: 580px;
+ box-shadow: 0 0 40px rgba(0,255,160,0.04);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ margin-bottom: 0.5rem;
+}
+
+.logo-icon {
+ width: 32px;
+ height: 32px;
+ background: linear-gradient(135deg, #00ffa0, #0080ff);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ color: #0d1117;
+ font-weight: bold;
+}
+
+h1 {
+ font-size: 1.4rem;
+ font-weight: bold;
+ color: #e6edf3;
+ letter-spacing: 0.15em;
+}
+
+.tagline {
+ font-size: 0.72rem;
+ color: #484f58;
+ margin-bottom: 2rem;
+ letter-spacing: 0.03em;
+}
+
+.badges {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+}
+
+.badge {
+ font-size: 0.65rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 20px;
+ letter-spacing: 0.05em;
+ font-weight: bold;
+}
+
+.badge-green { background: rgba(0,255,160,0.08); color: #00ffa0; border: 1px solid rgba(0,255,160,0.2); }
+.badge-blue { background: rgba(0,128,255,0.08); color: #58a6ff; border: 1px solid rgba(0,128,255,0.2); }
+.badge-gray { background: rgba(255,255,255,0.04); color: #8b949e; border: 1px solid #30363d; }
+
+label {
+ display: block;
+ font-size: 0.72rem;
+ color: #8b949e;
+ margin-bottom: 0.4rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+textarea {
+ width: 100%;
+ height: 160px;
+ background: #0d1117;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ color: #c9d1d9;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.88rem;
+ padding: 0.9rem;
+ resize: vertical;
+ margin-bottom: 1.4rem;
+ transition: border-color 0.2s;
+ outline: none;
+}
+
+textarea:focus { border-color: #00ffa0; box-shadow: 0 0 0 2px rgba(0,255,160,0.08); }
+
+.file-section { margin-bottom: 1.8rem; }
+
+.file-label {
+ display: inline-block;
+ background: #21262d;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ color: #8b949e;
+ font-size: 0.78rem;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ transition: border-color 0.2s, color 0.2s;
+}
+
+.file-label:hover { border-color: #58a6ff; color: #58a6ff; }
+
+#file { display: none; }
+
+#filename {
+ font-size: 0.72rem;
+ color: #58a6ff;
+ margin-left: 0.6rem;
+ vertical-align: middle;
+}
+
+.submit-btn {
+ width: 100%;
+ background: linear-gradient(135deg, #00ffa0 0%, #0080ff 100%);
+ border: none;
+ border-radius: 6px;
+ color: #0d1117;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.9rem;
+ font-weight: bold;
+ letter-spacing: 0.1em;
+ padding: 0.85rem;
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.1s;
+ text-transform: uppercase;
+}
+
+.submit-btn:hover { opacity: 0.9; }
+.submit-btn:active { transform: scale(0.99); }
+
+#status {
+ margin-top: 1rem;
+ font-size: 0.78rem;
+ min-height: 1.2rem;
+ text-align: center;
+ color: #00ffa0;
+}
+
+#status.error { color: #f85149; }
+
+.divider {
+ border: none;
+ border-top: 1px solid #21262d;
+ margin: 2rem 0 1.2rem;
+}
+
+.notice {
+ font-size: 0.68rem;
+ color: #3d444d;
+ line-height: 1.7;
+}
+
+.notice span { color: #484f58; }
+
+.footer {
+ margin-top: 1.5rem;
+ font-size: 0.62rem;
+ color: #21262d;
+ text-align: center;
+ letter-spacing: 0.05em;
+}