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 /internal/pow | |
| 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.
Diffstat (limited to 'internal/pow')
| -rw-r--r-- | internal/pow/pow.go | 91 |
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 +} |
