Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-20 23:59:57 +00:00
parent b879644e41
commit 53dd786d57
7 changed files with 329 additions and 47 deletions
+6 -1
View File
@@ -1404,7 +1404,8 @@ Include the hashcash stamp in the `meta` field:
**Server validation:** The server checks that the stamp is well-formed, meets **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 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 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, **Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a 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 - Stamp has not been used before (replay prevention; the spent stamps are
kept in memory, so a restart forgets them) 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 ### Stamp Format
Standard hashcash format: Standard hashcash format:
+38 -8
View File
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.validateHashcash( if !hdlr.checkHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, payload.Nick, username, writer, request,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username string, nick, username, stamp string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// validateHashcash validates a hashcash stamp if required. // checkHashcash checks a hashcash stamp if required,
// Returns false if validation failed and a response was // without spending it. Returns false if the check failed
// already sent. // and a response was already sent.
func (hdlr *Handlers) validateHashcash( func (hdlr *Handlers) checkHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
return false 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 the stamp 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, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
+70
View File
@@ -29,6 +29,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"modernc.org/sqlite" "modernc.org/sqlite"
"sneak.berlin/go/neoirc/internal/broker" "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/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
@@ -4073,6 +4074,75 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
} }
} }
// postSessionWithStamp posts a session request for nick
// carrying stamp and returns the response status.
func postSessionWithStamp(
t *testing.T,
tserver *testServer,
nick, stamp string,
) int {
t.Helper()
body, err := json.Marshal(map[string]string{
nickKey: nick, "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
}
// TestRefusedSessionLeavesStampUnspent checks that a
// session request refused with 400 or 409 does not spend
// its hashcash stamp, so the same stamp then creates a
// session, after which it is spent.
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
const bits = 2
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")
steps := []struct {
nick string
want int
}{
{"bad nick", http.StatusBadRequest},
{"taken", http.StatusConflict},
{"fresh", http.StatusCreated},
{"another", http.StatusPaymentRequired},
}
for _, step := range steps {
status = postSessionWithStamp(t, tserver, step.nick, stamp)
if status != step.want {
t.Fatalf(
"nick %q: want %d, got %d",
step.nick, step.want, status,
)
}
}
}
func TestNamesShowsHostmask(t *testing.T) { func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
+51 -9
View File
@@ -77,9 +77,12 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Validate checks a hashcash stamp. It returns nil if the // Check returns nil if the stamp is a valid proof of work
// stamp is valid and has not been seen before. // for this resource with at least requiredBits. It does not
func (v *Validator) Validate( // look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it once nothing else
// can refuse the request.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -87,9 +90,48 @@ func (v *Validator) Validate(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and 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, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return fmt.Errorf( return time.Time{}, fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -104,25 +146,25 @@ func (v *Validator) Validate(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return err return time.Time{}, err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
return v.checkAndRecordStamp(stamp, stampTime) return stampTime, nil
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+64 -13
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,17 +99,68 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Validate(stamp, testBits) err = validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once.
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")
}
}
// 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) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -118,7 +169,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -129,7 +180,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate( err := validator.Claim(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -146,7 +197,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -162,7 +213,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -178,7 +229,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -189,7 +240,7 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0) err := validator.Claim("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip: %v", err) t.Fatalf("zero bits should skip: %v", err)
} }
@@ -204,7 +255,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -219,7 +270,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -235,7 +286,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -251,7 +302,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+17 -16
View File
@@ -387,13 +387,14 @@ func excludeSession(
return out return out
} }
// SendChannelMessage validates the hashcash stamp of a // SendChannelMessage checks membership, bans and
// PRIVMSG to a +H channel, membership and moderation, then // moderation, then the hashcash stamp of a PRIVMSG to a +H
// fans out a message to all channel members except the // channel, then fans out a message to all channel members
// sender. Returns the database row ID, message UUID, and // except the sender. The stamp is checked last so that a
// any error. The dbID lets callers enqueue the same // message refused for another reason does not spend it.
// message to the sender when echo is needed (HTTP // Returns the database row ID, message UUID, and any error.
// transport). // The dbID lets callers enqueue the same message to the
// sender when echo is needed (HTTP transport).
func (s *Service) SendChannelMessage( func (s *Service) SendChannelMessage(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
@@ -409,15 +410,6 @@ func (s *Service) SendChannelMessage(
return 0, "", noSuchChannel 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( isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID, 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) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID) recipients := excludeSession(memberIDs, sessionID)
+83
View File
@@ -24,6 +24,7 @@ import (
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "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) { func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()