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
323 lines
6.9 KiB
Go
323 lines
6.9 KiB
Go
// Package hashcash implements SHA-256-based hashcash
|
|
// proof-of-work validation for abuse prevention.
|
|
//
|
|
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
|
//
|
|
// The SHA-256 hash of the entire stamp string must have
|
|
// at least `bits` leading zero bits.
|
|
package hashcash
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// stampVersion is the only supported hashcash version.
|
|
stampVersion = "1"
|
|
// stampFields is the number of fields in a stamp.
|
|
stampFields = 6
|
|
// maxStampAge is how old a stamp can be before
|
|
// rejection.
|
|
maxStampAge = 48 * time.Hour
|
|
// maxFutureSkew allows stamps slightly in the future.
|
|
maxFutureSkew = 1 * time.Hour
|
|
// pruneInterval controls how often expired stamps are
|
|
// removed from the spent set.
|
|
pruneInterval = 10 * time.Minute
|
|
// dateFormatShort is the YYMMDD date layout.
|
|
dateFormatShort = "060102"
|
|
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
|
dateFormatLong = "060102150405"
|
|
// dateShortLen is the length of YYMMDD.
|
|
dateShortLen = 6
|
|
// dateLongLen is the length of YYMMDDHHMMSS.
|
|
dateLongLen = 12
|
|
// bitsPerByte is the number of bits in a byte.
|
|
bitsPerByte = 8
|
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
fullByteMask = 0xFF
|
|
)
|
|
|
|
var (
|
|
errInvalidFields = errors.New("invalid stamp field count")
|
|
errBadVersion = errors.New("unsupported stamp version")
|
|
errInsufficientBits = errors.New("insufficient difficulty")
|
|
errWrongResource = errors.New("wrong resource")
|
|
errStampExpired = errors.New("stamp expired")
|
|
errStampFuture = errors.New("stamp date in future")
|
|
errProofFailed = errors.New("proof-of-work failed")
|
|
errStampReused = errors.New("stamp already used")
|
|
errBadDateFormat = errors.New("unrecognized date format")
|
|
)
|
|
|
|
// Validator checks hashcash stamps for validity and
|
|
// prevents replay attacks via an in-memory spent set.
|
|
type Validator struct {
|
|
resource string
|
|
mu sync.Mutex
|
|
spent map[string]time.Time
|
|
}
|
|
|
|
// NewValidator creates a Validator for the given resource.
|
|
func NewValidator(resource string) *Validator {
|
|
validator := &Validator{
|
|
resource: resource,
|
|
mu: sync.Mutex{},
|
|
spent: make(map[string]time.Time),
|
|
}
|
|
|
|
go validator.pruneLoop()
|
|
|
|
return validator
|
|
}
|
|
|
|
// Check returns nil if requiredBits is not positive, or if
|
|
// the stamp is a well-formed, recently dated proof of work
|
|
// for this resource with at least requiredBits. It does not
|
|
// look at or change the set of spent stamps, so a request
|
|
// can check its stamp first and Claim it once nothing else
|
|
// can refuse the request.
|
|
func (v *Validator) Check(
|
|
stamp string,
|
|
requiredBits int,
|
|
) error {
|
|
if requiredBits <= 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err := v.check(stamp, requiredBits)
|
|
|
|
return err
|
|
}
|
|
|
|
// Claim checks the stamp as Check does and, when
|
|
// requiredBits is positive, records it as spent until it
|
|
// expires. It returns an error if the stamp is already
|
|
// spent. Release gives a claimed stamp back.
|
|
func (v *Validator) Claim(
|
|
stamp string,
|
|
requiredBits int,
|
|
) error {
|
|
if requiredBits <= 0 {
|
|
return nil
|
|
}
|
|
|
|
stampTime, err := v.check(stamp, requiredBits)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return v.checkAndRecordStamp(stamp, stampTime)
|
|
}
|
|
|
|
// Release forgets a claimed stamp so that it can be claimed
|
|
// again, for a request that failed after its Claim.
|
|
// Releasing a stamp that is not claimed does nothing.
|
|
func (v *Validator) Release(stamp string) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
delete(v.spent, stamp)
|
|
}
|
|
|
|
// check returns the stamp's date if Check accepts it.
|
|
func (v *Validator) check(
|
|
stamp string,
|
|
requiredBits int,
|
|
) (time.Time, error) {
|
|
parts := strings.Split(stamp, ":")
|
|
if len(parts) != stampFields {
|
|
return time.Time{}, fmt.Errorf(
|
|
"%w: expected %d, got %d",
|
|
errInvalidFields, stampFields, len(parts),
|
|
)
|
|
}
|
|
|
|
version := parts[0]
|
|
bitsStr := parts[1]
|
|
dateStr := parts[2]
|
|
resource := parts[3]
|
|
|
|
err := v.validateHeader(
|
|
version, bitsStr, resource, requiredBits,
|
|
)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
stampTime, err := parseStampDate(dateStr)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
err = validateTime(stampTime)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
err = validateProof(stamp, requiredBits)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return stampTime, nil
|
|
}
|
|
|
|
func (v *Validator) validateHeader(
|
|
version, bitsStr, resource string,
|
|
requiredBits int,
|
|
) 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 != v.resource {
|
|
return fmt.Errorf(
|
|
"%w: got %q, want %q",
|
|
errWrongResource, resource, v.resource,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateTime(stampTime time.Time) error {
|
|
now := time.Now()
|
|
|
|
if now.Sub(stampTime) > maxStampAge {
|
|
return errStampExpired
|
|
}
|
|
|
|
if stampTime.Sub(now) > maxFutureSkew {
|
|
return errStampFuture
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateProof(stamp string, requiredBits int) error {
|
|
hash := sha256.Sum256([]byte(stamp))
|
|
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
|
return fmt.Errorf(
|
|
"%w: need %d leading zero bits",
|
|
errProofFailed, requiredBits,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) checkAndRecordStamp(
|
|
stamp string,
|
|
stampTime time.Time,
|
|
) error {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
if _, ok := v.spent[stamp]; ok {
|
|
return errStampReused
|
|
}
|
|
|
|
v.spent[stamp] = stampTime
|
|
|
|
return nil
|
|
}
|
|
|
|
// hasLeadingZeroBits checks if the hash has at least n
|
|
// leading zero bits.
|
|
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
|
fullBytes := numBits / bitsPerByte
|
|
remainBits := numBits % bitsPerByte
|
|
|
|
for idx := range fullBytes {
|
|
if hash[idx] != 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if remainBits > 0 && fullBytes < len(hash) {
|
|
mask := byte(
|
|
fullByteMask << (bitsPerByte - remainBits),
|
|
)
|
|
|
|
if hash[fullBytes]&mask != 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseStampDate parses a hashcash date stamp.
|
|
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
|
func parseStampDate(dateStr string) (time.Time, error) {
|
|
switch len(dateStr) {
|
|
case dateShortLen:
|
|
parsed, err := time.Parse(
|
|
dateFormatShort, dateStr,
|
|
)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf(
|
|
"parse date: %w", err,
|
|
)
|
|
}
|
|
|
|
return parsed, nil
|
|
case dateLongLen:
|
|
parsed, err := time.Parse(
|
|
dateFormatLong, dateStr,
|
|
)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf(
|
|
"parse date: %w", err,
|
|
)
|
|
}
|
|
|
|
return parsed, nil
|
|
default:
|
|
return time.Time{}, fmt.Errorf(
|
|
"%w: %q", errBadDateFormat, dateStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// pruneLoop periodically removes expired stamps from the
|
|
// spent set.
|
|
func (v *Validator) pruneLoop() {
|
|
ticker := time.NewTicker(pruneInterval)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
v.prune()
|
|
}
|
|
}
|
|
|
|
func (v *Validator) prune() {
|
|
cutoff := time.Now().Add(-maxStampAge)
|
|
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
for stamp, stampTime := range v.spent {
|
|
if stampTime.Before(cutoff) {
|
|
delete(v.spent, stamp)
|
|
}
|
|
}
|
|
}
|