feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 1m11s
All checks were successful
check / check (push) Successful in 1m11s
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:
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBodyHashMismatch = errors.New(
|
||||
"body hash mismatch",
|
||||
)
|
||||
errBodyHashMissing = errors.New(
|
||||
"body hash missing",
|
||||
)
|
||||
)
|
||||
|
||||
// ChannelValidator checks hashcash stamps for
|
||||
// per-channel PRIVMSG validation. It verifies that
|
||||
// stamps are bound to a specific channel and message
|
||||
// body. Replay prevention is handled externally via
|
||||
// the database spent_hashcash table for persistence
|
||||
// across server restarts (1-year TTL).
|
||||
type ChannelValidator struct{}
|
||||
|
||||
// NewChannelValidator creates a ChannelValidator.
|
||||
func NewChannelValidator() *ChannelValidator {
|
||||
return &ChannelValidator{}
|
||||
}
|
||||
|
||||
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||
// message body for use in hashcash stamp validation.
|
||||
func BodyHash(body []byte) string {
|
||||
hash := sha256.Sum256(body)
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateStamp checks a channel hashcash stamp. It
|
||||
// verifies the stamp format, difficulty, date, channel
|
||||
// binding, body hash binding, and proof-of-work. Replay
|
||||
// detection is NOT performed here — callers must check
|
||||
// the spent_hashcash table separately.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func (cv *ChannelValidator) ValidateStamp(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
stampBodyHash := parts[4]
|
||||
|
||||
headerErr := validateChannelHeader(
|
||||
version, bitsStr, resource,
|
||||
requiredBits, channel,
|
||||
)
|
||||
if headerErr != nil {
|
||||
return headerErr
|
||||
}
|
||||
|
||||
stampTime, parseErr := parseStampDate(dateStr)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
timeErr := validateTime(stampTime)
|
||||
if timeErr != nil {
|
||||
return timeErr
|
||||
}
|
||||
|
||||
bodyErr := validateBodyHash(
|
||||
stampBodyHash, bodyHash,
|
||||
)
|
||||
if bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
|
||||
return validateProof(stamp, requiredBits)
|
||||
}
|
||||
|
||||
// StampHash returns a deterministic hash of a stamp
|
||||
// string for use as a spent-token key.
|
||||
func StampHash(stamp string) string {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func validateChannelHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != channel {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, channel,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBodyHash(
|
||||
stampBodyHash, expectedBodyHash string,
|
||||
) error {
|
||||
if stampBodyHash == "" {
|
||||
return errBodyHashMissing
|
||||
}
|
||||
|
||||
if stampBodyHash != expectedBodyHash {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errBodyHashMismatch,
|
||||
stampBodyHash, expectedBodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MintChannelStamp computes a channel hashcash stamp
|
||||
// with the given difficulty, channel name, and body hash.
|
||||
// This is intended for clients to generate stamps before
|
||||
// sending PRIVMSG to hashcash-protected channels.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func MintChannelStamp(
|
||||
bits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) string {
|
||||
date := time.Now().UTC().Format(dateFormatShort)
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s:%s:",
|
||||
bits, date, channel, bodyHash,
|
||||
)
|
||||
|
||||
counter := uint64(0)
|
||||
|
||||
for {
|
||||
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
}
|
||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package hashcash_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
testChannel = "#general"
|
||||
testBodyText = `["hello world"]`
|
||||
)
|
||||
|
||||
func testBodyHash() string {
|
||||
hash := sha256.Sum256([]byte(testBodyText))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func TestChannelValidateHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, "#other", bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected channel mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
wrongHash := sha256.Sum256([]byte("different body"))
|
||||
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, wrongBodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected body hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with 2 bits but require 4.
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, 4, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"garbage", 0, "#ch", "abc",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"not:valid", testBits, testChannel, "abc",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with a very old date by manually constructing.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, "200101",
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Construct a stamp with empty body hash field.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, todayDate(),
|
||||
)
|
||||
|
||||
// This uses the session-style stamp which has empty
|
||||
// ext field — body hash is missing.
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing body hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`["hello world"]`)
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
if len(bodyHash) != 64 {
|
||||
t.Fatalf(
|
||||
"expected 64-char hex hash, got %d",
|
||||
len(bodyHash),
|
||||
)
|
||||
}
|
||||
|
||||
// Same input should produce same hash.
|
||||
bodyHash2 := hashcash.BodyHash(body)
|
||||
if bodyHash != bodyHash2 {
|
||||
t.Fatal("body hash not deterministic")
|
||||
}
|
||||
|
||||
// Different input should produce different hash.
|
||||
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||
if bodyHash == bodyHash3 {
|
||||
t.Fatal("different inputs produced same hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := hashcash.StampHash("stamp1")
|
||||
hash2 := hashcash.StampHash("stamp2")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("different stamps produced same hash")
|
||||
}
|
||||
|
||||
// Same input should be deterministic.
|
||||
hash1b := hashcash.StampHash("stamp1")
|
||||
if hash1 != hash1b {
|
||||
t.Fatal("stamp hash not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintChannelStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bodyHash := testBodyHash()
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
if stamp == "" {
|
||||
t.Fatal("expected non-empty stamp")
|
||||
}
|
||||
|
||||
// Validate the minted stamp.
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("minted stamp failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user