Compare commits
2 Commits
feat/chi-v
...
ff9a943e6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9a943e6d | ||
|
|
b48e164d88 |
183
README.md
183
README.md
@@ -987,7 +987,18 @@ the format:
|
|||||||
|
|
||||||
Create a new user session. This is the entry point for all clients.
|
Create a new user session. This is the entry point for all clients.
|
||||||
|
|
||||||
**Request:**
|
If the server requires hashcash proof-of-work (see
|
||||||
|
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||||
|
valid stamp in the `X-Hashcash` request header. The required difficulty is
|
||||||
|
advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||||
|
|
||||||
|
**Request Headers:**
|
||||||
|
|
||||||
|
| Header | Required | Description |
|
||||||
|
|--------------|----------|-------------|
|
||||||
|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice"}
|
{"nick": "alice"}
|
||||||
```
|
```
|
||||||
@@ -1016,12 +1027,15 @@ Create a new user session. This is the entry point for all clients.
|
|||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||||
|
| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled |
|
||||||
|
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||||
| 409 | `nick already taken` | Another active session holds this nick |
|
| 409 | `nick already taken` | Another active session holds this nick |
|
||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
|
||||||
-d '{"nick":"alice"}' | jq -r .token)
|
-d '{"nick":"alice"}' | jq -r .token)
|
||||||
echo $TOKEN
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
@@ -1376,16 +1390,18 @@ Return server metadata. No authentication required.
|
|||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42,
|
||||||
|
"hashcash_bits": 20
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-----------|---------|-------------|
|
|-----------------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `version` | string | Server version |
|
| `version` | string | Server version |
|
||||||
| `motd` | string | Message of the day |
|
| `motd` | string | Message of the day |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
| `users` | integer | Number of currently active user sessions |
|
||||||
|
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1624,10 +1640,6 @@ authenticity.
|
|||||||
termination.
|
termination.
|
||||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||||
Restrict this in production via reverse proxy configuration if needed.
|
Restrict this in production via reverse proxy configuration if needed.
|
||||||
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
|
||||||
responses, restricting resource loading to same-origin and disabling
|
|
||||||
dangerous features (object embeds, framing, base tag injection). The
|
|
||||||
embedded SPA works without `'unsafe-inline'` for scripts or styles.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1823,6 +1835,7 @@ directory is also loaded automatically via
|
|||||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||||
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -1834,6 +1847,7 @@ MOTD=Welcome! Be excellent to each other.
|
|||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||||
SESSION_IDLE_TIMEOUT=24h
|
SESSION_IDLE_TIMEOUT=24h
|
||||||
|
NEOIRC_HASHCASH_BITS=20
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2106,62 +2120,102 @@ Clients should handle these message commands from the queue:
|
|||||||
|
|
||||||
## Rate Limiting & Abuse Prevention
|
## Rate Limiting & Abuse Prevention
|
||||||
|
|
||||||
Session creation (`POST /api/v1/session`) will require a
|
### Hashcash Proof-of-Work
|
||||||
|
|
||||||
|
Session creation (`POST /api/v1/session`) requires a
|
||||||
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
||||||
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
||||||
account registration, no IP-based rate limits that punish shared networks.
|
account registration, no IP-based rate limits that punish shared networks.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. Client requests a challenge: `GET /api/v1/challenge`
|
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
|
||||||
```json
|
field (e.g., `20`) indicating the required difficulty.
|
||||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
2. Client computes a hashcash stamp: find a counter value such that the
|
||||||
```
|
SHA-256 hash of the stamp string has the required number of leading zero
|
||||||
2. Server returns a nonce and a required difficulty (number of leading zero
|
bits.
|
||||||
bits in the SHA-256 hash)
|
3. Client includes the stamp in the `X-Hashcash` request header when creating
|
||||||
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
|
a session: `POST /api/v1/session`.
|
||||||
has the required number of leading zero bits:
|
4. Server validates the stamp:
|
||||||
```
|
- Version is `1`
|
||||||
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
|
- Claimed bits ≥ required bits
|
||||||
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
|
- Resource matches the server name
|
||||||
...
|
- Date is within 48 hours (not expired, not too far in the future)
|
||||||
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
|
- SHA-256 hash has the required leading zero bits
|
||||||
```
|
- Stamp has not been used before (replay prevention)
|
||||||
4. Client submits the proof with the session request:
|
|
||||||
```json
|
|
||||||
POST /api/v1/session
|
|
||||||
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
|
|
||||||
```
|
|
||||||
5. Server verifies:
|
|
||||||
- Nonce was issued by this server and hasn't expired
|
|
||||||
- Nonce hasn't been used before (prevent replay)
|
|
||||||
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
|
|
||||||
- If valid, create the session normally
|
|
||||||
|
|
||||||
### Adaptive Difficulty
|
### Stamp Format
|
||||||
|
|
||||||
The required difficulty scales with server load. Under normal conditions, the
|
Standard hashcash format:
|
||||||
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
|
|
||||||
session creation rate increases, difficulty rises — making bulk session creation
|
|
||||||
exponentially more expensive for attackers while remaining cheap for legitimate
|
|
||||||
single-user connections.
|
|
||||||
|
|
||||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
```
|
||||||
|--------------------|-------------------|--------------------|
|
1:bits:date:resource::counter
|
||||||
| Normal (< 100/min) | 16 | ~1ms |
|
```
|
||||||
| Elevated | 20 | ~15ms |
|
|
||||||
| High | 24 | ~250ms |
|
|
||||||
| Under attack | 28+ | ~4s+ |
|
|
||||||
|
|
||||||
Each additional bit of difficulty doubles the expected work. An attacker
|
| Field | Description |
|
||||||
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
|
|------------|-------------|
|
||||||
user creating one session needs ~4 seconds once and never again for the
|
| `1` | Version (always `1`) |
|
||||||
duration of their session.
|
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
|
||||||
|
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
|
||||||
|
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
|
||||||
|
| (empty) | Extension field (unused) |
|
||||||
|
| `counter` | Hex counter value found by the client to satisfy the PoW |
|
||||||
|
|
||||||
|
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
|
||||||
|
|
||||||
|
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
|
||||||
|
|
||||||
|
### Computing a Stamp
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pseudocode
|
||||||
|
bits = 20
|
||||||
|
resource = "neoirc"
|
||||||
|
date = "260310" # YYMMDD in UTC
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
loop:
|
||||||
|
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
|
||||||
|
hash = SHA-256(stamp)
|
||||||
|
if leading_zero_bits(hash) >= bits:
|
||||||
|
return stamp
|
||||||
|
counter++
|
||||||
|
```
|
||||||
|
|
||||||
|
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||||
|
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||||
|
|
||||||
|
### Client Integration
|
||||||
|
|
||||||
|
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||||
|
|
||||||
|
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||||
|
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||||
|
3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session`
|
||||||
|
|
||||||
|
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||||
|
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
|
||||||
|
|
||||||
|
| Value | Effect | Approx. Client CPU |
|
||||||
|
|-------|--------|---------------------|
|
||||||
|
| `0` | Disabled (no proof-of-work required) | — |
|
||||||
|
| `16` | Light protection | ~1ms |
|
||||||
|
| `20` | Default — good balance | ~0.5–2s |
|
||||||
|
| `24` | Strong protection | ~10–30s |
|
||||||
|
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||||
|
|
||||||
|
Each additional bit doubles the expected work. An attacker creating 1000
|
||||||
|
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||||
|
creating one session pays once and keeps their session.
|
||||||
|
|
||||||
### Why Hashcash and Not Rate Limits?
|
### Why Hashcash and Not Rate Limits?
|
||||||
|
|
||||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||||
The server only needs to verify a hash.
|
The server only needs to verify a single hash.
|
||||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||||
campuses, corporate networks, Tor exits). Every client computes their own
|
campuses, corporate networks, Tor exits). Every client computes their own
|
||||||
proof independently.
|
proof independently.
|
||||||
@@ -2169,36 +2223,9 @@ duration of their session.
|
|||||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
||||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
||||||
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||||
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
|
|
||||||
once. A botnet trying to create thousands of sessions pays exponentially more.
|
|
||||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||||
The proof computation is trivially implementable in any client.
|
The proof computation is trivially implementable in any client.
|
||||||
|
|
||||||
### Challenge Endpoint (Planned)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/challenge
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nonce": "a1b2c3d4e5f6...",
|
|
||||||
"difficulty": 20,
|
|
||||||
"algorithm": "sha256",
|
|
||||||
"expires": "2026-02-10T20:01:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|--------------|---------|-------------|
|
|
||||||
| `nonce` | string | Server-generated random hex string (32+ chars) |
|
|
||||||
| `difficulty` | integer | Required number of leading zero bits in the hash |
|
|
||||||
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
|
|
||||||
| `expires` | string | ISO 8601 expiry time for this challenge |
|
|
||||||
|
|
||||||
**Status:** Not yet implemented. Tracked for post-MVP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -2227,7 +2254,7 @@ GET /api/v1/challenge
|
|||||||
|
|
||||||
### Post-MVP (Planned)
|
### Post-MVP (Planned)
|
||||||
|
|
||||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||||
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
@@ -2336,7 +2363,7 @@ neoirc/
|
|||||||
| Purpose | Library |
|
| Purpose | Library |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| DI | `go.uber.org/fx` |
|
| DI | `go.uber.org/fx` |
|
||||||
| Router | `github.com/go-chi/chi/v5` |
|
| Router | `github.com/go-chi/chi` |
|
||||||
| Logging | `log/slog` (stdlib) |
|
| Logging | `log/slog` (stdlib) |
|
||||||
| Config | `github.com/spf13/viper` |
|
| Config | `github.com/spf13/viper` |
|
||||||
| Env | `github.com/joho/godotenv/autoload` |
|
| Env | `github.com/joho/godotenv/autoload` |
|
||||||
|
|||||||
@@ -43,13 +43,34 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
|
// If the server requires hashcash proof-of-work, it
|
||||||
|
// automatically fetches the difficulty and computes a
|
||||||
|
// valid stamp.
|
||||||
func (client *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
data, err := client.do(
|
// Fetch server info to check for hashcash requirement.
|
||||||
|
info, err := client.GetServerInfo()
|
||||||
|
|
||||||
|
var headers map[string]string
|
||||||
|
|
||||||
|
if err == nil && info.HashcashBits > 0 {
|
||||||
|
resource := info.Name
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := MintHashcash(info.HashcashBits, resource)
|
||||||
|
headers = map[string]string{
|
||||||
|
"X-Hashcash": stamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.doWithHeaders(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick},
|
&SessionRequest{Nick: nick},
|
||||||
|
headers,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -261,6 +282,16 @@ func (client *Client) GetServerInfo() (
|
|||||||
func (client *Client) do(
|
func (client *Client) do(
|
||||||
method, path string,
|
method, path string,
|
||||||
body any,
|
body any,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return client.doWithHeaders(
|
||||||
|
method, path, body, nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) doWithHeaders(
|
||||||
|
method, path string,
|
||||||
|
body any,
|
||||||
|
extraHeaders map[string]string,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
var bodyReader io.Reader
|
var bodyReader io.Reader
|
||||||
|
|
||||||
@@ -293,6 +324,10 @@ func (client *Client) do(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for key, val := range extraHeaders {
|
||||||
|
request.Header.Set(key, val)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
|||||||
79
cmd/neoirc-cli/api/hashcash.go
Normal file
79
cmd/neoirc-cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ type ServerInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Config struct {
|
|||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
|
HashcashBits int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ func New(
|
|||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,6 +100,7 @@ func New(
|
|||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,14 +64,12 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -139,14 +137,12 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Package db provides database access and migration management.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"modernc.org/sqlite"
|
|
||||||
sqlite3 "modernc.org/sqlite/lib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsUniqueConstraintError reports whether err is a SQLite
|
|
||||||
// unique-constraint violation.
|
|
||||||
func IsUniqueConstraintError(err error) bool {
|
|
||||||
var sqliteErr *sqlite.Error
|
|
||||||
if !errors.As(err, &sqliteErr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -32,14 +31,6 @@ func generateToken() (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashToken returns the lowercase hex-encoded SHA-256
|
|
||||||
// digest of a plaintext token string.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// IRCMessage is the IRC envelope for all messages.
|
// IRCMessage is the IRC envelope for all messages.
|
||||||
type IRCMessage struct {
|
type IRCMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -114,14 +105,12 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -154,8 +143,6 @@ func (database *Database) GetSessionByToken(
|
|||||||
nick string
|
nick string
|
||||||
)
|
)
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT s.id, c.id, s.nick
|
`SELECT s.id, c.id, s.nick
|
||||||
@@ -163,7 +150,7 @@ func (database *Database) GetSessionByToken(
|
|||||||
INNER JOIN sessions s
|
INNER JOIN sessions s
|
||||||
ON s.id = c.session_id
|
ON s.id = c.session_id
|
||||||
WHERE c.token = ?`,
|
WHERE c.token = ?`,
|
||||||
tokenHash,
|
token,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -145,6 +144,33 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
// Validate hashcash proof-of-work if configured.
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
stamp := request.Header.Get("X-Hashcash")
|
||||||
|
if stamp == "" {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"hashcash proof-of-work required",
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.hashcashVal.Validate(
|
||||||
|
stamp, hdlr.params.Config.HashcashBits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid hashcash stamp: "+err.Error(),
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
}
|
}
|
||||||
@@ -200,7 +226,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -1428,7 +1454,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||||
@@ -2392,11 +2418,19 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
resp := map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"version": hdlr.params.Globals.Version,
|
"version": hdlr.params.Globals.Version,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
"users": users,
|
"users": users,
|
||||||
}, http.StatusOK)
|
}
|
||||||
|
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request, resp, http.StatusOK,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func newTestServer(
|
|||||||
|
|
||||||
cfg.DBURL = dbURL
|
cfg.DBURL = dbURL
|
||||||
cfg.Port = 0
|
cfg.Port = 0
|
||||||
|
cfg.HashcashBits = 0
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -96,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -39,6 +40,7 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
hashcashVal *hashcash.Validator
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +49,17 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
|
resource := params.Config.ServerName
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
|
|||||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a hashcash stamp. It returns nil if the
|
||||||
|
// stamp is valid and has not been seen before.
|
||||||
|
func (v *Validator) Validate(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) 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]
|
||||||
|
|
||||||
|
if err := v.validateHeader(
|
||||||
|
version, bitsStr, resource, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, err := parseStampDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTime(stampTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateProof(
|
||||||
|
stamp, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.checkAndRecordStamp(stamp, stampTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth returns middleware that performs authentication.
|
||||||
|
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
mware.log.Info("AUTH: before request")
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
@@ -166,36 +180,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cspPolicy is the Content-Security-Policy header value applied to all
|
|
||||||
// responses. The embedded SPA loads scripts and styles from same-origin
|
|
||||||
// files only (no inline scripts or inline style attributes), so a strict
|
|
||||||
// policy works without 'unsafe-inline'.
|
|
||||||
const cspPolicy = "default-src 'self'; " +
|
|
||||||
"script-src 'self'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"connect-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'self'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"frame-ancestors 'none'; " +
|
|
||||||
"base-uri 'self'; " +
|
|
||||||
"form-action 'self'"
|
|
||||||
|
|
||||||
// CSP returns middleware that sets the Content-Security-Policy header on
|
|
||||||
// every response for defense-in-depth against XSS.
|
|
||||||
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
cspPolicy,
|
|
||||||
)
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,7 +29,6 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
srv.router.Use(srv.mw.CSP())
|
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
|||||||
const ACTION_PREFIX = "\x01ACTION ";
|
const ACTION_PREFIX = "\x01ACTION ";
|
||||||
const ACTION_SUFFIX = "\x01";
|
const ACTION_SUFFIX = "\x01";
|
||||||
|
|
||||||
|
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||||
|
|
||||||
|
function checkLeadingZeros(hashBytes, bits) {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < hashBytes.length; i++) {
|
||||||
|
if (hashBytes[i] === 0) {
|
||||||
|
count += 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let b = hashBytes[i];
|
||||||
|
while ((b & 0x80) === 0) {
|
||||||
|
count++;
|
||||||
|
b <<= 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return count >= bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintHashcash(bits, resource) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const now = new Date();
|
||||||
|
const date =
|
||||||
|
String(now.getUTCFullYear()).slice(2) +
|
||||||
|
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||||
|
String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||||
|
let nonce = Math.floor(Math.random() * 0x100000);
|
||||||
|
const batchSize = 1024;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const stamps = [];
|
||||||
|
const hashPromises = [];
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const stamp = prefix + (nonce + i).toString(16);
|
||||||
|
stamps.push(stamp);
|
||||||
|
hashPromises.push(
|
||||||
|
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hashes = await Promise.all(hashPromises);
|
||||||
|
for (let i = 0; i < hashes.length; i++) {
|
||||||
|
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||||
|
return stamps[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nonce += batchSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
const token = localStorage.getItem("neoirc_token");
|
const token = localStorage.getItem("neoirc_token");
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -60,12 +110,16 @@ function LoginScreen({ onLogin }) {
|
|||||||
const [motd, setMotd] = useState("");
|
const [motd, setMotd] = useState("");
|
||||||
const [serverName, setServerName] = useState("NeoIRC");
|
const [serverName, setServerName] = useState("NeoIRC");
|
||||||
const inputRef = useRef();
|
const inputRef = useRef();
|
||||||
|
const hashcashBitsRef = useRef(0);
|
||||||
|
const hashcashResourceRef = useRef("neoirc");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api("/server")
|
api("/server")
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s.name) setServerName(s.name);
|
if (s.name) setServerName(s.name);
|
||||||
if (s.motd) setMotd(s.motd);
|
if (s.motd) setMotd(s.motd);
|
||||||
|
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||||
|
if (s.name) hashcashResourceRef.current = s.name;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
@@ -81,9 +135,20 @@ function LoginScreen({ onLogin }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
const extraHeaders = {};
|
||||||
|
if (hashcashBitsRef.current > 0) {
|
||||||
|
setError("Computing proof-of-work...");
|
||||||
|
const stamp = await mintHashcash(
|
||||||
|
hashcashBitsRef.current,
|
||||||
|
hashcashResourceRef.current,
|
||||||
|
);
|
||||||
|
extraHeaders["X-Hashcash"] = stamp;
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
const res = await api("/session", {
|
const res = await api("/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ nick: nick.trim() }),
|
body: JSON.stringify({ nick: nick.trim() }),
|
||||||
|
headers: extraHeaders,
|
||||||
});
|
});
|
||||||
localStorage.setItem("neoirc_token", res.token);
|
localStorage.setItem("neoirc_token", res.token);
|
||||||
onLogin(res.nick);
|
onLogin(res.nick);
|
||||||
|
|||||||
Reference in New Issue
Block a user