Spend a channel hashcash stamp in one statement (closes #150) #163
+10
-22
@@ -1783,45 +1783,33 @@ func (database *Database) SetChannelHashcashBits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||||
// for replay prevention.
|
// for replay prevention. It returns false when the hash was
|
||||||
|
// already stored. The one INSERT is both the check and the
|
||||||
|
// record, so of several concurrent callers with the same
|
||||||
|
// hash exactly one gets true.
|
||||||
func (database *Database) RecordSpentHashcash(
|
func (database *Database) RecordSpentHashcash(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stampHash string,
|
stampHash string,
|
||||||
) error {
|
) (bool, error) {
|
||||||
_, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO spent_hashcash
|
`INSERT OR IGNORE INTO spent_hashcash
|
||||||
(stamp_hash, created_at)
|
(stamp_hash, created_at)
|
||||||
VALUES (?, ?)`,
|
VALUES (?, ?)`,
|
||||||
stampHash, time.Now())
|
stampHash, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return false, fmt.Errorf(
|
||||||
"record spent hashcash: %w", err,
|
"record spent hashcash: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
inserted, err := res.RowsAffected()
|
||||||
}
|
|
||||||
|
|
||||||
// IsHashcashSpent checks whether a hashcash stamp hash
|
|
||||||
// has already been used.
|
|
||||||
func (database *Database) IsHashcashSpent(
|
|
||||||
ctx context.Context,
|
|
||||||
stampHash string,
|
|
||||||
) (bool, error) {
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM spent_hashcash
|
|
||||||
WHERE stamp_hash = ?`,
|
|
||||||
stampHash,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf(
|
return false, fmt.Errorf(
|
||||||
"check spent hashcash: %w", err,
|
"record spent hashcash: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return count > 0, nil
|
return inserted > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||||
|
|||||||
@@ -3368,6 +3368,137 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendConcurrently posts cmd in the given number of requests
|
||||||
|
// at once and returns the status field of each response.
|
||||||
|
func (tserver *testServer) sendConcurrently(
|
||||||
|
token string, cmd map[string]any, requests int,
|
||||||
|
) []any {
|
||||||
|
tserver.t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
tserver.t.Fatalf("marshal command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each request writes only its own entry.
|
||||||
|
statuses := make([]any, requests)
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
|
||||||
|
for idx := range requests {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
|
||||||
|
<-start
|
||||||
|
|
||||||
|
resp, reqErr := doRequestAuth(
|
||||||
|
tserver.t, http.MethodPost,
|
||||||
|
tserver.url(apiMessages), token,
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if reqErr != nil {
|
||||||
|
tserver.t.Error(reqErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
|
||||||
|
statuses[idx] = result[statusKey]
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
waitGroup.Wait()
|
||||||
|
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelHashcashRejectConcurrentReplay sends one stamp
|
||||||
|
// in several requests at once and checks that exactly one is
|
||||||
|
// accepted and the other member receives exactly one copy.
|
||||||
|
func TestChannelHashcashRejectConcurrentReplay(t *testing.T) {
|
||||||
|
const (
|
||||||
|
channel = "#hcrace"
|
||||||
|
text = "one stamp"
|
||||||
|
sender = "hcrace_sender"
|
||||||
|
requests = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
senderToken := tserver.createSession(sender)
|
||||||
|
receiverToken := tserver.createSession("hcrace_receiver")
|
||||||
|
|
||||||
|
tserver.sendCommand(senderToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: channel,
|
||||||
|
})
|
||||||
|
tserver.sendCommand(receiverToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: channel,
|
||||||
|
})
|
||||||
|
|
||||||
|
tserver.sendCommand(senderToken, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: channel,
|
||||||
|
bodyKey: []string{"+H", "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(receiverToken, 0)
|
||||||
|
|
||||||
|
msgBody, marshalErr := json.Marshal([]string{text})
|
||||||
|
if marshalErr != nil {
|
||||||
|
t.Fatal(marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := mintTestChannelHashcash(t, 2, channel, msgBody)
|
||||||
|
|
||||||
|
statuses := tserver.sendConcurrently(senderToken, map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: channel,
|
||||||
|
bodyKey: []string{text},
|
||||||
|
metaKey: map[string]any{hashcashKey: stamp},
|
||||||
|
}, requests)
|
||||||
|
|
||||||
|
accepted := 0
|
||||||
|
|
||||||
|
for _, status := range statuses {
|
||||||
|
if status == "sent" {
|
||||||
|
accepted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if accepted != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 1 accepted message, got %d: %v",
|
||||||
|
accepted, statuses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(receiverToken, lastID)
|
||||||
|
|
||||||
|
delivered := 0
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg[commandKey] == privmsgCmd &&
|
||||||
|
msg["from"] == sender {
|
||||||
|
delivered++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if delivered != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 1 delivered copy, got %d: %v",
|
||||||
|
delivered, msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("hcnone_user")
|
token := tserver.createSession("hcnone_user")
|
||||||
|
|||||||
@@ -1285,8 +1285,9 @@ func (s *Service) requireOper(
|
|||||||
// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless
|
// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless
|
||||||
// the channel has no +H, or meta carries a hashcash stamp
|
// the channel has no +H, or meta carries a hashcash stamp
|
||||||
// for this channel and body with enough bits that has not
|
// for this channel and body with enough bits that has not
|
||||||
// been used before. A stamp that passes is recorded as
|
// been used before. Recording the stamp as spent is the
|
||||||
// spent.
|
// check that it is unused, so a stamp sent in several
|
||||||
|
// concurrent requests passes in only one of them.
|
||||||
func (s *Service) checkChannelHashcash(
|
func (s *Service) checkChannelHashcash(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
@@ -1324,12 +1325,12 @@ func (s *Service) checkChannelHashcash(
|
|||||||
|
|
||||||
stampKey := hashcash.StampHash(stamp)
|
stampKey := hashcash.StampHash(stamp)
|
||||||
|
|
||||||
spent, err := s.db.IsHashcashSpent(ctx, stampKey)
|
recorded, err := s.db.RecordSpentHashcash(ctx, stampKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check spent hashcash: %w", err)
|
return fmt.Errorf("record spent hashcash: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if spent {
|
if !recorded {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
@@ -1337,13 +1338,6 @@ func (s *Service) checkChannelHashcash(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.db.RecordSpentHashcash(ctx, stampKey)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error(
|
|
||||||
"record spent hashcash", "error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user