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-21 01:07:36 +00:00
parent 3822cdaf1d
commit 436726dc57
8 changed files with 377 additions and 49 deletions
+38 -8
View File
@@ -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 {
+109
View File
@@ -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,114 @@ 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, and that no refused request creates a session.
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")
otherServerStamp := api.MintHashcash(bits, "elsewhere")
steps := []struct {
nick, username, stamp string
want int
}{
{"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},
// 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)