1 Commits
Author SHA1 Message Date
sneak 53dd786d57 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-20 23:59:57 +00:00
4 changed files with 25 additions and 37 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 Claim refused the stamp, normally because // Returns false if the stamp was already spent and a
// it was already spent, and a response was already sent. // response was already sent.
func (hdlr *Handlers) claimHashcash( func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
+17 -27
View File
@@ -4074,18 +4074,17 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
} }
} }
// postSessionWithStamp posts a session request for nick and // postSessionWithStamp posts a session request for nick
// username carrying stamp and returns the response status. // 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, username, stamp string, nick, stamp string,
) int { ) int {
t.Helper() t.Helper()
body, err := json.Marshal(map[string]string{ body, err := json.Marshal(map[string]string{
nickKey: nick, "username": username, "pow_token": stamp, nickKey: nick, "pow_token": stamp,
}) })
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
@@ -4105,11 +4104,9 @@ func postSessionWithStamp(
} }
// TestRefusedSessionLeavesStampUnspent checks that a // TestRefusedSessionLeavesStampUnspent checks that a
// session request is refused with 402 for an invalid stamp // session request refused with 400 or 409 does not spend
// before its nick is looked at, that a request refused with // its hashcash stamp, so the same stamp then creates a
// 400 or 409 does not spend its stamp, so the same stamp // session, after which it is spent.
// 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
@@ -4117,37 +4114,30 @@ 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, username, stamp string nick string
want int want int
}{ }{
{"bad nick", "", otherServerStamp, http.StatusPaymentRequired}, {"bad nick", http.StatusBadRequest},
{"bad nick", "", stamp, http.StatusBadRequest}, {"taken", http.StatusConflict},
{"fresh", "bad username", stamp, http.StatusBadRequest}, {"fresh", http.StatusCreated},
{"taken", "", stamp, http.StatusConflict}, {"another", http.StatusPaymentRequired},
{"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( status = postSessionWithStamp(t, tserver, step.nick, stamp)
t, tserver, step.nick, step.username, step.stamp,
)
if status != step.want { if status != step.want {
t.Fatalf( t.Fatalf(
"nick %q, username %q: want %d, got %d", "nick %q: want %d, got %d",
step.nick, step.username, step.want, status, step.nick, step.want, status,
) )
} }
} }
+4 -6
View File
@@ -77,8 +77,7 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if requiredBits is not positive, or if // Check returns nil if the stamp is a valid proof of work
// 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
@@ -96,10 +95,9 @@ func (v *Validator) Check(
return err return err
} }
// Claim checks the stamp as Check does and, when // Claim checks the stamp as Check does and records it as
// requiredBits is positive, records it as spent until it // spent until it expires. It returns an error if the stamp
// expires. It returns an error if the stamp is already // is already spent. Release gives a claimed stamp back.
// 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 after the other // except the sender. The stamp is checked last so that a
// checks so that a message they refuse does not spend it. // message refused for another reason 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).