1 Commits
Author SHA1 Message Date
sneak f6f2d2a038 Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Canceled after 0s
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
2026-10-21 00:40:56 +00:00
4 changed files with 37 additions and 25 deletions

No files matched your search

+2 -2
View File
@@ -318,8 +318,8 @@ func (hdlr *Handlers) checkHashcash(
} }
// claimHashcash spends a stamp that checkHashcash accepted. // claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if the stamp was already spent and a // Returns false if Claim refused the stamp, normally because
// response was already sent. // it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash( func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
+27 -17
View File
@@ -4074,17 +4074,18 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
} }
} }
// postSessionWithStamp posts a session request for nick // postSessionWithStamp posts a session request for nick and
// carrying stamp and returns the response status. // username carrying stamp and returns the response status.
// An empty username defaults to the nick.
func postSessionWithStamp( func postSessionWithStamp(
t *testing.T, t *testing.T,
tserver *testServer, tserver *testServer,
nick, stamp string, nick, username, stamp string,
) int { ) int {
t.Helper() t.Helper()
body, err := json.Marshal(map[string]string{ body, err := json.Marshal(map[string]string{
nickKey: nick, "pow_token": stamp, nickKey: nick, "username": username, "pow_token": stamp,
}) })
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
@@ -4104,9 +4105,11 @@ func postSessionWithStamp(
} }
// TestRefusedSessionLeavesStampUnspent checks that a // TestRefusedSessionLeavesStampUnspent checks that a
// session request refused with 400 or 409 does not spend // session request is refused with 402 for an invalid stamp
// its hashcash stamp, so the same stamp then creates a // before its nick is looked at, that a request refused with
// session, after which it is spent. // 400 or 409 does not spend its stamp, so the same stamp
// then creates a session, and that the stamp stays spent
// after that.
func TestRefusedSessionLeavesStampUnspent(t *testing.T) { func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
const bits = 2 const bits = 2
@@ -4114,30 +4117,37 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
// A server with no SERVER_NAME takes stamps for "neoirc". // A server with no SERVER_NAME takes stamps for "neoirc".
status := postSessionWithStamp( status := postSessionWithStamp(
t, tserver, "taken", api.MintHashcash(bits, "neoirc"), t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
) )
if status != http.StatusCreated { if status != http.StatusCreated {
t.Fatalf("create taken: want 201, got %d", status) t.Fatalf("create taken: want 201, got %d", status)
} }
stamp := api.MintHashcash(bits, "neoirc") stamp := api.MintHashcash(bits, "neoirc")
otherServerStamp := api.MintHashcash(bits, "elsewhere")
steps := []struct { steps := []struct {
nick string nick, username, stamp string
want int want int
}{ }{
{"bad nick", http.StatusBadRequest}, {"bad nick", "", otherServerStamp, http.StatusPaymentRequired},
{"taken", http.StatusConflict}, {"bad nick", "", stamp, http.StatusBadRequest},
{"fresh", http.StatusCreated}, {"fresh", "bad username", stamp, http.StatusBadRequest},
{"another", http.StatusPaymentRequired}, {"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},
} }
for _, step := range steps { for _, step := range steps {
status = postSessionWithStamp(t, tserver, step.nick, stamp) status = postSessionWithStamp(
t, tserver, step.nick, step.username, step.stamp,
)
if status != step.want { if status != step.want {
t.Fatalf( t.Fatalf(
"nick %q: want %d, got %d", "nick %q, username %q: want %d, got %d",
step.nick, step.want, status, step.nick, step.username, step.want, status,
) )
} }
} }
+6 -4
View File
@@ -77,7 +77,8 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if the stamp is a valid proof of work // 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 // for this resource with at least requiredBits. It does not
// look at or change the set of spent stamps, so a request // look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it once nothing else // can check its stamp first and Claim it once nothing else
@@ -95,9 +96,10 @@ func (v *Validator) Check(
return err return err
} }
// Claim checks the stamp as Check does and records it as // Claim checks the stamp as Check does and, when
// spent until it expires. It returns an error if the stamp // requiredBits is positive, records it as spent until it
// is already spent. Release gives a claimed stamp back. // expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim( func (v *Validator) Claim(
stamp string, stamp string,
requiredBits int, requiredBits int,
+2 -2
View File
@@ -390,8 +390,8 @@ func excludeSession(
// SendChannelMessage checks membership, bans and // SendChannelMessage checks membership, bans and
// moderation, then the hashcash stamp of a PRIVMSG to a +H // moderation, then the hashcash stamp of a PRIVMSG to a +H
// channel, then fans out a message to all channel members // channel, then fans out a message to all channel members
// except the sender. The stamp is checked last so that a // except the sender. The stamp is checked after the other
// message refused for another reason does not spend it. // checks so that a message they refuse does not spend it.
// Returns the database row ID, message UUID, and any error. // Returns the database row ID, message UUID, and any error.
// The dbID lets callers enqueue the same message to the // The dbID lets callers enqueue the same message to the
// sender when echo is needed (HTTP transport). // sender when echo is needed (HTTP transport).