summaryrefslogtreecommitdiffstats
path: root/internal/pow
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 /internal/pow
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 'internal/pow')
-rw-r--r--internal/pow/pow.go91
1 files changed, 91 insertions, 0 deletions
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
+}