summaryrefslogtreecommitdiffstats
path: root/internal/assets
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
commite9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch)
tree950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/assets
parentfb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff)
downloadn2usenet-main.tar.gz
n2usenet-main.tar.xz
n2usenet-main.zip
Harden transport and preserve profile identitiesHEADmain
Diffstat (limited to 'internal/assets')
-rw-r--r--internal/assets/web/templates/index.html230
1 files changed, 171 insertions, 59 deletions
diff --git a/internal/assets/web/templates/index.html b/internal/assets/web/templates/index.html
index 60df055..4595421 100644
--- a/internal/assets/web/templates/index.html
+++ b/internal/assets/web/templates/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>N2Usenet Gateway v2.7 Nym</title>
+ <title>N2Usenet Gateway v2.7.4 Nym</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="shortcut icon" href="/favicon.ico">
<style>
@@ -503,7 +503,7 @@
<body>
<div class="container">
<header>
- <h1>N2Usenet Gateway v2.7 Nym</h1>
+ <h1>N2Usenet Gateway v2.7.4 Nym</h1>
<div class="theme-toggle">
<span>🌞</span>
<input type="checkbox" id="themeToggle">
@@ -554,12 +554,12 @@
</div>
<div class="section-info">
- <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: an Ed25519 keypair whose public key deterministically generates your identicon. Same key &rarr; same identicon &rarr; same person over time.</p>
+ <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: username, email address, and Ed25519 keypair. The complete tuple deterministically generates the identicon and is published in the post.</p>
<p><strong>What is Proof-of-Work?</strong> This step generates a hashcash token that prevents spam by requiring your computer to perform some calculations.</p>
</div>
<label>Username (your pseudonym): <input type="text" id="fromName" placeholder="YourPseudonym" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
- <label>Email (for identity hash): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
+ <label>Email (public profile identity): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
<label>Difficulty (bits):
<select id="hcBits">
<option value="16">16 bits (very fast, ~instant - recommended for hardened browsers)</option>
@@ -597,7 +597,7 @@
<div class="section-info">
<p><strong>What is this?</strong> Your keypair is generated and your message is signed <strong>locally in your browser</strong> with the self-hosted TweetNaCl (<code>nacl</code>) library, and your Ed25519 secret key never leaves your device. Only the public identity (username, email, public key) is sent, to render the identicon. The signature proves you authored this post.</p>
- <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>, so it is fully deterministic: the same key always yields the same identicon. It is embedded as a folded <code>Face:</code> header (RFC&nbsp;4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>, visible in newsreaders like Newsgrouper and re-verifiable by any reader from your public key.</p>
+ <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>. It is deterministic from <code>username|email|public-key</code>, so all three fields must remain unchanged to preserve the same VFACE identity. It is embedded as a folded <code>Face:</code> header (RFC&nbsp;4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>.</p>
</div>
<label>Email (from PoW):</label>
@@ -610,7 +610,7 @@
<button id="genKeyBtn">🔑 Generate New Keypair</button>
<button id="saveIdentityBtn" disabled>💾 Save Identity</button>
</div>
- <p style="margin: 10px 0 0 0; font-size: 0.9em; opacity: 0.8;">Saving is optional: skip it for a fresh throwaway identity each time, or save the file to reuse the same identicon later and prove later posts are from the same author. Keep the file private, whoever holds it can sign as you.</p>
+ <p style="margin: 10px 0 0 0; font-size: 0.9em; opacity: 0.8;">Saving is optional: skip it for a fresh throwaway identity each time, or save the file to restore the same username, email, keypair, and identicon later. These fields are locked together while the identity is active. Keep the file private, whoever holds it can sign as you.</p>
<input type="file" id="keyFileInput" accept=".json" style="display:none;">
<button id="signMsgBtn" disabled>✍️ Sign Message</button>
@@ -624,45 +624,60 @@
<div id="send" class="tab-content">
<h2>Send Message</h2>
<div class="section-info">
- <p><strong>What is this?</strong> This final step sends your signed message to Usenet via Nym, Mail2News, and the <code>news.tcpreset.net</code> NNTP service.</p>
+ <p><strong>What is this?</strong> This final step sends your signed message through Nym-wrapped SMTP/TLS, the Virebent mail gateway, TCPReset Mail2News, and finally Tor to the TCPReset onion NNTP endpoint.</p>
<div class="gateway-info">
- <h4>🔐 Nym to TCPReset Posting Path</h4>
- <p style="margin-bottom: 15px; font-size: 0.95em;">Your message is submitted over HTTPS, then relayed from this server through Nym to the Mail2News gateway and finally into <code>news.tcpreset.net</code> NNTP:</p>
+ <h4>🔐 HTTPS, Nym, TLS and Onion NNTP Posting Path</h4>
+ <p style="margin-bottom: 15px; font-size: 0.95em;">Your browser submits the signed payload to N2Usenet over HTTPS. From the N2Usenet gateway onward, the route is Nym-wrapped SMTP/TLS, the authenticated Virebent MTA, TCPReset Mail2News, then Tor to the onion NNTP endpoint:</p>
<div class="gateway-item">
<span class="gateway-priority">Step 1</span>
<div style="flex: 1;">
- <strong>Nym SMTP transport</strong>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym SOCKS5/requester transport to the TCPReset Mail2News SMTP gateway.</p>
+ <strong>Nym-wrapped SMTP/TLS</strong>
+ <div class="gateway-address" style="margin-top: 5px;">Nym SOCKS5 → mixnet → dedicated requester</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym carries the SMTP connection to <code>mail.virebent.art:587</code>. Inside that Nym path, STARTTLS provides server-authenticated TLS and SMTP AUTH identifies N2Usenet to the MTA.</p>
</div>
</div>
<details style="margin-top: 8px; font-size: 0.8em;">
- <summary style="cursor: pointer; color: #007bff;">Show relay nodes</summary>
+ <summary style="cursor: pointer; color: #007bff;">Show addresses used</summary>
<ul style="margin: 8px 0; padding-left: 20px; font-family: monospace; color: #555;">
- <li><strong>SMTP:</strong> mail2news.tcpreset.net:587 STARTTLS via Nym SOCKS5</li>
+ <li><strong>HTTPS:</strong> https://n2usenet.virebent.art</li>
+ <li><strong>SMTP/TLS target:</strong> mail.virebent.art:587</li>
+ <li><strong>AUTH and MAIL FROM:</strong> n2usenet@virebent.art</li>
+ <li><strong>Sender:</strong> n2usenet@virebent.art</li>
+ <li><strong>Public From:</strong> username and email from your active identity</li>
+ <li><strong>RCPT TO:</strong> mail2news@mail2news.tcpreset.net</li>
</ul>
</details>
<div class="gateway-item">
<span class="gateway-priority">Step 2</span>
<div style="flex: 1;">
- <strong>Mail2News Gateway</strong>
- <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Converts the signed email payload into a Usenet post for the TCPReset news service.</p>
+ <strong>Authenticated Virebent MTA</strong>
+ <div class="gateway-address" style="margin-top: 5px;">mail.virebent.art:587 → mail2news.tcpreset.net:25</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Postfix accepts the authenticated submission, keeps the fixed SMTP envelope and transport Sender, adds the Virebent DKIM signature, and relays the message to TCPReset Mail2News. The public From remains the active profile identity.</p>
</div>
</div>
<div class="gateway-item">
<span class="gateway-priority">Step 3</span>
<div style="flex: 1;">
- <strong>TCPReset NNTP</strong>
- <div class="gateway-address" style="margin-top: 5px;">nntp://news.tcpreset.net</div>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Final posting target for the standalone N2Usenet path.</p>
+ <strong>TCPReset Mail2News Gateway</strong>
+ <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Mail2News validates and converts the signed email into a Usenet article, strips configured transport metadata, and regenerates the Message-ID for the NNTP post.</p>
+ </div>
+ </div>
+
+ <div class="gateway-item">
+ <span class="gateway-priority">Step 4</span>
+ <div style="flex: 1;">
+ <strong>Tor to TCPReset Onion NNTP</strong>
+ <div class="gateway-address" style="margin-top: 5px;">peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion:119</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Mail2News connects through Tor and issues NNTP <code>POST</code> to the onion endpoint. The TCPReset INN service publishes the article under <code>news.tcpreset.net</code>.</p>
</div>
</div>
<p style="margin-top: 15px; padding: 10px; background: rgba(0,123,255,0.1); border-radius: 4px; font-size: 0.9em;">
- <strong>🔒 Privacy Guarantee:</strong> The browser-to-server hop is HTTPS. The server-to-Mail2News hop uses Nym plus SMTP TLS. Mail2News then posts into <code>news.tcpreset.net</code> NNTP.
+ <strong>🔒 Privacy boundaries:</strong> HTTPS protects the browser-to-N2Usenet hop, but the N2Usenet server still receives the browser connection. Nym carries the N2Usenet-to-MTA session, while SMTP TLS protects that session end to end inside Nym. Mail2News then uses Tor for the final onion NNTP hop. Once accepted by INN, the Usenet article is public.
</p>
</div>
</div>
@@ -689,7 +704,7 @@
</div>
<footer>
- <div>N2Usenet Gateway v2.7.0 © 2025 - Privacy-focused Usenet posting via Nym</div>
+ <div>N2Usenet Gateway v2.7.4 © 2025 - Privacy-focused Usenet posting via Nym</div>
<div class="footer-links">
<a href="https://yamn.virebent.art">Home</a>
<a href="&#109;&#97;&#105;&#108;&#116;&#111;&#58;%69%6E%66%6F%40%76%69%72%65%62%65%6E%74%2E%61%72%74">Contact</a>
@@ -743,8 +758,47 @@ function updateTabIndicators() {
}
let keyPair = null;
+let identitySnapshot = null;
let workersSupported = true;
+function readIdentityFields() {
+ const usernameInput = document.getElementById('fromName');
+ const emailInput = document.getElementById('hcEmail');
+ const username = usernameInput.value.trim();
+ const email = emailInput.value.trim();
+ if (!username) throw new Error('Enter a username before creating the identity.');
+ if (!email || !emailInput.checkValidity()) throw new Error('Enter a valid email address before creating the identity.');
+ return {username, email};
+}
+
+function setIdentityFields(username, email) {
+ document.getElementById('fromName').value = username;
+ document.getElementById('hcEmail').value = email;
+ document.getElementById('readonlyFromName').value = username;
+ document.getElementById('readonlyEmailSign').value = email;
+ document.getElementById('readonlyEmailSend').value = email;
+ document.getElementById('fromFull').value = `${username} <${email}>`;
+}
+
+function lockIdentityFields() {
+ const identity = readIdentityFields();
+ const pubkey = keyPair ? nacl.util.encodeBase64(keyPair.publicKey) : '';
+ identitySnapshot = {username: identity.username, email: identity.email, pubkey};
+ document.getElementById('fromName').readOnly = true;
+ document.getElementById('hcEmail').readOnly = true;
+ setIdentityFields(identity.username, identity.email);
+ return identitySnapshot;
+}
+
+function assertIdentityUnchanged() {
+ if (!identitySnapshot) return;
+ const identity = readIdentityFields();
+ const pubkey = keyPair ? nacl.util.encodeBase64(keyPair.publicKey) : '';
+ if (identity.username !== identitySnapshot.username || identity.email !== identitySnapshot.email || (identitySnapshot.pubkey && pubkey !== identitySnapshot.pubkey)) {
+ throw new Error('The active username, email, and keypair must remain unchanged. Start a new identity to change them.');
+ }
+}
+
function setOutputField(id, value, emptyText) {
const el = document.getElementById(id);
if (!el) return;
@@ -763,8 +817,12 @@ function resetEphemeralState(messageID) {
if (keyPair.publicKey && typeof keyPair.publicKey.fill === 'function') keyPair.publicKey.fill(0);
}
keyPair = null;
+ identitySnapshot = null;
currentIdenticonData = null;
+ document.getElementById('fromName').readOnly = false;
+ document.getElementById('hcEmail').readOnly = false;
+
appState.step1Complete = false;
appState.step2Complete = false;
appState.step3Complete = false;
@@ -853,7 +911,7 @@ function resetEphemeralState(messageID) {
showTab('pow');
if (messageID) {
- showNotification(`✓ Message sent. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 12000);
+ showNotification(`✓ Message accepted by the mail relay. Final Usenet delivery is asynchronous. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 16000);
}
}
@@ -964,7 +1022,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
showTab('pow');
- showNotification('N2Usenet Gateway v2.7.0 ready. No identity or message data is persisted.', 'success', 4000);
+ showNotification('N2Usenet Gateway v2.7.4 ready. No identity or message data is persisted.', 'success', 4000);
});
// ============================================================================
@@ -1070,7 +1128,14 @@ function mineSingleThread(prefix, targetZeros, progressCallback, foundCallback)
// ============================================================================
document.getElementById('genTokenBtn').onclick = () => {
- const email = document.getElementById('hcEmail').value.trim();
+ let identity;
+ try {
+ identity = readIdentityFields();
+ } catch (error) {
+ showNotification('⚠ ' + error.message, 'warning');
+ return;
+ }
+ const email = identity.email;
const bits = parseInt(document.getElementById('hcBits').value);
if (!email) {
@@ -1129,11 +1194,12 @@ document.getElementById('genTokenBtn').onclick = () => {
outputField.classList.remove('empty');
document.getElementById('hcToken').value = token;
- const fromName = document.getElementById('fromName').value || 'Anonymous';
+ const fromName = identity.username;
document.getElementById('fromFull').value = `${fromName} <${email}>`;
document.getElementById('readonlyFromName').value = fromName;
document.getElementById('readonlyEmailSign').value = email;
document.getElementById('readonlyEmailSend').value = email;
+ lockIdentityFields();
btn.disabled = false;
btn.textContent = 'Generate Token';
@@ -1209,8 +1275,10 @@ document.getElementById('genTokenBtn').onclick = () => {
document.getElementById('genKeyBtn').onclick = async function() {
try {
+ const identity = readIdentityFields();
showNotification('🔐 Generating Ed25519 key pair...', 'info');
keyPair = nacl.sign.keyPair();
+ lockIdentityFields();
const pubB64 = nacl.util.encodeBase64(keyPair.publicKey);
@@ -1221,8 +1289,8 @@ document.getElementById('genKeyBtn').onclick = async function() {
document.getElementById('signMsgBtn').disabled = false;
document.getElementById('saveIdentityBtn').disabled = false;
- const username = document.getElementById('fromName').value || 'Anonymous';
- const email = document.getElementById('hcEmail').value || '';
+ const username = identity.username;
+ const email = identity.email;
document.getElementById('identityLoadedPow').style.display = 'flex';
document.getElementById('loadedIdentityName').textContent = username;
document.getElementById('loadedIdentityEmail').textContent = email || 'Identity active only in memory';
@@ -1240,15 +1308,17 @@ document.getElementById('saveIdentityBtn').onclick = function() {
showNotification('⚠ Generate or load an identity first!', 'warning');
return;
}
- const username = document.getElementById('fromName').value || 'Anonymous';
- const email = document.getElementById('hcEmail').value || '';
+ assertIdentityUnchanged();
+ const username = identitySnapshot.username;
+ const email = identitySnapshot.email;
const data = {
- version: 2,
+ version: 3,
type: 'n2usenet-identity',
publicKey: nacl.util.encodeBase64(keyPair.publicKey),
secretKey: nacl.util.encodeBase64(keyPair.secretKey),
username: username,
email: email,
+ identityHash: currentIdenticonData ? currentIdenticonData.hash : '',
created: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -1278,10 +1348,20 @@ document.getElementById('identityFileInput').onchange = async function(e) {
const text = await file.text();
const data = JSON.parse(text);
- if (!data.publicKey || !data.secretKey) {
+ if ((data.version !== 2 && data.version !== 3) || data.type !== 'n2usenet-identity' || !data.publicKey || !data.secretKey || typeof data.username !== 'string' || typeof data.email !== 'string') {
throw new Error('Invalid identity file format');
}
+ const username = data.username.trim();
+ const email = data.email.trim();
+ if (!username || !email) {
+ throw new Error('Identity file is missing username or email');
+ }
+
+ if (appState.step1Complete && document.getElementById('hcEmail').value.trim() !== email) {
+ throw new Error('The loaded identity does not match the generated proof-of-work token');
+ }
+
const pubKey = nacl.util.decodeBase64(data.publicKey);
const secKey = nacl.util.decodeBase64(data.secretKey);
@@ -1289,7 +1369,14 @@ document.getElementById('identityFileInput').onchange = async function(e) {
throw new Error('Invalid key lengths');
}
- keyPair = { publicKey: pubKey, secretKey: secKey };
+ const derivedKeyPair = nacl.sign.keyPair.fromSecretKey(secKey);
+ if (!derivedKeyPair.publicKey.every((value, index) => value === pubKey[index])) {
+ throw new Error('Public and secret keys do not belong to the same identity');
+ }
+
+ keyPair = derivedKeyPair;
+ setIdentityFields(username, email);
+ lockIdentityFields();
const pubB64 = data.publicKey;
const pubOutput = document.getElementById('pubKeyOutput');
@@ -1299,25 +1386,15 @@ document.getElementById('identityFileInput').onchange = async function(e) {
document.getElementById('signMsgBtn').disabled = false;
document.getElementById('saveIdentityBtn').disabled = false;
- if (data.username) {
- document.getElementById('fromName').value = data.username;
- document.getElementById('readonlyFromName').value = data.username;
- }
- if (data.email) {
- document.getElementById('hcEmail').value = data.email;
- const fromName = document.getElementById('fromName').value || 'Anonymous';
- document.getElementById('fromFull').value = `${fromName} <${data.email}>`;
- document.getElementById('readonlyEmailSign').value = data.email;
- document.getElementById('readonlyEmailSend').value = data.email;
- }
-
- const username = data.username || 'Anonymous';
- const email = data.email || '';
document.getElementById('identityLoadedPow').style.display = 'flex';
document.getElementById('loadedIdentityName').textContent = username;
document.getElementById('loadedIdentityEmail').textContent = email;
await updateIdenticonPreview();
+
+ if (data.identityHash && currentIdenticonData && data.identityHash !== currentIdenticonData.hash) {
+ throw new Error('Identity hash does not match username, email, and public key');
+ }
if (currentIdenticonData && currentIdenticonData.dataUrl) {
document.getElementById('identiconPreviewPow').src = currentIdenticonData.dataUrl;
@@ -1364,6 +1441,7 @@ document.getElementById('signMsgBtn').onclick = function() {
showNotification('⚠ Please generate or load a key pair first!', 'warning');
return;
}
+ assertIdentityUnchanged();
const msg = wrapUsenetText(document.getElementById('messageToSign').value.trim());
if (!msg) {
@@ -1416,9 +1494,35 @@ document.getElementById('fromName').addEventListener('input', function() {
// STEP 3: SEND FORM
// ============================================================================
+async function refreshCSRFToken() {
+ const resp = await fetch('/csrf', {
+ method: 'GET',
+ headers: {'Accept': 'application/json'},
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ if (!resp.ok) {
+ throw new Error('Could not refresh the request token.');
+ }
+ const data = await resp.json();
+ if (!data.token) {
+ throw new Error('The server returned an invalid request token.');
+ }
+ document.querySelectorAll('input[name="csrf_token"]').forEach(field => {
+ field.value = data.token;
+ });
+}
+
document.getElementById('sendForm').addEventListener('submit', async function(e) {
e.preventDefault();
+ try {
+ assertIdentityUnchanged();
+ } catch (error) {
+ showNotification('⚠ ' + error.message, 'warning', 8000);
+ return;
+ }
+
const requiredFields = [
{id: 'fromFull', name: 'From'},
{id: 'newsgroups', name: 'Newsgroups'},
@@ -1482,20 +1586,28 @@ document.getElementById('sendForm').addEventListener('submit', async function(e)
sendBtn.parentNode.insertBefore(progressDiv, sendBtn.nextSibling);
try {
- const resp = await fetch('/submit', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
- },
- body: new URLSearchParams(new FormData(this)).toString(),
- credentials: 'same-origin',
- cache: 'no-store'
- });
+ let resp;
let data = {};
- try {
- data = await resp.json();
- } catch (err) {}
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await refreshCSRFToken();
+ resp = await fetch('/submit', {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
+ },
+ body: new URLSearchParams(new FormData(this)).toString(),
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ data = {};
+ try {
+ data = await resp.json();
+ } catch (err) {}
+ if (resp.ok || data.error !== 'Invalid request token.' || attempt > 0) {
+ break;
+ }
+ }
if (!resp.ok) {
throw new Error(data.error || 'Delivery failed.');
}