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
245 lines
4.9 KiB
Go
245 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|