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
187 lines
3.8 KiB
Go
187 lines
3.8 KiB
Go
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++
|
|
}
|
|
}
|