feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 2m18s
All checks were successful
check / check (push) Successful in 2m18s
Add per-channel hashcash requirement via MODE +H <bits>. When set, PRIVMSG to the channel must include a valid hashcash stamp in the meta.hashcash field bound to the channel name and message body hash. Server validates stamp format, difficulty, date freshness, channel binding, body hash binding, and proof-of-work. Spent stamps are persisted to SQLite with 1-year TTL for replay prevention. Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter Changes: - Schema: add hashcash_bits column to channels, spent_hashcash table - DB: queries for get/set channel hashcash bits, spent token CRUD - Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp - Handlers: validate hashcash on PRIVMSG, MODE +H/-H support - Pass meta through fanOut chain to store in messages - Prune spent hashcash tokens in cleanup loop (1-year TTL) - Client: MintChannelHashcash helper for CLI - Tests: 12 new channel_test.go + 10 new api_test.go integration tests - README: document +H mode, stamp format, and usage
This commit is contained in:
@@ -1266,3 +1266,110 @@ func (database *Database) PruneOldMessages(
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// GetChannelHashcashBits returns the hashcash difficulty
|
||||
// requirement for a channel. Returns 0 if not set.
|
||||
func (database *Database) GetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (int, error) {
|
||||
var bits int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
||||
channelID,
|
||||
).Scan(&bits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
// SetChannelHashcashBits sets the hashcash difficulty
|
||||
// requirement for a channel. A value of 0 disables the
|
||||
// requirement.
|
||||
func (database *Database) SetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
bits int,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET hashcash_bits = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
bits, time.Now(), channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"set channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||
// for replay prevention.
|
||||
func (database *Database) RecordSpentHashcash(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO spent_hashcash
|
||||
(stamp_hash, created_at)
|
||||
VALUES (?, ?)`,
|
||||
stampHash, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"record spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false, fmt.Errorf(
|
||||
"check spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||
// than the cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneSpentHashcash(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||
topic_set_at DATETIME,
|
||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -61,6 +62,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||
|
||||
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stamp_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||
|
||||
-- Per-client message queues for fan-out delivery
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
Reference in New Issue
Block a user