diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-01 17:41:47 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-01 17:41:47 +0200 |
| commit | 78d57b1090a5a23abce2a732f091a4ef007d60cc (patch) | |
| tree | 997b816317241db94810f789eb7122857b27ddd2 | |
| parent | aa459e3b84cc4c5d908f3781c9253848c18640c3 (diff) | |
| download | nymdrop-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.go | 5 | ||||
| -rw-r--r-- | internal/handler/submit.go | 14 | ||||
| -rw-r--r-- | internal/pow/pow.go | 91 | ||||
| -rw-r--r-- | static/crypto.js | 49 |
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, }); |
