summaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--cmd/nymdrop/main.go5
-rw-r--r--internal/handler/submit.go14
-rw-r--r--internal/pow/pow.go91
-rw-r--r--static/crypto.js49
4 files changed, 151 insertions, 8 deletions
diff --git a/cmd/nymdrop/main.go b/cmd/nymdrop/main.go
index 9119b9c..c2090ce 100644
--- a/cmd/nymdrop/main.go
+++ b/cmd/nymdrop/main.go
@@ -8,6 +8,7 @@ import (
"nymdrop/internal/handler"
"nymdrop/internal/nolog"
"nymdrop/internal/nym"
+ "nymdrop/internal/pow"
"nymdrop/internal/relay"
)
@@ -21,6 +22,7 @@ func main() {
journalistAddr := flag.String("journalist", "", "journalist Nym address (required)")
staticDir := flag.String("static", "./static", "path to static files")
dryRun := flag.Bool("dry-run", false, "skip Nym, log payloads to stdout (testing only)")
+ powBits := flag.Int("pow-difficulty", 18, "required leading zero bits for the client-side proof of work")
flag.Parse()
if *journalistAddr == "" {
@@ -39,9 +41,10 @@ func main() {
defer nymClient.Stop()
r := relay.New(nymClient)
+ powVerifier := pow.NewVerifier(*powBits)
mux := http.NewServeMux()
- mux.Handle("/submit", handler.NewSubmit(r))
+ mux.Handle("/submit", handler.NewSubmit(r, powVerifier))
mux.Handle("/", handler.Static(*staticDir))
srv := &http.Server{
diff --git a/internal/handler/submit.go b/internal/handler/submit.go
index 15251cf..2822488 100644
--- a/internal/handler/submit.go
+++ b/internal/handler/submit.go
@@ -2,15 +2,17 @@ package handler
import (
"net/http"
+ "nymdrop/internal/pow"
"nymdrop/internal/relay"
)
type Submit struct {
relay *relay.Relay
+ pow *pow.Verifier
}
-func NewSubmit(r *relay.Relay) *Submit {
- return &Submit{relay: r}
+func NewSubmit(r *relay.Relay, p *pow.Verifier) *Submit {
+ return &Submit{relay: r, pow: p}
}
func (s *Submit) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -19,6 +21,14 @@ func (s *Submit) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Proof-of-work computed client-side (X-Nymdrop-Pow: "<unix-ts>:<nonce>").
+ // Rejects unproven, expired, and replayed stamps before touching the relay.
+ if err := s.pow.Verify(r.Header.Get("X-Nymdrop-Pow")); err != nil {
+ // Generic error — don't reveal which check failed.
+ http.Error(w, "error", http.StatusBadRequest)
+ return
+ }
+
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
defer r.Body.Close()
diff --git a/internal/pow/pow.go b/internal/pow/pow.go
new file mode 100644
index 0000000..fa6f77a
--- /dev/null
+++ b/internal/pow/pow.go
@@ -0,0 +1,91 @@
+// Package pow implements a self-contained hashcash-style proof of work for
+// the /submit endpoint. The client computes "<unix-ts>:<nonce>" locally
+// (no challenge round-trip) such that SHA-256 of the stamp has a required
+// number of leading zero bits. The server only has to verify, not compute.
+package pow
+
+import (
+ "crypto/sha256"
+ "errors"
+ "math/bits"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// MaxAge bounds how long a stamp remains acceptable, limiting replay window
+// and precomputation of stamps far in advance.
+const MaxAge = 5 * time.Minute
+
+// maxClockSkew tolerates a stamp timestamped slightly ahead of the server's clock.
+const maxClockSkew = time.Minute
+
+// Verifier checks proof-of-work stamps against a fixed difficulty and keeps
+// a bounded in-memory cache of seen stamps for replay protection.
+type Verifier struct {
+ difficulty int
+
+ mu sync.Mutex
+ seen map[string]time.Time
+}
+
+func NewVerifier(difficultyBits int) *Verifier {
+ return &Verifier{
+ difficulty: difficultyBits,
+ seen: make(map[string]time.Time),
+ }
+}
+
+// Verify validates stamp "<unix-ts>:<nonce>": well-formed, not expired,
+// meets the required leading-zero-bit difficulty, and not replayed.
+func (v *Verifier) Verify(stamp string) error {
+ tsPart, _, ok := strings.Cut(stamp, ":")
+ if !ok {
+ return errors.New("malformed stamp")
+ }
+ tsSec, err := strconv.ParseInt(tsPart, 10, 64)
+ if err != nil {
+ return errors.New("malformed timestamp")
+ }
+ ts := time.Unix(tsSec, 0)
+ now := time.Now()
+ if now.Sub(ts) > MaxAge || ts.Sub(now) > maxClockSkew {
+ return errors.New("stamp expired or in the future")
+ }
+
+ sum := sha256.Sum256([]byte(stamp))
+ if leadingZeroBits(sum[:]) < v.difficulty {
+ return errors.New("insufficient proof of work")
+ }
+
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ v.cleanupLocked(now)
+ if _, dup := v.seen[stamp]; dup {
+ return errors.New("replayed stamp")
+ }
+ v.seen[stamp] = now
+ return nil
+}
+
+func (v *Verifier) cleanupLocked(now time.Time) {
+ for stamp, seenAt := range v.seen {
+ if now.Sub(seenAt) > MaxAge {
+ delete(v.seen, stamp)
+ }
+ }
+}
+
+func leadingZeroBits(b []byte) int {
+ count := 0
+ for _, by := range b {
+ if by == 0 {
+ count += 8
+ continue
+ }
+ count += bits.LeadingZeros8(by)
+ break
+ }
+ return count
+}
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,
});