summaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-01 17:41:47 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-01 17:41:47 +0200
commit78d57b1090a5a23abce2a732f091a4ef007d60cc (patch)
tree997b816317241db94810f789eb7122857b27ddd2 /static
parentaa459e3b84cc4c5d908f3781c9253848c18640c3 (diff)
downloadnymdrop-78d57b1090a5a23abce2a732f091a4ef007d60cc.tar.gz
nymdrop-78d57b1090a5a23abce2a732f091a4ef007d60cc.tar.xz
nymdrop-78d57b1090a5a23abce2a732f091a4ef007d60cc.zip
Add client-side proof of work and fix X25519 WebCrypto API usage
Self-contained hashcash-style PoW on /submit: client finds a nonce so SHA-256("<unix-ts>:<nonce>") has enough leading zero bits, sent as an X-Nymdrop-Pow header; server verifies and rejects expired or replayed stamps, no challenge round-trip required. Difficulty tunable via --pow-difficulty without a rebuild. Also fixes a latent bug in the browser crypto: X25519 was being requested as ECDH with namedCurve "X25519", which is not a valid WebCrypto combination and always throws. Modern WebCrypto exposes X25519 as its own algorithm identifier.
Diffstat (limited to 'static')
-rw-r--r--static/crypto.js49
1 files changed, 44 insertions, 5 deletions
diff --git a/static/crypto.js b/static/crypto.js
index 63b395f..0455958 100644
--- a/static/crypto.js
+++ b/static/crypto.js
@@ -4,6 +4,10 @@
// Server's X25519 public key — replaced at deploy time.
const NYMDROP_PUBKEY_HEX = "6aa99a672c48cb8eee65ddd5c5e5f8947a8d52d39a8b8eb4e11b15c87fe49038";
+// Required leading zero bits for the proof of work — must match the
+// server's --pow-difficulty flag.
+const NYMDROP_POW_DIFFICULTY = 18;
+
async function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2)
@@ -11,12 +15,40 @@ async function hexToBytes(hex) {
return bytes;
}
+function leadingZeroBits(bytes) {
+ let count = 0;
+ for (const b of bytes) {
+ if (b === 0) { count += 8; continue; }
+ count += Math.clz32(b) - 24; // Math.clz32 counts over 32 bits, b is 8 bits
+ break;
+ }
+ return count;
+}
+
+// Self-contained hashcash-style proof of work: no challenge round-trip with
+// the server. Finds a nonce such that SHA-256("<unix-ts>:<nonce>") has at
+// least `difficultyBits` leading zero bits. The server only verifies this,
+// it never computes it — the cost is entirely client-side.
+async function computeProofOfWork(difficultyBits) {
+ const encoder = new TextEncoder();
+ const ts = Math.floor(Date.now() / 1000);
+ for (let nonce = 0; ; nonce++) {
+ const stamp = ts + ":" + nonce;
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(stamp)));
+ if (leadingZeroBits(hash) >= difficultyBits) {
+ return stamp;
+ }
+ }
+}
+
async function encryptPayload(plaintext) {
const serverPubBytes = await hexToBytes(NYMDROP_PUBKEY_HEX);
// Generate ephemeral X25519 keypair for this submission.
+ // Modern WebCrypto exposes X25519 as its own algorithm (secure-curves
+ // spec), not as ECDH+namedCurve — that combination doesn't exist.
const ephemeral = await crypto.subtle.generateKey(
- { name: "ECDH", namedCurve: "X25519" },
+ { name: "X25519" },
true,
["deriveKey", "deriveBits"]
);
@@ -25,14 +57,14 @@ async function encryptPayload(plaintext) {
const serverKey = await crypto.subtle.importKey(
"raw",
serverPubBytes,
- { name: "ECDH", namedCurve: "X25519" },
+ { name: "X25519" },
false,
[]
);
- // ECDH key agreement → 32-byte shared secret.
+ // X25519 key agreement → 32-byte shared secret.
const sharedBits = await crypto.subtle.deriveBits(
- { name: "ECDH", public: serverKey },
+ { name: "X25519", public: serverKey },
ephemeral.privateKey,
256
);
@@ -90,9 +122,16 @@ document.getElementById("drop-form").addEventListener("submit", async (e) => {
const encrypted = await encryptPayload(plaintext);
+ status.textContent = "Computing proof of work (a few seconds)...";
+ const powStamp = await computeProofOfWork(NYMDROP_POW_DIFFICULTY);
+
+ status.textContent = "Sending...";
const resp = await fetch("/submit", {
method: "POST",
- headers: { "Content-Type": "application/octet-stream" },
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "X-Nymdrop-Pow": powStamp,
+ },
body: encrypted,
});