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 was merged in pull request #171.
This commit is contained in:
2026-10-21 04:02:06 +02:00
parent e7e408c93e
commit 0e6d06a7fc
8 changed files with 393 additions and 51 deletions
+116
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,121 @@ 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, that a spent stamp with an invalid nick is refused
// for its nick, and that no refused request creates a
// session.
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
const (
bits = 2
badNick = "bad nick"
)
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
}{
{badNick, "", otherServerStamp, http.StatusPaymentRequired},
{badNick, "", 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},
// Whether the stamp is spent is looked at after the nick.
{badNick, "", stamp, http.StatusBadRequest},
// 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)