feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam #79
Reference in New Issue
Block a user
Delete Branch "feature/channel-hashcash-privmsg"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
closes #12
Summary
Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via
MODE +H <bits>, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.Changes
Database
hashcash_bitscolumn tochannelstable (default 0 = no requirement)spent_hashcashtable withstamp_hashunique key andcreated_atfor TTL pruningGetChannelHashcashBits,SetChannelHashcashBits,RecordSpentHashcash,IsHashcashSpent,PruneSpentHashcashHashcash Validation (
internal/hashcash/channel.go)ChannelValidatortype for per-channel stamp validationBodyHash()computes hex-encoded SHA-256 of message bodyStampHash()computes deterministic hash of stamp for spent-token keyMintChannelStamp()generates valid stamps (for clients)1:bits:YYMMDD:channel:bodyhash:counterHandler Changes (
internal/handlers/api.go)validateChannelHashcash()+verifyChannelStamp()— checks hashcash on PRIVMSG to protected channelsextractHashcashFromMeta()— parses hashcash stamp from meta JSONapplyChannelMode()/setHashcashMode()/clearHashcashMode()— MODE +H/-H supportqueryChannelMode()— shows +nH in mode query when hashcash is setCHANMODES=,H,,imnst(H in type B = parameter when set)Replay Prevention
spent_hashcashtableClient Support (
internal/cli/api/hashcash.go)MintChannelHashcash(bits, channel, body)— computes stamps for channel messagesTests
internal/hashcash/channel_test.go: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-tripinternal/handlers/api_test.go: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits argREADME
+Hto channel modes tablemetafield description to mention hashcashHow It Works
MODE #general +H 20(20 bits)#general+ SHA-256(body)meta.hashcashfield containing the stampDocker Build
docker build .passes clean (formatting, linting, all tests).Review: PR #79 — Per-channel hashcash requirement for PRIVMSG
Build Result
docker build . PASSES — all tests pass (12 unit + 10 integration for hashcash, all existing tests green), linting clean, compilation clean.
Requirements Checklist (vs Issue #12)
Policy Compliance
Issues Found
BUG: CHANMODES ISUPPORT classification is wrong
File: internal/handlers/api.go line 303
Current: CHANMODES=,H,,imnst — H is in Type B (parameter required on both set and unset).
Should be: CHANMODES=,,H,imnst — H should be Type C (parameter required only when set).
Per IRC ISUPPORT spec, the 4 comma-separated groups are: A (list modes), B (always takes param on set AND unset, e.g. channel key +k), C (takes param only when set, e.g. user limit +l), D (never takes param).
+H 20 requires a bits parameter when setting, but -H does NOT require a parameter. This makes H a Type C mode. Clients parsing ISUPPORT will incorrectly expect a parameter for -H, causing protocol-level misparse of mode change notifications.
Fix: Change ,H,, to ,,H, in the ISUPPORT string.
Code duplication: MintChannelHashcash in CLI package
File: internal/cli/api/hashcash.go
MintChannelHashcash() duplicates logic from hashcash.BodyHash() + hashcash.MintChannelStamp(). The CLI package can import internal/hashcash/ since they are in the same module. Refactor to call the existing functions.
Verdict
FAIL — The CHANMODES Type B vs Type C misclassification is a real protocol interoperability bug. One-character fix: move H from the second to the third comma-separated position. The code duplication is a secondary concern that should also be addressed.
Otherwise this is a very solid PR — the core hashcash logic is correct, the 1-year TTL is properly implemented, replay prevention is DB-backed, test coverage is thorough, and the README documentation is comprehensive. Just needs these two fixes.
Rework Complete
Fixed both review findings:
1. CHANMODES ISUPPORT classification (bug fix)
File:
internal/handlers/api.goline 303CHANMODES=,H,,imnst→CHANMODES=,,H,imnstMODE -H2. MintChannelHashcash deduplication (refactor)
File:
internal/cli/api/hashcash.goMintChannelHashcash()now delegates tohashcash.BodyHash()+hashcash.MintChannelStamp()frominternal/hashcash/Build
docker build .passes clean — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation.Review: PR #79 — Per-channel hashcash (post-rework)
Build Result
docker build .PASSES — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation clean.Rework Verification
Fix 1: CHANMODES ISUPPORT classification ✅
File:
internal/handlers/api.goline 303Now reads
CHANMODES=,,H,imnst— H is correctly in Type C (parameter required only when setting). The code concatenates"CHANMODES=,,H," + "imnst". Clients will correctly expect a parameter for+Hbut not for-H. Fix is correct.Fix 2: CLI MintChannelHashcash deduplication ✅
File:
internal/cli/api/hashcash.goMintChannelHashcash()now delegates tohashcash.BodyHash(body)+hashcash.MintChannelStamp(bits, channel, bodyHash). No reimplemented SHA-256 or proof-of-work logic. Fix is correct.Requirements Checklist (vs Issue #12)
setHashcashModevalidates range 1-40, persists to DBclearHashcashModesets hashcash_bits = 0validateChannelHashcash+verifyChannelStampvalidateChannelHeaderBodyHash()computes SHA-256, validated invalidateBodyHashparseStampDate+validateTime(48h max age, 1h future skew)spentHashcashTTL = 365 * 24 * time.Hourspent_hashcashtable withstamp_hash UNIQUE, checked before relay1:bits:YYMMDD:channel:bodyhash:counter(6 fields)Policy Compliance
001_initial.sql(pre-1.0) — not a new migration filego.mod/go.sumunchangedcmd/HandleSendCommand→dispatchCommand→handlePrivmsg→handleChannelMsg/handleDirectMsg→sendChannelMsg/fanOut→InsertMessage)validateProof,validateTime,parseStampDate,hasLeadingZeroBits) — no duplicationVerdict
PASS — Both rework findings are correctly addressed. All issue requirements are met. Code is clean, well-structured, and thoroughly tested. No issues found.
make testfails in CI - did you try it locally? @clawbot80f17dc335to68558ce5b5Rework: Fix
make testfailure outside DockerRoot Cause
make test(i.e.go test ./...) compiles all packages includingweb/, which contains:Inside Docker, the
web-builderstage builds the SPA and copiesweb/dist/into the build context beforemake testruns. But outside Docker (including baremake testin CI or locally),web/dist/doesn't exist, sogo:embedfails withpattern dist/*: no matching files found— breaking compilation of every package that transitively importsweb.The Dockerfile's lint stage already had a workaround (
mkdir -p web/dist && touch web/dist/...), but the Makefile had no equivalent.Fix
Added an
ensure-web-disttarget to the Makefile that creates placeholder files (index.html,style.css,app.js) whenweb/dist/doesn't exist. Madebuild,test, andlintdepend on it. This mirrors the existing pattern in the Dockerfile lint stage.Additional: Rebase conflict resolution
Rebased onto current
main(which gained #80 stats feature and #75 topic security fix since the branch was created). Resolved merge conflicts ininternal/db/queries.go(newGetClientCount/GetQueueEntryCountfrom stats vs new hashcash query functions) andinternal/handlers/handlers.go(newstatsfield vs newchannelHashcashfield — kept both).Verification
make testpasses (withweb/dist/absent — placeholder creation works)docker build .passes (all tests, lint, fmt-check, compilation)mainReview: PR #79 — Per-channel hashcash (post-rework #2)
Build Results
make testPASSES — withweb/dist/absent (placeholder creation works). All tests green: 12 unit (hashcash package, 88.1% coverage), 10 integration (handlers, 59.4% coverage), plus all existing tests.docker build .PASSES — formatting, linting, all tests, compilation, binary build all clean.Rework Verification:
ensure-web-distMakefile targetRoot cause was correct:
go:embed dist/*inweb/embed.gofails whenweb/dist/doesn't exist outside Docker.Fix is correct:
ensure-web-disttarget createsweb/dist/{index.html,style.css,app.js}only when the directory is absent (if [ ! -d web/dist ])build,test, andlinttargets all depend onensure-web-distweb-builderbeforemake testruns, and the lint stage already had its own equivalentmkdir -p && touchworkaroundRebase Verification
68558ce) on top of currentmainGetClientCount/GetQueueEntryCountinqueries.goandstatsfield inhandlers.go— both present and integrated correctlyhandleTopic/executeTopic— present and workinggo.mod/go.sumunchanged — no new dependenciesRequirements Checklist (vs Issue #12)
setHashcashModevalidates range 1-40, persists to DBclearHashcashModesets hashcash_bits = 0validateChannelHashcash+verifyChannelStampvalidateChannelHeaderBodyHash()computes SHA-256, validated invalidateBodyHashparseStampDate+validateTime(48h max age, 1h future skew)spentHashcashTTL = 365 * 24 * time.Hourspent_hashcashtable withstamp_hash UNIQUE, checked before relay, 1-year pruning in cleanup loop1:bits:YYMMDD:channel:bodyhash:counter(6 fields)CHANMODES=,,H,imnst(H correctly in Type C)Policy Compliance
001_initial.sql(pre-1.0) — not a new migration filego.mod/go.sumunchanged.golangci.ymlNOT modifiedcmd/MintChannelHashcashdelegates tohashcash.BodyHash()+hashcash.MintChannelStamp()— no duplicationvalidateProof,validateTime,parseStampDate,hasLeadingZeroBits)Verdict
PASS — The
ensure-web-distfix correctly resolves themake testfailure outside Docker without interfering with the Docker build. Rebase onto main resolved cleanly with no conflicts. All original hashcash requirements remain satisfied. Bothmake testanddocker build .pass clean. No issues found.