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++ } }