From 1436d4e409d763ce4f5f3a87c993a8aa46306d1c Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 20 Oct 2026 23:57:28 +0000 Subject: [PATCH] Spend a hashcash stamp only when the request succeeds (closes #158) A session request refused with 400 or 409, or a +H channel message refused for membership, a ban or +m, used to spend its stamp, so a retry with the same stamp was refused as already used. hashcash.Validator.Validate is split into Check, which spends nothing, and Claim, which records the stamp; Release undoes a claim. Session creation checks the stamp first, claims it just before CreateSession and releases it if CreateSession fails. SendChannelMessage checks the stamp after the membership, ban and +m checks. Model: opus-5-5 --- README.md | 12 ++- internal/cli/api/client_test.go | 2 +- internal/handlers/api.go | 46 ++++++++++-- internal/handlers/api_test.go | 116 +++++++++++++++++++++++++++++ internal/hashcash/hashcash.go | 62 ++++++++++++--- internal/hashcash/hashcash_test.go | 90 ++++++++++++++++++---- internal/service/service.go | 33 ++++---- internal/service/service_test.go | 83 +++++++++++++++++++++ 8 files changed, 393 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 36caadd..ebd8708 100644 --- a/README.md +++ b/README.md @@ -1404,7 +1404,8 @@ Include the hashcash stamp in the `meta` field: **Server validation:** The server checks that the stamp is well-formed, meets the required difficulty, is bound to the correct channel and message body, has a recent date, and has not been previously used. Spent stamps are cached for 1 -year to prevent replay attacks. +year to prevent replay attacks. The stamp is checked after membership, bans and +`+m`, so a message refused for one of those does not spend it. **Error responses:** If the channel requires hashcash and the stamp is missing, invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a @@ -3084,6 +3085,10 @@ limits that punish shared networks. - Stamp has not been used before (replay prevention; the spent stamps are kept in memory, so a restart forgets them) + The stamp is spent only when the session is created. A request refused with + 400 or 409 leaves it unspent, and the client can send the same stamp again + with another nick or username. + ### Stamp Format Standard hashcash format: @@ -3156,12 +3161,13 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? - **Little state to track**: No IP tables, no token buckets, no sliding windows. - The server verifies a single hash and remembers the stamps already spent. + The server hashes the stamp and remembers the stamps already spent. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. - **Cost falls on the requester**: The server's verification cost is constant - (one SHA-256 hash) regardless of difficulty. Only the client does more work. + regardless of difficulty: at most two SHA-256 hashes of the stamp per session + request, one to check it and one to spend it. Only the client does more work. - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No registration, no email, no phone number, no CAPTCHA. Just compute. - **Language-agnostic**: SHA-256 is available in every programming language. The diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go index 7a5149c..39a7803 100644 --- a/internal/cli/api/client_test.go +++ b/internal/cli/api/client_test.go @@ -207,7 +207,7 @@ func TestCreateSessionSendsNickAndStamp(t *testing.T) { return } - err = validator.Validate(payload.Hashcash, stampBits) + err = validator.Claim(payload.Hashcash, stampBits) if err != nil { writer.WriteHeader(http.StatusPaymentRequired) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 5f73c49..b9bf7e2 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession( return } - if !hdlr.validateHashcash( + if !hdlr.checkHashcash( writer, request, payload.Hashcash, ) { return @@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession( } hdlr.executeCreateSession( - writer, request, payload.Nick, username, + writer, request, + payload.Nick, username, payload.Hashcash, ) } func (hdlr *Handlers) executeCreateSession( writer http.ResponseWriter, request *http.Request, - nick, username string, + nick, username, stamp string, ) { remoteIP := clientIP(request) @@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession( request.Context(), remoteIP, ) + if !hdlr.claimHashcash(writer, request, stamp) { + return + } + sessionID, clientID, token, err := hdlr.params.Database.CreateSession( request.Context(), nick, username, hostname, remoteIP, ) if err != nil { + hdlr.hashcashVal.Release(stamp) hdlr.handleCreateSessionError( writer, request, err, ) @@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession( }, http.StatusCreated) } -// validateHashcash validates a hashcash stamp if required. -// Returns false if validation failed and a response was -// already sent. -func (hdlr *Handlers) validateHashcash( +// checkHashcash checks a hashcash stamp if required, +// without spending it. Returns false if the check failed +// and a response was already sent. +func (hdlr *Handlers) checkHashcash( writer http.ResponseWriter, request *http.Request, stamp string, @@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash( return false } - err := hdlr.hashcashVal.Validate( + err := hdlr.hashcashVal.Check( + stamp, hdlr.params.Config.HashcashBits, + ) + if err != nil { + hdlr.respondError( + writer, request, + "invalid hashcash stamp: "+err.Error(), + http.StatusPaymentRequired, + ) + + return false + } + + return true +} + +// claimHashcash spends a stamp that checkHashcash accepted. +// Returns false if Claim refused the stamp, normally because +// it was already spent, and a response was already sent. +func (hdlr *Handlers) claimHashcash( + writer http.ResponseWriter, + request *http.Request, + stamp string, +) bool { + err := hdlr.hashcashVal.Claim( stamp, hdlr.params.Config.HashcashBits, ) if err != nil { diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 66985a3..05b8603 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -29,6 +29,7 @@ import ( "golang.org/x/crypto/bcrypt" "modernc.org/sqlite" "sneak.berlin/go/neoirc/internal/broker" + api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" @@ -4073,6 +4074,121 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) { } } +// postSessionWithStamp posts a session request for nick and +// username carrying stamp and returns the response status. +// An empty username defaults to the nick. +func postSessionWithStamp( + t *testing.T, + tserver *testServer, + nick, username, stamp string, +) int { + t.Helper() + + body, err := json.Marshal(map[string]string{ + nickKey: nick, "username": username, "pow_token": stamp, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp, err := doRequest( + t, http.MethodPost, tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + + _ = resp.Body.Close() + + return resp.StatusCode +} + +// sessionCount returns the number of sessions in tserver's +// database. +func sessionCount(t *testing.T, tserver *testServer) int64 { + t.Helper() + + count, err := tserver.database.GetUserCount(t.Context()) + if err != nil { + t.Fatalf("count sessions: %v", err) + } + + return count +} + +// TestRefusedSessionLeavesStampUnspent checks that a +// session request is refused with 402 for an invalid stamp +// before its nick is looked at, that a request refused with +// 400 or 409 does not spend its stamp, so the same stamp +// then creates a session, that the stamp stays spent after +// that, that a spent stamp with an invalid nick is refused +// for its nick, and that no refused request creates a +// session. +func TestRefusedSessionLeavesStampUnspent(t *testing.T) { + const ( + bits = 2 + badNick = "bad nick" + ) + + tserver := newTestServerWith(t, bits, "", "") + + // A server with no SERVER_NAME takes stamps for "neoirc". + status := postSessionWithStamp( + t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"), + ) + if status != http.StatusCreated { + t.Fatalf("create taken: want 201, got %d", status) + } + + stamp := api.MintHashcash(bits, "neoirc") + otherServerStamp := api.MintHashcash(bits, "elsewhere") + + steps := []struct { + nick, username, stamp string + want int + }{ + {badNick, "", otherServerStamp, http.StatusPaymentRequired}, + {badNick, "", stamp, http.StatusBadRequest}, + {"fresh", "bad username", stamp, http.StatusBadRequest}, + {"taken", "", stamp, http.StatusConflict}, + {"fresh", "", stamp, http.StatusCreated}, + {"another", "", stamp, http.StatusPaymentRequired}, + // Refused as already spent, the stamp is not given back. + {"later", "", stamp, http.StatusPaymentRequired}, + // Whether the stamp is spent is looked at after the nick. + {badNick, "", stamp, http.StatusBadRequest}, + // The refused request for "another" left the nick free. + {"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated}, + } + + sessions := sessionCount(t, tserver) + + for _, step := range steps { + status = postSessionWithStamp( + t, tserver, step.nick, step.username, step.stamp, + ) + if status != step.want { + t.Fatalf( + "nick %q, username %q: want %d, got %d", + step.nick, step.username, step.want, status, + ) + } + + if status == http.StatusCreated { + sessions++ + } + + count := sessionCount(t, tserver) + if count != sessions { + t.Fatalf( + "nick %q, username %q: want %d sessions, got %d", + step.nick, step.username, sessions, count, + ) + } + } +} + func TestNamesShowsHostmask(t *testing.T) { tserver := newTestServer(t) diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go index 2c00725..02f51ad 100644 --- a/internal/hashcash/hashcash.go +++ b/internal/hashcash/hashcash.go @@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator { return validator } -// Validate checks a hashcash stamp. It returns nil if the -// stamp is valid and has not been seen before. -func (v *Validator) Validate( +// Check returns nil if requiredBits is not positive, or if +// the stamp is a well-formed, recently dated proof of work +// for this resource with at least requiredBits. It does not +// look at or change the set of spent stamps, so a request +// can check its stamp first and Claim it after its other +// checks. +func (v *Validator) Check( stamp string, requiredBits int, ) error { @@ -87,9 +91,49 @@ func (v *Validator) Validate( return nil } + _, err := v.check(stamp, requiredBits) + + return err +} + +// Claim checks the stamp as Check does and, when +// requiredBits is positive, records it as spent until it +// expires. It returns an error if the stamp is already +// spent. Release gives a claimed stamp back. +func (v *Validator) Claim( + stamp string, + requiredBits int, +) error { + if requiredBits <= 0 { + return nil + } + + stampTime, err := v.check(stamp, requiredBits) + if err != nil { + return err + } + + return v.checkAndRecordStamp(stamp, stampTime) +} + +// Release forgets a claimed stamp so that it can be claimed +// again, for a request that failed after its Claim. +// Releasing a stamp that is not claimed does nothing. +func (v *Validator) Release(stamp string) { + v.mu.Lock() + defer v.mu.Unlock() + + delete(v.spent, stamp) +} + +// check returns the stamp's date if Check accepts it. +func (v *Validator) check( + stamp string, + requiredBits int, +) (time.Time, error) { parts := strings.Split(stamp, ":") if len(parts) != stampFields { - return fmt.Errorf( + return time.Time{}, fmt.Errorf( "%w: expected %d, got %d", errInvalidFields, stampFields, len(parts), ) @@ -104,25 +148,25 @@ func (v *Validator) Validate( version, bitsStr, resource, requiredBits, ) if err != nil { - return err + return time.Time{}, err } stampTime, err := parseStampDate(dateStr) if err != nil { - return err + return time.Time{}, err } err = validateTime(stampTime) if err != nil { - return err + return time.Time{}, err } err = validateProof(stamp, requiredBits) if err != nil { - return err + return time.Time{}, err } - return v.checkAndRecordStamp(stamp, stampTime) + return stampTime, nil } func (v *Validator) validateHeader( diff --git a/internal/hashcash/hashcash_test.go b/internal/hashcash/hashcash_test.go index 8d78383..fcc5dbe 100644 --- a/internal/hashcash/hashcash_test.go +++ b/internal/hashcash/hashcash_test.go @@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("valid stamp rejected: %v", err) } @@ -99,17 +99,74 @@ func TestReplayDetection(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("first use failed: %v", err) } - err = validator.Validate(stamp, testBits) + err = validator.Claim(stamp, testBits) if err == nil { t.Fatal("replay not detected") } } +// TestCheckDoesNotSpendStamp checks that a stamp Check +// accepts can still be claimed, and only once, and that +// Check still accepts it once it is spent. +func TestCheckDoesNotSpendStamp(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := mintStampWithDate( + t, testBits, "test-resource", todayDate(), + ) + + for range 2 { + err := validator.Check(stamp, testBits) + if err != nil { + t.Fatalf("valid stamp rejected: %v", err) + } + } + + err := validator.Claim(stamp, testBits) + if err != nil { + t.Fatalf("claim after check failed: %v", err) + } + + err = validator.Claim(stamp, testBits) + if err == nil { + t.Fatal("replay not detected") + } + + err = validator.Check(stamp, testBits) + if err != nil { + t.Fatalf("spent stamp rejected by Check: %v", err) + } +} + +// TestReleasedStampCanBeClaimedAgain checks that Release +// gives a claimed stamp back. +func TestReleasedStampCanBeClaimedAgain(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := mintStampWithDate( + t, testBits, "test-resource", todayDate(), + ) + + err := validator.Claim(stamp, testBits) + if err != nil { + t.Fatalf("first claim failed: %v", err) + } + + validator.Release(stamp) + + err = validator.Claim(stamp, testBits) + if err != nil { + t.Fatalf("claim after release failed: %v", err) + } +} + func TestResourceMismatch(t *testing.T) { t.Parallel() @@ -118,7 +175,7 @@ func TestResourceMismatch(t *testing.T) { t, testBits, "wrong-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected resource mismatch error") } @@ -129,7 +186,7 @@ func TestInvalidStampFormat(t *testing.T) { validator := hashcash.NewValidator("test-resource") - err := validator.Validate( + err := validator.Claim( "not:a:valid:stamp", testBits, ) if err == nil { @@ -146,7 +203,7 @@ func TestBadVersion(t *testing.T) { testBits, todayDate(), "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected bad version error") } @@ -162,7 +219,7 @@ func TestInsufficientDifficulty(t *testing.T) { todayDate(), "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected insufficient bits error") } @@ -178,7 +235,7 @@ func TestExpiredStamp(t *testing.T) { t, testBits, "test-resource", oldDate, ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected expired stamp error") } @@ -189,9 +246,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) { validator := hashcash.NewValidator("test-resource") - err := validator.Validate("garbage", 0) + err := validator.Check("garbage", 0) if err != nil { - t.Fatalf("zero bits should skip: %v", err) + t.Fatalf("zero bits should skip the check: %v", err) + } + + err = validator.Claim("garbage", 0) + if err != nil { + t.Fatalf("zero bits should skip the claim: %v", err) } } @@ -204,7 +266,7 @@ func TestLongDateFormat(t *testing.T) { t, testBits, "test-resource", longDate, ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("long date stamp rejected: %v", err) } @@ -219,7 +281,7 @@ func TestBadDateFormat(t *testing.T) { testBits, "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected bad date error") } @@ -235,7 +297,7 @@ func TestMultipleUniqueStamps(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("unique stamp rejected: %v", err) } @@ -251,7 +313,7 @@ func TestHigherBitsStillValid(t *testing.T) { t, 4, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf( "higher-difficulty stamp rejected: %v", diff --git a/internal/service/service.go b/internal/service/service.go index ea75f7a..716549f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -387,13 +387,14 @@ func excludeSession( return out } -// SendChannelMessage validates the hashcash stamp of a -// PRIVMSG to a +H channel, membership and moderation, then -// fans out a message to all channel members except the -// sender. Returns the database row ID, message UUID, and -// any error. The dbID lets callers enqueue the same -// message to the sender when echo is needed (HTTP -// transport). +// SendChannelMessage checks membership, bans and +// moderation, then the hashcash stamp of a PRIVMSG to a +H +// channel, then fans out a message to all channel members +// except the sender. The stamp is checked after the other +// checks so that a message they refuse does not spend it. +// Returns the database row ID, message UUID, and any error. +// The dbID lets callers enqueue the same message to the +// sender when echo is needed (HTTP transport). func (s *Service) SendChannelMessage( ctx context.Context, sessionID int64, @@ -409,15 +410,6 @@ func (s *Service) SendChannelMessage( return 0, "", noSuchChannel } - if command == irc.CmdPrivmsg { - err = s.checkChannelHashcash( - ctx, chID, channel, body, meta, - ) - if err != nil { - return 0, "", err - } - } - isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) @@ -459,6 +451,15 @@ func (s *Service) SendChannelMessage( } } + if command == irc.CmdPrivmsg { + err = s.checkChannelHashcash( + ctx, chID, channel, body, meta, + ) + if err != nil { + return 0, "", err + } + } + memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) recipients := excludeSession(memberIDs, sessionID) diff --git a/internal/service/service_test.go b/internal/service/service_test.go index f3e3d0b..79ba6bb 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -24,6 +24,7 @@ import ( "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" + "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/pkg/irc" @@ -1056,6 +1057,88 @@ func TestSendChannelMessage_Moderated(t *testing.T) { } } +// TestRefusedChannelMessageLeavesStampUnspent checks that a +// PRIVMSG to a +H channel refused because the sender is not +// a member, is banned, or has no voice on a +m channel does +// not spend its hashcash stamp: the operator can then send +// the same message with it. +func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { + const ( + channel = "#paid" + bits = 2 + ) + + refusals := []struct { + name string + // refuse stops bob from sending to the channel. + refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error + }{ + {"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error { + return env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + }}, + {"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error { + return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice") + }}, + {"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error { + return env.svc.SetChannelFlag(ctx, chID, 'm', true) + }}, + } + + body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson + + for _, refusal := range refusals { + t.Run(refusal.name, func(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + aliceSID := createSession(ctx, t, env.db, "alice") + bobSID := createSession(ctx, t, env.db, "bob") + + joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + if err != nil { + t.Fatalf("join bob: %v", err) + } + + err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits) + if err != nil { + t.Fatalf("set +H: %v", err) + } + + err = refusal.refuse(ctx, env, joined.ChannelID, bobSID) + if err != nil { + t.Fatalf("refuse bob: %v", err) + } + + stamp := hashcash.MintChannelStamp( + bits, channel, hashcash.BodyHash(body), + ) + meta, _ := json.Marshal(map[string]string{"hashcash": stamp}) + + _, _, err = env.svc.SendChannelMessage( + ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta, + ) + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || + ircErr.Code != irc.ErrCannotSendToChan { + t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err) + } + + _, _, err = env.svc.SendChannelMessage( + ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta, + ) + if err != nil { + t.Fatalf("alice with the same stamp: %v", err) + } + }) + } +} + func TestQueryUserMode(t *testing.T) { env := newTestEnv(t) ctx := t.Context()