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
109 lines
2.2 KiB
Go
109 lines
2.2 KiB
Go
package neoircapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// bitsPerByte is the number of bits in a byte.
|
|
bitsPerByte = 8
|
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
fullByteMask = 0xFF
|
|
// counterSpace is the range for random counter seeds.
|
|
counterSpace = 1 << 48
|
|
)
|
|
|
|
// MintHashcash computes a hashcash stamp with the given
|
|
// difficulty (leading zero bits) and resource string.
|
|
func MintHashcash(bits int, resource string) string {
|
|
date := time.Now().UTC().Format("060102")
|
|
prefix := fmt.Sprintf(
|
|
"1:%d:%s:%s::", bits, date, resource,
|
|
)
|
|
|
|
for {
|
|
counter := randomCounter()
|
|
stamp := prefix + counter
|
|
hash := sha256.Sum256([]byte(stamp))
|
|
|
|
if hasLeadingZeroBits(hash[:], bits) {
|
|
return stamp
|
|
}
|
|
}
|
|
}
|
|
|
|
// MintChannelHashcash computes a hashcash stamp bound to
|
|
// a specific channel and message body. The stamp format
|
|
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
|
// bodyhash is the hex-encoded SHA-256 of the message
|
|
// body bytes.
|
|
func MintChannelHashcash(
|
|
bits int,
|
|
channel string,
|
|
body []byte,
|
|
) string {
|
|
bodyHash := sha256.Sum256(body)
|
|
bodyHashHex := hex.EncodeToString(bodyHash[:])
|
|
date := time.Now().UTC().Format("060102")
|
|
prefix := fmt.Sprintf(
|
|
"1:%d:%s:%s:%s:",
|
|
bits, date, channel, bodyHashHex,
|
|
)
|
|
|
|
for {
|
|
counter := randomCounter()
|
|
stamp := prefix + counter
|
|
hash := sha256.Sum256([]byte(stamp))
|
|
|
|
if hasLeadingZeroBits(hash[:], bits) {
|
|
return stamp
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasLeadingZeroBits checks if hash has at least numBits
|
|
// 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
|
|
}
|
|
|
|
// randomCounter generates a random hex counter string.
|
|
func randomCounter() string {
|
|
counterVal, err := rand.Int(
|
|
rand.Reader, big.NewInt(counterSpace),
|
|
)
|
|
if err != nil {
|
|
// Fallback to timestamp-based counter on error.
|
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
}
|
|
|
|
return hex.EncodeToString(counterVal.Bytes())
|
|
}
|