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
+2 -2
View File
@@ -318,8 +318,8 @@ func (hdlr *Handlers) checkHashcash(
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if the stamp was already spent and a
// response was already sent.
// 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,
+27 -17
View File
@@ -4074,17 +4074,18 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
}
}
// postSessionWithStamp posts a session request for nick
// carrying stamp and returns the response status.
// 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, stamp string,
nick, username, stamp string,
) int {
t.Helper()
body, err := json.Marshal(map[string]string{
nickKey: nick, "pow_token": stamp,
nickKey: nick, "username": username, "pow_token": stamp,
})
if err != nil {
t.Fatalf("marshal: %v", err)
@@ -4104,9 +4105,11 @@ func postSessionWithStamp(
}
// 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.
// 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, and that the stamp stays spent
// after that.
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
const bits = 2
@@ -4114,30 +4117,37 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
// A server with no SERVER_NAME takes stamps for "neoirc".
status := postSessionWithStamp(
t, tserver, "taken", api.MintHashcash(bits, "neoirc"),
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 string
want int
nick, username, stamp string
want int
}{
{"bad nick", http.StatusBadRequest},
{"taken", http.StatusConflict},
{"fresh", http.StatusCreated},
{"another", http.StatusPaymentRequired},
{"bad nick", "", otherServerStamp, http.StatusPaymentRequired},
{"bad nick", "", 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},
}
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 {
t.Fatalf(
"nick %q: want %d, got %d",
step.nick, step.want, status,
"nick %q, username %q: want %d, got %d",
step.nick, step.username, step.want, status,
)
}
}
+6 -4
View File
@@ -77,7 +77,8 @@ func NewValidator(resource string) *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
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it once nothing else
@@ -95,9 +96,10 @@ func (v *Validator) Check(
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.
// 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,
+2 -2
View File
@@ -390,8 +390,8 @@ func excludeSession(
// 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 last so that a
// message refused for another reason does not spend it.
// 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).