summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-03 17:35:07 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-03 17:35:07 +0200
commitfb83c4d70616ec23d8a5397409a5d31c70b70d66 (patch)
tree1582f086ad44aac20e7a9fb14344e71ca5c8a298
parentcd58d593789c6facb1d05b7f255bc2c5f2e46010 (diff)
downloadn2usenet-fb83c4d70616ec23d8a5397409a5d31c70b70d66.tar.gz
n2usenet-fb83c4d70616ec23d8a5397409a5d31c70b70d66.tar.xz
n2usenet-fb83c4d70616ec23d8a5397409a5d31c70b70d66.zip
Add Usenet-style line wrapping, optional identity save/load, update README
-rw-r--r--README.md33
-rw-r--r--internal/assets/web/templates/index.html42
2 files changed, 73 insertions, 2 deletions
diff --git a/README.md b/README.md
index 2e7f835..35acc73 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,39 @@ go build -o n2usenet ./cmd/n2usenet
go build -o n2u-check ./cmd/n2u-check
```
+## Features
+
+- **Proof-of-work anti-spam**: client computes a hashcash-style token before
+ submission; the server only verifies it (never computes it) and caches
+ spent tokens in memory with a TTL to reject replays.
+- **Ed25519 signing**: the message is signed locally in the browser with the
+ self-hosted TweetNaCl (`nacl`) library. The secret key never leaves the
+ device; the server only verifies the signature against the submitted text.
+- **VFACE identicon / `Face:` header**: the public key deterministically
+ generates a visual identicon (`identicons-cli` engine, same backend as
+ `identicons.virebent.art`), embedded as a folded `Face:` header alongside
+ `X-Ed25519-Pub`/`X-Ed25519-Sig` so any reader can re-verify authorship.
+- **Optional persistent identity (save/load)**: after generating a keypair,
+ "Save Identity" downloads a JSON file with the public/secret key and
+ username/email. Loading that file later reuses the same keypair, so the
+ same identicon keeps appearing across posts and proves later messages
+ come from the same author. This is entirely optional: skip it for a
+ fresh, unlinkable throwaway identity on every post. The file contains the
+ secret key in clear, so it must be kept private, anyone holding it can
+ sign as that identity.
+- **Usenet-style line wrapping**: before signing, the message body is
+ automatically word-wrapped to 72 columns (RFC 1855 Netiquette
+ convention), for compatibility with classic terminal-based newsreaders.
+ Blank lines and quoted lines (`>`) are left untouched. The wrap happens
+ client-side, before the Ed25519 signature is computed, so the signature
+ always covers the exact bytes that end up posted.
+- **CSRF protection and per-IP rate limiting**, both server-side.
+- **No access logs, ephemeral state**: identity and form data are kept only
+ in browser tab memory and wiped after a successful send.
+- **Delivery over the Nym mixnet**, not Tor: SOCKS5 through a local
+ `nym-socks5-client`/`nym-network-requester` path to the Mail2News relay,
+ then into `news.tcpreset.net` via NNTP.
+
## Required Configuration
Set these in an environment file or systemd unit:
diff --git a/internal/assets/web/templates/index.html b/internal/assets/web/templates/index.html
index 0d2d820..60df055 100644
--- a/internal/assets/web/templates/index.html
+++ b/internal/assets/web/templates/index.html
@@ -608,7 +608,9 @@
<div class="keypair-actions">
<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>
<input type="file" id="keyFileInput" accept=".json" style="display:none;">
<button id="signMsgBtn" disabled>✍️ Sign Message</button>
@@ -830,6 +832,8 @@ function resetEphemeralState(messageID) {
genKeyBtn.disabled = false;
genKeyBtn.textContent = '🔑 Generate New Keypair';
}
+ const saveIdentityBtn = document.getElementById('saveIdentityBtn');
+ if (saveIdentityBtn) saveIdentityBtn.disabled = true;
const signBtn = document.getElementById('signMsgBtn');
if (signBtn) signBtn.disabled = true;
const sendBtn = document.getElementById('sendBtn');
@@ -1215,15 +1219,48 @@ document.getElementById('genKeyBtn').onclick = async function() {
pubOutput.classList.remove('empty');
document.getElementById('x-ed25519-pub').value = pubB64;
document.getElementById('signMsgBtn').disabled = false;
+ document.getElementById('saveIdentityBtn').disabled = false;
const username = document.getElementById('fromName').value || 'Anonymous';
const email = document.getElementById('hcEmail').value || '';
document.getElementById('identityLoadedPow').style.display = 'flex';
document.getElementById('loadedIdentityName').textContent = username;
document.getElementById('loadedIdentityEmail').textContent = email || 'Identity active only in memory';
-
+
await updateIdenticonPreview();
- showNotification('✓ Identity generated in memory. Now write and sign your message.', 'success', 6000);
+ showNotification('✓ Identity generated in memory. Save it now if you want to reuse this identicon later, then write and sign your message.', 'success', 6000);
+ } catch (error) {
+ showNotification('✗ Error: ' + error.message, 'error');
+ }
+};
+
+document.getElementById('saveIdentityBtn').onclick = function() {
+ try {
+ if (!keyPair) {
+ showNotification('⚠ Generate or load an identity first!', 'warning');
+ return;
+ }
+ const username = document.getElementById('fromName').value || 'Anonymous';
+ const email = document.getElementById('hcEmail').value || '';
+ const data = {
+ version: 2,
+ type: 'n2usenet-identity',
+ publicKey: nacl.util.encodeBase64(keyPair.publicKey),
+ secretKey: nacl.util.encodeBase64(keyPair.secretKey),
+ username: username,
+ email: email,
+ created: new Date().toISOString()
+ };
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'n2usenet-identity.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ showNotification('✓ Identity file downloaded. Keep it private, it contains your secret key. Load it next time to keep the same identicon.', 'success', 8000);
} catch (error) {
showNotification('✗ Error: ' + error.message, 'error');
}
@@ -1260,6 +1297,7 @@ document.getElementById('identityFileInput').onchange = async function(e) {
pubOutput.classList.remove('empty');
document.getElementById('x-ed25519-pub').value = pubB64;
document.getElementById('signMsgBtn').disabled = false;
+ document.getElementById('saveIdentityBtn').disabled = false;
if (data.username) {
document.getElementById('fromName').value = data.username;